首次转发成功
This commit is contained in:
108
src/direct-adapter-chat.ts
Normal file
108
src/direct-adapter-chat.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { CanonicalEvent, CanonicalMetadata, CanonicalState } from './direct-types';
|
||||
|
||||
function toChatFinishReason(finishReason: string | null): string | null {
|
||||
return finishReason === null ? null : finishReason;
|
||||
}
|
||||
|
||||
export function collectChatCompletionStreamFrames(metadata: CanonicalMetadata, events: CanonicalEvent[]): string[] {
|
||||
return [
|
||||
...events.flatMap((event) => chatCompletionStreamFramesForEvent(metadata, event)),
|
||||
'data: [DONE]',
|
||||
];
|
||||
}
|
||||
|
||||
export function chatCompletionStreamFramesForEvent(metadata: CanonicalMetadata, event: CanonicalEvent): string[] {
|
||||
if (event.type === 'text_delta') {
|
||||
return [`data: ${JSON.stringify({
|
||||
id: metadata.id,
|
||||
object: 'chat.completion.chunk',
|
||||
created: metadata.created,
|
||||
model: metadata.model,
|
||||
choices: [{ index: 0, delta: { content: event.text }, finish_reason: null }],
|
||||
})}`];
|
||||
}
|
||||
if (event.type === 'reasoning_delta') {
|
||||
return [`data: ${JSON.stringify({
|
||||
id: metadata.id,
|
||||
object: 'chat.completion.chunk',
|
||||
created: metadata.created,
|
||||
model: metadata.model,
|
||||
choices: [{ index: 0, delta: { reasoning_content: event.text }, finish_reason: null }],
|
||||
})}`];
|
||||
}
|
||||
if (event.type === 'tool_call_delta') {
|
||||
return [`data: ${JSON.stringify({
|
||||
id: metadata.id,
|
||||
object: 'chat.completion.chunk',
|
||||
created: metadata.created,
|
||||
model: metadata.model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: event.index,
|
||||
id: event.id,
|
||||
type: event.id ? 'function' : undefined,
|
||||
function: {
|
||||
name: event.name,
|
||||
arguments: event.arguments,
|
||||
},
|
||||
}],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
})}`];
|
||||
}
|
||||
if (event.type === 'finish') {
|
||||
return [`data: ${JSON.stringify({
|
||||
id: metadata.id,
|
||||
object: 'chat.completion.chunk',
|
||||
created: metadata.created,
|
||||
model: metadata.model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: toChatFinishReason(event.finishReason) }],
|
||||
})}`];
|
||||
}
|
||||
if (event.type === 'usage') {
|
||||
return [`data: ${JSON.stringify({
|
||||
id: metadata.id,
|
||||
object: 'chat.completion.chunk',
|
||||
created: metadata.created,
|
||||
model: metadata.model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: null }],
|
||||
usage: event.usage,
|
||||
})}`];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildChatCompletionResponse(state: CanonicalState) {
|
||||
const toolCalls = state.toolCalls
|
||||
.filter((toolCall) => toolCall.function.name || toolCall.function.arguments)
|
||||
.map((toolCall) => ({
|
||||
id: toolCall.id ?? `call_${toolCall.index}`,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall.function.name,
|
||||
arguments: toolCall.function.arguments,
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
id: `chatcmpl-${randomUUID()}`,
|
||||
object: 'chat.completion',
|
||||
created: state.created,
|
||||
model: state.model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: state.text,
|
||||
reasoning_content: state.reasoning || undefined,
|
||||
tool_calls: toolCalls.length ? toolCalls : undefined,
|
||||
},
|
||||
finish_reason: state.finishReason ?? 'stop',
|
||||
}],
|
||||
usage: state.usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
};
|
||||
}
|
||||
208
src/direct-adapter-messages.ts
Normal file
208
src/direct-adapter-messages.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { CanonicalEvent, CanonicalMetadata, CanonicalState, CanonicalToolCall } from './direct-types';
|
||||
|
||||
function mapStopReason(finishReason: string | null): string {
|
||||
if (finishReason === 'length') return 'max_tokens';
|
||||
if (finishReason === 'tool_calls') return 'tool_use';
|
||||
return 'end_turn';
|
||||
}
|
||||
|
||||
export function collectAnthropicStreamEvents(metadata: CanonicalMetadata, events: CanonicalEvent[]) {
|
||||
const encoder = new AnthropicStreamEncoder(metadata);
|
||||
return [
|
||||
...encoder.start(),
|
||||
...events.flatMap((event) => encoder.push(event)),
|
||||
...encoder.finish(),
|
||||
];
|
||||
}
|
||||
|
||||
export class AnthropicStreamEncoder {
|
||||
private readonly messageId: string;
|
||||
private nextIndex = 0;
|
||||
private currentBlock: 'thinking' | 'text' | 'tool_use' | null = null;
|
||||
private currentBlockIndex: number | null = null;
|
||||
private finalStopReason = 'end_turn';
|
||||
private finalUsage: Record<string, unknown> = { output_tokens: 0 };
|
||||
private finished = false;
|
||||
private toolIndexes = new Map<number, number>();
|
||||
|
||||
constructor(private readonly metadata: CanonicalMetadata) {
|
||||
this.messageId = `msg_${metadata.id.replace(/[^a-zA-Z0-9_]/g, '')}`;
|
||||
}
|
||||
|
||||
start(): Array<{ event: string; data: unknown }> {
|
||||
return [{
|
||||
event: 'message_start',
|
||||
data: {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: this.messageId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: this.metadata.model,
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
push(event: CanonicalEvent): Array<{ event: string; data: unknown }> {
|
||||
const output: Array<{ event: string; data: unknown }> = [];
|
||||
|
||||
if (event.type === 'reasoning_delta') {
|
||||
output.push(...this.openBlock('thinking', { type: 'thinking', thinking: '' }));
|
||||
output.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index: this.currentBlockIndex, delta: { type: 'thinking_delta', thinking: event.text } } });
|
||||
return output;
|
||||
}
|
||||
|
||||
if (event.type === 'text_delta') {
|
||||
output.push(...this.openBlock('text', { type: 'text', text: '' }));
|
||||
output.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index: this.currentBlockIndex, delta: { type: 'text_delta', text: event.text } } });
|
||||
return output;
|
||||
}
|
||||
|
||||
if (event.type === 'function_call_name') {
|
||||
output.push(...this.openToolBlock(0, undefined, event.name));
|
||||
return output;
|
||||
}
|
||||
|
||||
if (event.type === 'function_call_arguments_delta') {
|
||||
output.push(...this.openToolBlock(0));
|
||||
output.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index: this.toolIndexes.get(0), delta: { type: 'input_json_delta', partial_json: event.text } } });
|
||||
return output;
|
||||
}
|
||||
|
||||
if (event.type === 'tool_call_delta') {
|
||||
output.push(...this.openToolBlock(event.index, event.id, event.name));
|
||||
if (event.arguments) {
|
||||
output.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index: this.toolIndexes.get(event.index), delta: { type: 'input_json_delta', partial_json: event.arguments } } });
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
if (event.type === 'finish') {
|
||||
this.finalStopReason = mapStopReason(event.finishReason);
|
||||
output.push(...this.closeCurrentBlock());
|
||||
return output;
|
||||
}
|
||||
|
||||
if (event.type === 'usage') {
|
||||
this.finalUsage = {
|
||||
input_tokens: event.usage.prompt_tokens ?? 0,
|
||||
output_tokens: event.usage.completion_tokens ?? 0,
|
||||
cache_read_input_tokens: event.usage.cache_read_input_tokens ?? 0,
|
||||
};
|
||||
return output;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
finish(): Array<{ event: string; data: unknown }> {
|
||||
if (this.finished) return [];
|
||||
this.finished = true;
|
||||
return [
|
||||
...this.closeCurrentBlock(),
|
||||
{
|
||||
event: 'message_delta',
|
||||
data: {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: this.finalStopReason, stop_sequence: null },
|
||||
usage: this.finalUsage,
|
||||
},
|
||||
},
|
||||
{ event: 'message_stop', data: { type: 'message_stop' } },
|
||||
];
|
||||
}
|
||||
|
||||
private openBlock(type: 'thinking' | 'text', contentBlock: Record<string, unknown>): Array<{ event: string; data: unknown }> {
|
||||
if (this.currentBlock === type && this.currentBlockIndex !== null) return [];
|
||||
const output = this.closeCurrentBlock();
|
||||
const index = this.nextIndex++;
|
||||
this.currentBlock = type;
|
||||
this.currentBlockIndex = index;
|
||||
output.push({ event: 'content_block_start', data: { type: 'content_block_start', index, content_block: contentBlock } });
|
||||
return output;
|
||||
}
|
||||
|
||||
private openToolBlock(index: number, id?: string, name?: string): Array<{ event: string; data: unknown }> {
|
||||
const existing = this.toolIndexes.get(index);
|
||||
if (existing !== undefined) return [];
|
||||
const output = this.closeCurrentBlock();
|
||||
const blockIndex = this.nextIndex++;
|
||||
this.toolIndexes.set(index, blockIndex);
|
||||
this.currentBlock = 'tool_use';
|
||||
this.currentBlockIndex = blockIndex;
|
||||
output.push({
|
||||
event: 'content_block_start',
|
||||
data: {
|
||||
type: 'content_block_start',
|
||||
index: blockIndex,
|
||||
content_block: {
|
||||
type: 'tool_use',
|
||||
id: id ?? `toolu_${this.metadata.id}_${index}`,
|
||||
name: name ?? '',
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
private closeCurrentBlock(): Array<{ event: string; data: unknown }> {
|
||||
if (this.currentBlockIndex === null) return [];
|
||||
const index = this.currentBlockIndex;
|
||||
this.currentBlock = null;
|
||||
this.currentBlockIndex = null;
|
||||
return [{ event: 'content_block_stop', data: { type: 'content_block_stop', index } }];
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAnthropicMessageResponse(state: CanonicalState) {
|
||||
const content: Array<Record<string, unknown>> = [];
|
||||
if (state.reasoning) {
|
||||
content.push({ type: 'thinking', thinking: state.reasoning });
|
||||
}
|
||||
if (state.text) {
|
||||
content.push({ type: 'text', text: state.text });
|
||||
}
|
||||
for (const toolCall of state.toolCalls) {
|
||||
content.push(toolCallToAnthropicBlock(toolCall));
|
||||
}
|
||||
|
||||
return {
|
||||
id: `msg_${randomUUID().replaceAll('-', '')}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: state.model,
|
||||
content,
|
||||
stop_reason: mapStopReason(state.finishReason),
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: state.usage?.prompt_tokens ?? 0,
|
||||
output_tokens: state.usage?.completion_tokens ?? 0,
|
||||
cache_read_input_tokens: state.usage?.cache_read_input_tokens ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toolCallToAnthropicBlock(toolCall: CanonicalToolCall): Record<string, unknown> {
|
||||
return {
|
||||
type: 'tool_use',
|
||||
id: toolCall.id ?? `toolu_${toolCall.index}`,
|
||||
name: toolCall.function.name,
|
||||
input: parseToolInput(toolCall.function.arguments),
|
||||
};
|
||||
}
|
||||
|
||||
function parseToolInput(value: string): unknown {
|
||||
if (!value) return {};
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
271
src/direct-adapter-responses.ts
Normal file
271
src/direct-adapter-responses.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { CanonicalEvent, CanonicalMetadata, CanonicalState } from './direct-types';
|
||||
|
||||
function makeMessageItem(itemId: string, text: string) {
|
||||
return {
|
||||
type: 'message',
|
||||
id: itemId,
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
function makeReasoningItem(itemId: string, text: string) {
|
||||
return {
|
||||
type: 'reasoning',
|
||||
id: itemId,
|
||||
summary: [{ type: 'summary_text', text }],
|
||||
};
|
||||
}
|
||||
|
||||
function makeFunctionCallItem(itemId: string, callId: string, name: string, argumentsText: string) {
|
||||
return {
|
||||
type: 'function_call',
|
||||
id: itemId,
|
||||
call_id: callId,
|
||||
name,
|
||||
arguments: argumentsText,
|
||||
status: 'completed',
|
||||
};
|
||||
}
|
||||
|
||||
export function collectResponsesStreamEvents(metadata: CanonicalMetadata, events: CanonicalEvent[]) {
|
||||
const encoder = new ResponsesStreamEncoder(metadata);
|
||||
return [
|
||||
...encoder.start(),
|
||||
...events.flatMap((event) => encoder.push(event)),
|
||||
...encoder.finish(),
|
||||
];
|
||||
}
|
||||
|
||||
export class ResponsesStreamEncoder {
|
||||
private readonly messageItemId: string;
|
||||
private readonly reasoningItemId: string;
|
||||
private sequenceNumber = 0;
|
||||
private nextOutputIndex = 0;
|
||||
private openedMessage = false;
|
||||
private openedReasoning = false;
|
||||
private messageOutputIndex: number | null = null;
|
||||
private reasoningOutputIndex: number | null = null;
|
||||
private completed = false;
|
||||
private toolItems = new Map<number, { itemId: string; callId: string; name: string; opened: boolean; outputIndex: number }>();
|
||||
|
||||
constructor(private readonly metadata: CanonicalMetadata) {
|
||||
this.messageItemId = `item_${metadata.id}_message`;
|
||||
this.reasoningItemId = `item_${metadata.id}_reasoning`;
|
||||
}
|
||||
|
||||
start(): Array<Record<string, unknown>> {
|
||||
return [{
|
||||
type: 'response.created',
|
||||
sequence_number: this.sequenceNumber++,
|
||||
response: { id: this.metadata.id, object: 'response', model: this.metadata.model, status: 'in_progress', output: [] },
|
||||
}];
|
||||
}
|
||||
|
||||
push(event: CanonicalEvent): Array<Record<string, unknown>> {
|
||||
const output: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (event.type === 'reasoning_delta') {
|
||||
if (!this.openedReasoning) {
|
||||
this.reasoningOutputIndex = this.allocateOutputIndex();
|
||||
output.push({
|
||||
type: 'response.output_item.added',
|
||||
sequence_number: this.sequenceNumber++,
|
||||
output_index: this.reasoningOutputIndex,
|
||||
item: { type: 'reasoning', id: this.reasoningItemId },
|
||||
});
|
||||
this.openedReasoning = true;
|
||||
}
|
||||
output.push({
|
||||
type: 'response.reasoning_summary_text.delta',
|
||||
sequence_number: this.sequenceNumber++,
|
||||
output_index: this.reasoningOutputIndex,
|
||||
summary_index: 0,
|
||||
item_id: this.reasoningItemId,
|
||||
delta: event.text,
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
if (event.type === 'text_delta') {
|
||||
if (!this.openedMessage) {
|
||||
this.messageOutputIndex = this.allocateOutputIndex();
|
||||
output.push({
|
||||
type: 'response.output_item.added',
|
||||
sequence_number: this.sequenceNumber++,
|
||||
output_index: this.messageOutputIndex,
|
||||
item: { type: 'message', id: this.messageItemId, role: 'assistant', status: 'in_progress' },
|
||||
});
|
||||
this.openedMessage = true;
|
||||
}
|
||||
output.push({
|
||||
type: 'response.output_text.delta',
|
||||
sequence_number: this.sequenceNumber++,
|
||||
output_index: this.messageOutputIndex,
|
||||
content_index: 0,
|
||||
item_id: this.messageItemId,
|
||||
delta: event.text,
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
if (event.type === 'function_call_name') {
|
||||
output.push(...this.ensureToolItem(0, undefined, event.name));
|
||||
return output;
|
||||
}
|
||||
|
||||
if (event.type === 'function_call_arguments_delta') {
|
||||
output.push(...this.ensureToolItem(0));
|
||||
output.push(this.toolArgumentsDelta(0, event.text));
|
||||
return output;
|
||||
}
|
||||
|
||||
if (event.type === 'tool_call_delta') {
|
||||
output.push(...this.ensureToolItem(event.index, event.id, event.name));
|
||||
if (event.arguments) output.push(this.toolArgumentsDelta(event.index, event.arguments));
|
||||
return output;
|
||||
}
|
||||
|
||||
if (event.type === 'finish') {
|
||||
output.push(...this.closeOpenItems());
|
||||
return output;
|
||||
}
|
||||
|
||||
if (event.type === 'usage') {
|
||||
output.push({
|
||||
type: 'response.completed',
|
||||
sequence_number: this.sequenceNumber++,
|
||||
response: {
|
||||
id: this.metadata.id,
|
||||
object: 'response',
|
||||
model: this.metadata.model,
|
||||
status: 'completed',
|
||||
output: [],
|
||||
usage: event.usage,
|
||||
},
|
||||
});
|
||||
this.completed = true;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
finish(): Array<Record<string, unknown>> {
|
||||
if (this.completed) return [];
|
||||
return [{
|
||||
type: 'response.completed',
|
||||
sequence_number: this.sequenceNumber++,
|
||||
response: {
|
||||
id: this.metadata.id,
|
||||
object: 'response',
|
||||
model: this.metadata.model,
|
||||
status: 'completed',
|
||||
output: [],
|
||||
},
|
||||
}];
|
||||
}
|
||||
|
||||
private ensureToolItem(index: number, id?: string, name?: string): Array<Record<string, unknown>> {
|
||||
const item = this.getToolItem(index, id, name);
|
||||
if (name) item.name = name;
|
||||
if (id) item.callId = id;
|
||||
if (item.opened) return [];
|
||||
|
||||
item.opened = true;
|
||||
return [{
|
||||
type: 'response.output_item.added',
|
||||
sequence_number: this.sequenceNumber++,
|
||||
output_index: item.outputIndex,
|
||||
item: { type: 'function_call', id: item.itemId, call_id: item.callId, name: item.name, status: 'in_progress' },
|
||||
}];
|
||||
}
|
||||
|
||||
private toolArgumentsDelta(index: number, delta: string): Record<string, unknown> {
|
||||
const item = this.getToolItem(index);
|
||||
return {
|
||||
type: 'response.function_call_arguments.delta',
|
||||
sequence_number: this.sequenceNumber++,
|
||||
output_index: item.outputIndex,
|
||||
item_id: item.itemId,
|
||||
call_id: item.callId,
|
||||
name: item.name,
|
||||
delta,
|
||||
};
|
||||
}
|
||||
|
||||
private closeOpenItems(): Array<Record<string, unknown>> {
|
||||
const output: Array<Record<string, unknown>> = [];
|
||||
if (this.openedReasoning) {
|
||||
output.push({ type: 'response.reasoning_summary_text.done', sequence_number: this.sequenceNumber++, output_index: this.reasoningOutputIndex, summary_index: 0, item_id: this.reasoningItemId });
|
||||
output.push({ type: 'response.output_item.done', sequence_number: this.sequenceNumber++, output_index: this.reasoningOutputIndex, item: { type: 'reasoning', id: this.reasoningItemId, status: 'completed' } });
|
||||
this.openedReasoning = false;
|
||||
}
|
||||
if (this.openedMessage) {
|
||||
output.push({ type: 'response.output_text.done', sequence_number: this.sequenceNumber++, output_index: this.messageOutputIndex, content_index: 0, item_id: this.messageItemId });
|
||||
output.push({ type: 'response.output_item.done', sequence_number: this.sequenceNumber++, output_index: this.messageOutputIndex, item: { type: 'message', id: this.messageItemId, role: 'assistant', status: 'completed' } });
|
||||
this.openedMessage = false;
|
||||
}
|
||||
for (const item of [...this.toolItems.values()].sort((left, right) => left.outputIndex - right.outputIndex)) {
|
||||
if (!item.opened) continue;
|
||||
output.push({ type: 'response.function_call_arguments.done', sequence_number: this.sequenceNumber++, output_index: item.outputIndex, item_id: item.itemId, call_id: item.callId, name: item.name });
|
||||
output.push({ type: 'response.output_item.done', sequence_number: this.sequenceNumber++, output_index: item.outputIndex, item: { type: 'function_call', id: item.itemId, status: 'completed' } });
|
||||
item.opened = false;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
private allocateOutputIndex(): number {
|
||||
return this.nextOutputIndex++;
|
||||
}
|
||||
|
||||
private getToolItem(index: number, id?: string, name?: string) {
|
||||
let item = this.toolItems.get(index);
|
||||
if (!item) {
|
||||
const itemId = `item_${this.metadata.id}_function_${index}`;
|
||||
item = {
|
||||
itemId,
|
||||
callId: id ?? `call_${itemId}`,
|
||||
name: name ?? '',
|
||||
opened: false,
|
||||
outputIndex: this.allocateOutputIndex(),
|
||||
};
|
||||
this.toolItems.set(index, item);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function buildResponsesResponse(state: CanonicalState) {
|
||||
const output: Array<Record<string, unknown>> = [];
|
||||
if (state.reasoning) {
|
||||
output.push(makeReasoningItem(`item_${randomUUID().replaceAll('-', '')}`, state.reasoning));
|
||||
}
|
||||
if (state.text) {
|
||||
output.push(makeMessageItem(`item_${randomUUID().replaceAll('-', '')}`, state.text));
|
||||
}
|
||||
if (state.functionCallName || state.functionCallArguments) {
|
||||
const itemId = `item_${randomUUID().replaceAll('-', '')}`;
|
||||
output.push(makeFunctionCallItem(itemId, `call_${itemId}`, state.functionCallName, state.functionCallArguments));
|
||||
}
|
||||
for (const toolCall of state.toolCalls) {
|
||||
const itemId = `item_${randomUUID().replaceAll('-', '')}`;
|
||||
output.push(makeFunctionCallItem(
|
||||
itemId,
|
||||
toolCall.id ?? `call_${itemId}`,
|
||||
toolCall.function.name,
|
||||
toolCall.function.arguments,
|
||||
));
|
||||
}
|
||||
|
||||
return {
|
||||
id: state.id,
|
||||
object: 'response',
|
||||
model: state.model,
|
||||
status: state.finishReason === 'length' ? 'incomplete' : 'completed',
|
||||
output,
|
||||
usage: state.usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||
};
|
||||
}
|
||||
100
src/direct-canonical.ts
Normal file
100
src/direct-canonical.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { CanonicalEvent, CanonicalMetadata, CanonicalState, UpstreamChunk } from './direct-types';
|
||||
|
||||
export type { CanonicalEvent } from './direct-types';
|
||||
|
||||
export function createCanonicalState(metadata: CanonicalMetadata): CanonicalState {
|
||||
return {
|
||||
...metadata,
|
||||
text: '',
|
||||
reasoning: '',
|
||||
functionCallName: '',
|
||||
functionCallArguments: '',
|
||||
toolCalls: [],
|
||||
finishReason: null,
|
||||
usage: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyCanonicalEvent(state: CanonicalState, event: CanonicalEvent): void {
|
||||
if (event.type === 'text_delta') {
|
||||
state.text += event.text;
|
||||
return;
|
||||
}
|
||||
if (event.type === 'reasoning_delta') {
|
||||
state.reasoning += event.text;
|
||||
return;
|
||||
}
|
||||
if (event.type === 'function_call_name') {
|
||||
state.functionCallName = event.name;
|
||||
return;
|
||||
}
|
||||
if (event.type === 'function_call_arguments_delta') {
|
||||
state.functionCallArguments += event.text;
|
||||
return;
|
||||
}
|
||||
if (event.type === 'tool_call_delta') {
|
||||
const toolCall = getToolCall(state, event.index);
|
||||
if (event.id) toolCall.id = event.id;
|
||||
if (event.name) toolCall.function.name = event.name;
|
||||
if (event.arguments) toolCall.function.arguments += event.arguments;
|
||||
return;
|
||||
}
|
||||
if (event.type === 'finish') {
|
||||
state.finishReason = event.finishReason;
|
||||
return;
|
||||
}
|
||||
state.usage = event.usage;
|
||||
}
|
||||
|
||||
export function parseUpstreamChunk(chunk: UpstreamChunk): CanonicalEvent[] {
|
||||
const choice = chunk.choices?.[0];
|
||||
const delta = choice?.delta;
|
||||
const events: CanonicalEvent[] = [];
|
||||
|
||||
if (delta?.content) {
|
||||
events.push({ type: 'text_delta', text: delta.content });
|
||||
}
|
||||
if (delta?.reasoning_content) {
|
||||
events.push({ type: 'reasoning_delta', text: delta.reasoning_content });
|
||||
}
|
||||
if (delta?.function_call?.name) {
|
||||
events.push({ type: 'function_call_name', name: delta.function_call.name });
|
||||
}
|
||||
if (delta?.function_call?.arguments) {
|
||||
events.push({ type: 'function_call_arguments_delta', text: delta.function_call.arguments });
|
||||
}
|
||||
for (const toolCall of delta?.tool_calls ?? []) {
|
||||
events.push({
|
||||
type: 'tool_call_delta',
|
||||
index: toolCall.index ?? 0,
|
||||
id: toolCall.id,
|
||||
name: toolCall.function?.name,
|
||||
arguments: toolCall.function?.arguments,
|
||||
});
|
||||
}
|
||||
if (typeof choice?.finish_reason !== 'undefined' && choice.finish_reason !== '') {
|
||||
events.push({ type: 'finish', finishReason: choice.finish_reason ?? null });
|
||||
}
|
||||
if (chunk.usage) {
|
||||
events.push({ type: 'usage', usage: chunk.usage });
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function getToolCall(state: CanonicalState, index: number) {
|
||||
let toolCall = state.toolCalls.find((item) => item.index === index);
|
||||
if (!toolCall) {
|
||||
toolCall = {
|
||||
index,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: '',
|
||||
arguments: '',
|
||||
},
|
||||
};
|
||||
state.toolCalls.push(toolCall);
|
||||
state.toolCalls.sort((left, right) => left.index - right.index);
|
||||
}
|
||||
return toolCall;
|
||||
}
|
||||
75
src/direct-config.ts
Normal file
75
src/direct-config.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import type { DirectBuilderContext, DirectModelConfig, DirectTextBlock } from './direct-types';
|
||||
|
||||
const defaultSystemPromptFile = 'config/system-prompt.txt';
|
||||
const defaultModelsFile = 'config/models.json';
|
||||
const defaultCliCaptureFile = 'captures/codebuddy-chat-completion-full.redacted.json';
|
||||
|
||||
export function loadApiKey(): string {
|
||||
const fromEnv = process.env.CODEBUDDY_API_KEY?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
const file = process.env.CODEBUDDY_APIKEY_FILE ?? 'apikey';
|
||||
if (!existsSync(file)) {
|
||||
throw new Error(`Missing API key. Set CODEBUDDY_API_KEY or create ${file}.`);
|
||||
}
|
||||
|
||||
const key = readFileSync(file, 'utf8')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line && !line.startsWith('#'));
|
||||
if (!key) throw new Error(`${file} has no usable API key line.`);
|
||||
return key;
|
||||
}
|
||||
|
||||
export function loadSystemPrompt(): string {
|
||||
if (process.env.CODEBUDDY_DISABLE_SYSTEM_PROMPT === '1') return '';
|
||||
const inline = process.env.CODEBUDDY_SYSTEM_PROMPT?.trim();
|
||||
if (inline) return inline;
|
||||
const file = process.env.CODEBUDDY_SYSTEM_PROMPT_FILE ?? defaultSystemPromptFile;
|
||||
if (!existsSync(file)) return '';
|
||||
return readFileSync(file, 'utf8').trim();
|
||||
}
|
||||
|
||||
export function loadSystemPromptMode(): DirectBuilderContext['systemPromptMode'] {
|
||||
const mode = process.env.CODEBUDDY_SYSTEM_PROMPT_MODE?.trim().toLowerCase();
|
||||
if (!mode || mode === 'original') return 'original';
|
||||
if (mode === 'passthrough') return 'passthrough';
|
||||
if (mode === 'hybrid') return 'hybrid';
|
||||
throw new Error('CODEBUDDY_SYSTEM_PROMPT_MODE must be original, passthrough, or hybrid.');
|
||||
}
|
||||
|
||||
export function loadModels(): DirectModelConfig[] {
|
||||
const file = process.env.CODEBUDDY_MODELS_FILE ?? defaultModelsFile;
|
||||
if (!existsSync(file)) return [];
|
||||
|
||||
const parsed = JSON.parse(readFileSync(file, 'utf8')) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed
|
||||
.filter((item): item is DirectModelConfig => Boolean(item) && typeof item === 'object'
|
||||
&& typeof (item as DirectModelConfig).id === 'string'
|
||||
&& typeof (item as DirectModelConfig).name === 'string'
|
||||
&& typeof (item as DirectModelConfig).credits_multiplier === 'number');
|
||||
}
|
||||
|
||||
export function loadCliUserContextBlocks(): DirectTextBlock[] {
|
||||
if (process.env.CODEBUDDY_DISABLE_CLI_USER_CONTEXT === '1') return [];
|
||||
const file = process.env.CODEBUDDY_CLI_CAPTURE_FILE ?? defaultCliCaptureFile;
|
||||
if (!existsSync(file)) return [];
|
||||
|
||||
try {
|
||||
const capture = JSON.parse(readFileSync(file, 'utf8')) as {
|
||||
request_body?: { messages?: Array<{ role?: string; content?: unknown }> };
|
||||
};
|
||||
const userMessage = capture.request_body?.messages?.find((message) => message.role === 'user');
|
||||
if (!Array.isArray(userMessage?.content)) return [];
|
||||
|
||||
return userMessage.content
|
||||
.filter((value): value is DirectTextBlock => Boolean(value) && typeof value === 'object' && (value as DirectTextBlock).type === 'text' && typeof (value as DirectTextBlock).text === 'string')
|
||||
.filter((block) => !block.text.includes('<user_query>'))
|
||||
.map((block) => ({ type: 'text', text: block.text }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
742
src/direct-conversion.test.ts
Normal file
742
src/direct-conversion.test.ts
Normal file
@@ -0,0 +1,742 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildUpstreamMessagesFromAnthropic,
|
||||
buildUpstreamMessagesFromChat,
|
||||
buildUpstreamMessagesFromResponses,
|
||||
buildUpstreamOptionsFromAnthropic,
|
||||
buildUpstreamOptionsFromChat,
|
||||
buildUpstreamOptionsFromResponses,
|
||||
} from './direct-request-builders';
|
||||
|
||||
import {
|
||||
applyCanonicalEvent,
|
||||
createCanonicalState,
|
||||
parseUpstreamChunk,
|
||||
type CanonicalEvent,
|
||||
} from './direct-canonical';
|
||||
import {
|
||||
buildChatCompletionResponse,
|
||||
collectChatCompletionStreamFrames,
|
||||
} from './direct-adapter-chat';
|
||||
import {
|
||||
buildResponsesResponse,
|
||||
collectResponsesStreamEvents,
|
||||
} from './direct-adapter-responses';
|
||||
import {
|
||||
buildAnthropicMessageResponse,
|
||||
collectAnthropicStreamEvents,
|
||||
} from './direct-adapter-messages';
|
||||
import { loadModels } from './direct-config';
|
||||
import type { DirectTextBlock } from './direct-types';
|
||||
|
||||
const cliContextBlocks: DirectTextBlock[] = [
|
||||
{ type: 'text', text: '<system-reminder>ctx</system-reminder>' },
|
||||
{ type: 'text', text: '<cwd>/workspace</cwd>' },
|
||||
];
|
||||
|
||||
function builderContext(systemPromptMode: 'original' | 'passthrough' | 'hybrid' = 'original') {
|
||||
return {
|
||||
systemPrompt: 'captured system prompt',
|
||||
systemPromptMode,
|
||||
cliUserContextBlocks: cliContextBlocks,
|
||||
};
|
||||
}
|
||||
|
||||
function accumulate(events: CanonicalEvent[]) {
|
||||
const state = createCanonicalState({
|
||||
id: 'resp_1',
|
||||
model: 'minimax-m2.7',
|
||||
created: 123,
|
||||
});
|
||||
|
||||
for (const event of events) {
|
||||
applyCanonicalEvent(state, event);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
test('buildUpstreamMessagesFromChat uses original system prompt by default and wraps final user query', () => {
|
||||
const messages = buildUpstreamMessagesFromChat(
|
||||
{
|
||||
messages: [
|
||||
{ role: 'system', content: 'request system' },
|
||||
{ role: 'assistant', content: 'older answer' },
|
||||
{ role: 'user', content: 'hello world' },
|
||||
],
|
||||
},
|
||||
builderContext(),
|
||||
);
|
||||
|
||||
assert.equal(messages[0]?.role, 'system');
|
||||
assert.equal(messages[0]?.content, 'captured system prompt');
|
||||
assert.equal(messages.filter((message) => message.role === 'system').length, 1);
|
||||
assert.equal(messages[1]?.role, 'assistant');
|
||||
assert.equal(messages[2]?.role, 'user');
|
||||
assert.equal(messages[2]?.agent, 'cli');
|
||||
assert.ok(Array.isArray(messages[2]?.content));
|
||||
assert.deepEqual(messages[2]?.content, [
|
||||
...cliContextBlocks,
|
||||
{ type: 'text', text: '<user_query>hello world</user_query>' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildUpstreamMessagesFromChat can passthrough request system prompt instead of original', () => {
|
||||
const messages = buildUpstreamMessagesFromChat(
|
||||
{
|
||||
messages: [
|
||||
{ role: 'system', content: 'request system' },
|
||||
{ role: 'user', content: 'hello world' },
|
||||
],
|
||||
},
|
||||
builderContext('passthrough'),
|
||||
);
|
||||
|
||||
assert.equal(messages[0]?.role, 'system');
|
||||
assert.equal(messages[0]?.content, 'request system');
|
||||
assert.equal(messages.filter((message) => message.role === 'system').length, 1);
|
||||
assert.equal(messages[1]?.role, 'user');
|
||||
});
|
||||
|
||||
test('buildUpstreamMessagesFromChat can send original then passthrough system prompts in hybrid mode', () => {
|
||||
const messages = buildUpstreamMessagesFromChat(
|
||||
{
|
||||
messages: [
|
||||
{ role: 'system', content: 'request system' },
|
||||
{ role: 'user', content: 'hello world' },
|
||||
],
|
||||
},
|
||||
builderContext('hybrid'),
|
||||
);
|
||||
|
||||
assert.equal(messages[0]?.role, 'system');
|
||||
assert.equal(messages[0]?.content, 'captured system prompt');
|
||||
assert.equal(messages[1]?.role, 'system');
|
||||
assert.equal(messages[1]?.content, 'request system');
|
||||
assert.equal(messages.filter((message) => message.role === 'system').length, 2);
|
||||
assert.equal(messages[2]?.role, 'user');
|
||||
});
|
||||
|
||||
test('buildUpstreamMessagesFromChat preserves assistant tool calls and tool results', () => {
|
||||
const messages = buildUpstreamMessagesFromChat(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'lookup', arguments: '{"q":"x"}' },
|
||||
}],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'call_1', content: 'result' },
|
||||
{ role: 'user', content: 'continue' },
|
||||
],
|
||||
},
|
||||
builderContext(),
|
||||
);
|
||||
|
||||
assert.equal(messages[1]?.role, 'assistant');
|
||||
assert.deepEqual(messages[1]?.tool_calls, [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'lookup', arguments: '{"q":"x"}' },
|
||||
}]);
|
||||
assert.equal(messages[2]?.role, 'tool');
|
||||
assert.equal(messages[2]?.tool_call_id, 'call_1');
|
||||
assert.equal(messages[2]?.content, 'result');
|
||||
});
|
||||
|
||||
test('buildUpstreamMessagesFromResponses uses original system prompt by default', () => {
|
||||
const messages = buildUpstreamMessagesFromResponses(
|
||||
{
|
||||
instructions: 'be concise',
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'say hi' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
builderContext(),
|
||||
);
|
||||
|
||||
assert.equal(messages[0]?.role, 'system');
|
||||
assert.equal(messages[0]?.content, 'captured system prompt');
|
||||
assert.equal(messages.filter((message) => message.role === 'system').length, 1);
|
||||
assert.equal(messages[1]?.role, 'user');
|
||||
assert.equal(messages[1]?.agent, 'cli');
|
||||
assert.deepEqual(messages[1]?.content, [
|
||||
...cliContextBlocks,
|
||||
{ type: 'text', text: '<user_query>say hi</user_query>' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildUpstreamMessagesFromResponses can passthrough request instructions instead of original', () => {
|
||||
const messages = buildUpstreamMessagesFromResponses(
|
||||
{
|
||||
instructions: 'be concise',
|
||||
input: 'say hi',
|
||||
},
|
||||
builderContext('passthrough'),
|
||||
);
|
||||
|
||||
assert.equal(messages[0]?.role, 'system');
|
||||
assert.equal(messages[0]?.content, 'be concise');
|
||||
assert.equal(messages.filter((message) => message.role === 'system').length, 1);
|
||||
assert.equal(messages[1]?.role, 'user');
|
||||
});
|
||||
|
||||
test('buildUpstreamMessagesFromResponses can send original then instructions in hybrid mode', () => {
|
||||
const messages = buildUpstreamMessagesFromResponses(
|
||||
{
|
||||
instructions: 'be concise',
|
||||
input: 'say hi',
|
||||
},
|
||||
builderContext('hybrid'),
|
||||
);
|
||||
|
||||
assert.equal(messages[0]?.role, 'system');
|
||||
assert.equal(messages[0]?.content, 'captured system prompt');
|
||||
assert.equal(messages[1]?.role, 'system');
|
||||
assert.equal(messages[1]?.content, 'be concise');
|
||||
assert.equal(messages.filter((message) => message.role === 'system').length, 2);
|
||||
assert.equal(messages[2]?.role, 'user');
|
||||
});
|
||||
|
||||
test('buildUpstreamMessagesFromResponses accepts string message content', () => {
|
||||
const messages = buildUpstreamMessagesFromResponses(
|
||||
{
|
||||
input: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: 'say hi',
|
||||
},
|
||||
],
|
||||
},
|
||||
builderContext(),
|
||||
);
|
||||
|
||||
assert.equal(messages[1]?.role, 'user');
|
||||
assert.deepEqual(messages[1]?.content, [
|
||||
...cliContextBlocks,
|
||||
{ type: 'text', text: '<user_query>say hi</user_query>' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildUpstreamMessagesFromResponses preserves function call continuation items', () => {
|
||||
const messages = buildUpstreamMessagesFromResponses(
|
||||
{
|
||||
input: [
|
||||
{ type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{"q":"x"}' },
|
||||
{ type: 'function_call_output', call_id: 'call_1', output: 'result' },
|
||||
{ type: 'message', role: 'user', content: [{ type: 'input_text', text: 'continue' }] },
|
||||
],
|
||||
},
|
||||
builderContext(),
|
||||
);
|
||||
|
||||
assert.equal(messages[1]?.role, 'assistant');
|
||||
assert.deepEqual(messages[1]?.tool_calls, [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'lookup', arguments: '{"q":"x"}' },
|
||||
}]);
|
||||
assert.equal(messages[2]?.role, 'tool');
|
||||
assert.equal(messages[2]?.tool_call_id, 'call_1');
|
||||
assert.equal(messages[2]?.content, 'result');
|
||||
});
|
||||
|
||||
test('buildUpstreamMessagesFromAnthropic uses original system prompt by default', () => {
|
||||
const messages = buildUpstreamMessagesFromAnthropic(
|
||||
{
|
||||
system: 'anthropic system',
|
||||
messages: [
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'older answer' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'new question' }] },
|
||||
],
|
||||
},
|
||||
builderContext(),
|
||||
);
|
||||
|
||||
assert.equal(messages[0]?.role, 'system');
|
||||
assert.equal(messages[0]?.content, 'captured system prompt');
|
||||
assert.equal(messages.filter((message) => message.role === 'system').length, 1);
|
||||
assert.equal(messages[1]?.role, 'assistant');
|
||||
assert.equal(messages[1]?.content, 'older answer');
|
||||
assert.equal(messages[2]?.role, 'user');
|
||||
assert.equal(messages[2]?.agent, 'cli');
|
||||
});
|
||||
|
||||
test('buildUpstreamMessagesFromAnthropic can passthrough request system instead of original', () => {
|
||||
const messages = buildUpstreamMessagesFromAnthropic(
|
||||
{
|
||||
system: 'anthropic system',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'new question' }] }],
|
||||
},
|
||||
builderContext('passthrough'),
|
||||
);
|
||||
|
||||
assert.equal(messages[0]?.role, 'system');
|
||||
assert.equal(messages[0]?.content, 'anthropic system');
|
||||
assert.equal(messages.filter((message) => message.role === 'system').length, 1);
|
||||
assert.equal(messages[1]?.role, 'user');
|
||||
});
|
||||
|
||||
test('buildUpstreamMessagesFromAnthropic can send original then request system in hybrid mode', () => {
|
||||
const messages = buildUpstreamMessagesFromAnthropic(
|
||||
{
|
||||
system: 'anthropic system',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'new question' }] }],
|
||||
},
|
||||
builderContext('hybrid'),
|
||||
);
|
||||
|
||||
assert.equal(messages[0]?.role, 'system');
|
||||
assert.equal(messages[0]?.content, 'captured system prompt');
|
||||
assert.equal(messages[1]?.role, 'system');
|
||||
assert.equal(messages[1]?.content, 'anthropic system');
|
||||
assert.equal(messages.filter((message) => message.role === 'system').length, 2);
|
||||
assert.equal(messages[2]?.role, 'user');
|
||||
});
|
||||
|
||||
test('buildUpstreamMessagesFromAnthropic preserves tool_use and tool_result blocks', () => {
|
||||
const messages = buildUpstreamMessagesFromAnthropic(
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: 'call_1', name: 'lookup', input: { q: 'x' } }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'result' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
builderContext(),
|
||||
);
|
||||
|
||||
assert.equal(messages[1]?.role, 'assistant');
|
||||
assert.deepEqual(messages[1]?.tool_calls, [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'lookup', arguments: '{"q":"x"}' },
|
||||
}]);
|
||||
assert.equal(messages[2]?.role, 'tool');
|
||||
assert.equal(messages[2]?.tool_call_id, 'call_1');
|
||||
assert.equal(messages[2]?.content, 'result');
|
||||
});
|
||||
|
||||
test('buildUpstreamOptionsFromChat passes through model tools and reasoning settings', () => {
|
||||
const options = buildUpstreamOptionsFromChat({
|
||||
model: 'kimi-k2.6',
|
||||
tools: [{ type: 'function', function: { name: 'lookup', description: 'd', parameters: { type: 'object' } } }],
|
||||
temperature: 0.3,
|
||||
max_tokens: 4096,
|
||||
reasoning_effort: 'high',
|
||||
verbosity: 'low',
|
||||
reasoning_summary: 'detailed',
|
||||
});
|
||||
|
||||
assert.equal(options.model, 'kimi-k2.6');
|
||||
assert.equal(options.temperature, 0.3);
|
||||
assert.equal(options.max_tokens, 4096);
|
||||
assert.equal(options.reasoning_effort, 'high');
|
||||
assert.equal(options.verbosity, 'low');
|
||||
assert.equal(options.reasoning_summary, 'detailed');
|
||||
assert.deepEqual(options.tools, [{ type: 'function', function: { name: 'lookup', description: 'd', parameters: { type: 'object' } } }]);
|
||||
});
|
||||
|
||||
test('buildUpstreamOptionsFromResponses passes through model tools and reasoning settings', () => {
|
||||
const options = buildUpstreamOptionsFromResponses({
|
||||
model: 'glm-5.1',
|
||||
tools: [{ type: 'function', name: 'lookup', description: 'd', parameters: { type: 'object' } }],
|
||||
temperature: 0.2,
|
||||
max_output_tokens: 2048,
|
||||
reasoning: { effort: 'medium', summary: 'auto' },
|
||||
text: { verbosity: 'high' },
|
||||
});
|
||||
|
||||
assert.equal(options.model, 'glm-5.1');
|
||||
assert.equal(options.temperature, 0.2);
|
||||
assert.equal(options.max_tokens, 2048);
|
||||
assert.equal(options.reasoning_effort, 'medium');
|
||||
assert.equal(options.reasoning_summary, 'auto');
|
||||
assert.equal(options.verbosity, 'high');
|
||||
assert.deepEqual(options.tools, [{ type: 'function', function: { name: 'lookup', description: 'd', parameters: { type: 'object' } } }]);
|
||||
});
|
||||
|
||||
test('buildUpstreamOptionsFromAnthropic passes through model tools and thinking budget', () => {
|
||||
const options = buildUpstreamOptionsFromAnthropic({
|
||||
model: 'hunyuan-2.0-thinking',
|
||||
tools: [{ name: 'lookup', description: 'd', input_schema: { type: 'object' } }],
|
||||
max_tokens: 1024,
|
||||
thinking: { type: 'enabled', budget_tokens: 2048 },
|
||||
});
|
||||
|
||||
assert.equal(options.model, 'hunyuan-2.0-thinking');
|
||||
assert.equal(options.max_tokens, 1024);
|
||||
assert.equal(options.reasoning_effort, 'high');
|
||||
assert.deepEqual(options.tools, [{ type: 'function', function: { name: 'lookup', description: 'd', parameters: { type: 'object' } } }]);
|
||||
});
|
||||
|
||||
test('loadModels reads config-backed model registry', () => {
|
||||
const models = loadModels();
|
||||
assert.ok(models.some((model) => model.id === 'kimi-k2.6' && model.name === 'Kimi-K2.6' && model.credits_multiplier === 0.59));
|
||||
assert.ok(models.some((model) => model.id === 'hunyuan-2.0-thinking' && model.credits_multiplier === 0.04));
|
||||
});
|
||||
test('parseUpstreamChunk extracts content, reasoning, finish reason, and usage', () => {
|
||||
const events = parseUpstreamChunk({
|
||||
id: 'up_1',
|
||||
model: 'ep-model',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 456,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
content: 'hello',
|
||||
reasoning_content: 'thinking',
|
||||
function_call: { name: 'lookup', arguments: '{"q":"x"}' },
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 30,
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{ type: 'text_delta', text: 'hello' },
|
||||
{ type: 'reasoning_delta', text: 'thinking' },
|
||||
{ type: 'function_call_name', name: 'lookup' },
|
||||
{ type: 'function_call_arguments_delta', text: '{"q":"x"}' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
{
|
||||
type: 'usage',
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseUpstreamChunk extracts tool call deltas', () => {
|
||||
const events = parseUpstreamChunk({
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'fake_lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
}],
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{
|
||||
type: 'tool_call_delta',
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
name: 'fake_lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{ type: 'finish', finishReason: 'tool_calls' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('parseUpstreamChunk ignores empty upstream finish reason placeholders', () => {
|
||||
const events = parseUpstreamChunk({
|
||||
choices: [
|
||||
{
|
||||
delta: { reasoning_content: 'thinking' },
|
||||
finish_reason: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(events, [{ type: 'reasoning_delta', text: 'thinking' }]);
|
||||
});
|
||||
|
||||
test('buildChatCompletionResponse maps canonical state to non-stream OpenAI response', () => {
|
||||
const state = accumulate([
|
||||
{ type: 'reasoning_delta', text: 'thinking' },
|
||||
{ type: 'text_delta', text: 'hello world' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
{ type: 'usage', usage: { prompt_tokens: 2, completion_tokens: 3, total_tokens: 5 } },
|
||||
]);
|
||||
|
||||
const response = buildChatCompletionResponse(state);
|
||||
assert.equal(response.object, 'chat.completion');
|
||||
assert.equal(response.choices[0]?.message?.content, 'hello world');
|
||||
assert.equal(response.choices[0]?.message?.reasoning_content, 'thinking');
|
||||
assert.equal(response.usage?.total_tokens, 5);
|
||||
});
|
||||
|
||||
test('buildChatCompletionResponse maps tool calls to OpenAI message tool_calls', () => {
|
||||
const state = accumulate([
|
||||
{
|
||||
type: 'tool_call_delta',
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
name: 'fake_lookup_weather',
|
||||
arguments: '{"city":"Shanghai","unit":"celsius"}',
|
||||
},
|
||||
{ type: 'finish', finishReason: 'tool_calls' },
|
||||
]);
|
||||
|
||||
const response = buildChatCompletionResponse(state);
|
||||
assert.equal(response.choices[0]?.finish_reason, 'tool_calls');
|
||||
assert.deepEqual(response.choices[0]?.message?.tool_calls, [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'fake_lookup_weather',
|
||||
arguments: '{"city":"Shanghai","unit":"celsius"}',
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
test('collectChatCompletionStreamFrames emits OpenAI SSE chunks and DONE', () => {
|
||||
const frames = collectChatCompletionStreamFrames(
|
||||
{ id: 'resp_1', model: 'minimax-m2.7', created: 123 },
|
||||
[
|
||||
{ type: 'reasoning_delta', text: 'thinking' },
|
||||
{ type: 'text_delta', text: 'hello' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
{ type: 'usage', usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 } },
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(frames.at(-1), 'data: [DONE]');
|
||||
assert.ok(frames.some((frame) => frame.includes('reasoning_content')));
|
||||
assert.ok(frames.some((frame) => frame.includes('"content":"hello"')));
|
||||
});
|
||||
|
||||
test('collectChatCompletionStreamFrames emits tool call deltas', () => {
|
||||
const frames = collectChatCompletionStreamFrames(
|
||||
{ id: 'resp_1', model: 'minimax-m2.7', created: 123 },
|
||||
[
|
||||
{
|
||||
type: 'tool_call_delta',
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
name: 'fake_lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{ type: 'finish', finishReason: 'tool_calls' },
|
||||
],
|
||||
);
|
||||
|
||||
assert.ok(frames.some((frame) => frame.includes('"tool_calls"')));
|
||||
assert.ok(frames.some((frame) => frame.includes('"name":"fake_lookup_weather"')));
|
||||
assert.ok(frames.some((frame) => frame.includes('"finish_reason":"tool_calls"')));
|
||||
assert.equal(frames.at(-1), 'data: [DONE]');
|
||||
});
|
||||
|
||||
test('buildResponsesResponse maps canonical state to OpenAI responses payload', () => {
|
||||
const state = accumulate([
|
||||
{ type: 'reasoning_delta', text: 'thinking' },
|
||||
{ type: 'text_delta', text: 'hello world' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
{ type: 'usage', usage: { prompt_tokens: 4, completion_tokens: 5, total_tokens: 9 } },
|
||||
]);
|
||||
|
||||
const response = buildResponsesResponse(state);
|
||||
const messageItem = response.output[1] as { type?: string; content?: Array<{ text?: string }> };
|
||||
assert.equal(response.object, 'response');
|
||||
assert.equal(response.status, 'completed');
|
||||
assert.equal(response.output[0]?.type, 'reasoning');
|
||||
assert.equal(messageItem.type, 'message');
|
||||
assert.equal(messageItem.content?.[0]?.text, 'hello world');
|
||||
});
|
||||
|
||||
test('buildResponsesResponse maps tool calls to function_call output items', () => {
|
||||
const state = accumulate([
|
||||
{
|
||||
type: 'tool_call_delta',
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
name: 'fake_lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{ type: 'finish', finishReason: 'tool_calls' },
|
||||
]);
|
||||
|
||||
const response = buildResponsesResponse(state);
|
||||
assert.deepEqual(response.output.at(-1), {
|
||||
type: 'function_call',
|
||||
id: (response.output.at(-1) as { id: string }).id,
|
||||
call_id: 'call_1',
|
||||
name: 'fake_lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
status: 'completed',
|
||||
});
|
||||
});
|
||||
|
||||
test('collectResponsesStreamEvents emits sub2api-style response events', () => {
|
||||
const events = collectResponsesStreamEvents(
|
||||
{ id: 'resp_1', model: 'minimax-m2.7', created: 123 },
|
||||
[
|
||||
{ type: 'reasoning_delta', text: 'thinking' },
|
||||
{ type: 'text_delta', text: 'hello' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
{ type: 'usage', usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 } },
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(events[0]?.type, 'response.created');
|
||||
assert.ok(events.some((event) => event.type === 'response.reasoning_summary_text.delta'));
|
||||
assert.ok(events.some((event) => event.type === 'response.output_text.delta'));
|
||||
assert.equal(events.at(-1)?.type, 'response.completed');
|
||||
});
|
||||
|
||||
test('collectResponsesStreamEvents starts text-only output at index zero', () => {
|
||||
const events = collectResponsesStreamEvents(
|
||||
{ id: 'resp_1', model: 'minimax-m2.7', created: 123 },
|
||||
[
|
||||
{ type: 'text_delta', text: 'hello' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
],
|
||||
);
|
||||
|
||||
const added = events.find((event) => event.type === 'response.output_item.added') as { output_index?: number } | undefined;
|
||||
const delta = events.find((event) => event.type === 'response.output_text.delta') as { output_index?: number } | undefined;
|
||||
const done = events.find((event) => event.type === 'response.output_text.done') as { output_index?: number } | undefined;
|
||||
assert.equal(added?.output_index, 0);
|
||||
assert.equal(delta?.output_index, 0);
|
||||
assert.equal(done?.output_index, 0);
|
||||
});
|
||||
|
||||
test('collectResponsesStreamEvents emits function call stream events', () => {
|
||||
const events = collectResponsesStreamEvents(
|
||||
{ id: 'resp_1', model: 'minimax-m2.7', created: 123 },
|
||||
[
|
||||
{
|
||||
type: 'tool_call_delta',
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
name: 'fake_lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{ type: 'finish', finishReason: 'tool_calls' },
|
||||
],
|
||||
);
|
||||
|
||||
assert.ok(events.some((event) => event.type === 'response.output_item.added' && JSON.stringify(event).includes('fake_lookup_weather')));
|
||||
assert.ok(events.some((event) => event.type === 'response.function_call_arguments.delta' && JSON.stringify(event).includes('Shanghai')));
|
||||
assert.ok(events.some((event) => event.type === 'response.output_item.done' && JSON.stringify(event).includes('function_call')));
|
||||
});
|
||||
|
||||
test('collectResponsesStreamEvents starts tool-only output at index zero', () => {
|
||||
const events = collectResponsesStreamEvents(
|
||||
{ id: 'resp_1', model: 'minimax-m2.7', created: 123 },
|
||||
[
|
||||
{
|
||||
type: 'tool_call_delta',
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
name: 'fake_lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{ type: 'finish', finishReason: 'tool_calls' },
|
||||
],
|
||||
);
|
||||
|
||||
const added = events.find((event) => event.type === 'response.output_item.added') as { output_index?: number } | undefined;
|
||||
const delta = events.find((event) => event.type === 'response.function_call_arguments.delta') as { output_index?: number } | undefined;
|
||||
const done = events.find((event) => event.type === 'response.function_call_arguments.done') as { output_index?: number } | undefined;
|
||||
assert.equal(added?.output_index, 0);
|
||||
assert.equal(delta?.output_index, 0);
|
||||
assert.equal(done?.output_index, 0);
|
||||
});
|
||||
|
||||
test('buildAnthropicMessageResponse includes thinking and text blocks', () => {
|
||||
const state = accumulate([
|
||||
{ type: 'reasoning_delta', text: 'thinking' },
|
||||
{ type: 'text_delta', text: 'hello world' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
{ type: 'usage', usage: { prompt_tokens: 7, completion_tokens: 8, total_tokens: 15 } },
|
||||
]);
|
||||
|
||||
const response = buildAnthropicMessageResponse(state);
|
||||
assert.equal(response.type, 'message');
|
||||
assert.equal(response.content[0]?.type, 'thinking');
|
||||
assert.equal(response.content[1]?.type, 'text');
|
||||
assert.equal(response.content[1]?.text, 'hello world');
|
||||
assert.equal(response.stop_reason, 'end_turn');
|
||||
});
|
||||
|
||||
test('buildAnthropicMessageResponse maps tool calls to tool_use blocks', () => {
|
||||
const state = accumulate([
|
||||
{
|
||||
type: 'tool_call_delta',
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
name: 'fake_lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{ type: 'finish', finishReason: 'tool_calls' },
|
||||
]);
|
||||
|
||||
const response = buildAnthropicMessageResponse(state);
|
||||
assert.equal(response.stop_reason, 'tool_use');
|
||||
assert.deepEqual(response.content.at(-1), {
|
||||
type: 'tool_use',
|
||||
id: 'call_1',
|
||||
name: 'fake_lookup_weather',
|
||||
input: { city: 'Shanghai' },
|
||||
});
|
||||
});
|
||||
|
||||
test('collectAnthropicStreamEvents emits thinking and text SSE events', () => {
|
||||
const events = collectAnthropicStreamEvents(
|
||||
{ id: 'resp_1', model: 'minimax-m2.7', created: 123 },
|
||||
[
|
||||
{ type: 'reasoning_delta', text: 'thinking' },
|
||||
{ type: 'text_delta', text: 'hello' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
{ type: 'usage', usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 } },
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(events[0]?.event, 'message_start');
|
||||
assert.ok(events.some((event) => event.event === 'content_block_delta' && JSON.stringify(event.data).includes('thinking_delta')));
|
||||
assert.ok(events.some((event) => event.event === 'content_block_delta' && JSON.stringify(event.data).includes('text_delta')));
|
||||
assert.equal(events.at(-1)?.event, 'message_stop');
|
||||
});
|
||||
|
||||
test('collectAnthropicStreamEvents emits tool_use stream events', () => {
|
||||
const events = collectAnthropicStreamEvents(
|
||||
{ id: 'resp_1', model: 'minimax-m2.7', created: 123 },
|
||||
[
|
||||
{
|
||||
type: 'tool_call_delta',
|
||||
index: 0,
|
||||
id: 'call_1',
|
||||
name: 'fake_lookup_weather',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{ type: 'finish', finishReason: 'tool_calls' },
|
||||
],
|
||||
);
|
||||
|
||||
assert.ok(events.some((event) => event.event === 'content_block_start' && JSON.stringify(event.data).includes('tool_use')));
|
||||
assert.ok(events.some((event) => event.event === 'content_block_delta' && JSON.stringify(event.data).includes('input_json_delta')));
|
||||
assert.ok(events.some((event) => event.event === 'message_delta' && JSON.stringify(event.data).includes('tool_use')));
|
||||
});
|
||||
273
src/direct-request-builders.ts
Normal file
273
src/direct-request-builders.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { contentToText } from './prompt';
|
||||
import type {
|
||||
AnthropicMessagesRequestLike,
|
||||
AnthropicContentBlockLike,
|
||||
DirectBuilderContext,
|
||||
DirectMessageContent,
|
||||
DirectTool,
|
||||
DirectUpstreamMessage,
|
||||
DirectUpstreamOptions,
|
||||
OpenAIChatRequestLike,
|
||||
ResponsesInputMessage,
|
||||
ResponsesRequestLike,
|
||||
} from './direct-types';
|
||||
|
||||
export function buildUpstreamMessagesFromChat(
|
||||
request: OpenAIChatRequestLike,
|
||||
context: DirectBuilderContext,
|
||||
): DirectUpstreamMessage[] {
|
||||
const messages = request.messages ?? [];
|
||||
const normalized = messages.map((message, index) => normalizeConversationMessage(
|
||||
message.role,
|
||||
message.content,
|
||||
index === messages.length - 1,
|
||||
context,
|
||||
{
|
||||
tool_call_id: message.tool_call_id,
|
||||
tool_calls: message.tool_calls,
|
||||
},
|
||||
));
|
||||
const requestSystemPrompt = contentToText(messages.find((message) => message.role === 'system')?.content);
|
||||
|
||||
return prependSystemMessages(normalized, selectSystemPrompts(context, requestSystemPrompt));
|
||||
}
|
||||
|
||||
export function buildUpstreamOptionsFromChat(request: OpenAIChatRequestLike): DirectUpstreamOptions {
|
||||
return {
|
||||
model: request.model,
|
||||
tools: normalizeChatTools(request.tools),
|
||||
temperature: request.temperature,
|
||||
max_tokens: request.max_tokens,
|
||||
reasoning_effort: request.reasoning_effort,
|
||||
verbosity: request.verbosity,
|
||||
reasoning_summary: request.reasoning_summary,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUpstreamMessagesFromResponses(
|
||||
request: ResponsesRequestLike,
|
||||
context: DirectBuilderContext,
|
||||
): DirectUpstreamMessage[] {
|
||||
const input = request.input;
|
||||
const normalizedInputs = typeof input === 'string'
|
||||
? [buildFinalUserMessage(input, context)]
|
||||
: (input ?? [])
|
||||
.flatMap((item, index, list) => normalizeResponsesInputItem(
|
||||
item,
|
||||
index === list.length - 1,
|
||||
context,
|
||||
));
|
||||
|
||||
return prependSystemMessages(normalizedInputs, selectSystemPrompts(context, contentToText(request.instructions)));
|
||||
}
|
||||
|
||||
export function buildUpstreamOptionsFromResponses(request: ResponsesRequestLike): DirectUpstreamOptions {
|
||||
return {
|
||||
model: request.model,
|
||||
tools: normalizeResponsesTools(request.tools),
|
||||
temperature: request.temperature,
|
||||
max_tokens: request.max_output_tokens,
|
||||
reasoning_effort: request.reasoning?.effort,
|
||||
verbosity: request.text?.verbosity,
|
||||
reasoning_summary: request.reasoning?.summary,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUpstreamMessagesFromAnthropic(
|
||||
request: AnthropicMessagesRequestLike,
|
||||
context: DirectBuilderContext,
|
||||
): DirectUpstreamMessage[] {
|
||||
const messages = request.messages ?? [];
|
||||
const normalized = messages.flatMap((message, index) => anthropicContentToUpstream(
|
||||
message.role,
|
||||
message.content,
|
||||
index === messages.length - 1,
|
||||
context,
|
||||
));
|
||||
|
||||
return prependSystemMessages(normalized, selectSystemPrompts(context, contentToText(request.system)));
|
||||
}
|
||||
|
||||
export function buildUpstreamOptionsFromAnthropic(request: AnthropicMessagesRequestLike): DirectUpstreamOptions {
|
||||
return {
|
||||
model: request.model,
|
||||
tools: normalizeAnthropicTools(request.tools),
|
||||
max_tokens: request.max_tokens,
|
||||
reasoning_effort: request.thinking?.type === 'enabled' ? 'high' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function selectSystemPrompts(context: DirectBuilderContext, requestSystemPrompt: string): string[] {
|
||||
const original = context.systemPrompt.trim();
|
||||
const passthrough = requestSystemPrompt.trim();
|
||||
if (context.systemPromptMode === 'passthrough') return passthrough ? [passthrough] : [];
|
||||
if (context.systemPromptMode === 'hybrid') return [original, passthrough].filter(Boolean);
|
||||
return original ? [original] : [];
|
||||
}
|
||||
|
||||
function prependSystemMessages(messages: DirectUpstreamMessage[], systemPrompts: string[]): DirectUpstreamMessage[] {
|
||||
const nonSystemMessages = messages.filter((message) => message.role !== 'system');
|
||||
return [
|
||||
...systemPrompts.map((content) => ({ role: 'system', content }) as DirectUpstreamMessage),
|
||||
...nonSystemMessages,
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeConversationMessage(
|
||||
role: string | undefined,
|
||||
content: unknown,
|
||||
isFinalMessage: boolean,
|
||||
context: DirectBuilderContext,
|
||||
extra: Pick<DirectUpstreamMessage, 'tool_call_id' | 'tool_calls'> = {},
|
||||
): DirectUpstreamMessage {
|
||||
const normalizedRole = role === 'assistant' ? 'assistant' : role === 'system' ? 'system' : role === 'tool' ? 'tool' : 'user';
|
||||
const text = contentToText(content).trim();
|
||||
|
||||
if (normalizedRole === 'user' && isFinalMessage) {
|
||||
return buildFinalUserMessage(text, context);
|
||||
}
|
||||
|
||||
return {
|
||||
role: normalizedRole,
|
||||
content: text,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResponsesInputItem(
|
||||
item: ResponsesInputMessage,
|
||||
isFinalMessage: boolean,
|
||||
context: DirectBuilderContext,
|
||||
): DirectUpstreamMessage[] {
|
||||
if (item.type === 'function_call') {
|
||||
return [{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
tool_calls: [{
|
||||
id: item.call_id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: item.name ?? '',
|
||||
arguments: item.arguments ?? '{}',
|
||||
},
|
||||
}],
|
||||
}];
|
||||
}
|
||||
if (item.type === 'function_call_output') {
|
||||
return [{
|
||||
role: 'tool',
|
||||
content: item.output ?? '',
|
||||
tool_call_id: item.call_id,
|
||||
}];
|
||||
}
|
||||
if (item.type && item.type !== 'message') return [];
|
||||
return [normalizeConversationMessage(
|
||||
item.role,
|
||||
item.content,
|
||||
isFinalMessage,
|
||||
context,
|
||||
)];
|
||||
}
|
||||
|
||||
function buildFinalUserMessage(text: string, context: DirectBuilderContext): DirectUpstreamMessage {
|
||||
const content: DirectMessageContent = [
|
||||
...context.cliUserContextBlocks,
|
||||
{ type: 'text', text: `<user_query>${text}</user_query>` },
|
||||
];
|
||||
|
||||
return {
|
||||
role: 'user',
|
||||
content,
|
||||
agent: 'cli',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeChatTools(tools: OpenAIChatRequestLike['tools']): DirectTool[] | undefined {
|
||||
const normalized = (tools ?? [])
|
||||
.filter((tool): tool is NonNullable<OpenAIChatRequestLike['tools']>[number] => tool?.type === 'function' && Boolean(tool.function?.name))
|
||||
.map((tool) => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.function!.name!,
|
||||
description: tool.function?.description,
|
||||
parameters: tool.function?.parameters,
|
||||
},
|
||||
}));
|
||||
return normalized.length ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeResponsesTools(tools: ResponsesRequestLike['tools']): DirectTool[] | undefined {
|
||||
const normalized = (tools ?? [])
|
||||
.filter((tool): tool is NonNullable<ResponsesRequestLike['tools']>[number] => (tool.type === 'function' || !tool.type) && typeof tool.name === 'string' && tool.name.length > 0)
|
||||
.map((tool) => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.name!,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
},
|
||||
}));
|
||||
return normalized.length ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeAnthropicTools(tools: AnthropicMessagesRequestLike['tools']): DirectTool[] | undefined {
|
||||
const normalized = (tools ?? [])
|
||||
.filter((tool): tool is NonNullable<AnthropicMessagesRequestLike['tools']>[number] => typeof tool.name === 'string' && tool.name.length > 0)
|
||||
.map((tool) => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: tool.name!,
|
||||
description: tool.description,
|
||||
parameters: tool.input_schema,
|
||||
},
|
||||
}));
|
||||
return normalized.length ? normalized : undefined;
|
||||
}
|
||||
|
||||
function anthropicContentToUpstream(
|
||||
role: string | undefined,
|
||||
content: unknown,
|
||||
isFinalMessage: boolean,
|
||||
context: DirectBuilderContext,
|
||||
): DirectUpstreamMessage[] {
|
||||
if (!Array.isArray(content)) {
|
||||
return [normalizeConversationMessage(role, content, isFinalMessage, context)];
|
||||
}
|
||||
|
||||
const text = contentToText(content).trim();
|
||||
const messages: DirectUpstreamMessage[] = [];
|
||||
const toolCalls = content
|
||||
.filter((block): block is AnthropicContentBlockLike => Boolean(block) && typeof block === 'object' && (block as AnthropicContentBlockLike).type === 'tool_use')
|
||||
.map((block) => ({
|
||||
id: block.id,
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: block.name ?? '',
|
||||
arguments: JSON.stringify(block.input ?? {}),
|
||||
},
|
||||
}));
|
||||
|
||||
if (role === 'assistant') {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: text,
|
||||
tool_calls: toolCalls.length ? toolCalls : undefined,
|
||||
});
|
||||
return messages;
|
||||
}
|
||||
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== 'object' || (block as AnthropicContentBlockLike).type !== 'tool_result') continue;
|
||||
const result = block as AnthropicContentBlockLike;
|
||||
messages.push({
|
||||
role: 'tool',
|
||||
content: contentToText(result.content),
|
||||
tool_call_id: result.tool_use_id,
|
||||
});
|
||||
}
|
||||
|
||||
if (text || !messages.length) {
|
||||
messages.push(normalizeConversationMessage(role, text, isFinalMessage, context));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
259
src/direct-server.ts
Normal file
259
src/direct-server.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import {
|
||||
AnthropicStreamEncoder,
|
||||
buildAnthropicMessageResponse,
|
||||
} from './direct-adapter-messages';
|
||||
import {
|
||||
buildChatCompletionResponse,
|
||||
chatCompletionStreamFramesForEvent,
|
||||
} from './direct-adapter-chat';
|
||||
import {
|
||||
buildResponsesResponse,
|
||||
ResponsesStreamEncoder,
|
||||
} from './direct-adapter-responses';
|
||||
import {
|
||||
applyCanonicalEvent,
|
||||
createCanonicalState,
|
||||
type CanonicalEvent,
|
||||
} from './direct-canonical';
|
||||
import {
|
||||
buildUpstreamMessagesFromAnthropic,
|
||||
buildUpstreamMessagesFromChat,
|
||||
buildUpstreamMessagesFromResponses,
|
||||
buildUpstreamOptionsFromAnthropic,
|
||||
buildUpstreamOptionsFromChat,
|
||||
buildUpstreamOptionsFromResponses,
|
||||
} from './direct-request-builders';
|
||||
import { loadCliUserContextBlocks, loadModels, loadSystemPrompt, loadSystemPromptMode } from './direct-config';
|
||||
import { streamDirectCanonicalEvents } from './direct-upstream';
|
||||
import type {
|
||||
AnthropicMessagesRequestLike,
|
||||
DirectUpstreamOptions,
|
||||
OpenAIChatRequestLike,
|
||||
ResponsesRequestLike,
|
||||
} from './direct-types';
|
||||
|
||||
const port = Number(process.env.DIRECT_PORT ?? 3101);
|
||||
const proxyApiKey = process.env.PROXY_API_KEY?.trim();
|
||||
const systemPrompt = loadSystemPrompt();
|
||||
const systemPromptMode = loadSystemPromptMode();
|
||||
const cliUserContextBlocks = loadCliUserContextBlocks();
|
||||
const models = loadModels();
|
||||
const maxRequestBytes = Number(process.env.DIRECT_MAX_REQUEST_BYTES ?? 1024 * 1024);
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
|
||||
|
||||
try {
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
return writeJson(res, 200, { ok: true, mode: 'direct' });
|
||||
}
|
||||
|
||||
if (!authorize(req)) {
|
||||
writeEndpointError(res, url.pathname, 401, 'Unauthorized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/v1/models') {
|
||||
return writeJson(res, 200, {
|
||||
object: 'list',
|
||||
data: models.map((model) => ({
|
||||
id: model.id,
|
||||
object: 'model',
|
||||
owned_by: 'codebuddy-direct',
|
||||
name: model.name,
|
||||
credits_multiplier: model.credits_multiplier,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/v1/chat/completions') {
|
||||
await handleChat(req, res);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/v1/responses') {
|
||||
await handleResponses(req, res);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/v1/messages') {
|
||||
await handleMessages(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(res, 404, { error: { message: 'Not found' } });
|
||||
} catch (error) {
|
||||
if (!res.headersSent) {
|
||||
if (error instanceof RequestBodyError) {
|
||||
writeEndpointError(res, url.pathname, error.statusCode, error.message);
|
||||
return;
|
||||
}
|
||||
writeJson(res, 502, { error: { message: 'Upstream request failed' } });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(`codebuddy direct service listening on http://127.0.0.1:${port}`);
|
||||
});
|
||||
|
||||
async function handleChat(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const body = await readJson<OpenAIChatRequestLike>(req);
|
||||
const metadata = { id: `chatcmpl_${Date.now()}`, model: body.model ?? (process.env.CODEBUDDY_MODEL ?? 'minimax-m2.7'), created: Math.floor(Date.now() / 1000) };
|
||||
const upstreamMessages = buildUpstreamMessagesFromChat(body, { systemPrompt, systemPromptMode, cliUserContextBlocks });
|
||||
const upstreamOptions = buildUpstreamOptionsFromChat(body);
|
||||
|
||||
if (body.stream) {
|
||||
writeSseHeaders(res);
|
||||
for await (const event of streamDirectCanonicalEvents(upstreamMessages, upstreamOptions)) {
|
||||
for (const frame of chatCompletionStreamFramesForEvent(metadata, event)) {
|
||||
res.write(`${frame}\n\n`);
|
||||
}
|
||||
}
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const events = await collectEvents(upstreamMessages, upstreamOptions);
|
||||
const state = accumulate(metadata, events);
|
||||
writeJson(res, 200, buildChatCompletionResponse(state));
|
||||
}
|
||||
|
||||
async function handleResponses(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const body = await readJson<ResponsesRequestLike>(req);
|
||||
const metadata = { id: `resp_${Date.now()}`, model: body.model ?? (process.env.CODEBUDDY_MODEL ?? 'minimax-m2.7'), created: Math.floor(Date.now() / 1000) };
|
||||
const upstreamMessages = buildUpstreamMessagesFromResponses(body, { systemPrompt, systemPromptMode, cliUserContextBlocks });
|
||||
const upstreamOptions = buildUpstreamOptionsFromResponses(body);
|
||||
|
||||
if (body.stream) {
|
||||
writeSseHeaders(res);
|
||||
const encoder = new ResponsesStreamEncoder(metadata);
|
||||
for (const event of encoder.start()) {
|
||||
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
for await (const upstreamEvent of streamDirectCanonicalEvents(upstreamMessages, upstreamOptions)) {
|
||||
for (const event of encoder.push(upstreamEvent)) {
|
||||
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
}
|
||||
for (const event of encoder.finish()) {
|
||||
res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const events = await collectEvents(upstreamMessages, upstreamOptions);
|
||||
const state = accumulate(metadata, events);
|
||||
writeJson(res, 200, buildResponsesResponse(state));
|
||||
}
|
||||
|
||||
async function handleMessages(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const body = await readJson<AnthropicMessagesRequestLike>(req);
|
||||
const metadata = { id: `msg_${Date.now()}`, model: body.model ?? (process.env.CODEBUDDY_MODEL ?? 'minimax-m2.7'), created: Math.floor(Date.now() / 1000) };
|
||||
const upstreamMessages = buildUpstreamMessagesFromAnthropic(body, { systemPrompt, systemPromptMode, cliUserContextBlocks });
|
||||
const upstreamOptions = buildUpstreamOptionsFromAnthropic(body);
|
||||
|
||||
if (body.stream) {
|
||||
writeSseHeaders(res);
|
||||
const encoder = new AnthropicStreamEncoder(metadata);
|
||||
for (const event of encoder.start()) {
|
||||
res.write(`event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`);
|
||||
}
|
||||
for await (const upstreamEvent of streamDirectCanonicalEvents(upstreamMessages, upstreamOptions)) {
|
||||
for (const event of encoder.push(upstreamEvent)) {
|
||||
res.write(`event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`);
|
||||
}
|
||||
}
|
||||
for (const event of encoder.finish()) {
|
||||
res.write(`event: ${event.event}\ndata: ${JSON.stringify(event.data)}\n\n`);
|
||||
}
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const events = await collectEvents(upstreamMessages, upstreamOptions);
|
||||
const state = accumulate(metadata, events);
|
||||
writeJson(res, 200, buildAnthropicMessageResponse(state));
|
||||
}
|
||||
|
||||
async function collectEvents(
|
||||
upstreamMessages: ReturnType<typeof buildUpstreamMessagesFromChat>,
|
||||
upstreamOptions: DirectUpstreamOptions,
|
||||
): Promise<CanonicalEvent[]> {
|
||||
const events: CanonicalEvent[] = [];
|
||||
for await (const event of streamDirectCanonicalEvents(upstreamMessages, upstreamOptions)) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function accumulate(metadata: { id: string; model: string; created: number }, events: CanonicalEvent[]) {
|
||||
const state = createCanonicalState(metadata);
|
||||
for (const event of events) {
|
||||
applyCanonicalEvent(state, event);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function authorize(req: IncomingMessage): boolean {
|
||||
if (!proxyApiKey) return true;
|
||||
return req.headers.authorization === `Bearer ${proxyApiKey}`;
|
||||
}
|
||||
|
||||
async function readJson<T>(req: IncomingMessage): Promise<T> {
|
||||
const contentLengthHeader = req.headers['content-length'];
|
||||
const contentLength = typeof contentLengthHeader === 'string' ? Number(contentLengthHeader) : NaN;
|
||||
if (Number.isFinite(contentLength) && contentLength > maxRequestBytes) {
|
||||
throw new RequestBodyError(413, 'Request body too large');
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
for await (const chunk of req) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
totalBytes += buffer.length;
|
||||
if (totalBytes > maxRequestBytes) {
|
||||
throw new RequestBodyError(413, 'Request body too large');
|
||||
}
|
||||
chunks.push(buffer);
|
||||
}
|
||||
if (!chunks.length) return {} as T;
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as T;
|
||||
} catch {
|
||||
throw new RequestBodyError(400, 'Invalid JSON body');
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(res: ServerResponse, statusCode: number, value: unknown): void {
|
||||
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function writeSseHeaders(res: ServerResponse): void {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
'cache-control': 'no-cache, no-transform',
|
||||
connection: 'keep-alive',
|
||||
});
|
||||
}
|
||||
|
||||
function writeEndpointError(res: ServerResponse, endpoint: string, statusCode: number, message: string): void {
|
||||
if (endpoint === '/v1/messages') {
|
||||
writeJson(res, statusCode, { type: 'error', error: { type: 'invalid_request_error', message } });
|
||||
return;
|
||||
}
|
||||
writeJson(res, statusCode, { error: { message } });
|
||||
}
|
||||
|
||||
class RequestBodyError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
229
src/direct-types.ts
Normal file
229
src/direct-types.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
export type DirectTextBlock = {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type DirectMessageContent = string | DirectTextBlock[];
|
||||
|
||||
export type DirectUpstreamToolCall = {
|
||||
id?: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DirectUpstreamMessage = {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: DirectMessageContent;
|
||||
agent?: 'cli';
|
||||
tool_call_id?: string;
|
||||
tool_calls?: DirectUpstreamToolCall[];
|
||||
};
|
||||
|
||||
export type DirectTool = {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type DirectUsage = {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
cache_read_input_tokens?: number;
|
||||
cache_creation_input_tokens?: number;
|
||||
};
|
||||
|
||||
export type CanonicalToolCall = {
|
||||
index: number;
|
||||
id?: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type CanonicalEvent =
|
||||
| { type: 'text_delta'; text: string }
|
||||
| { type: 'reasoning_delta'; text: string }
|
||||
| { type: 'function_call_name'; name: string }
|
||||
| { type: 'function_call_arguments_delta'; text: string }
|
||||
| { type: 'tool_call_delta'; index: number; id?: string; name?: string; arguments?: string }
|
||||
| { type: 'finish'; finishReason: string | null }
|
||||
| { type: 'usage'; usage: DirectUsage };
|
||||
|
||||
export type CanonicalState = {
|
||||
id: string;
|
||||
model: string;
|
||||
created: number;
|
||||
text: string;
|
||||
reasoning: string;
|
||||
functionCallName: string;
|
||||
functionCallArguments: string;
|
||||
toolCalls: CanonicalToolCall[];
|
||||
finishReason: string | null;
|
||||
usage: DirectUsage | null;
|
||||
};
|
||||
|
||||
export type CanonicalMetadata = Pick<CanonicalState, 'id' | 'model' | 'created'>;
|
||||
|
||||
export type DirectBuilderContext = {
|
||||
systemPrompt: string;
|
||||
systemPromptMode: 'original' | 'passthrough' | 'hybrid';
|
||||
cliUserContextBlocks: DirectTextBlock[];
|
||||
};
|
||||
|
||||
export type DirectUpstreamOptions = {
|
||||
model?: string;
|
||||
tools?: DirectTool[];
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
reasoning_effort?: string;
|
||||
verbosity?: string;
|
||||
reasoning_summary?: string;
|
||||
};
|
||||
|
||||
export type OpenAIChatInputMessage = {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: DirectUpstreamToolCall[];
|
||||
};
|
||||
|
||||
export type OpenAIChatToolLike = {
|
||||
type?: string;
|
||||
function?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
parameters?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type OpenAIChatRequestLike = {
|
||||
model?: string;
|
||||
messages?: OpenAIChatInputMessage[];
|
||||
tools?: OpenAIChatToolLike[];
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
reasoning_effort?: string;
|
||||
verbosity?: string;
|
||||
reasoning_summary?: string;
|
||||
stream?: boolean;
|
||||
};
|
||||
|
||||
export type ResponsesInputTextPart = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
export type ResponsesInputMessage = {
|
||||
type?: string;
|
||||
role?: string;
|
||||
content?: string | ResponsesInputTextPart[];
|
||||
call_id?: string;
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
output?: string;
|
||||
};
|
||||
|
||||
export type ResponsesToolLike = {
|
||||
type?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
parameters?: unknown;
|
||||
};
|
||||
|
||||
export type ResponsesRequestLike = {
|
||||
model?: string;
|
||||
instructions?: unknown;
|
||||
input?: string | ResponsesInputMessage[];
|
||||
tools?: ResponsesToolLike[];
|
||||
temperature?: number;
|
||||
max_output_tokens?: number;
|
||||
reasoning?: {
|
||||
effort?: string;
|
||||
summary?: string;
|
||||
};
|
||||
text?: {
|
||||
verbosity?: string;
|
||||
};
|
||||
stream?: boolean;
|
||||
};
|
||||
|
||||
export type AnthropicContentBlockLike = {
|
||||
type?: string;
|
||||
text?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: unknown;
|
||||
tool_use_id?: string;
|
||||
content?: unknown;
|
||||
};
|
||||
|
||||
export type AnthropicMessageLike = {
|
||||
role?: string;
|
||||
content?: string | AnthropicContentBlockLike[];
|
||||
};
|
||||
|
||||
export type AnthropicToolLike = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
input_schema?: unknown;
|
||||
};
|
||||
|
||||
export type AnthropicMessagesRequestLike = {
|
||||
model?: string;
|
||||
system?: unknown;
|
||||
messages?: AnthropicMessageLike[];
|
||||
tools?: AnthropicToolLike[];
|
||||
max_tokens?: number;
|
||||
thinking?: {
|
||||
type?: string;
|
||||
budget_tokens?: number;
|
||||
};
|
||||
stream?: boolean;
|
||||
};
|
||||
|
||||
export type DirectModelConfig = {
|
||||
id: string;
|
||||
name: string;
|
||||
credits_multiplier: number;
|
||||
};
|
||||
|
||||
export type UpstreamChunkChoice = {
|
||||
index?: number;
|
||||
delta?: {
|
||||
role?: string;
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
function_call?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
} | null;
|
||||
tool_calls?: Array<{
|
||||
index?: number;
|
||||
id?: string;
|
||||
type?: string;
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
finish_reason?: string | null;
|
||||
};
|
||||
|
||||
export type UpstreamChunk = {
|
||||
id?: string;
|
||||
model?: string;
|
||||
object?: string;
|
||||
created?: number;
|
||||
choices?: UpstreamChunkChoice[];
|
||||
usage?: DirectUsage | null;
|
||||
};
|
||||
94
src/direct-upstream.ts
Normal file
94
src/direct-upstream.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import { loadApiKey, loadCliUserContextBlocks, loadSystemPrompt } from './direct-config';
|
||||
import type { DirectUpstreamMessage, DirectUpstreamOptions, UpstreamChunk } from './direct-types';
|
||||
import { parseUpstreamChunk, type CanonicalEvent } from './direct-canonical';
|
||||
|
||||
const endpoint = process.env.CODEBUDDY_DIRECT_ENDPOINT ?? 'https://copilot.tencent.com/v2/chat/completions';
|
||||
const defaultModel = process.env.CODEBUDDY_MODEL ?? 'minimax-m2.7';
|
||||
|
||||
export { loadApiKey, loadCliUserContextBlocks, loadSystemPrompt } from './direct-config';
|
||||
|
||||
export function buildDirectHeaders(apiKey = loadApiKey()): Record<string, string> {
|
||||
const traceId = randomBytes(16).toString('hex');
|
||||
const spanId = randomBytes(8).toString('hex');
|
||||
return {
|
||||
'content-type': 'application/json',
|
||||
'content-encoding': 'gzip',
|
||||
accept: 'application/json',
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
'x-api-key': apiKey,
|
||||
'x-conversation-id': randomUUID(),
|
||||
'x-conversation-request-id': randomBytes(16).toString('hex'),
|
||||
'x-conversation-message-id': randomBytes(16).toString('hex'),
|
||||
'x-agent-intent': 'craft',
|
||||
'x-ide-type': 'CLI',
|
||||
'x-ide-name': 'CLI',
|
||||
'x-ide-version': '2.93.3',
|
||||
'user-agent': process.env.CODEBUDDY_USER_AGENT ?? 'CLI/2.93.3 CodeBuddy/2.93.3 CodeBuddy Agent SDK/0.3.28 (Node.js/25.2.1) CodeBuddy Code/2.93.3',
|
||||
'x-trace-id': traceId,
|
||||
'x-request-id': traceId,
|
||||
b3: `${traceId}-${spanId}-1-`,
|
||||
'x-b3-traceid': traceId,
|
||||
'x-b3-parentspanid': '',
|
||||
'x-b3-spanid': spanId,
|
||||
'x-b3-sampled': '1',
|
||||
'x-codebuddy-request': '1',
|
||||
'x-user-id': `anonymous_${apiKey.slice(-8)}`,
|
||||
'x-product': 'SaaS',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDirectRequestBody(messages: DirectUpstreamMessage[], options: DirectUpstreamOptions = {}) {
|
||||
return gzipSync(JSON.stringify({
|
||||
model: options.model ?? defaultModel,
|
||||
messages,
|
||||
tools: options.tools,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
temperature: options.temperature ?? Number(process.env.CODEBUDDY_TEMPERATURE ?? 1),
|
||||
max_tokens: options.max_tokens ?? Number(process.env.CODEBUDDY_MAX_TOKENS ?? 48000),
|
||||
reasoning_effort: options.reasoning_effort ?? process.env.CODEBUDDY_REASONING_EFFORT ?? 'medium',
|
||||
verbosity: options.verbosity ?? process.env.CODEBUDDY_VERBOSITY ?? 'high',
|
||||
reasoning_summary: options.reasoning_summary ?? process.env.CODEBUDDY_REASONING_SUMMARY ?? 'auto',
|
||||
}));
|
||||
}
|
||||
|
||||
export async function* streamDirectCanonicalEvents(
|
||||
messages: DirectUpstreamMessage[],
|
||||
options: DirectUpstreamOptions = {},
|
||||
): AsyncGenerator<CanonicalEvent> {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: buildDirectHeaders(),
|
||||
body: buildDirectRequestBody(messages, options),
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
for await (const chunk of response.body) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const data = line.slice(5).trim();
|
||||
if (!data || data === '[DONE]') continue;
|
||||
try {
|
||||
const parsed = JSON.parse(data) as UpstreamChunk;
|
||||
for (const event of parseUpstreamChunk(parsed)) {
|
||||
yield event;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user