From a57c23842eed738c10382194f88f7384ba8f7c6a Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Fri, 26 Sep 2025 13:08:57 +0800 Subject: [PATCH 01/13] feature gpt-5-codex responses api --- src/routes/messages/handler.ts | 138 +++- .../messages/responses-stream-translation.ts | 664 ++++++++++++++++++ src/routes/messages/responses-translation.ts | 638 +++++++++++++++++ src/routes/responses/handler.ts | 78 ++ src/routes/responses/route.ts | 15 + src/routes/responses/utils.ts | 71 ++ src/server.ts | 3 + src/services/copilot/create-responses.ts | 212 ++++++ src/services/copilot/get-models.ts | 4 + tests/responses-stream-translation.test.ts | 137 ++++ tests/translation.test.ts | 159 +++++ 11 files changed, 2115 insertions(+), 4 deletions(-) create mode 100644 src/routes/messages/responses-stream-translation.ts create mode 100644 src/routes/messages/responses-translation.ts create mode 100644 src/routes/responses/handler.ts create mode 100644 src/routes/responses/route.ts create mode 100644 src/routes/responses/utils.ts create mode 100644 src/services/copilot/create-responses.ts create mode 100644 tests/responses-stream-translation.test.ts create mode 100644 tests/translation.test.ts diff --git a/src/routes/messages/handler.ts b/src/routes/messages/handler.ts index 85dbf624..10b97c53 100644 --- a/src/routes/messages/handler.ts +++ b/src/routes/messages/handler.ts @@ -6,11 +6,24 @@ import { streamSSE } from "hono/streaming" import { awaitApproval } from "~/lib/approval" import { checkRateLimit } from "~/lib/rate-limit" import { state } from "~/lib/state" +import { + createResponsesStreamState, + translateResponsesStreamEvent, +} from "~/routes/messages/responses-stream-translation" +import { + translateAnthropicMessagesToResponsesPayload, + translateResponsesResultToAnthropic, +} from "~/routes/messages/responses-translation" +import { getResponsesRequestOptions } from "~/routes/responses/utils" import { createChatCompletions, type ChatCompletionChunk, type ChatCompletionResponse, } from "~/services/copilot/create-chat-completions" +import { + createResponses, + type ResponsesResult, +} from "~/services/copilot/create-responses" import { type AnthropicMessagesPayload, @@ -28,16 +41,31 @@ export async function handleCompletion(c: Context) { const anthropicPayload = await c.req.json() consola.debug("Anthropic request payload:", JSON.stringify(anthropicPayload)) + const useResponsesApi = shouldUseResponsesApi(anthropicPayload.model) + + if (state.manualApprove) { + await awaitApproval() + } + + if (useResponsesApi) { + return await handleWithResponsesApi(c, anthropicPayload) + } + + return await handleWithChatCompletions(c, anthropicPayload) +} + +const RESPONSES_ENDPOINT = "/responses" + +const handleWithChatCompletions = async ( + c: Context, + anthropicPayload: AnthropicMessagesPayload, +) => { const openAIPayload = translateToOpenAI(anthropicPayload) consola.debug( "Translated OpenAI request payload:", JSON.stringify(openAIPayload), ) - if (state.manualApprove) { - await awaitApproval() - } - const response = await createChatCompletions(openAIPayload) if (isNonStreaming(response)) { @@ -86,6 +114,108 @@ export async function handleCompletion(c: Context) { }) } +const handleWithResponsesApi = async ( + c: Context, + anthropicPayload: AnthropicMessagesPayload, +) => { + const responsesPayload = + translateAnthropicMessagesToResponsesPayload(anthropicPayload) + consola.debug( + "Translated Responses payload:", + JSON.stringify(responsesPayload), + ) + + const { vision, initiator } = getResponsesRequestOptions(responsesPayload) + const response = await createResponses(responsesPayload, { + vision, + initiator, + }) + + if (responsesPayload.stream && isAsyncIterable(response)) { + consola.debug("Streaming response from Copilot (Responses API)") + return streamSSE(c, async (stream) => { + const streamState = createResponsesStreamState() + + for await (const chunk of response) { + consola.debug("Responses raw stream event:", JSON.stringify(chunk)) + + const eventName = (chunk as { event?: string }).event + if (eventName === "ping") { + await stream.writeSSE({ event: "ping", data: "" }) + continue + } + + const data = (chunk as { data?: string }).data + if (!data) { + continue + } + + if (data === "[DONE]") { + break + } + + const parsed = safeJsonParse(data) + if (!parsed) { + continue + } + + const events = translateResponsesStreamEvent(parsed, streamState) + for (const event of events) { + consola.debug("Translated Anthropic event:", JSON.stringify(event)) + await stream.writeSSE({ + event: event.type, + data: JSON.stringify(event), + }) + } + } + + if (!streamState.messageCompleted) { + consola.warn( + "Responses stream ended without completion; sending fallback message_stop", + ) + const fallback = { type: "message_stop" as const } + await stream.writeSSE({ + event: fallback.type, + data: JSON.stringify(fallback), + }) + } + }) + } + + consola.debug( + "Non-streaming Responses result:", + JSON.stringify(response).slice(-400), + ) + const anthropicResponse = translateResponsesResultToAnthropic( + response as ResponsesResult, + ) + consola.debug( + "Translated Anthropic response:", + JSON.stringify(anthropicResponse), + ) + return c.json(anthropicResponse) +} + +const shouldUseResponsesApi = (modelId: string): boolean => { + const selectedModel = state.models?.data.find((model) => model.id === modelId) + return ( + selectedModel?.supported_endpoints?.includes(RESPONSES_ENDPOINT) ?? false + ) +} + const isNonStreaming = ( response: Awaited>, ): response is ChatCompletionResponse => Object.hasOwn(response, "choices") + +const isAsyncIterable = (value: unknown): value is AsyncIterable => + Boolean(value) + && typeof (value as AsyncIterable)[Symbol.asyncIterator] === "function" + +const safeJsonParse = (value: string): Record | undefined => { + try { + return JSON.parse(value) as Record + } catch (error) { + consola.warn("Failed to parse Responses stream chunk:", value, error) + return undefined + } +} diff --git a/src/routes/messages/responses-stream-translation.ts b/src/routes/messages/responses-stream-translation.ts new file mode 100644 index 00000000..06feab1a --- /dev/null +++ b/src/routes/messages/responses-stream-translation.ts @@ -0,0 +1,664 @@ +import { type ResponsesResult } from "~/services/copilot/create-responses" + +import { type AnthropicStreamEventData } from "./anthropic-types" +import { translateResponsesResultToAnthropic } from "./responses-translation" + +export interface ResponsesStreamState { + messageStartSent: boolean + messageCompleted: boolean + nextContentBlockIndex: number + blockIndexByKey: Map + openBlocks: Set + blockHasDelta: Set + currentResponseId?: string + currentModel?: string + initialInputTokens?: number + functionCallStateByOutputIndex: Map + functionCallOutputIndexByItemId: Map +} + +type FunctionCallStreamState = { + blockIndex: number + toolCallId: string + name: string +} + +export const createResponsesStreamState = (): ResponsesStreamState => ({ + messageStartSent: false, + messageCompleted: false, + nextContentBlockIndex: 0, + blockIndexByKey: new Map(), + openBlocks: new Set(), + blockHasDelta: new Set(), + functionCallStateByOutputIndex: new Map(), + functionCallOutputIndexByItemId: new Map(), +}) + +export const translateResponsesStreamEvent = ( + rawEvent: Record, + state: ResponsesStreamState, +): Array => { + const eventType = + typeof rawEvent.type === "string" ? rawEvent.type : undefined + if (!eventType) { + return [] + } + + switch (eventType) { + case "response.created": { + return handleResponseCreated(rawEvent, state) + } + + case "response.reasoning_summary_text.delta": + case "response.output_text.delta": { + return handleOutputTextDelta(rawEvent, state) + } + + case "response.reasoning_summary_part.done": + case "response.output_text.done": { + return handleOutputTextDone(rawEvent, state) + } + + case "response.output_item.added": { + return handleOutputItemAdded(rawEvent, state) + } + + case "response.function_call_arguments.delta": { + return handleFunctionCallArgumentsDelta(rawEvent, state) + } + + case "response.function_call_arguments.done": { + return handleFunctionCallArgumentsDone(rawEvent, state) + } + + case "response.completed": + case "response.incomplete": { + return handleResponseCompleted(rawEvent, state) + } + + case "response.failed": { + return handleResponseFailed(rawEvent, state) + } + + case "error": { + return handleErrorEvent(rawEvent, state) + } + + default: { + return [] + } + } +} + +// Helper handlers to keep translateResponsesStreamEvent concise +const handleResponseCreated = ( + rawEvent: Record, + state: ResponsesStreamState, +): Array => { + const response = toResponsesResult(rawEvent.response) + if (response) { + cacheResponseMetadata(state, response) + } + return ensureMessageStart(state, response) +} + +const handleOutputItemAdded = ( + rawEvent: Record, + state: ResponsesStreamState, +): Array => { + const response = toResponsesResult(rawEvent.response) + const events = ensureMessageStart(state, response) + + const functionCallDetails = extractFunctionCallDetails(rawEvent, state) + if (!functionCallDetails) { + return events + } + + const { outputIndex, toolCallId, name, initialArguments, itemId } = + functionCallDetails + + if (itemId) { + state.functionCallOutputIndexByItemId.set(itemId, outputIndex) + } + + const blockIndex = openFunctionCallBlock(state, { + outputIndex, + toolCallId, + name, + events, + }) + + if (initialArguments !== undefined && initialArguments.length > 0) { + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "input_json_delta", + partial_json: initialArguments, + }, + }) + state.blockHasDelta.add(blockIndex) + } + + return events +} + +const handleFunctionCallArgumentsDelta = ( + rawEvent: Record, + state: ResponsesStreamState, +): Array => { + const events = ensureMessageStart(state) + + const outputIndex = resolveFunctionCallOutputIndex(state, rawEvent) + if (outputIndex === undefined) { + return events + } + + const deltaText = typeof rawEvent.delta === "string" ? rawEvent.delta : "" + if (!deltaText) { + return events + } + + const blockIndex = openFunctionCallBlock(state, { + outputIndex, + events, + }) + + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "input_json_delta", + partial_json: deltaText, + }, + }) + state.blockHasDelta.add(blockIndex) + + return events +} + +const handleFunctionCallArgumentsDone = ( + rawEvent: Record, + state: ResponsesStreamState, +): Array => { + const events = ensureMessageStart(state) + + const outputIndex = resolveFunctionCallOutputIndex(state, rawEvent) + if (outputIndex === undefined) { + return events + } + + const blockIndex = openFunctionCallBlock(state, { + outputIndex, + events, + }) + + const finalArguments = + typeof rawEvent.arguments === "string" ? rawEvent.arguments : undefined + + if (!state.blockHasDelta.has(blockIndex) && finalArguments) { + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "input_json_delta", + partial_json: finalArguments, + }, + }) + state.blockHasDelta.add(blockIndex) + } + + closeBlockIfOpen(state, blockIndex, events) + + const existingState = state.functionCallStateByOutputIndex.get(outputIndex) + if (existingState) { + state.functionCallOutputIndexByItemId.delete(existingState.toolCallId) + } + state.functionCallStateByOutputIndex.delete(outputIndex) + + const itemId = toNonEmptyString(rawEvent.item_id) + if (itemId) { + state.functionCallOutputIndexByItemId.delete(itemId) + } + + return events +} + +const handleOutputTextDelta = ( + rawEvent: Record, + state: ResponsesStreamState, +): Array => { + const events = ensureMessageStart(state) + + const outputIndex = toNumber(rawEvent.output_index) + const contentIndex = toNumber(rawEvent.content_index) + const deltaText = typeof rawEvent.delta === "string" ? rawEvent.delta : "" + + if (!deltaText) { + return events + } + + const blockIndex = openTextBlockIfNeeded(state, { + outputIndex, + contentIndex, + events, + }) + + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "text_delta", + text: deltaText, + }, + }) + state.blockHasDelta.add(blockIndex) + + return events +} + +const handleOutputTextDone = ( + rawEvent: Record, + state: ResponsesStreamState, +): Array => { + const events = ensureMessageStart(state) + + const outputIndex = toNumber(rawEvent.output_index) + const contentIndex = toNumber(rawEvent.content_index) + const text = typeof rawEvent.text === "string" ? rawEvent.text : "" + + const blockIndex = openTextBlockIfNeeded(state, { + outputIndex, + contentIndex, + events, + }) + + if (text && !state.blockHasDelta.has(blockIndex)) { + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "text_delta", + text, + }, + }) + } + + closeBlockIfOpen(state, blockIndex, events) + + return events +} + +const handleResponseCompleted = ( + rawEvent: Record, + state: ResponsesStreamState, +): Array => { + const response = toResponsesResult(rawEvent.response) + const events = ensureMessageStart(state, response) + + closeAllOpenBlocks(state, events) + + if (response) { + const anthropic = translateResponsesResultToAnthropic(response) + events.push({ + type: "message_delta", + delta: { + stop_reason: anthropic.stop_reason, + stop_sequence: anthropic.stop_sequence, + }, + usage: anthropic.usage, + }) + } else { + events.push({ + type: "message_delta", + delta: { + stop_reason: null, + stop_sequence: null, + }, + }) + } + + events.push({ type: "message_stop" }) + state.messageCompleted = true + + return events +} + +const handleResponseFailed = ( + rawEvent: Record, + state: ResponsesStreamState, +): Array => { + const response = toResponsesResult(rawEvent.response) + const events = ensureMessageStart(state, response) + + closeAllOpenBlocks(state, events) + + const message = + typeof rawEvent.error === "string" ? + rawEvent.error + : "Response generation failed." + + events.push(buildErrorEvent(message)) + state.messageCompleted = true + + return events +} + +const handleErrorEvent = ( + rawEvent: Record, + state: ResponsesStreamState, +): Array => { + const message = + typeof rawEvent.message === "string" ? + rawEvent.message + : "An unexpected error occurred during streaming." + + state.messageCompleted = true + return [buildErrorEvent(message)] +} + +const ensureMessageStart = ( + state: ResponsesStreamState, + response?: ResponsesResult, +): Array => { + if (state.messageStartSent) { + return [] + } + + if (response) { + cacheResponseMetadata(state, response) + } + + const id = response?.id ?? state.currentResponseId ?? "response" + const model = response?.model ?? state.currentModel ?? "" + + const inputTokens = + response?.usage?.input_tokens ?? state.initialInputTokens ?? 0 + + state.messageStartSent = true + + return [ + { + type: "message_start", + message: { + id, + type: "message", + role: "assistant", + content: [], + model, + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: inputTokens, + output_tokens: 0, + }, + }, + }, + ] +} + +const openTextBlockIfNeeded = ( + state: ResponsesStreamState, + params: { + outputIndex: number + contentIndex: number + events: Array + }, +): number => { + const { outputIndex, contentIndex, events } = params + const key = getBlockKey(outputIndex, contentIndex) + let blockIndex = state.blockIndexByKey.get(key) + + if (blockIndex === undefined) { + blockIndex = state.nextContentBlockIndex + state.nextContentBlockIndex += 1 + state.blockIndexByKey.set(key, blockIndex) + } + + if (!state.openBlocks.has(blockIndex)) { + events.push({ + type: "content_block_start", + index: blockIndex, + content_block: { + type: "text", + text: "", + }, + }) + state.openBlocks.add(blockIndex) + } + + return blockIndex +} + +const closeBlockIfOpen = ( + state: ResponsesStreamState, + blockIndex: number, + events: Array, +) => { + if (!state.openBlocks.has(blockIndex)) { + return + } + + events.push({ type: "content_block_stop", index: blockIndex }) + state.openBlocks.delete(blockIndex) + state.blockHasDelta.delete(blockIndex) +} + +const closeAllOpenBlocks = ( + state: ResponsesStreamState, + events: Array, +) => { + for (const blockIndex of state.openBlocks) { + closeBlockIfOpen(state, blockIndex, events) + } + + state.functionCallStateByOutputIndex.clear() + state.functionCallOutputIndexByItemId.clear() +} + +const cacheResponseMetadata = ( + state: ResponsesStreamState, + response: ResponsesResult, +) => { + state.currentResponseId = response.id + state.currentModel = response.model + state.initialInputTokens = response.usage?.input_tokens ?? 0 +} + +const buildErrorEvent = (message: string): AnthropicStreamEventData => ({ + type: "error", + error: { + type: "api_error", + message, + }, +}) + +const getBlockKey = (outputIndex: number, contentIndex: number): string => + `${outputIndex}:${contentIndex}` + +const resolveFunctionCallOutputIndex = ( + state: ResponsesStreamState, + rawEvent: Record, +): number | undefined => { + if ( + typeof rawEvent.output_index === "number" + || (typeof rawEvent.output_index === "string" + && rawEvent.output_index.length > 0) + ) { + const parsed = toOptionalNumber(rawEvent.output_index) + if (parsed !== undefined) { + return parsed + } + } + + const itemId = toNonEmptyString(rawEvent.item_id) + if (itemId) { + const mapped = state.functionCallOutputIndexByItemId.get(itemId) + if (mapped !== undefined) { + return mapped + } + } + + return undefined +} + +const openFunctionCallBlock = ( + state: ResponsesStreamState, + params: { + outputIndex: number + toolCallId?: string + name?: string + events: Array + }, +): number => { + const { outputIndex, toolCallId, name, events } = params + + let functionCallState = state.functionCallStateByOutputIndex.get(outputIndex) + + if (!functionCallState) { + const blockIndex = state.nextContentBlockIndex + state.nextContentBlockIndex += 1 + + const resolvedToolCallId = toolCallId ?? `tool_call_${blockIndex}` + const resolvedName = name ?? "function" + + functionCallState = { + blockIndex, + toolCallId: resolvedToolCallId, + name: resolvedName, + } + + state.functionCallStateByOutputIndex.set(outputIndex, functionCallState) + state.functionCallOutputIndexByItemId.set(resolvedToolCallId, outputIndex) + } + + const { blockIndex } = functionCallState + + if (!state.openBlocks.has(blockIndex)) { + events.push({ + type: "content_block_start", + index: blockIndex, + content_block: { + type: "tool_use", + id: functionCallState.toolCallId, + name: functionCallState.name, + input: {}, + }, + }) + state.openBlocks.add(blockIndex) + } + + return blockIndex +} + +type FunctionCallDetails = { + outputIndex: number + toolCallId: string + name: string + initialArguments?: string + itemId?: string +} + +const extractFunctionCallDetails = ( + rawEvent: Record, + state: ResponsesStreamState, +): FunctionCallDetails | undefined => { + const item = isRecord(rawEvent.item) ? rawEvent.item : undefined + if (!item) { + return undefined + } + + const itemType = typeof item.type === "string" ? item.type : undefined + if (itemType !== "function_call") { + return undefined + } + + const outputIndex = resolveFunctionCallOutputIndex(state, rawEvent) + if (outputIndex === undefined) { + return undefined + } + + const callId = toNonEmptyString(item.call_id) + const itemId = toNonEmptyString(item.id) + const name = toNonEmptyString(item.name) ?? "function" + + const toolCallId = callId ?? itemId ?? `tool_call_${outputIndex}` + const initialArguments = + typeof item.arguments === "string" ? item.arguments : undefined + + return { + outputIndex, + toolCallId, + name, + initialArguments, + itemId, + } +} + +const toResponsesResult = (value: unknown): ResponsesResult | undefined => + isResponsesResult(value) ? value : undefined + +const toOptionalNumber = (value: unknown): number | undefined => { + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + + if (typeof value === "string" && value.length > 0) { + const parsed = Number(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + + return undefined +} + +const toNonEmptyString = (value: unknown): string | undefined => { + if (typeof value === "string" && value.length > 0) { + return value + } + + return undefined +} + +const toNumber = (value: unknown): number => { + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + + if (typeof value === "string") { + const parsed = Number(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + + return 0 +} + +const isResponsesResult = (value: unknown): value is ResponsesResult => { + if (!isRecord(value)) { + return false + } + + if (typeof value.id !== "string") { + return false + } + + if (typeof value.model !== "string") { + return false + } + + if (!Array.isArray(value.output)) { + return false + } + + if (typeof value.object !== "string") { + return false + } + + return true +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null diff --git a/src/routes/messages/responses-translation.ts b/src/routes/messages/responses-translation.ts new file mode 100644 index 00000000..057a7b96 --- /dev/null +++ b/src/routes/messages/responses-translation.ts @@ -0,0 +1,638 @@ +import consola from "consola" + +import { + type ResponsesPayload, + type ResponseInputContent, + type ResponseInputImage, + type ResponseInputItem, + type ResponseInputMessage, + type ResponseInputText, + type ResponsesResult, + type ResponseOutputContentBlock, + type ResponseOutputFunctionCall, + type ResponseOutputFunctionCallOutput, + type ResponseOutputItem, + type ResponseOutputReasoning, + type ResponseReasoningBlock, + type ResponseOutputRefusal, + type ResponseOutputText, + type ResponseFunctionToolCallItem, + type ResponseFunctionCallOutputItem, +} from "~/services/copilot/create-responses" + +import { + type AnthropicAssistantContentBlock, + type AnthropicAssistantMessage, + type AnthropicResponse, + type AnthropicImageBlock, + type AnthropicMessage, + type AnthropicMessagesPayload, + type AnthropicTextBlock, + type AnthropicTool, + type AnthropicToolResultBlock, + type AnthropicToolUseBlock, + type AnthropicUserContentBlock, + type AnthropicUserMessage, +} from "./anthropic-types" + +const MESSAGE_TYPE = "message" + +export const translateAnthropicMessagesToResponsesPayload = ( + payload: AnthropicMessagesPayload, +): ResponsesPayload => { + const input: Array = [] + + for (const message of payload.messages) { + input.push(...translateMessage(message)) + } + + const translatedTools = convertAnthropicTools(payload.tools) + const toolChoice = convertAnthropicToolChoice(payload.tool_choice) + + const { safetyIdentifier, promptCacheKey } = parseUserId( + payload.metadata?.user_id, + ) + + const responsesPayload: ResponsesPayload = { + model: payload.model, + input, + instructions: translateSystemPrompt(payload.system), + temperature: payload.temperature ?? null, + top_p: payload.top_p ?? null, + max_output_tokens: payload.max_tokens, + tools: translatedTools, + tool_choice: toolChoice, + metadata: payload.metadata ? { ...payload.metadata } : null, + safety_identifier: safetyIdentifier, + prompt_cache_key: promptCacheKey, + stream: payload.stream ?? null, + store: false, + parallel_tool_calls: true, + reasoning: { effort: "high", summary: "auto" }, + include: ["reasoning.encrypted_content"], + } + + return responsesPayload +} + +const translateMessage = ( + message: AnthropicMessage, +): Array => { + if (message.role === "user") { + return translateUserMessage(message) + } + + return translateAssistantMessage(message) +} + +const translateUserMessage = ( + message: AnthropicUserMessage, +): Array => { + if (typeof message.content === "string") { + return [createMessage("user", message.content)] + } + + if (!Array.isArray(message.content)) { + return [] + } + + const items: Array = [] + const pendingContent: Array = [] + + for (const block of message.content) { + if (block.type === "tool_result") { + flushPendingContent("user", pendingContent, items) + items.push(createFunctionCallOutput(block)) + continue + } + + const converted = translateUserContentBlock(block) + if (converted) { + pendingContent.push(converted) + } + } + + flushPendingContent("user", pendingContent, items) + + return items +} + +const translateAssistantMessage = ( + message: AnthropicAssistantMessage, +): Array => { + if (typeof message.content === "string") { + return [createMessage("assistant", message.content)] + } + + if (!Array.isArray(message.content)) { + return [] + } + + const items: Array = [] + const pendingContent: Array = [] + + for (const block of message.content) { + if (block.type === "tool_use") { + flushPendingContent("assistant", pendingContent, items) + items.push(createFunctionToolCall(block)) + continue + } + + const converted = translateAssistantContentBlock(block) + if (converted) { + pendingContent.push(converted) + } + } + + flushPendingContent("assistant", pendingContent, items) + + return items +} + +const translateUserContentBlock = ( + block: AnthropicUserContentBlock, +): ResponseInputContent | undefined => { + switch (block.type) { + case "text": { + return createTextContent(block.text) + } + case "image": { + return createImageContent(block) + } + case "tool_result": { + return undefined + } + default: { + return undefined + } + } +} + +const translateAssistantContentBlock = ( + block: AnthropicAssistantContentBlock, +): ResponseInputContent | undefined => { + switch (block.type) { + case "text": { + return createOutPutTextContent(block.text) + } + case "thinking": { + return createOutPutTextContent(block.thinking) + } + case "tool_use": { + return undefined + } + default: { + return undefined + } + } +} + +const flushPendingContent = ( + role: ResponseInputMessage["role"], + pendingContent: Array, + target: Array, +) => { + if (pendingContent.length === 0) { + return + } + + const messageContent = + pendingContent.length === 1 && isPlainText(pendingContent[0]) ? + pendingContent[0].text + : [...pendingContent] + + target.push(createMessage(role, messageContent)) + pendingContent.length = 0 +} + +const createMessage = ( + role: ResponseInputMessage["role"], + content: string | Array, +): ResponseInputMessage => ({ + type: MESSAGE_TYPE, + role, + content, +}) + +const createTextContent = (text: string): ResponseInputText => ({ + type: "input_text", + text, +}) + +const createOutPutTextContent = (text: string): ResponseInputText => ({ + type: "output_text", + text, +}) + +const createImageContent = ( + block: AnthropicImageBlock, +): ResponseInputImage => ({ + type: "input_image", + image_url: `data:${block.source.media_type};base64,${block.source.data}`, +}) + +const createFunctionToolCall = ( + block: AnthropicToolUseBlock, +): ResponseFunctionToolCallItem => ({ + type: "function_call", + call_id: block.id, + name: block.name, + arguments: JSON.stringify(block.input), + status: "completed", +}) + +const createFunctionCallOutput = ( + block: AnthropicToolResultBlock, +): ResponseFunctionCallOutputItem => ({ + type: "function_call_output", + call_id: block.tool_use_id, + output: block.content, + status: block.is_error ? "incomplete" : "completed", +}) + +const translateSystemPrompt = ( + system: string | Array | undefined, +): string | null => { + if (!system) { + return null + } + + const toolUsePrompt = ` +## Tool use +- You have access to many tools. If a tool exists to perform a specific task, you MUST use that tool instead of running a terminal command to perform that task. +### Bash tool +When using the Bash tool, follow these rules: +- always run_in_background set to false, unless you are running a long-running command (e.g., a server or a watch command). +### BashOutput tool +When using the BashOutput tool, follow these rules: +- Only Bash Tool run_in_background set to true, Use BashOutput to read the output later +### TodoWrite tool +When using the TodoWrite tool, follow these rules: +- Skip using the TodoWrite tool for straightforward tasks (roughly the easiest 25%). +- Do not make single-step todo lists. +- When you made a todo, update it after having performed one of the sub-tasks that you shared on the todo list.` + + if (typeof system === "string") { + return system + toolUsePrompt + } + + const text = system + .map((block, index) => { + if (index === 0) { + return block.text + toolUsePrompt + } + return block.text + }) + .join(" ") + return text.length > 0 ? text : null +} + +const convertAnthropicTools = ( + tools: Array | undefined, +): Array> | null => { + if (!tools || tools.length === 0) { + return null + } + + return tools.map((tool) => ({ + type: "function", + name: tool.name, + parameters: tool.input_schema, + strict: false, + ...(tool.description ? { description: tool.description } : {}), + })) +} + +const convertAnthropicToolChoice = ( + choice: AnthropicMessagesPayload["tool_choice"], +): unknown => { + if (!choice) { + return undefined + } + + switch (choice.type) { + case "auto": { + return "auto" + } + case "any": { + return "required" + } + case "tool": { + return choice.name ? { type: "function", name: choice.name } : undefined + } + case "none": { + return "none" + } + default: { + return undefined + } + } +} + +const isPlainText = ( + content: ResponseInputContent, +): content is ResponseInputText | { text: string } => { + if (typeof content !== "object") { + return false + } + + return ( + "text" in content + && typeof (content as ResponseInputText).text === "string" + && !("image_url" in content) + ) +} + +export const translateResponsesResultToAnthropic = ( + response: ResponsesResult, +): AnthropicResponse => { + const contentBlocks = mapOutputToAnthropicContent(response.output) + const usage = mapResponsesUsage(response) + let anthropicContent = fallbackContentBlocks(response.output_text) + if (contentBlocks.length > 0) { + anthropicContent = contentBlocks + } + + const stopReason = mapResponsesStopReason(response) + + return { + id: response.id, + type: "message", + role: "assistant", + content: anthropicContent, + model: response.model, + stop_reason: stopReason, + stop_sequence: null, + usage, + } +} + +const mapOutputToAnthropicContent = ( + output: Array, +): Array => { + const contentBlocks: Array = [] + + for (const item of output) { + switch (item.type) { + case "reasoning": { + const thinkingText = extractReasoningText(item) + if (thinkingText.length > 0) { + contentBlocks.push({ type: "thinking", thinking: thinkingText }) + } + break + } + case "function_call": { + const toolUseBlock = createToolUseContentBlock(item) + if (toolUseBlock) { + contentBlocks.push(toolUseBlock) + } + break + } + case "function_call_output": { + const outputBlock = createFunctionCallOutputBlock(item) + if (outputBlock) { + contentBlocks.push(outputBlock) + } + break + } + case "message": + case "output_text": { + const combinedText = combineMessageTextContent(item.content) + if (combinedText.length > 0) { + contentBlocks.push({ type: "text", text: combinedText }) + } + break + } + default: { + // Future compatibility for unrecognized output item types. + const combinedText = combineMessageTextContent( + (item as { content?: Array }).content, + ) + if (combinedText.length > 0) { + contentBlocks.push({ type: "text", text: combinedText }) + } + } + } + } + + return contentBlocks +} + +const combineMessageTextContent = ( + content: Array | undefined, +): string => { + if (!Array.isArray(content)) { + return "" + } + + let aggregated = "" + + for (const block of content) { + if (isResponseOutputText(block)) { + aggregated += block.text + continue + } + + if (isResponseOutputRefusal(block)) { + aggregated += block.refusal + continue + } + + if (typeof (block as { text?: unknown }).text === "string") { + aggregated += (block as { text: string }).text + continue + } + + if (typeof (block as { reasoning?: unknown }).reasoning === "string") { + aggregated += (block as { reasoning: string }).reasoning + continue + } + } + + return aggregated +} + +const extractReasoningText = (item: ResponseOutputReasoning): string => { + const segments: Array = [] + + const collectFromBlocks = (blocks?: Array) => { + if (!Array.isArray(blocks)) { + return + } + + for (const block of blocks) { + if (typeof block.text === "string") { + segments.push(block.text) + continue + } + + if (typeof block.thinking === "string") { + segments.push(block.thinking) + continue + } + + const reasoningValue = (block as Record).reasoning + if (typeof reasoningValue === "string") { + segments.push(reasoningValue) + } + } + } + + collectFromBlocks(item.reasoning) + collectFromBlocks(item.summary) + + if (typeof item.thinking === "string") { + segments.push(item.thinking) + } + + const textValue = (item as Record).text + if (typeof textValue === "string") { + segments.push(textValue) + } + + return segments.join("").trim() +} + +const createToolUseContentBlock = ( + call: ResponseOutputFunctionCall, +): AnthropicToolUseBlock | null => { + const toolId = call.call_id ?? call.id + if (!call.name || !toolId) { + return null + } + + const input = parseFunctionCallArguments(call.arguments) + + return { + type: "tool_use", + id: toolId, + name: call.name, + input, + } +} + +const createFunctionCallOutputBlock = ( + output: ResponseOutputFunctionCallOutput, +): AnthropicAssistantContentBlock | null => { + if (typeof output.output !== "string" || output.output.length === 0) { + return null + } + + return { + type: "text", + text: output.output, + } +} + +const parseFunctionCallArguments = ( + rawArguments: string, +): Record => { + if (typeof rawArguments !== "string" || rawArguments.trim().length === 0) { + return {} + } + + try { + const parsed: unknown = JSON.parse(rawArguments) + + if (Array.isArray(parsed)) { + return { arguments: parsed } + } + + if (parsed && typeof parsed === "object") { + return parsed as Record + } + } catch (error) { + consola.warn("Failed to parse function call arguments", { + error, + rawArguments, + }) + } + + return { raw_arguments: rawArguments } +} + +const fallbackContentBlocks = ( + outputText: string, +): Array => { + if (!outputText) { + return [] + } + + return [ + { + type: "text", + text: outputText, + }, + ] +} + +const mapResponsesStopReason = ( + response: ResponsesResult, +): AnthropicResponse["stop_reason"] => { + const { status, incomplete_details: incompleteDetails } = response + + if (status === "completed") { + return "end_turn" + } + + if (status === "incomplete") { + if (incompleteDetails?.reason === "max_output_tokens") { + return "max_tokens" + } + if (incompleteDetails?.reason === "content_filter") { + return "end_turn" + } + if (incompleteDetails?.reason === "tool_use") { + return "tool_use" + } + } + + return null +} + +const mapResponsesUsage = ( + response: ResponsesResult, +): AnthropicResponse["usage"] => { + const promptTokens = response.usage?.input_tokens ?? 0 + const completionTokens = response.usage?.output_tokens ?? 0 + + return { + input_tokens: promptTokens, + output_tokens: completionTokens, + } +} + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +const isResponseOutputText = ( + block: ResponseOutputContentBlock, +): block is ResponseOutputText => + isRecord(block) + && "type" in block + && (block as { type?: unknown }).type === "output_text" + +const isResponseOutputRefusal = ( + block: ResponseOutputContentBlock, +): block is ResponseOutputRefusal => + isRecord(block) + && "type" in block + && (block as { type?: unknown }).type === "refusal" + +const parseUserId = ( + userId: string | undefined, +): { safetyIdentifier: string | null; promptCacheKey: string | null } => { + if (!userId || typeof userId !== "string") { + return { safetyIdentifier: null, promptCacheKey: null } + } + + // Parse safety_identifier: content between "user_" and "_account" + const userMatch = userId.match(/user_([^_]+)_account/) + const safetyIdentifier = userMatch ? userMatch[1] : null + + // Parse prompt_cache_key: content after "_session_" + const sessionMatch = userId.match(/_session_(.+)$/) + const promptCacheKey = sessionMatch ? sessionMatch[1] : null + + return { safetyIdentifier, promptCacheKey } +} diff --git a/src/routes/responses/handler.ts b/src/routes/responses/handler.ts new file mode 100644 index 00000000..ef7b38b9 --- /dev/null +++ b/src/routes/responses/handler.ts @@ -0,0 +1,78 @@ +import type { Context } from "hono" + +import consola from "consola" +import { streamSSE } from "hono/streaming" + +import { awaitApproval } from "~/lib/approval" +import { checkRateLimit } from "~/lib/rate-limit" +import { state } from "~/lib/state" +import { + createResponses, + type ResponsesPayload, + type ResponsesResult, +} from "~/services/copilot/create-responses" + +import { getResponsesRequestOptions } from "./utils" + +const RESPONSES_ENDPOINT = "/responses" + +export const handleResponses = async (c: Context) => { + await checkRateLimit(state) + + const payload = await c.req.json() + consola.debug("Responses request payload:", JSON.stringify(payload)) + + const selectedModel = state.models?.data.find( + (model) => model.id === payload.model, + ) + const supportsResponses = + selectedModel?.supported_endpoints?.includes(RESPONSES_ENDPOINT) ?? false + + if (!supportsResponses) { + return c.json( + { + error: { + message: + "This model does not support the responses endpoint. Please choose a different model.", + type: "invalid_request_error", + }, + }, + 400, + ) + } + + const { vision, initiator } = getResponsesRequestOptions(payload) + + if (state.manualApprove) { + await awaitApproval() + } + + const response = await createResponses(payload, { vision, initiator }) + + if (isStreamingRequested(payload) && isAsyncIterable(response)) { + consola.debug("Forwarding native Responses stream") + return streamSSE(c, async (stream) => { + for await (const chunk of response) { + consola.debug("Responses stream chunk:", JSON.stringify(chunk)) + await stream.writeSSE({ + id: (chunk as { id?: string }).id, + event: (chunk as { event?: string }).event, + data: (chunk as { data?: string }).data ?? "", + }) + } + }) + } + + consola.debug( + "Forwarding native Responses result:", + JSON.stringify(response).slice(-400), + ) + return c.json(response as ResponsesResult) +} + +const isAsyncIterable = (value: unknown): value is AsyncIterable => + Boolean(value) + && typeof (value as AsyncIterable)[Symbol.asyncIterator] === "function" + +const isStreamingRequested = (payload: ResponsesPayload): boolean => + Boolean(payload.stream) diff --git a/src/routes/responses/route.ts b/src/routes/responses/route.ts new file mode 100644 index 00000000..af242342 --- /dev/null +++ b/src/routes/responses/route.ts @@ -0,0 +1,15 @@ +import { Hono } from "hono" + +import { forwardError } from "~/lib/error" + +import { handleResponses } from "./handler" + +export const responsesRoutes = new Hono() + +responsesRoutes.post("/", async (c) => { + try { + return await handleResponses(c) + } catch (error) { + return await forwardError(c, error) + } +}) diff --git a/src/routes/responses/utils.ts b/src/routes/responses/utils.ts new file mode 100644 index 00000000..5dea1daa --- /dev/null +++ b/src/routes/responses/utils.ts @@ -0,0 +1,71 @@ +import type { + ResponseInputItem, + ResponsesPayload, +} from "~/services/copilot/create-responses" + +export const getResponsesRequestOptions = ( + payload: ResponsesPayload, +): { vision: boolean; initiator: "agent" | "user" } => { + const vision = hasVisionInput(payload) + const initiator = hasAgentInitiator(payload) ? "agent" : "user" + + return { vision, initiator } +} + +export const hasAgentInitiator = (payload: ResponsesPayload): boolean => + getPayloadItems(payload).some((item) => { + if (!("role" in item) || !item.role) { + return true + } + const role = typeof item.role === "string" ? item.role.toLowerCase() : "" + return role === "assistant" + }) + +export const hasVisionInput = (payload: ResponsesPayload): boolean => { + const values = getPayloadItems(payload) + return values.some((item) => containsVisionContent(item)) +} + +const getPayloadItems = ( + payload: ResponsesPayload, +): Array => { + const result: Array = [] + + const { input, instructions } = payload + + if (Array.isArray(input)) { + result.push(...input) + } + + if (Array.isArray(instructions)) { + result.push(...instructions) + } + + return result +} + +const containsVisionContent = (value: unknown): boolean => { + if (!value) return false + + if (Array.isArray(value)) { + return value.some((entry) => containsVisionContent(entry)) + } + + if (typeof value !== "object") { + return false + } + + const record = value as Record + const type = + typeof record.type === "string" ? record.type.toLowerCase() : undefined + + if (type === "input_image") { + return true + } + + if (Array.isArray(record.content)) { + return record.content.some((entry) => containsVisionContent(entry)) + } + + return false +} diff --git a/src/server.ts b/src/server.ts index 3cb2bb86..2d792c56 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,6 +6,7 @@ import { completionRoutes } from "./routes/chat-completions/route" import { embeddingRoutes } from "./routes/embeddings/route" import { messageRoutes } from "./routes/messages/route" import { modelRoutes } from "./routes/models/route" +import { responsesRoutes } from "./routes/responses/route" import { tokenRoute } from "./routes/token/route" import { usageRoute } from "./routes/usage/route" @@ -21,11 +22,13 @@ server.route("/models", modelRoutes) server.route("/embeddings", embeddingRoutes) server.route("/usage", usageRoute) server.route("/token", tokenRoute) +server.route("/responses", responsesRoutes) // Compatibility with tools that expect v1/ prefix server.route("/v1/chat/completions", completionRoutes) server.route("/v1/models", modelRoutes) server.route("/v1/embeddings", embeddingRoutes) +server.route("/v1/responses", responsesRoutes) // Anthropic compatible endpoints server.route("/v1/messages", messageRoutes) diff --git a/src/services/copilot/create-responses.ts b/src/services/copilot/create-responses.ts new file mode 100644 index 00000000..9009abf6 --- /dev/null +++ b/src/services/copilot/create-responses.ts @@ -0,0 +1,212 @@ +import consola from "consola" +import { events } from "fetch-event-stream" + +import { copilotBaseUrl, copilotHeaders } from "~/lib/api-config" +import { HTTPError } from "~/lib/error" +import { state } from "~/lib/state" + +export interface ResponsesPayload { + model: string + input?: string | Array + instructions?: string | Array | null + temperature?: number | null + top_p?: number | null + max_output_tokens?: number | null + tools?: Array> | null + tool_choice?: unknown + metadata?: Record | null + stream?: boolean | null + response_format?: Record | null + safety_identifier?: string | null + prompt_cache_key?: string | null + parallel_tool_calls?: boolean | null + store?: boolean | null + reasoning?: Record | null + include?: Array + [key: string]: unknown +} + +export interface ResponseInputMessage { + type?: "message" + role: "user" | "assistant" | "system" | "developer" + content?: string | Array + status?: string +} + +export interface ResponseFunctionToolCallItem { + type: "function_call" + call_id: string + name: string + arguments: string + status?: "in_progress" | "completed" | "incomplete" +} + +export interface ResponseFunctionCallOutputItem { + type: "function_call_output" + call_id: string + output: string + status?: "in_progress" | "completed" | "incomplete" +} + +export type ResponseInputItem = + | ResponseInputMessage + | ResponseFunctionToolCallItem + | ResponseFunctionCallOutputItem + | Record + +export type ResponseInputContent = + | ResponseInputText + | ResponseInputImage + | ResponseContentTextLike + | Record + +export interface ResponseInputText { + type?: "input_text" | "text" | "output_text" + text: string +} + +export interface ResponseInputImage { + type: "input_image" + image_url?: string | null + file_id?: string | null + detail?: "low" | "high" | "auto" +} + +export interface ResponseContentTextLike { + type?: "text" + text: string +} + +export interface ResponsesResult { + id: string + object: "response" + created_at: number + model: string + output: Array + output_text: string + status: string + usage?: ResponseUsage | null + error: Record | null + incomplete_details: Record | null + instructions: string | null + metadata: Record | null + parallel_tool_calls: boolean + temperature: number | null + tool_choice: unknown + tools: Array> + top_p: number | null +} + +export type ResponseOutputItem = + | ResponseOutputMessage + | ResponseOutputReasoning + | ResponseOutputFunctionCall + | ResponseOutputFunctionCallOutput + +export interface ResponseOutputMessage { + id: string + type: "message" | "output_text" + role: "assistant" + status: "completed" | "in_progress" | "incomplete" + content?: Array +} + +export interface ResponseOutputReasoning { + id: string + type: "reasoning" + reasoning?: Array + summary?: Array + thinking?: string + [key: string]: unknown +} + +export interface ResponseReasoningBlock { + type: string + text?: string + thinking?: string + [key: string]: unknown +} + +export interface ResponseOutputFunctionCall { + id: string + type: "function_call" + call_id?: string + name: string + arguments: string + status?: "in_progress" | "completed" | "incomplete" + [key: string]: unknown +} + +export interface ResponseOutputFunctionCallOutput { + id: string + type: "function_call_output" + call_id: string + output: string + status?: "in_progress" | "completed" | "incomplete" + [key: string]: unknown +} + +export type ResponseOutputContentBlock = + | ResponseOutputText + | ResponseOutputRefusal + | Record + +export interface ResponseOutputText { + type: "output_text" + text: string + annotations: Array +} + +export interface ResponseOutputRefusal { + type: "refusal" + refusal: string +} + +export interface ResponseUsage { + input_tokens: number + output_tokens?: number + total_tokens: number + input_tokens_details?: { + cached_tokens: number + } + output_tokens_details?: { + reasoning_tokens: number + } +} + +export type ResponsesStream = ReturnType +export type CreateResponsesReturn = ResponsesResult | ResponsesStream + +interface ResponsesRequestOptions { + vision: boolean + initiator: "agent" | "user" +} + +export const createResponses = async ( + payload: ResponsesPayload, + { vision, initiator }: ResponsesRequestOptions, +): Promise => { + if (!state.copilotToken) throw new Error("Copilot token not found") + + const headers: Record = { + ...copilotHeaders(state, vision), + "X-Initiator": initiator, + } + + const response = await fetch(`${copilotBaseUrl(state)}/responses`, { + method: "POST", + headers, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + consola.error("Failed to create responses", response) + throw new HTTPError("Failed to create responses", response) + } + + if (payload.stream) { + return events(response) + } + + return (await response.json()) as ResponsesResult +} diff --git a/src/services/copilot/get-models.ts b/src/services/copilot/get-models.ts index 792adc48..d5618085 100644 --- a/src/services/copilot/get-models.ts +++ b/src/services/copilot/get-models.ts @@ -28,6 +28,9 @@ interface ModelSupports { tool_calls?: boolean parallel_tool_calls?: boolean dimensions?: boolean + streaming?: boolean + structured_outputs?: boolean + vision?: boolean } interface ModelCapabilities { @@ -52,4 +55,5 @@ interface Model { state: string terms: string } + supported_endpoints?: Array } diff --git a/tests/responses-stream-translation.test.ts b/tests/responses-stream-translation.test.ts new file mode 100644 index 00000000..9f149e1b --- /dev/null +++ b/tests/responses-stream-translation.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "bun:test" + +import type { AnthropicStreamEventData } from "~/routes/messages/anthropic-types" + +import { + createResponsesStreamState, + translateResponsesStreamEvent, +} from "~/routes/messages/responses-stream-translation" + +const createFunctionCallAddedEvent = () => ({ + type: "response.output_item.added", + output_index: 1, + item: { + id: "item-1", + type: "function_call", + call_id: "call-1", + name: "TodoWrite", + arguments: "", + status: "in_progress", + }, +}) + +describe("translateResponsesStreamEvent tool calls", () => { + test("streams function call arguments across deltas", () => { + const state = createResponsesStreamState() + + const events = [ + translateResponsesStreamEvent(createFunctionCallAddedEvent(), state), + translateResponsesStreamEvent( + { + type: "response.function_call_arguments.delta", + output_index: 1, + delta: '{"todos":', + }, + state, + ), + translateResponsesStreamEvent( + { + type: "response.function_call_arguments.delta", + output_index: 1, + delta: "[]}", + }, + state, + ), + translateResponsesStreamEvent( + { + type: "response.function_call_arguments.done", + output_index: 1, + arguments: '{"todos":[]}', + }, + state, + ), + ].flat() + + const messageStart = events.find((event) => event.type === "message_start") + expect(messageStart).toBeDefined() + + const blockStart = events.find( + (event) => event.type === "content_block_start", + ) + expect(blockStart).toBeDefined() + if (blockStart?.type === "content_block_start") { + expect(blockStart.content_block).toEqual({ + type: "tool_use", + id: "call-1", + name: "TodoWrite", + input: {}, + }) + } + + const deltas = events.filter( + ( + event, + ): event is Extract< + AnthropicStreamEventData, + { type: "content_block_delta" } + > => event.type === "content_block_delta", + ) + expect(deltas).toHaveLength(2) + expect(deltas[0].delta).toEqual({ + type: "input_json_delta", + partial_json: '{"todos":', + }) + expect(deltas[1].delta).toEqual({ + type: "input_json_delta", + partial_json: "[]}", + }) + + const blockStop = events.find( + (event) => event.type === "content_block_stop", + ) + expect(blockStop).toBeDefined() + + expect(state.openBlocks.size).toBe(0) + expect(state.functionCallStateByOutputIndex.size).toBe(0) + }) + + test("emits full arguments when only done payload is present", () => { + const state = createResponsesStreamState() + + const events = [ + translateResponsesStreamEvent(createFunctionCallAddedEvent(), state), + translateResponsesStreamEvent( + { + type: "response.function_call_arguments.done", + output_index: 1, + arguments: + '{"todos":[{"content":"Review src/routes/responses/translation.ts"}]}', + }, + state, + ), + ].flat() + + const deltas = events.filter( + ( + event, + ): event is Extract< + AnthropicStreamEventData, + { type: "content_block_delta" } + > => event.type === "content_block_delta", + ) + expect(deltas).toHaveLength(1) + expect(deltas[0].delta).toEqual({ + type: "input_json_delta", + partial_json: + '{"todos":[{"content":"Review src/routes/responses/translation.ts"}]}', + }) + + const blockStop = events.find( + (event) => event.type === "content_block_stop", + ) + expect(blockStop).toBeDefined() + + expect(state.openBlocks.size).toBe(0) + expect(state.functionCallStateByOutputIndex.size).toBe(0) + }) +}) diff --git a/tests/translation.test.ts b/tests/translation.test.ts new file mode 100644 index 00000000..84856b93 --- /dev/null +++ b/tests/translation.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "bun:test" + +import type { AnthropicMessagesPayload } from "~/routes/messages/anthropic-types" +import type { + ResponseInputMessage, + ResponsesResult, +} from "~/services/copilot/create-responses" + +import { + translateAnthropicMessagesToResponsesPayload, + translateResponsesResultToAnthropic, +} from "~/routes/messages/responses-translation" + +const samplePayload = { + model: "claude-3-5-sonnet", + max_tokens: 1024, + messages: [ + { + role: "user", + content: [ + { + type: "text", + text: "\nThis is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware. If you are working on tasks that would benefit from a todo list please use the TodoWrite tool to create one. If not, please feel free to ignore. Again do not mention this message to the user.\n", + }, + { + type: "text", + text: "\nAs you answer the user's questions, you can use the following context:\n# important-instruction-reminders\nDo what has been asked; nothing more, nothing less.\nNEVER create files unless they're absolutely necessary for achieving your goal.\nALWAYS prefer editing an existing file to creating a new one.\nNEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.\n\n \n IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.\n", + }, + { + type: "text", + text: "hi", + }, + { + type: "text", + text: "\nThe user opened the file c:\\Work2\\copilot-api\\src\\routes\\responses\\translation.ts in the IDE. This may or may not be related to the current task.\n", + }, + { + type: "text", + text: "hi", + cache_control: { + type: "ephemeral", + }, + }, + ], + }, + ], +} as unknown as AnthropicMessagesPayload + +describe("translateAnthropicMessagesToResponsesPayload", () => { + it("converts anthropic text blocks into response input messages", () => { + const result = translateAnthropicMessagesToResponsesPayload(samplePayload) + + console.log("result:", JSON.stringify(result, null, 2)) + expect(Array.isArray(result.input)).toBe(true) + const input = result.input as Array + expect(input).toHaveLength(1) + + const message = input[0] + expect(message.role).toBe("user") + expect(Array.isArray(message.content)).toBe(true) + + const content = message.content as Array<{ text: string }> + expect(content.map((item) => item.text)).toEqual([ + "\nThis is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware. If you are working on tasks that would benefit from a todo list please use the TodoWrite tool to create one. If not, please feel free to ignore. Again do not mention this message to the user.\n", + "\nAs you answer the user's questions, you can use the following context:\n# important-instruction-reminders\nDo what has been asked; nothing more, nothing less.\nNEVER create files unless they're absolutely necessary for achieving your goal.\nALWAYS prefer editing an existing file to creating a new one.\nNEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.\n\n \n IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.\n", + "hi", + "\nThe user opened the file c:\\Work2\\copilot-api\\src\\routes\\responses\\translation.ts in the IDE. This may or may not be related to the current task.\n", + "hi", + ]) + }) +}) + +describe("translateResponsesResultToAnthropic", () => { + it("handles reasoning and function call items", () => { + const responsesResult: ResponsesResult = { + id: "resp_123", + object: "response", + created_at: 0, + model: "gpt-4.1", + output: [ + { + id: "reason_1", + type: "reasoning", + reasoning: [{ type: "text", text: "Thinking about the task." }], + }, + { + id: "call_1", + type: "function_call", + call_id: "call_1", + name: "TodoWrite", + arguments: + '{"todos":[{"content":"Read src/routes/responses/translation.ts","status":"in_progress"}]}', + status: "completed", + }, + { + id: "message_1", + type: "message", + role: "assistant", + status: "completed", + content: [ + { + type: "output_text", + text: "Added the task to your todo list.", + annotations: [], + }, + ], + }, + ], + output_text: "Added the task to your todo list.", + status: "incomplete", + usage: { + input_tokens: 120, + output_tokens: 36, + total_tokens: 156, + }, + error: null, + incomplete_details: { reason: "tool_use" }, + instructions: null, + metadata: null, + parallel_tool_calls: false, + temperature: null, + tool_choice: null, + tools: [], + top_p: null, + } + + const anthropicResponse = + translateResponsesResultToAnthropic(responsesResult) + + expect(anthropicResponse.stop_reason).toBe("tool_use") + expect(anthropicResponse.content).toHaveLength(3) + + const [thinkingBlock, toolUseBlock, textBlock] = anthropicResponse.content + + expect(thinkingBlock.type).toBe("thinking") + if (thinkingBlock.type === "thinking") { + expect(thinkingBlock.thinking).toContain("Thinking about the task") + } + + expect(toolUseBlock.type).toBe("tool_use") + if (toolUseBlock.type === "tool_use") { + expect(toolUseBlock.id).toBe("call_1") + expect(toolUseBlock.name).toBe("TodoWrite") + expect(toolUseBlock.input).toEqual({ + todos: [ + { + content: "Read src/routes/responses/translation.ts", + status: "in_progress", + }, + ], + }) + } + + expect(textBlock.type).toBe("text") + if (textBlock.type === "text") { + expect(textBlock.text).toBe("Added the task to your todo list.") + } + }) +}) From 87899a137a711e571852b41b35f90a91aa7013bd Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Mon, 29 Sep 2025 15:43:04 +0800 Subject: [PATCH 02/13] feat: enhance output type for function call and add content conversion utility --- src/routes/messages/responses-translation.ts | 28 ++++++++++++++++++-- src/services/copilot/create-responses.ts | 10 ++----- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/routes/messages/responses-translation.ts b/src/routes/messages/responses-translation.ts index 057a7b96..5d0ff3b9 100644 --- a/src/routes/messages/responses-translation.ts +++ b/src/routes/messages/responses-translation.ts @@ -246,7 +246,7 @@ const createFunctionCallOutput = ( ): ResponseFunctionCallOutputItem => ({ type: "function_call_output", call_id: block.tool_use_id, - output: block.content, + output: convertToolResultContent(block.content), status: block.is_error ? "incomplete" : "completed", }) @@ -268,7 +268,7 @@ When using the BashOutput tool, follow these rules: - Only Bash Tool run_in_background set to true, Use BashOutput to read the output later ### TodoWrite tool When using the TodoWrite tool, follow these rules: -- Skip using the TodoWrite tool for straightforward tasks (roughly the easiest 25%). +- Skip using the TodoWrite tool for simple or straightforward tasks (roughly the easiest 25%). - Do not make single-step todo lists. - When you made a todo, update it after having performed one of the sub-tasks that you shared on the todo list.` @@ -636,3 +636,27 @@ const parseUserId = ( return { safetyIdentifier, promptCacheKey } } + +const convertToolResultContent = ( + content: string | Array | Array, +): string | Array => { + if (typeof content === "string") { + return content + } + + if (Array.isArray(content)) { + if (content.length > 0 && content[0].type === "text") { + return (content as Array).map((block) => + createTextContent(block.text), + ) + } + + if (content.length > 0 && content[0].type === "image") { + return (content as Array).map((block) => + createImageContent(block), + ) + } + } + + return "" +} diff --git a/src/services/copilot/create-responses.ts b/src/services/copilot/create-responses.ts index 9009abf6..52a162ef 100644 --- a/src/services/copilot/create-responses.ts +++ b/src/services/copilot/create-responses.ts @@ -44,7 +44,7 @@ export interface ResponseFunctionToolCallItem { export interface ResponseFunctionCallOutputItem { type: "function_call_output" call_id: string - output: string + output: string | Array status?: "in_progress" | "completed" | "incomplete" } @@ -57,11 +57,10 @@ export type ResponseInputItem = export type ResponseInputContent = | ResponseInputText | ResponseInputImage - | ResponseContentTextLike | Record export interface ResponseInputText { - type?: "input_text" | "text" | "output_text" + type?: "input_text" | "output_text" text: string } @@ -72,11 +71,6 @@ export interface ResponseInputImage { detail?: "low" | "high" | "auto" } -export interface ResponseContentTextLike { - type?: "text" - text: string -} - export interface ResponsesResult { id: string object: "response" From 4fc0fa0e6d5b70623344081e6ca4eab9eb6fc128 Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Mon, 29 Sep 2025 20:09:45 +0800 Subject: [PATCH 03/13] refactor: optimize content conversion logic in convertToolResultContent function --- src/routes/messages/responses-translation.ts | 26 ++++++++++++-------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/routes/messages/responses-translation.ts b/src/routes/messages/responses-translation.ts index 5d0ff3b9..71c877e1 100644 --- a/src/routes/messages/responses-translation.ts +++ b/src/routes/messages/responses-translation.ts @@ -645,17 +645,23 @@ const convertToolResultContent = ( } if (Array.isArray(content)) { - if (content.length > 0 && content[0].type === "text") { - return (content as Array).map((block) => - createTextContent(block.text), - ) - } - - if (content.length > 0 && content[0].type === "image") { - return (content as Array).map((block) => - createImageContent(block), - ) + const result: Array = [] + for (const block of content) { + switch (block.type) { + case "text": { + result.push(createTextContent(block.text)) + break + } + case "image": { + result.push(createImageContent(block)) + break + } + default: { + break + } + } } + return result } return "" From 2b9733bc0d0bc7af22ba834e7cc7d46f26cb10ae Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Tue, 30 Sep 2025 09:31:52 +0800 Subject: [PATCH 04/13] refactor: remove unused function call output type and simplify response output message type --- src/routes/messages/responses-translation.ts | 24 +------------------- src/services/copilot/create-responses.ts | 12 +--------- 2 files changed, 2 insertions(+), 34 deletions(-) diff --git a/src/routes/messages/responses-translation.ts b/src/routes/messages/responses-translation.ts index 71c877e1..41c26299 100644 --- a/src/routes/messages/responses-translation.ts +++ b/src/routes/messages/responses-translation.ts @@ -10,7 +10,6 @@ import { type ResponsesResult, type ResponseOutputContentBlock, type ResponseOutputFunctionCall, - type ResponseOutputFunctionCallOutput, type ResponseOutputItem, type ResponseOutputReasoning, type ResponseReasoningBlock, @@ -388,15 +387,7 @@ const mapOutputToAnthropicContent = ( } break } - case "function_call_output": { - const outputBlock = createFunctionCallOutputBlock(item) - if (outputBlock) { - contentBlocks.push(outputBlock) - } - break - } - case "message": - case "output_text": { + case "message": { const combinedText = combineMessageTextContent(item.content) if (combinedText.length > 0) { contentBlocks.push({ type: "text", text: combinedText }) @@ -511,19 +502,6 @@ const createToolUseContentBlock = ( } } -const createFunctionCallOutputBlock = ( - output: ResponseOutputFunctionCallOutput, -): AnthropicAssistantContentBlock | null => { - if (typeof output.output !== "string" || output.output.length === 0) { - return null - } - - return { - type: "text", - text: output.output, - } -} - const parseFunctionCallArguments = ( rawArguments: string, ): Record => { diff --git a/src/services/copilot/create-responses.ts b/src/services/copilot/create-responses.ts index 52a162ef..b13349e4 100644 --- a/src/services/copilot/create-responses.ts +++ b/src/services/copilot/create-responses.ts @@ -95,11 +95,10 @@ export type ResponseOutputItem = | ResponseOutputMessage | ResponseOutputReasoning | ResponseOutputFunctionCall - | ResponseOutputFunctionCallOutput export interface ResponseOutputMessage { id: string - type: "message" | "output_text" + type: "message" role: "assistant" status: "completed" | "in_progress" | "incomplete" content?: Array @@ -131,15 +130,6 @@ export interface ResponseOutputFunctionCall { [key: string]: unknown } -export interface ResponseOutputFunctionCallOutput { - id: string - type: "function_call_output" - call_id: string - output: string - status?: "in_progress" | "completed" | "incomplete" - [key: string]: unknown -} - export type ResponseOutputContentBlock = | ResponseOutputText | ResponseOutputRefusal From 505f648a77af6036cd3b846b91fe1eb67c3168c1 Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Tue, 30 Sep 2025 15:43:05 +0800 Subject: [PATCH 05/13] feat: add signature and reasoning handling to responses translation and state management --- src/routes/messages/anthropic-types.ts | 1 + .../messages/responses-stream-translation.ts | 159 +++++++++++++++++- src/routes/messages/responses-translation.ts | 56 +++--- src/routes/responses/utils.ts | 6 +- src/services/copilot/create-responses.ts | 22 ++- 5 files changed, 200 insertions(+), 44 deletions(-) diff --git a/src/routes/messages/anthropic-types.ts b/src/routes/messages/anthropic-types.ts index 881fffcc..f07485bf 100644 --- a/src/routes/messages/anthropic-types.ts +++ b/src/routes/messages/anthropic-types.ts @@ -56,6 +56,7 @@ export interface AnthropicToolUseBlock { export interface AnthropicThinkingBlock { type: "thinking" thinking: string + signature: string } export type AnthropicUserContentBlock = diff --git a/src/routes/messages/responses-stream-translation.ts b/src/routes/messages/responses-stream-translation.ts index 06feab1a..a3857e8a 100644 --- a/src/routes/messages/responses-stream-translation.ts +++ b/src/routes/messages/responses-stream-translation.ts @@ -15,6 +15,7 @@ export interface ResponsesStreamState { initialInputTokens?: number functionCallStateByOutputIndex: Map functionCallOutputIndexByItemId: Map + summryIndex: number } type FunctionCallStreamState = { @@ -32,6 +33,7 @@ export const createResponsesStreamState = (): ResponsesStreamState => ({ blockHasDelta: new Set(), functionCallStateByOutputIndex: new Map(), functionCallOutputIndexByItemId: new Map(), + summryIndex: 0, }) export const translateResponsesStreamEvent = ( @@ -49,12 +51,18 @@ export const translateResponsesStreamEvent = ( return handleResponseCreated(rawEvent, state) } - case "response.reasoning_summary_text.delta": + case "response.reasoning_summary_text.delta": { + return handleReasoningSummaryTextDelta(rawEvent, state) + } + case "response.output_text.delta": { return handleOutputTextDelta(rawEvent, state) } - case "response.reasoning_summary_part.done": + case "response.reasoning_summary_part.done": { + return handleReasoningSummaryPartDone(rawEvent, state) + } + case "response.output_text.done": { return handleOutputTextDone(rawEvent, state) } @@ -63,6 +71,10 @@ export const translateResponsesStreamEvent = ( return handleOutputItemAdded(rawEvent, state) } + case "response.output_item.done": { + return handleOutputItemDone(rawEvent, state) + } + case "response.function_call_arguments.delta": { return handleFunctionCallArgumentsDelta(rawEvent, state) } @@ -143,6 +155,51 @@ const handleOutputItemAdded = ( return events } +const handleOutputItemDone = ( + rawEvent: Record, + state: ResponsesStreamState, +): Array => { + const events = ensureMessageStart(state) + + const item = isRecord(rawEvent.item) ? rawEvent.item : undefined + if (!item) { + return events + } + + const itemType = typeof item.type === "string" ? item.type : undefined + if (itemType !== "reasoning") { + return events + } + + const outputIndex = toNumber(rawEvent.output_index) + const contentIndex = state.summryIndex + + const blockIndex = openThinkingBlockIfNeeded(state, { + outputIndex, + contentIndex, + events, + }) + + const signature = + typeof item.encrypted_content === "string" ? item.encrypted_content : "" + + if (signature) { + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "signature_delta", + signature, + }, + }) + state.blockHasDelta.add(blockIndex) + } + + closeBlockIfOpen(state, blockIndex, events) + + return events +} + const handleFunctionCallArgumentsDelta = ( rawEvent: Record, state: ResponsesStreamState, @@ -257,6 +314,71 @@ const handleOutputTextDelta = ( return events } +const handleReasoningSummaryTextDelta = ( + rawEvent: Record, + state: ResponsesStreamState, +): Array => { + const events = ensureMessageStart(state) + + const outputIndex = toNumber(rawEvent.output_index) + const contentIndex = toNumber(rawEvent.summary_index) + const deltaText = typeof rawEvent.delta === "string" ? rawEvent.delta : "" + + if (!deltaText) { + return events + } + + const blockIndex = openThinkingBlockIfNeeded(state, { + outputIndex, + contentIndex, + events, + }) + + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "thinking_delta", + thinking: deltaText, + }, + }) + state.blockHasDelta.add(blockIndex) + + return events +} + +const handleReasoningSummaryPartDone = ( + rawEvent: Record, + state: ResponsesStreamState, +): Array => { + const events = ensureMessageStart(state) + + const outputIndex = toNumber(rawEvent.output_index) + const contentIndex = toNumber(rawEvent.summary_index) + state.summryIndex = contentIndex + const part = isRecord(rawEvent.part) ? rawEvent.part : undefined + const text = part && typeof part.text === "string" ? part.text : "" + + const blockIndex = openThinkingBlockIfNeeded(state, { + outputIndex, + contentIndex, + events, + }) + + if (text && !state.blockHasDelta.has(blockIndex)) { + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "thinking_delta", + thinking: text, + }, + }) + } + + return events +} + const handleOutputTextDone = ( rawEvent: Record, state: ResponsesStreamState, @@ -430,6 +552,39 @@ const openTextBlockIfNeeded = ( return blockIndex } +const openThinkingBlockIfNeeded = ( + state: ResponsesStreamState, + params: { + outputIndex: number + contentIndex: number + events: Array + }, +): number => { + const { outputIndex, contentIndex, events } = params + const key = getBlockKey(outputIndex, contentIndex) + let blockIndex = state.blockIndexByKey.get(key) + + if (blockIndex === undefined) { + blockIndex = state.nextContentBlockIndex + state.nextContentBlockIndex += 1 + state.blockIndexByKey.set(key, blockIndex) + } + + if (!state.openBlocks.has(blockIndex)) { + events.push({ + type: "content_block_start", + index: blockIndex, + content_block: { + type: "thinking", + thinking: "", + }, + }) + state.openBlocks.add(blockIndex) + } + + return blockIndex +} + const closeBlockIfOpen = ( state: ResponsesStreamState, blockIndex: number, diff --git a/src/routes/messages/responses-translation.ts b/src/routes/messages/responses-translation.ts index 41c26299..50ae3a60 100644 --- a/src/routes/messages/responses-translation.ts +++ b/src/routes/messages/responses-translation.ts @@ -6,6 +6,7 @@ import { type ResponseInputImage, type ResponseInputItem, type ResponseInputMessage, + type ResponseInputReasoning, type ResponseInputText, type ResponsesResult, type ResponseOutputContentBlock, @@ -27,6 +28,7 @@ import { type AnthropicMessage, type AnthropicMessagesPayload, type AnthropicTextBlock, + type AnthropicThinkingBlock, type AnthropicTool, type AnthropicToolResultBlock, type AnthropicToolUseBlock, @@ -137,6 +139,12 @@ const translateAssistantMessage = ( continue } + if (block.type === "thinking") { + flushPendingContent("assistant", pendingContent, items) + items.push(createReasoningContent(block)) + continue + } + const converted = translateAssistantContentBlock(block) if (converted) { pendingContent.push(converted) @@ -158,9 +166,6 @@ const translateUserContentBlock = ( case "image": { return createImageContent(block) } - case "tool_result": { - return undefined - } default: { return undefined } @@ -174,12 +179,6 @@ const translateAssistantContentBlock = ( case "text": { return createOutPutTextContent(block.text) } - case "thinking": { - return createOutPutTextContent(block.thinking) - } - case "tool_use": { - return undefined - } default: { return undefined } @@ -230,6 +229,19 @@ const createImageContent = ( image_url: `data:${block.source.media_type};base64,${block.source.data}`, }) +const createReasoningContent = ( + block: AnthropicThinkingBlock, +): ResponseInputReasoning => ({ + type: "reasoning", + summary: [ + { + type: "summary_text", + text: block.thinking, + }, + ], + encrypted_content: block.signature, +}) + const createFunctionToolCall = ( block: AnthropicToolUseBlock, ): ResponseFunctionToolCallItem => ({ @@ -376,7 +388,11 @@ const mapOutputToAnthropicContent = ( case "reasoning": { const thinkingText = extractReasoningText(item) if (thinkingText.length > 0) { - contentBlocks.push({ type: "thinking", thinking: thinkingText }) + contentBlocks.push({ + type: "thinking", + thinking: thinkingText, + signature: item.encrypted_content ?? "", + }) } break } @@ -456,31 +472,11 @@ const extractReasoningText = (item: ResponseOutputReasoning): string => { segments.push(block.text) continue } - - if (typeof block.thinking === "string") { - segments.push(block.thinking) - continue - } - - const reasoningValue = (block as Record).reasoning - if (typeof reasoningValue === "string") { - segments.push(reasoningValue) - } } } - collectFromBlocks(item.reasoning) collectFromBlocks(item.summary) - if (typeof item.thinking === "string") { - segments.push(item.thinking) - } - - const textValue = (item as Record).text - if (typeof textValue === "string") { - segments.push(textValue) - } - return segments.join("").trim() } diff --git a/src/routes/responses/utils.ts b/src/routes/responses/utils.ts index 5dea1daa..734319cd 100644 --- a/src/routes/responses/utils.ts +++ b/src/routes/responses/utils.ts @@ -31,16 +31,12 @@ const getPayloadItems = ( ): Array => { const result: Array = [] - const { input, instructions } = payload + const { input } = payload if (Array.isArray(input)) { result.push(...input) } - if (Array.isArray(instructions)) { - result.push(...instructions) - } - return result } diff --git a/src/services/copilot/create-responses.ts b/src/services/copilot/create-responses.ts index b13349e4..8322cace 100644 --- a/src/services/copilot/create-responses.ts +++ b/src/services/copilot/create-responses.ts @@ -7,13 +7,13 @@ import { state } from "~/lib/state" export interface ResponsesPayload { model: string + instructions?: string | null input?: string | Array - instructions?: string | Array | null + tools?: Array> | null + tool_choice?: unknown temperature?: number | null top_p?: number | null max_output_tokens?: number | null - tools?: Array> | null - tool_choice?: unknown metadata?: Record | null stream?: boolean | null response_format?: Record | null @@ -48,10 +48,20 @@ export interface ResponseFunctionCallOutputItem { status?: "in_progress" | "completed" | "incomplete" } +export interface ResponseInputReasoning { + type: "reasoning" + summary: Array<{ + type: "summary_text" + text: string + }> + encrypted_content: string +} + export type ResponseInputItem = | ResponseInputMessage | ResponseFunctionToolCallItem | ResponseFunctionCallOutputItem + | ResponseInputReasoning | Record export type ResponseInputContent = @@ -107,17 +117,15 @@ export interface ResponseOutputMessage { export interface ResponseOutputReasoning { id: string type: "reasoning" - reasoning?: Array summary?: Array - thinking?: string + encrypted_content?: string + status: "completed" | "in_progress" | "incomplete" [key: string]: unknown } export interface ResponseReasoningBlock { type: string text?: string - thinking?: string - [key: string]: unknown } export interface ResponseOutputFunctionCall { From 9477b4541280246541f16c3416865d58d8170a1d Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Tue, 30 Sep 2025 15:48:32 +0800 Subject: [PATCH 06/13] feat: add signature to thinking messages and enhance reasoning structure in translation tests --- tests/anthropic-request.test.ts | 2 ++ tests/translation.test.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/anthropic-request.test.ts b/tests/anthropic-request.test.ts index a4a5b06b..c86bcac1 100644 --- a/tests/anthropic-request.test.ts +++ b/tests/anthropic-request.test.ts @@ -136,6 +136,7 @@ describe("Anthropic to OpenAI translation logic", () => { { type: "thinking", thinking: "Let me think about this simple math problem...", + signature: "abc123", }, { type: "text", text: "2+2 equals 4." }, ], @@ -168,6 +169,7 @@ describe("Anthropic to OpenAI translation logic", () => { type: "thinking", thinking: "I need to call the weather API to get current weather information.", + signature: "def456", }, { type: "text", text: "I'll check the weather for you." }, { diff --git a/tests/translation.test.ts b/tests/translation.test.ts index 84856b93..0c3ececb 100644 --- a/tests/translation.test.ts +++ b/tests/translation.test.ts @@ -81,7 +81,9 @@ describe("translateResponsesResultToAnthropic", () => { { id: "reason_1", type: "reasoning", - reasoning: [{ type: "text", text: "Thinking about the task." }], + summary: [{ type: "text", text: "Thinking about the task." }], + status: "completed", + encrypted_content: "encrypted_reasoning_content", }, { id: "call_1", From 44551f9aae3fd6cb99a27b349cce827df37021f5 Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Tue, 30 Sep 2025 15:56:34 +0800 Subject: [PATCH 07/13] refactor: remove summaryIndex from ResponsesStreamState and related handlers --- .../messages/responses-stream-translation.ts | 33 ++++--------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/src/routes/messages/responses-stream-translation.ts b/src/routes/messages/responses-stream-translation.ts index a3857e8a..3a4bdfd9 100644 --- a/src/routes/messages/responses-stream-translation.ts +++ b/src/routes/messages/responses-stream-translation.ts @@ -15,7 +15,6 @@ export interface ResponsesStreamState { initialInputTokens?: number functionCallStateByOutputIndex: Map functionCallOutputIndexByItemId: Map - summryIndex: number } type FunctionCallStreamState = { @@ -33,7 +32,6 @@ export const createResponsesStreamState = (): ResponsesStreamState => ({ blockHasDelta: new Set(), functionCallStateByOutputIndex: new Map(), functionCallOutputIndexByItemId: new Map(), - summryIndex: 0, }) export const translateResponsesStreamEvent = ( @@ -172,13 +170,8 @@ const handleOutputItemDone = ( } const outputIndex = toNumber(rawEvent.output_index) - const contentIndex = state.summryIndex - const blockIndex = openThinkingBlockIfNeeded(state, { - outputIndex, - contentIndex, - events, - }) + const blockIndex = openThinkingBlockIfNeeded(state, outputIndex, events) const signature = typeof item.encrypted_content === "string" ? item.encrypted_content : "" @@ -321,18 +314,13 @@ const handleReasoningSummaryTextDelta = ( const events = ensureMessageStart(state) const outputIndex = toNumber(rawEvent.output_index) - const contentIndex = toNumber(rawEvent.summary_index) const deltaText = typeof rawEvent.delta === "string" ? rawEvent.delta : "" if (!deltaText) { return events } - const blockIndex = openThinkingBlockIfNeeded(state, { - outputIndex, - contentIndex, - events, - }) + const blockIndex = openThinkingBlockIfNeeded(state, outputIndex, events) events.push({ type: "content_block_delta", @@ -354,16 +342,10 @@ const handleReasoningSummaryPartDone = ( const events = ensureMessageStart(state) const outputIndex = toNumber(rawEvent.output_index) - const contentIndex = toNumber(rawEvent.summary_index) - state.summryIndex = contentIndex const part = isRecord(rawEvent.part) ? rawEvent.part : undefined const text = part && typeof part.text === "string" ? part.text : "" - const blockIndex = openThinkingBlockIfNeeded(state, { - outputIndex, - contentIndex, - events, - }) + const blockIndex = openThinkingBlockIfNeeded(state, outputIndex, events) if (text && !state.blockHasDelta.has(blockIndex)) { events.push({ @@ -554,13 +536,10 @@ const openTextBlockIfNeeded = ( const openThinkingBlockIfNeeded = ( state: ResponsesStreamState, - params: { - outputIndex: number - contentIndex: number - events: Array - }, + outputIndex: number, + events: Array, ): number => { - const { outputIndex, contentIndex, events } = params + const contentIndex = 0 const key = getBlockKey(outputIndex, contentIndex) let blockIndex = state.blockIndexByKey.get(key) From 708ae3377f58ff1b7902d5983e308434ee00bb4f Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Tue, 30 Sep 2025 23:21:27 +0800 Subject: [PATCH 08/13] feat: enhance streaming response handling with ping mechanism --- README.md | 11 ++++++----- src/routes/responses/handler.ts | 30 +++++++++++++++++++++++------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e5b39099..e0aa9d3e 100644 --- a/README.md +++ b/README.md @@ -184,11 +184,12 @@ The server exposes several endpoints to interact with the Copilot API. It provid These endpoints mimic the OpenAI API structure. -| Endpoint | Method | Description | -| --------------------------- | ------ | --------------------------------------------------------- | -| `POST /v1/chat/completions` | `POST` | Creates a model response for the given chat conversation. | -| `GET /v1/models` | `GET` | Lists the currently available models. | -| `POST /v1/embeddings` | `POST` | Creates an embedding vector representing the input text. | +| Endpoint | Method | Description | +| --------------------------- | ------ | ---------------------------------------------------------------- | +| `POST /v1/responses` | `POST` | Most advanced interface for generating model responses. | +| `POST /v1/chat/completions` | `POST` | Creates a model response for the given chat conversation. | +| `GET /v1/models` | `GET` | Lists the currently available models. | +| `POST /v1/embeddings` | `POST` | Creates an embedding vector representing the input text. | ### Anthropic Compatible Endpoints diff --git a/src/routes/responses/handler.ts b/src/routes/responses/handler.ts index ef7b38b9..d06d02d6 100644 --- a/src/routes/responses/handler.ts +++ b/src/routes/responses/handler.ts @@ -52,13 +52,29 @@ export const handleResponses = async (c: Context) => { if (isStreamingRequested(payload) && isAsyncIterable(response)) { consola.debug("Forwarding native Responses stream") return streamSSE(c, async (stream) => { - for await (const chunk of response) { - consola.debug("Responses stream chunk:", JSON.stringify(chunk)) - await stream.writeSSE({ - id: (chunk as { id?: string }).id, - event: (chunk as { event?: string }).event, - data: (chunk as { data?: string }).data ?? "", - }) + const pingInterval = setInterval(async () => { + try { + await stream.writeSSE({ + event: "ping", + data: JSON.stringify({ timestamp: Date.now() }), + }) + } catch (error) { + consola.warn("Failed to send ping:", error) + clearInterval(pingInterval) + } + }, 3000) + + try { + for await (const chunk of response) { + consola.debug("Responses stream chunk:", JSON.stringify(chunk)) + await stream.writeSSE({ + id: (chunk as { id?: string }).id, + event: (chunk as { event?: string }).event, + data: (chunk as { data?: string }).data ?? "", + }) + } + } finally { + clearInterval(pingInterval) } }) } From 47fb3e46032ed8062fb50e9f20609733588bc95c Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Wed, 1 Oct 2025 18:43:11 +0800 Subject: [PATCH 09/13] feat: responses translation add cache_read_input_tokens --- src/routes/messages/responses-stream-translation.ts | 11 ++++++++--- src/routes/messages/responses-translation.ts | 13 +++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/src/routes/messages/responses-stream-translation.ts b/src/routes/messages/responses-stream-translation.ts index 3a4bdfd9..db09bf14 100644 --- a/src/routes/messages/responses-stream-translation.ts +++ b/src/routes/messages/responses-stream-translation.ts @@ -13,6 +13,7 @@ export interface ResponsesStreamState { currentResponseId?: string currentModel?: string initialInputTokens?: number + initialInputCachedTokens?: number functionCallStateByOutputIndex: Map functionCallOutputIndexByItemId: Map } @@ -476,11 +477,10 @@ const ensureMessageStart = ( const id = response?.id ?? state.currentResponseId ?? "response" const model = response?.model ?? state.currentModel ?? "" - const inputTokens = - response?.usage?.input_tokens ?? state.initialInputTokens ?? 0 - state.messageStartSent = true + const inputTokens = + (state.initialInputTokens ?? 0) - (state.initialInputCachedTokens ?? 0) return [ { type: "message_start", @@ -495,6 +495,9 @@ const ensureMessageStart = ( usage: { input_tokens: inputTokens, output_tokens: 0, + ...(state.initialInputCachedTokens !== undefined && { + cache_creation_input_tokens: state.initialInputCachedTokens, + }), }, }, }, @@ -597,6 +600,8 @@ const cacheResponseMetadata = ( state.currentResponseId = response.id state.currentModel = response.model state.initialInputTokens = response.usage?.input_tokens ?? 0 + state.initialInputCachedTokens = + response.usage?.input_tokens_details?.cached_tokens } const buildErrorEvent = (message: string): AnthropicStreamEventData => ({ diff --git a/src/routes/messages/responses-translation.ts b/src/routes/messages/responses-translation.ts index 50ae3a60..00f48129 100644 --- a/src/routes/messages/responses-translation.ts +++ b/src/routes/messages/responses-translation.ts @@ -567,12 +567,17 @@ const mapResponsesStopReason = ( const mapResponsesUsage = ( response: ResponsesResult, ): AnthropicResponse["usage"] => { - const promptTokens = response.usage?.input_tokens ?? 0 - const completionTokens = response.usage?.output_tokens ?? 0 + const inputTokens = response.usage?.input_tokens ?? 0 + const outputTokens = response.usage?.output_tokens ?? 0 + const inputCachedTokens = response.usage?.input_tokens_details?.cached_tokens return { - input_tokens: promptTokens, - output_tokens: completionTokens, + input_tokens: inputTokens - (inputCachedTokens ?? 0), + output_tokens: outputTokens, + ...(response.usage?.input_tokens_details?.cached_tokens !== undefined && { + cache_read_input_tokens: + response.usage.input_tokens_details.cached_tokens, + }), } } From 437028e7fa647546e2dbf865d98bf37d3e2530cf Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Sun, 5 Oct 2025 23:21:27 +0800 Subject: [PATCH 10/13] feat: enhance response event handling with event types and improved parsing --- src/routes/messages/handler.ts | 24 +- .../messages/responses-stream-translation.ts | 387 +++++------------- src/routes/messages/responses-translation.ts | 25 +- src/services/copilot/create-responses.ts | 161 +++++++- tests/responses-stream-translation.test.ts | 29 +- 5 files changed, 275 insertions(+), 351 deletions(-) diff --git a/src/routes/messages/handler.ts b/src/routes/messages/handler.ts index 10b97c53..a3faf045 100644 --- a/src/routes/messages/handler.ts +++ b/src/routes/messages/handler.ts @@ -23,6 +23,7 @@ import { import { createResponses, type ResponsesResult, + type ResponseStreamEvent, } from "~/services/copilot/create-responses" import { @@ -150,16 +151,10 @@ const handleWithResponsesApi = async ( continue } - if (data === "[DONE]") { - break - } - - const parsed = safeJsonParse(data) - if (!parsed) { - continue - } - - const events = translateResponsesStreamEvent(parsed, streamState) + const events = translateResponsesStreamEvent( + JSON.parse(data) as ResponseStreamEvent, + streamState, + ) for (const event of events) { consola.debug("Translated Anthropic event:", JSON.stringify(event)) await stream.writeSSE({ @@ -210,12 +205,3 @@ const isNonStreaming = ( const isAsyncIterable = (value: unknown): value is AsyncIterable => Boolean(value) && typeof (value as AsyncIterable)[Symbol.asyncIterator] === "function" - -const safeJsonParse = (value: string): Record | undefined => { - try { - return JSON.parse(value) as Record - } catch (error) { - consola.warn("Failed to parse Responses stream chunk:", value, error) - return undefined - } -} diff --git a/src/routes/messages/responses-stream-translation.ts b/src/routes/messages/responses-stream-translation.ts index db09bf14..826feff7 100644 --- a/src/routes/messages/responses-stream-translation.ts +++ b/src/routes/messages/responses-stream-translation.ts @@ -1,4 +1,22 @@ -import { type ResponsesResult } from "~/services/copilot/create-responses" +import consola from "consola" + +import { + type ResponseCompletedEvent, + type ResponseCreatedEvent, + type ResponseErrorEvent, + type ResponseFailedEvent, + type ResponseFunctionCallArgumentsDeltaEvent, + type ResponseFunctionCallArgumentsDoneEvent, + type ResponseIncompleteEvent, + type ResponseOutputItemAddedEvent, + type ResponseOutputItemDoneEvent, + type ResponseReasoningSummaryTextDeltaEvent, + type ResponseReasoningSummaryTextDoneEvent, + type ResponsesResult, + type ResponseStreamEvent, + type ResponseTextDeltaEvent, + type ResponseTextDoneEvent, +} from "~/services/copilot/create-responses" import { type AnthropicStreamEventData } from "./anthropic-types" import { translateResponsesResultToAnthropic } from "./responses-translation" @@ -10,12 +28,7 @@ export interface ResponsesStreamState { blockIndexByKey: Map openBlocks: Set blockHasDelta: Set - currentResponseId?: string - currentModel?: string - initialInputTokens?: number - initialInputCachedTokens?: number functionCallStateByOutputIndex: Map - functionCallOutputIndexByItemId: Map } type FunctionCallStreamState = { @@ -32,24 +45,22 @@ export const createResponsesStreamState = (): ResponsesStreamState => ({ openBlocks: new Set(), blockHasDelta: new Set(), functionCallStateByOutputIndex: new Map(), - functionCallOutputIndexByItemId: new Map(), }) export const translateResponsesStreamEvent = ( - rawEvent: Record, + rawEvent: ResponseStreamEvent, state: ResponsesStreamState, ): Array => { - const eventType = - typeof rawEvent.type === "string" ? rawEvent.type : undefined - if (!eventType) { - return [] - } - + const eventType = rawEvent.type switch (eventType) { case "response.created": { return handleResponseCreated(rawEvent, state) } + case "response.output_item.added": { + return handleOutputItemAdded(rawEvent, state) + } + case "response.reasoning_summary_text.delta": { return handleReasoningSummaryTextDelta(rawEvent, state) } @@ -58,18 +69,13 @@ export const translateResponsesStreamEvent = ( return handleOutputTextDelta(rawEvent, state) } - case "response.reasoning_summary_part.done": { - return handleReasoningSummaryPartDone(rawEvent, state) + case "response.reasoning_summary_text.done": { + return handleReasoningSummaryTextDone(rawEvent, state) } case "response.output_text.done": { return handleOutputTextDone(rawEvent, state) } - - case "response.output_item.added": { - return handleOutputItemAdded(rawEvent, state) - } - case "response.output_item.done": { return handleOutputItemDone(rawEvent, state) } @@ -96,6 +102,7 @@ export const translateResponsesStreamEvent = ( } default: { + consola.debug("Unknown Responses stream event type:", eventType) return [] } } @@ -103,35 +110,24 @@ export const translateResponsesStreamEvent = ( // Helper handlers to keep translateResponsesStreamEvent concise const handleResponseCreated = ( - rawEvent: Record, + rawEvent: ResponseCreatedEvent, state: ResponsesStreamState, ): Array => { - const response = toResponsesResult(rawEvent.response) - if (response) { - cacheResponseMetadata(state, response) - } - return ensureMessageStart(state, response) + return messageStart(state, rawEvent.response) } const handleOutputItemAdded = ( - rawEvent: Record, + rawEvent: ResponseOutputItemAddedEvent, state: ResponsesStreamState, ): Array => { - const response = toResponsesResult(rawEvent.response) - const events = ensureMessageStart(state, response) - - const functionCallDetails = extractFunctionCallDetails(rawEvent, state) + const events = new Array() + const functionCallDetails = extractFunctionCallDetails(rawEvent) if (!functionCallDetails) { return events } - const { outputIndex, toolCallId, name, initialArguments, itemId } = + const { outputIndex, toolCallId, name, initialArguments } = functionCallDetails - - if (itemId) { - state.functionCallOutputIndexByItemId.set(itemId, outputIndex) - } - const blockIndex = openFunctionCallBlock(state, { outputIndex, toolCallId, @@ -155,28 +151,19 @@ const handleOutputItemAdded = ( } const handleOutputItemDone = ( - rawEvent: Record, + rawEvent: ResponseOutputItemDoneEvent, state: ResponsesStreamState, ): Array => { - const events = ensureMessageStart(state) - - const item = isRecord(rawEvent.item) ? rawEvent.item : undefined - if (!item) { - return events - } - - const itemType = typeof item.type === "string" ? item.type : undefined + const events = new Array() + const item = rawEvent.item + const itemType = item.type if (itemType !== "reasoning") { return events } - const outputIndex = toNumber(rawEvent.output_index) - + const outputIndex = rawEvent.output_index const blockIndex = openThinkingBlockIfNeeded(state, outputIndex, events) - - const signature = - typeof item.encrypted_content === "string" ? item.encrypted_content : "" - + const signature = item.encrypted_content if (signature) { events.push({ type: "content_block_delta", @@ -195,21 +182,12 @@ const handleOutputItemDone = ( } const handleFunctionCallArgumentsDelta = ( - rawEvent: Record, + rawEvent: ResponseFunctionCallArgumentsDeltaEvent, state: ResponsesStreamState, ): Array => { - const events = ensureMessageStart(state) - - const outputIndex = resolveFunctionCallOutputIndex(state, rawEvent) - if (outputIndex === undefined) { - return events - } - - const deltaText = typeof rawEvent.delta === "string" ? rawEvent.delta : "" - if (!deltaText) { - return events - } - + const events = new Array() + const outputIndex = rawEvent.output_index + const deltaText = rawEvent.delta const blockIndex = openFunctionCallBlock(state, { outputIndex, events, @@ -229,16 +207,11 @@ const handleFunctionCallArgumentsDelta = ( } const handleFunctionCallArgumentsDone = ( - rawEvent: Record, + rawEvent: ResponseFunctionCallArgumentsDoneEvent, state: ResponsesStreamState, ): Array => { - const events = ensureMessageStart(state) - - const outputIndex = resolveFunctionCallOutputIndex(state, rawEvent) - if (outputIndex === undefined) { - return events - } - + const events = new Array() + const outputIndex = rawEvent.output_index const blockIndex = openFunctionCallBlock(state, { outputIndex, events, @@ -260,30 +233,18 @@ const handleFunctionCallArgumentsDone = ( } closeBlockIfOpen(state, blockIndex, events) - - const existingState = state.functionCallStateByOutputIndex.get(outputIndex) - if (existingState) { - state.functionCallOutputIndexByItemId.delete(existingState.toolCallId) - } state.functionCallStateByOutputIndex.delete(outputIndex) - - const itemId = toNonEmptyString(rawEvent.item_id) - if (itemId) { - state.functionCallOutputIndexByItemId.delete(itemId) - } - return events } const handleOutputTextDelta = ( - rawEvent: Record, + rawEvent: ResponseTextDeltaEvent, state: ResponsesStreamState, ): Array => { - const events = ensureMessageStart(state) - - const outputIndex = toNumber(rawEvent.output_index) - const contentIndex = toNumber(rawEvent.content_index) - const deltaText = typeof rawEvent.delta === "string" ? rawEvent.delta : "" + const events = new Array() + const outputIndex = rawEvent.output_index + const contentIndex = rawEvent.content_index + const deltaText = rawEvent.delta if (!deltaText) { return events @@ -309,18 +270,12 @@ const handleOutputTextDelta = ( } const handleReasoningSummaryTextDelta = ( - rawEvent: Record, + rawEvent: ResponseReasoningSummaryTextDeltaEvent, state: ResponsesStreamState, ): Array => { - const events = ensureMessageStart(state) - - const outputIndex = toNumber(rawEvent.output_index) - const deltaText = typeof rawEvent.delta === "string" ? rawEvent.delta : "" - - if (!deltaText) { - return events - } - + const outputIndex = rawEvent.output_index + const deltaText = rawEvent.delta + const events = new Array() const blockIndex = openThinkingBlockIfNeeded(state, outputIndex, events) events.push({ @@ -336,16 +291,13 @@ const handleReasoningSummaryTextDelta = ( return events } -const handleReasoningSummaryPartDone = ( - rawEvent: Record, +const handleReasoningSummaryTextDone = ( + rawEvent: ResponseReasoningSummaryTextDoneEvent, state: ResponsesStreamState, ): Array => { - const events = ensureMessageStart(state) - - const outputIndex = toNumber(rawEvent.output_index) - const part = isRecord(rawEvent.part) ? rawEvent.part : undefined - const text = part && typeof part.text === "string" ? part.text : "" - + const outputIndex = rawEvent.output_index + const text = rawEvent.text + const events = new Array() const blockIndex = openThinkingBlockIfNeeded(state, outputIndex, events) if (text && !state.blockHasDelta.has(blockIndex)) { @@ -363,14 +315,13 @@ const handleReasoningSummaryPartDone = ( } const handleOutputTextDone = ( - rawEvent: Record, + rawEvent: ResponseTextDoneEvent, state: ResponsesStreamState, ): Array => { - const events = ensureMessageStart(state) - - const outputIndex = toNumber(rawEvent.output_index) - const contentIndex = toNumber(rawEvent.content_index) - const text = typeof rawEvent.text === "string" ? rawEvent.text : "" + const events = new Array() + const outputIndex = rawEvent.output_index + const contentIndex = rawEvent.content_index + const text = rawEvent.text const blockIndex = openTextBlockIfNeeded(state, { outputIndex, @@ -395,53 +346,39 @@ const handleOutputTextDone = ( } const handleResponseCompleted = ( - rawEvent: Record, + rawEvent: ResponseCompletedEvent | ResponseIncompleteEvent, state: ResponsesStreamState, ): Array => { - const response = toResponsesResult(rawEvent.response) - const events = ensureMessageStart(state, response) + const response = rawEvent.response + const events = new Array() closeAllOpenBlocks(state, events) - - if (response) { - const anthropic = translateResponsesResultToAnthropic(response) - events.push({ + const anthropic = translateResponsesResultToAnthropic(response) + events.push( + { type: "message_delta", delta: { stop_reason: anthropic.stop_reason, stop_sequence: anthropic.stop_sequence, }, usage: anthropic.usage, - }) - } else { - events.push({ - type: "message_delta", - delta: { - stop_reason: null, - stop_sequence: null, - }, - }) - } - - events.push({ type: "message_stop" }) + }, + { type: "message_stop" }, + ) state.messageCompleted = true - return events } const handleResponseFailed = ( - rawEvent: Record, + rawEvent: ResponseFailedEvent, state: ResponsesStreamState, ): Array => { - const response = toResponsesResult(rawEvent.response) - const events = ensureMessageStart(state, response) - + const response = rawEvent.response + const events = new Array() closeAllOpenBlocks(state, events) const message = - typeof rawEvent.error === "string" ? - rawEvent.error - : "Response generation failed." + response.error?.message ?? "The response failed due to an unknown error." events.push(buildErrorEvent(message)) state.messageCompleted = true @@ -450,7 +387,7 @@ const handleResponseFailed = ( } const handleErrorEvent = ( - rawEvent: Record, + rawEvent: ResponseErrorEvent, state: ResponsesStreamState, ): Array => { const message = @@ -462,41 +399,30 @@ const handleErrorEvent = ( return [buildErrorEvent(message)] } -const ensureMessageStart = ( +const messageStart = ( state: ResponsesStreamState, - response?: ResponsesResult, + response: ResponsesResult, ): Array => { - if (state.messageStartSent) { - return [] - } - - if (response) { - cacheResponseMetadata(state, response) - } - - const id = response?.id ?? state.currentResponseId ?? "response" - const model = response?.model ?? state.currentModel ?? "" - state.messageStartSent = true - + const inputCachedTokens = response.usage?.input_tokens_details?.cached_tokens const inputTokens = - (state.initialInputTokens ?? 0) - (state.initialInputCachedTokens ?? 0) + (response.usage?.input_tokens ?? 0) - (inputCachedTokens ?? 0) return [ { type: "message_start", message: { - id, + id: response.id, type: "message", role: "assistant", content: [], - model, + model: response.model, stop_reason: null, stop_sequence: null, usage: { input_tokens: inputTokens, output_tokens: 0, - ...(state.initialInputCachedTokens !== undefined && { - cache_creation_input_tokens: state.initialInputCachedTokens, + ...(inputCachedTokens !== undefined && { + cache_creation_input_tokens: inputCachedTokens, }), }, }, @@ -590,18 +516,6 @@ const closeAllOpenBlocks = ( } state.functionCallStateByOutputIndex.clear() - state.functionCallOutputIndexByItemId.clear() -} - -const cacheResponseMetadata = ( - state: ResponsesStreamState, - response: ResponsesResult, -) => { - state.currentResponseId = response.id - state.currentModel = response.model - state.initialInputTokens = response.usage?.input_tokens ?? 0 - state.initialInputCachedTokens = - response.usage?.input_tokens_details?.cached_tokens } const buildErrorEvent = (message: string): AnthropicStreamEventData => ({ @@ -615,32 +529,6 @@ const buildErrorEvent = (message: string): AnthropicStreamEventData => ({ const getBlockKey = (outputIndex: number, contentIndex: number): string => `${outputIndex}:${contentIndex}` -const resolveFunctionCallOutputIndex = ( - state: ResponsesStreamState, - rawEvent: Record, -): number | undefined => { - if ( - typeof rawEvent.output_index === "number" - || (typeof rawEvent.output_index === "string" - && rawEvent.output_index.length > 0) - ) { - const parsed = toOptionalNumber(rawEvent.output_index) - if (parsed !== undefined) { - return parsed - } - } - - const itemId = toNonEmptyString(rawEvent.item_id) - if (itemId) { - const mapped = state.functionCallOutputIndexByItemId.get(itemId) - if (mapped !== undefined) { - return mapped - } - } - - return undefined -} - const openFunctionCallBlock = ( state: ResponsesStreamState, params: { @@ -668,7 +556,6 @@ const openFunctionCallBlock = ( } state.functionCallStateByOutputIndex.set(outputIndex, functionCallState) - state.functionCallOutputIndexByItemId.set(resolvedToolCallId, outputIndex) } const { blockIndex } = functionCallState @@ -695,109 +582,25 @@ type FunctionCallDetails = { toolCallId: string name: string initialArguments?: string - itemId?: string } const extractFunctionCallDetails = ( - rawEvent: Record, - state: ResponsesStreamState, + rawEvent: ResponseOutputItemAddedEvent, ): FunctionCallDetails | undefined => { - const item = isRecord(rawEvent.item) ? rawEvent.item : undefined - if (!item) { - return undefined - } - - const itemType = typeof item.type === "string" ? item.type : undefined + const item = rawEvent.item + const itemType = item.type if (itemType !== "function_call") { return undefined } - const outputIndex = resolveFunctionCallOutputIndex(state, rawEvent) - if (outputIndex === undefined) { - return undefined - } - - const callId = toNonEmptyString(item.call_id) - const itemId = toNonEmptyString(item.id) - const name = toNonEmptyString(item.name) ?? "function" - - const toolCallId = callId ?? itemId ?? `tool_call_${outputIndex}` - const initialArguments = - typeof item.arguments === "string" ? item.arguments : undefined - + const outputIndex = rawEvent.output_index + const toolCallId = item.call_id + const name = item.name + const initialArguments = item.arguments return { outputIndex, toolCallId, name, initialArguments, - itemId, } } - -const toResponsesResult = (value: unknown): ResponsesResult | undefined => - isResponsesResult(value) ? value : undefined - -const toOptionalNumber = (value: unknown): number | undefined => { - if (typeof value === "number" && Number.isFinite(value)) { - return value - } - - if (typeof value === "string" && value.length > 0) { - const parsed = Number(value) - if (Number.isFinite(parsed)) { - return parsed - } - } - - return undefined -} - -const toNonEmptyString = (value: unknown): string | undefined => { - if (typeof value === "string" && value.length > 0) { - return value - } - - return undefined -} - -const toNumber = (value: unknown): number => { - if (typeof value === "number" && Number.isFinite(value)) { - return value - } - - if (typeof value === "string") { - const parsed = Number(value) - if (Number.isFinite(parsed)) { - return parsed - } - } - - return 0 -} - -const isResponsesResult = (value: unknown): value is ResponsesResult => { - if (!isRecord(value)) { - return false - } - - if (typeof value.id !== "string") { - return false - } - - if (typeof value.model !== "string") { - return false - } - - if (!Array.isArray(value.output)) { - return false - } - - if (typeof value.object !== "string") { - return false - } - - return true -} - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null diff --git a/src/routes/messages/responses-translation.ts b/src/routes/messages/responses-translation.ts index 00f48129..b518710d 100644 --- a/src/routes/messages/responses-translation.ts +++ b/src/routes/messages/responses-translation.ts @@ -18,6 +18,7 @@ import { type ResponseOutputText, type ResponseFunctionToolCallItem, type ResponseFunctionCallOutputItem, + type Tool, } from "~/services/copilot/create-responses" import { @@ -194,10 +195,7 @@ const flushPendingContent = ( return } - const messageContent = - pendingContent.length === 1 && isPlainText(pendingContent[0]) ? - pendingContent[0].text - : [...pendingContent] + const messageContent = [...pendingContent] target.push(createMessage(role, messageContent)) pendingContent.length = 0 @@ -227,6 +225,7 @@ const createImageContent = ( ): ResponseInputImage => ({ type: "input_image", image_url: `data:${block.source.media_type};base64,${block.source.data}`, + detail: "auto", }) const createReasoningContent = ( @@ -300,7 +299,7 @@ When using the TodoWrite tool, follow these rules: const convertAnthropicTools = ( tools: Array | undefined, -): Array> | null => { +): Array | null => { if (!tools || tools.length === 0) { return null } @@ -340,20 +339,6 @@ const convertAnthropicToolChoice = ( } } -const isPlainText = ( - content: ResponseInputContent, -): content is ResponseInputText | { text: string } => { - if (typeof content !== "object") { - return false - } - - return ( - "text" in content - && typeof (content as ResponseInputText).text === "string" - && !("image_url" in content) - ) -} - export const translateResponsesResultToAnthropic = ( response: ResponsesResult, ): AnthropicResponse => { @@ -483,7 +468,7 @@ const extractReasoningText = (item: ResponseOutputReasoning): string => { const createToolUseContentBlock = ( call: ResponseOutputFunctionCall, ): AnthropicToolUseBlock | null => { - const toolId = call.call_id ?? call.id + const toolId = call.call_id if (!call.name || !toolId) { return null } diff --git a/src/services/copilot/create-responses.ts b/src/services/copilot/create-responses.ts index 8322cace..6dd979de 100644 --- a/src/services/copilot/create-responses.ts +++ b/src/services/copilot/create-responses.ts @@ -9,23 +9,44 @@ export interface ResponsesPayload { model: string instructions?: string | null input?: string | Array - tools?: Array> | null + tools?: Array | null tool_choice?: unknown temperature?: number | null top_p?: number | null max_output_tokens?: number | null metadata?: Record | null stream?: boolean | null - response_format?: Record | null safety_identifier?: string | null prompt_cache_key?: string | null parallel_tool_calls?: boolean | null store?: boolean | null - reasoning?: Record | null - include?: Array + reasoning?: Reasoning | null + include?: Array [key: string]: unknown } +export type Tool = FunctionTool + +export interface FunctionTool { + name: string + parameters: { [key: string]: unknown } | null + strict: boolean | null + type: "function" + description?: string | null +} + +export type ResponseIncludable = + | "file_search_call.results" + | "message.input_image.image_url" + | "computer_call_output.output.image_url" + | "reasoning.encrypted_content" + | "code_interpreter_call.outputs" + +export interface Reasoning { + effort?: "minimal" | "low" | "medium" | "high" | null + summary?: "auto" | "concise" | "detailed" | null +} + export interface ResponseInputMessage { type?: "message" role: "user" | "assistant" | "system" | "developer" @@ -70,7 +91,7 @@ export type ResponseInputContent = | Record export interface ResponseInputText { - type?: "input_text" | "output_text" + type: "input_text" | "output_text" text: string } @@ -78,7 +99,7 @@ export interface ResponseInputImage { type: "input_image" image_url?: string | null file_id?: string | null - detail?: "low" | "high" | "auto" + detail: "low" | "high" | "auto" } export interface ResponsesResult { @@ -90,7 +111,7 @@ export interface ResponsesResult { output_text: string status: string usage?: ResponseUsage | null - error: Record | null + error: ResponseError | null incomplete_details: Record | null instructions: string | null metadata: Record | null @@ -101,6 +122,10 @@ export interface ResponsesResult { top_p: number | null } +export interface ResponseError { + message: string +} + export type ResponseOutputItem = | ResponseOutputMessage | ResponseOutputReasoning @@ -119,8 +144,7 @@ export interface ResponseOutputReasoning { type: "reasoning" summary?: Array encrypted_content?: string - status: "completed" | "in_progress" | "incomplete" - [key: string]: unknown + status?: "completed" | "in_progress" | "incomplete" } export interface ResponseReasoningBlock { @@ -129,13 +153,12 @@ export interface ResponseReasoningBlock { } export interface ResponseOutputFunctionCall { - id: string + id?: string type: "function_call" - call_id?: string + call_id: string name: string arguments: string status?: "in_progress" | "completed" | "incomplete" - [key: string]: unknown } export type ResponseOutputContentBlock = @@ -166,6 +189,120 @@ export interface ResponseUsage { } } +export type ResponseStreamEvent = + | ResponseCompletedEvent + | ResponseIncompleteEvent + | ResponseCreatedEvent + | ResponseErrorEvent + | ResponseFunctionCallArgumentsDeltaEvent + | ResponseFunctionCallArgumentsDoneEvent + | ResponseFailedEvent + | ResponseOutputItemAddedEvent + | ResponseOutputItemDoneEvent + | ResponseReasoningSummaryTextDeltaEvent + | ResponseReasoningSummaryTextDoneEvent + | ResponseTextDeltaEvent + | ResponseTextDoneEvent + +export interface ResponseCompletedEvent { + response: ResponsesResult + sequence_number: number + type: "response.completed" +} + +export interface ResponseIncompleteEvent { + response: ResponsesResult + sequence_number: number + type: "response.incomplete" +} + +export interface ResponseCreatedEvent { + response: ResponsesResult + sequence_number: number + type: "response.created" +} + +export interface ResponseErrorEvent { + code: string | null + message: string + param: string | null + sequence_number: number + type: "error" +} + +export interface ResponseFunctionCallArgumentsDeltaEvent { + delta: string + item_id: string + output_index: number + sequence_number: number + type: "response.function_call_arguments.delta" +} + +export interface ResponseFunctionCallArgumentsDoneEvent { + arguments: string + item_id: string + name: string + output_index: number + sequence_number: number + type: "response.function_call_arguments.done" +} + +export interface ResponseFailedEvent { + response: ResponsesResult + sequence_number: number + type: "response.failed" +} + +export interface ResponseOutputItemAddedEvent { + item: ResponseOutputItem + output_index: number + sequence_number: number + type: "response.output_item.added" +} + +export interface ResponseOutputItemDoneEvent { + item: ResponseOutputItem + output_index: number + sequence_number: number + type: "response.output_item.done" +} + +export interface ResponseReasoningSummaryTextDeltaEvent { + delta: string + item_id: string + output_index: number + sequence_number: number + summary_index: number + type: "response.reasoning_summary_text.delta" +} + +export interface ResponseReasoningSummaryTextDoneEvent { + item_id: string + output_index: number + sequence_number: number + summary_index: number + text: string + type: "response.reasoning_summary_text.done" +} + +export interface ResponseTextDeltaEvent { + content_index: number + delta: string + item_id: string + output_index: number + sequence_number: number + type: "response.output_text.delta" +} + +export interface ResponseTextDoneEvent { + content_index: number + item_id: string + output_index: number + sequence_number: number + text: string + type: "response.output_text.done" +} + export type ResponsesStream = ReturnType export type CreateResponsesReturn = ResponsesResult | ResponsesStream diff --git a/tests/responses-stream-translation.test.ts b/tests/responses-stream-translation.test.ts index 9f149e1b..039411cf 100644 --- a/tests/responses-stream-translation.test.ts +++ b/tests/responses-stream-translation.test.ts @@ -1,14 +1,20 @@ import { describe, expect, test } from "bun:test" import type { AnthropicStreamEventData } from "~/routes/messages/anthropic-types" +import type { + ResponseOutputItemAddedEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, +} from "~/services/copilot/create-responses" import { createResponsesStreamState, translateResponsesStreamEvent, } from "~/routes/messages/responses-stream-translation" -const createFunctionCallAddedEvent = () => ({ +const createFunctionCallAddedEvent = (): ResponseOutputItemAddedEvent => ({ type: "response.output_item.added", + sequence_number: 1, output_index: 1, item: { id: "item-1", @@ -29,32 +35,36 @@ describe("translateResponsesStreamEvent tool calls", () => { translateResponsesStreamEvent( { type: "response.function_call_arguments.delta", + item_id: "item-1", output_index: 1, + sequence_number: 2, delta: '{"todos":', - }, + } as ResponseFunctionCallArgumentsDeltaEvent, state, ), translateResponsesStreamEvent( { type: "response.function_call_arguments.delta", + item_id: "item-1", output_index: 1, + sequence_number: 3, delta: "[]}", - }, + } as ResponseFunctionCallArgumentsDeltaEvent, state, ), translateResponsesStreamEvent( { type: "response.function_call_arguments.done", + item_id: "item-1", + name: "TodoWrite", output_index: 1, + sequence_number: 4, arguments: '{"todos":[]}', - }, + } as ResponseFunctionCallArgumentsDoneEvent, state, ), ].flat() - const messageStart = events.find((event) => event.type === "message_start") - expect(messageStart).toBeDefined() - const blockStart = events.find( (event) => event.type === "content_block_start", ) @@ -103,10 +113,13 @@ describe("translateResponsesStreamEvent tool calls", () => { translateResponsesStreamEvent( { type: "response.function_call_arguments.done", + item_id: "item-1", + name: "TodoWrite", output_index: 1, + sequence_number: 2, arguments: '{"todos":[{"content":"Review src/routes/responses/translation.ts"}]}', - }, + } as ResponseFunctionCallArgumentsDoneEvent, state, ), ].flat() From 10595c94a0ade4e4fd4c2e12f8d02bf3dbfb4f1a Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Mon, 6 Oct 2025 10:07:53 +0800 Subject: [PATCH 11/13] feat: update tool choice handling and enhance response interfaces --- src/routes/messages/responses-translation.ts | 13 +++++------ src/services/copilot/create-responses.ts | 23 +++++++++++++++---- ....test.ts => responses-translation.test.ts} | 7 +++--- 3 files changed, 27 insertions(+), 16 deletions(-) rename tests/{translation.test.ts => responses-translation.test.ts} (96%) diff --git a/src/routes/messages/responses-translation.ts b/src/routes/messages/responses-translation.ts index b518710d..51142554 100644 --- a/src/routes/messages/responses-translation.ts +++ b/src/routes/messages/responses-translation.ts @@ -19,6 +19,8 @@ import { type ResponseFunctionToolCallItem, type ResponseFunctionCallOutputItem, type Tool, + type ToolChoiceFunction, + type ToolChoiceOptions, } from "~/services/copilot/create-responses" import { @@ -315,9 +317,9 @@ const convertAnthropicTools = ( const convertAnthropicToolChoice = ( choice: AnthropicMessagesPayload["tool_choice"], -): unknown => { +): ToolChoiceOptions | ToolChoiceFunction => { if (!choice) { - return undefined + return "auto" } switch (choice.type) { @@ -328,13 +330,13 @@ const convertAnthropicToolChoice = ( return "required" } case "tool": { - return choice.name ? { type: "function", name: choice.name } : undefined + return choice.name ? { type: "function", name: choice.name } : "auto" } case "none": { return "none" } default: { - return undefined + return "auto" } } } @@ -541,9 +543,6 @@ const mapResponsesStopReason = ( if (incompleteDetails?.reason === "content_filter") { return "end_turn" } - if (incompleteDetails?.reason === "tool_use") { - return "tool_use" - } } return null diff --git a/src/services/copilot/create-responses.ts b/src/services/copilot/create-responses.ts index 6dd979de..b28b9208 100644 --- a/src/services/copilot/create-responses.ts +++ b/src/services/copilot/create-responses.ts @@ -10,11 +10,11 @@ export interface ResponsesPayload { instructions?: string | null input?: string | Array tools?: Array | null - tool_choice?: unknown + tool_choice?: ToolChoiceOptions | ToolChoiceFunction temperature?: number | null top_p?: number | null max_output_tokens?: number | null - metadata?: Record | null + metadata?: Metadata | null stream?: boolean | null safety_identifier?: string | null prompt_cache_key?: string | null @@ -25,6 +25,13 @@ export interface ResponsesPayload { [key: string]: unknown } +export type ToolChoiceOptions = "none" | "auto" | "required" + +export interface ToolChoiceFunction { + name: string + type: "function" +} + export type Tool = FunctionTool export interface FunctionTool { @@ -112,16 +119,22 @@ export interface ResponsesResult { status: string usage?: ResponseUsage | null error: ResponseError | null - incomplete_details: Record | null + incomplete_details: IncompleteDetails | null instructions: string | null - metadata: Record | null + metadata: Metadata | null parallel_tool_calls: boolean temperature: number | null tool_choice: unknown - tools: Array> + tools: Array top_p: number | null } +export type Metadata = { [key: string]: string } + +export interface IncompleteDetails { + reason?: "max_output_tokens" | "content_filter" +} + export interface ResponseError { message: string } diff --git a/tests/translation.test.ts b/tests/responses-translation.test.ts similarity index 96% rename from tests/translation.test.ts rename to tests/responses-translation.test.ts index 0c3ececb..3ce5f708 100644 --- a/tests/translation.test.ts +++ b/tests/responses-translation.test.ts @@ -50,7 +50,6 @@ describe("translateAnthropicMessagesToResponsesPayload", () => { it("converts anthropic text blocks into response input messages", () => { const result = translateAnthropicMessagesToResponsesPayload(samplePayload) - console.log("result:", JSON.stringify(result, null, 2)) expect(Array.isArray(result.input)).toBe(true) const input = result.input as Array expect(input).toHaveLength(1) @@ -81,7 +80,7 @@ describe("translateResponsesResultToAnthropic", () => { { id: "reason_1", type: "reasoning", - summary: [{ type: "text", text: "Thinking about the task." }], + summary: [{ type: "summary_text", text: "Thinking about the task." }], status: "completed", encrypted_content: "encrypted_reasoning_content", }, @@ -116,7 +115,7 @@ describe("translateResponsesResultToAnthropic", () => { total_tokens: 156, }, error: null, - incomplete_details: { reason: "tool_use" }, + incomplete_details: { reason: "content_filter" }, instructions: null, metadata: null, parallel_tool_calls: false, @@ -129,7 +128,7 @@ describe("translateResponsesResultToAnthropic", () => { const anthropicResponse = translateResponsesResultToAnthropic(responsesResult) - expect(anthropicResponse.stop_reason).toBe("tool_use") + expect(anthropicResponse.stop_reason).toBe("end_turn") expect(anthropicResponse.content).toHaveLength(3) const [thinkingBlock, toolUseBlock, textBlock] = anthropicResponse.content From b71c5b00eea1ca73edf0b0721499a60a8e2718b3 Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Tue, 7 Oct 2025 11:18:47 +0800 Subject: [PATCH 12/13] feat: allign with vscode-copilot-chat extractThinkingData, otherwise it will cause miss cache occasionally --- .../messages/responses-stream-translation.ts | 7 ++--- src/routes/messages/responses-translation.ts | 26 +++++++++++-------- src/services/copilot/create-responses.ts | 1 + 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/routes/messages/responses-stream-translation.ts b/src/routes/messages/responses-stream-translation.ts index 826feff7..56fd536e 100644 --- a/src/routes/messages/responses-stream-translation.ts +++ b/src/routes/messages/responses-stream-translation.ts @@ -163,7 +163,7 @@ const handleOutputItemDone = ( const outputIndex = rawEvent.output_index const blockIndex = openThinkingBlockIfNeeded(state, outputIndex, events) - const signature = item.encrypted_content + const signature = (item.encrypted_content ?? "") + "@" + item.id if (signature) { events.push({ type: "content_block_delta", @@ -468,8 +468,9 @@ const openThinkingBlockIfNeeded = ( outputIndex: number, events: Array, ): number => { - const contentIndex = 0 - const key = getBlockKey(outputIndex, contentIndex) + //thinking blocks has multiple summary_index, should combine into one block + const summaryIndex = 0 + const key = getBlockKey(outputIndex, summaryIndex) let blockIndex = state.blockIndexByKey.get(key) if (blockIndex === undefined) { diff --git a/src/routes/messages/responses-translation.ts b/src/routes/messages/responses-translation.ts index 51142554..2ba2eaac 100644 --- a/src/routes/messages/responses-translation.ts +++ b/src/routes/messages/responses-translation.ts @@ -72,7 +72,7 @@ export const translateAnthropicMessagesToResponsesPayload = ( stream: payload.stream ?? null, store: false, parallel_tool_calls: true, - reasoning: { effort: "high", summary: "auto" }, + reasoning: { effort: "high", summary: "detailed" }, include: ["reasoning.encrypted_content"], } @@ -232,16 +232,20 @@ const createImageContent = ( const createReasoningContent = ( block: AnthropicThinkingBlock, -): ResponseInputReasoning => ({ - type: "reasoning", - summary: [ - { - type: "summary_text", - text: block.thinking, - }, - ], - encrypted_content: block.signature, -}) +): ResponseInputReasoning => { + // allign with vscode-copilot-chat extractThinkingData, otherwise it will cause miss cache occasionally —— the usage input cached tokens to be 0 + // https://github.com/microsoft/vscode-copilot-chat/blob/main/src/platform/endpoint/node/responsesApi.ts#L162 + // when use in codex cli, reasoning id is empty, so it will cause miss cache occasionally + const array = block.signature.split("@") + const signature = array[0] + const id = array[1] + return { + id, + type: "reasoning", + summary: [], + encrypted_content: signature, + } +} const createFunctionToolCall = ( block: AnthropicToolUseBlock, diff --git a/src/services/copilot/create-responses.ts b/src/services/copilot/create-responses.ts index b28b9208..4e4448ec 100644 --- a/src/services/copilot/create-responses.ts +++ b/src/services/copilot/create-responses.ts @@ -77,6 +77,7 @@ export interface ResponseFunctionCallOutputItem { } export interface ResponseInputReasoning { + id?: string type: "reasoning" summary: Array<{ type: "summary_text" From eb7329f3a8628d0131204ce766bfe4215b1ba281 Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Tue, 7 Oct 2025 11:45:59 +0800 Subject: [PATCH 13/13] feat: Compatibility when @ is missing in block.signature --- src/routes/messages/responses-translation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/messages/responses-translation.ts b/src/routes/messages/responses-translation.ts index 2ba2eaac..98d0536a 100644 --- a/src/routes/messages/responses-translation.ts +++ b/src/routes/messages/responses-translation.ts @@ -238,7 +238,7 @@ const createReasoningContent = ( // when use in codex cli, reasoning id is empty, so it will cause miss cache occasionally const array = block.signature.split("@") const signature = array[0] - const id = array[1] + const id = array.length > 1 ? array[1] : undefined return { id, type: "reasoning",