From 97bdaec9e0f1f6ab96f5f1fbbc39d928ecc8f92c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:42:44 +0000 Subject: [PATCH 1/5] Initial plan From 72abf17b60985579bc0fda1a65314cd7188cef77 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:54:49 +0000 Subject: [PATCH 2/5] fix: preserve anthropic thinking signatures via responses api Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- src/routes/messages/anthropic-types.ts | 1 + src/routes/messages/handler.ts | 79 ++++ .../messages/responses-stream-translation.ts | 429 ++++++++++++++++++ src/routes/messages/responses-translation.ts | 405 +++++++++++++++++ src/services/copilot/create-responses.ts | 49 +- tests/messages-responses-translation.test.ts | 250 ++++++++++ 6 files changed, 1209 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 tests/messages-responses-translation.test.ts diff --git a/src/routes/messages/anthropic-types.ts b/src/routes/messages/anthropic-types.ts index 881fffcc8..1395bda6a 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/handler.ts b/src/routes/messages/handler.ts index 85dbf6243..f558e4b88 100644 --- a/src/routes/messages/handler.ts +++ b/src/routes/messages/handler.ts @@ -11,6 +11,10 @@ import { type ChatCompletionChunk, type ChatCompletionResponse, } from "~/services/copilot/create-chat-completions" +import { + createResponses, + type ResponsesApiResponse, +} from "~/services/copilot/create-responses" import { type AnthropicMessagesPayload, @@ -20,6 +24,14 @@ import { translateToAnthropic, translateToOpenAI, } from "./non-stream-translation" +import { + createResponsesStreamState, + translateResponsesStreamEvent, +} from "./responses-stream-translation" +import { + translateAnthropicToResponses, + translateResponsesToAnthropic, +} from "./responses-translation" import { translateChunkToAnthropicEvents } from "./stream-translation" export async function handleCompletion(c: Context) { @@ -38,6 +50,57 @@ export async function handleCompletion(c: Context) { await awaitApproval() } + if (shouldUseResponsesApi(anthropicPayload)) { + const responsesPayload = translateAnthropicToResponses(anthropicPayload) + consola.debug( + "Translated Responses payload:", + JSON.stringify(responsesPayload), + ) + + const response = await createResponses(responsesPayload) + + if (isNonStreamingResponse(response)) { + consola.debug( + "Non-streaming response from Responses API:", + JSON.stringify(response).slice(-400), + ) + const anthropicResponse = translateResponsesToAnthropic(response) + consola.debug( + "Translated Anthropic response:", + JSON.stringify(anthropicResponse), + ) + return c.json(anthropicResponse) + } + + consola.debug("Streaming response from Responses API") + return streamSSE(c, async (stream) => { + const streamState = createResponsesStreamState() + + for await (const rawEvent of response) { + consola.debug("Responses raw stream event:", JSON.stringify(rawEvent)) + if (!rawEvent.data || rawEvent.data === "[DONE]") { + continue + } + + const events = translateResponsesStreamEvent( + JSON.parse(rawEvent.data) as { + type?: string + [key: string]: unknown + }, + streamState, + ) + + for (const event of events) { + consola.debug("Translated Anthropic event:", JSON.stringify(event)) + await stream.writeSSE({ + event: event.type, + data: JSON.stringify(event), + }) + } + } + }) + } + const response = await createChatCompletions(openAIPayload) if (isNonStreaming(response)) { @@ -89,3 +152,19 @@ export async function handleCompletion(c: Context) { const isNonStreaming = ( response: Awaited>, ): response is ChatCompletionResponse => Object.hasOwn(response, "choices") + +const isNonStreamingResponse = ( + response: Awaited>, +): response is ResponsesApiResponse => !(Symbol.asyncIterator in response) + +function shouldUseResponsesApi(payload: AnthropicMessagesPayload): boolean { + return ( + Boolean(payload.thinking) + || payload.messages.some( + (message) => + message.role === "assistant" + && Array.isArray(message.content) + && message.content.some((block) => block.type === "thinking"), + ) + ) +} diff --git a/src/routes/messages/responses-stream-translation.ts b/src/routes/messages/responses-stream-translation.ts new file mode 100644 index 000000000..a2ef09f69 --- /dev/null +++ b/src/routes/messages/responses-stream-translation.ts @@ -0,0 +1,429 @@ +import type { ResponsesApiResponse } from "~/services/copilot/create-responses" + +import type { AnthropicStreamEventData } from "./anthropic-types" + +import { THINKING_TEXT } from "./responses-translation" + +export interface ResponsesStreamState { + messageStartSent: boolean + nextContentBlockIndex: number + openBlockKey?: string + openBlockIndex?: number + blockIndexByKey: Map + blockHasDelta: Set +} + +export function createResponsesStreamState(): ResponsesStreamState { + return { + messageStartSent: false, + nextContentBlockIndex: 0, + blockIndexByKey: new Map(), + blockHasDelta: new Set(), + } +} + +export function translateResponsesStreamEvent( + parsedEvent: { type?: string; [key: string]: unknown }, + state: ResponsesStreamState, +): Array { + switch (parsedEvent.type) { + case "response.created": { + return handleCreated(parsedEvent, state) + } + case "response.reasoning_summary_text.delta": { + return handleThinkingDelta(parsedEvent, state) + } + case "response.output_item.done": { + return handleOutputItemDone(parsedEvent, state) + } + case "response.output_text.delta": { + return handleTextDelta(parsedEvent, state) + } + case "response.output_item.added": { + return handleToolAdded(parsedEvent, state) + } + case "response.function_call_arguments.delta": { + return handleToolArgumentsDelta(parsedEvent, state) + } + case "response.function_call_arguments.done": { + return handleToolArgumentsDone(parsedEvent, state) + } + case "response.completed": { + return handleCompleted(parsedEvent, state) + } + default: { + return [] + } + } +} + +function handleCreated( + parsedEvent: { response?: unknown }, + state: ResponsesStreamState, +): Array { + if (state.messageStartSent || !isResponseObject(parsedEvent.response)) { + return [] + } + + state.messageStartSent = true + const cachedTokens = + parsedEvent.response.usage?.input_tokens_details?.cached_tokens + + return [ + { + type: "message_start", + message: { + id: parsedEvent.response.id, + type: "message", + role: "assistant", + content: [], + model: parsedEvent.response.model, + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: + (parsedEvent.response.usage?.input_tokens ?? 0) + - (cachedTokens ?? 0), + output_tokens: 0, + ...(cachedTokens !== undefined && { + cache_read_input_tokens: cachedTokens, + }), + }, + }, + }, + ] +} + +function handleThinkingDelta( + parsedEvent: { output_index?: unknown; delta?: unknown }, + state: ResponsesStreamState, +): Array { + if ( + typeof parsedEvent.output_index !== "number" + || typeof parsedEvent.delta !== "string" + ) { + return [] + } + + const events: Array = [] + const blockIndex = openBlock({ + state, + key: `thinking:${parsedEvent.output_index}`, + contentBlock: { + type: "thinking", + thinking: "", + }, + events, + }) + + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "thinking_delta", + thinking: parsedEvent.delta, + }, + }) + state.blockHasDelta.add(blockIndex) + return events +} + +function handleOutputItemDone( + parsedEvent: { output_index?: unknown; item?: unknown }, + state: ResponsesStreamState, +): Array { + if ( + typeof parsedEvent.output_index !== "number" + || !isRecord(parsedEvent.item) + ) { + return [] + } + + if ( + parsedEvent.item.type !== "reasoning" + || typeof parsedEvent.item.encrypted_content !== "string" + ) { + return [] + } + + const events: Array = [] + const blockIndex = openBlock({ + state, + key: `thinking:${parsedEvent.output_index}`, + contentBlock: { + type: "thinking", + thinking: "", + }, + events, + }) + + if (!state.blockHasDelta.has(blockIndex)) { + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "thinking_delta", + thinking: THINKING_TEXT, + }, + }) + } + + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "signature_delta", + signature: + typeof parsedEvent.item.id === "string" ? + `${parsedEvent.item.encrypted_content}@${parsedEvent.item.id}` + : parsedEvent.item.encrypted_content, + }, + }) + state.blockHasDelta.add(blockIndex) + return events +} + +function handleTextDelta( + parsedEvent: { + output_index?: unknown + content_index?: unknown + delta?: unknown + }, + state: ResponsesStreamState, +): Array { + if ( + typeof parsedEvent.output_index !== "number" + || typeof parsedEvent.content_index !== "number" + || typeof parsedEvent.delta !== "string" + ) { + return [] + } + + const events: Array = [] + const blockIndex = openBlock({ + state, + key: `text:${parsedEvent.output_index}:${parsedEvent.content_index}`, + contentBlock: { type: "text", text: "" }, + events, + }) + + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "text_delta", + text: parsedEvent.delta, + }, + }) + state.blockHasDelta.add(blockIndex) + return events +} + +function handleToolAdded( + parsedEvent: { + output_index?: unknown + item?: unknown + }, + state: ResponsesStreamState, +): Array { + if ( + typeof parsedEvent.output_index !== "number" + || !isRecord(parsedEvent.item) + || parsedEvent.item.type !== "function_call" + || typeof parsedEvent.item.call_id !== "string" + || typeof parsedEvent.item.name !== "string" + ) { + return [] + } + + const events: Array = [] + openBlock({ + state, + key: `tool:${parsedEvent.output_index}`, + contentBlock: { + type: "tool_use", + id: parsedEvent.item.call_id, + name: parsedEvent.item.name, + input: {}, + }, + events, + }) + return events +} + +function handleToolArgumentsDelta( + parsedEvent: { + output_index?: unknown + delta?: unknown + }, + state: ResponsesStreamState, +): Array { + if ( + typeof parsedEvent.output_index !== "number" + || typeof parsedEvent.delta !== "string" + ) { + return [] + } + + const blockIndex = state.blockIndexByKey.get( + `tool:${parsedEvent.output_index}`, + ) + if (blockIndex === undefined) { + return [] + } + + return [ + { + type: "content_block_delta", + index: blockIndex, + delta: { + type: "input_json_delta", + partial_json: parsedEvent.delta, + }, + }, + ] +} + +function handleToolArgumentsDone( + parsedEvent: { + output_index?: unknown + arguments?: unknown + }, + state: ResponsesStreamState, +): Array { + if ( + typeof parsedEvent.output_index !== "number" + || typeof parsedEvent.arguments !== "string" + ) { + return [] + } + + const blockIndex = state.blockIndexByKey.get( + `tool:${parsedEvent.output_index}`, + ) + if (blockIndex === undefined || state.blockHasDelta.has(blockIndex)) { + return [] + } + + state.blockHasDelta.add(blockIndex) + return [ + { + type: "content_block_delta", + index: blockIndex, + delta: { + type: "input_json_delta", + partial_json: parsedEvent.arguments, + }, + }, + ] +} + +function handleCompleted( + parsedEvent: { response?: unknown }, + state: ResponsesStreamState, +): Array { + if (!isResponseObject(parsedEvent.response)) { + return [] + } + + const events: Array = [] + closeOpenBlock(state, events) + const cachedTokens = + parsedEvent.response.usage?.input_tokens_details?.cached_tokens + + events.push( + { + type: "message_delta", + delta: { + stop_reason: + ( + parsedEvent.response.output?.some( + (item) => item.type === "function_call", + ) + ) ? + "tool_use" + : "end_turn", + stop_sequence: null, + }, + usage: { + input_tokens: + (parsedEvent.response.usage?.input_tokens ?? 0) - (cachedTokens ?? 0), + output_tokens: parsedEvent.response.usage?.output_tokens ?? 0, + ...(cachedTokens !== undefined && { + cache_read_input_tokens: cachedTokens, + }), + }, + }, + { + type: "message_stop", + }, + ) + + return events +} + +function openBlock(params: { + state: ResponsesStreamState + key: string + contentBlock: + | { type: "thinking"; thinking: string } + | { type: "text"; text: string } + | { + type: "tool_use" + id: string + name: string + input: Record + } + events: Array +}): number { + const { state, key, contentBlock, events } = params + let blockIndex = state.blockIndexByKey.get(key) + if (blockIndex === undefined) { + blockIndex = state.nextContentBlockIndex + state.nextContentBlockIndex += 1 + state.blockIndexByKey.set(key, blockIndex) + } + + if (state.openBlockKey === key) { + return blockIndex + } + + closeOpenBlock(state, events) + events.push({ + type: "content_block_start", + index: blockIndex, + content_block: contentBlock, + }) + state.openBlockKey = key + state.openBlockIndex = blockIndex + return blockIndex +} + +function closeOpenBlock( + state: ResponsesStreamState, + events: Array, +): void { + if (state.openBlockIndex === undefined) { + return + } + + events.push({ + type: "content_block_stop", + index: state.openBlockIndex, + }) + state.blockHasDelta.delete(state.openBlockIndex) + state.openBlockKey = undefined + state.openBlockIndex = undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function isResponseObject(value: unknown): value is ResponsesApiResponse { + return ( + isRecord(value) + && typeof value.id === "string" + && typeof value.model === "string" + ) +} diff --git a/src/routes/messages/responses-translation.ts b/src/routes/messages/responses-translation.ts new file mode 100644 index 000000000..fdf20d10c --- /dev/null +++ b/src/routes/messages/responses-translation.ts @@ -0,0 +1,405 @@ +import type { + ResponseInputItem, + ResponseInputContentPart, + ResponsesApiResponse, + ResponsesOutputItem, + ResponsesPayload, + ResponsesReasoningItem, +} from "~/services/copilot/create-responses" + +import type { + AnthropicAssistantContentBlock, + AnthropicAssistantMessage, + AnthropicMessage, + AnthropicMessagesPayload, + AnthropicResponse, + AnthropicTextBlock, + AnthropicThinkingBlock, + AnthropicTool, + AnthropicToolResultBlock, + AnthropicToolUseBlock, + AnthropicUserContentBlock, + AnthropicUserMessage, +} from "./anthropic-types" + +export const THINKING_TEXT = "Thinking..." + +type InputItem = ResponseInputItem + +export function translateAnthropicToResponses( + payload: AnthropicMessagesPayload, +): ResponsesPayload { + const input = [ + ...translateSystemPrompt(payload.system), + ...payload.messages.flatMap((message) => translateMessage(message)), + ] + + return { + model: payload.model, + input, + stream: payload.stream, + temperature: payload.temperature, + top_p: payload.top_p, + max_output_tokens: payload.max_tokens, + stop: payload.stop_sequences, + tools: translateAnthropicTools(payload.tools) as Array | undefined, + tool_choice: translateAnthropicToolChoice(payload.tool_choice), + user: payload.metadata?.user_id, + reasoning: { + summary: "detailed", + }, + include: ["reasoning.encrypted_content"], + } +} + +export function translateResponsesToAnthropic( + response: ResponsesApiResponse, +): AnthropicResponse { + const content = response.output?.flatMap((item) => mapOutputItem(item)) ?? [] + + const anthropicContent = + content.length > 0 || !response.output_text ? + content + : [{ type: "text", text: response.output_text }] + + return { + id: response.id, + type: "message", + role: "assistant", + content: anthropicContent, + model: response.model, + stop_reason: getStopReason(response), + stop_sequence: null, + usage: { + input_tokens: + (response.usage?.input_tokens ?? 0) + - (response.usage?.input_tokens_details?.cached_tokens ?? 0), + output_tokens: response.usage?.output_tokens ?? 0, + ...(response.usage?.input_tokens_details?.cached_tokens !== undefined && { + cache_read_input_tokens: + response.usage.input_tokens_details.cached_tokens, + }), + }, + } +} + +function translateSystemPrompt( + system: AnthropicMessagesPayload["system"], +): Array { + if (!system) { + return [] + } + + if (typeof system === "string") { + return [{ role: "system", content: system }] + } + + return [ + { + role: "system", + content: system.map((block) => block.text).join("\n\n"), + }, + ] +} + +function translateMessage(message: AnthropicMessage): Array { + return message.role === "user" ? + translateUserMessage(message) + : translateAssistantMessage(message) +} + +function translateUserMessage(message: AnthropicUserMessage): Array { + if (typeof message.content === "string") { + return [{ role: "user", content: message.content }] + } + + const toolResults = message.content.filter( + (block): block is AnthropicToolResultBlock => block.type === "tool_result", + ) + const otherBlocks = message.content.filter( + (block) => block.type !== "tool_result", + ) + + const translated = toolResults.map((block) => ({ + role: "tool" as const, + tool_call_id: block.tool_use_id, + content: typeof block.content === "string" ? block.content : "", + })) + + if (otherBlocks.length === 0) { + return translated + } + + return [ + ...translated, + { + role: "user", + content: mapUserContent(otherBlocks), + }, + ] +} + +function translateAssistantMessage( + message: AnthropicAssistantMessage, +): Array { + if (typeof message.content === "string") { + return [{ role: "assistant", content: message.content }] + } + + const reasoningItems = message.content.flatMap((block) => + block.type === "thinking" && block.signature ? + [translateThinkingBlock(block)] + : [], + ) + const text = message.content + .filter( + (block): block is AnthropicTextBlock | AnthropicThinkingBlock => + block.type === "text" + || (block.type === "thinking" && !block.signature), + ) + .map((block) => (block.type === "text" ? block.text : block.thinking)) + .join("\n\n") + const toolCalls = message.content + .filter( + (block): block is AnthropicToolUseBlock => block.type === "tool_use", + ) + .map((block) => ({ + id: block.id, + type: "function" as const, + function: { + name: block.name, + arguments: JSON.stringify(block.input), + }, + })) + + if (text.length === 0 && toolCalls.length === 0) { + return reasoningItems + } + + return [ + ...reasoningItems, + { + role: "assistant", + content: text, + ...(toolCalls.length > 0 && { tool_calls: toolCalls }), + }, + ] +} + +function mapUserContent( + content: Array, +): string | Array { + const hasImage = content.some((block) => block.type === "image") + if (!hasImage) { + return content + .filter((block): block is AnthropicTextBlock => block.type === "text") + .map((block) => block.text) + .join("\n\n") + } + + return content.flatMap((block) => { + if (block.type === "text") { + return [{ type: "input_text" as const, text: block.text }] + } + + if (block.type === "image") { + return [ + { + type: "input_image" as const, + image_url: `data:${block.source.media_type};base64,${block.source.data}`, + detail: "auto" as const, + }, + ] + } + + return [] + }) +} + +function translateThinkingBlock(block: AnthropicThinkingBlock) { + const { encryptedContent, id } = parseThinkingSignature(block.signature) + return { + type: "reasoning" as const, + encrypted_content: encryptedContent, + ...(id ? { id } : {}), + summary: + block.thinking && block.thinking !== THINKING_TEXT ? + [{ type: "summary_text" as const, text: block.thinking }] + : [], + } +} + +function mapOutputItem( + item: ResponsesOutputItem, +): Array { + if (item.type === "reasoning") { + return mapReasoningItem(item) + } + + if (item.type === "function_call") { + if (!item.call_id || !item.name) { + return [] + } + + return [ + { + type: "tool_use", + id: item.call_id, + name: item.name, + input: parseFunctionArguments(item.arguments), + }, + ] + } + + const text = extractMessageText(item.content) + return text.length > 0 ? [{ type: "text", text }] : [] +} + +function mapReasoningItem( + item: ResponsesReasoningItem, +): Array { + const signature = buildThinkingSignature(item.encrypted_content, item.id) + if (!signature) { + return [] + } + + return [ + { + type: "thinking", + thinking: extractThinkingText(item.summary), + signature, + }, + ] +} + +function parseThinkingSignature(signature: string): { + encryptedContent: string + id: string | undefined +} { + const separatorIndex = signature.lastIndexOf("@") + if (separatorIndex <= 0 || separatorIndex === signature.length - 1) { + return { encryptedContent: signature, id: undefined } + } + + return { + encryptedContent: signature.slice(0, separatorIndex), + id: signature.slice(separatorIndex + 1), + } +} + +function extractThinkingText( + summary: Array<{ type: string; text?: string }> | undefined, +): string { + const text = summary + ?.filter((block) => typeof block.text === "string") + .map((block) => block.text) + .join("") + .trim() + + return text && text.length > 0 ? text : THINKING_TEXT +} + +function buildThinkingSignature( + encryptedContent: string | undefined, + id: string | undefined, +): string | undefined { + if (!encryptedContent) { + return undefined + } + + return id ? `${encryptedContent}@${id}` : encryptedContent +} + +function extractMessageText( + content: string | Array<{ type: string; text?: string }>, +): string { + if (typeof content === "string") { + return content + } + + return content + .filter((part) => part.type === "output_text" || part.type === "text") + .map((part) => part.text ?? "") + .join("") +} + +function parseFunctionArguments( + argumentsText: string | undefined, +): Record { + if (!argumentsText) { + return {} + } + + try { + return JSON.parse(argumentsText) as Record + } catch { + return {} + } +} + +function translateAnthropicTools( + anthropicTools: Array | undefined, +): + | Array<{ + type: "function" + function: { + name: string + description?: string + parameters: Record + } + }> + | undefined { + if (!anthropicTools) { + return undefined + } + + return anthropicTools.map((tool) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.input_schema, + }, + })) +} + +function translateAnthropicToolChoice( + anthropicToolChoice: AnthropicMessagesPayload["tool_choice"], +): + | "auto" + | "required" + | "none" + | { type: "function"; function: { name: string } } + | undefined { + if (!anthropicToolChoice) { + return undefined + } + + switch (anthropicToolChoice.type) { + case "auto": { + return "auto" + } + case "any": { + return "required" + } + case "tool": { + return anthropicToolChoice.name ? + { type: "function", function: { name: anthropicToolChoice.name } } + : "auto" + } + case "none": { + return "none" + } + default: { + return undefined + } + } +} + +function getStopReason( + response: ResponsesApiResponse, +): AnthropicResponse["stop_reason"] { + return response.output?.some((item) => item.type === "function_call") ? + "tool_use" + : "end_turn" +} diff --git a/src/services/copilot/create-responses.ts b/src/services/copilot/create-responses.ts index f1c12316e..e07b7415c 100644 --- a/src/services/copilot/create-responses.ts +++ b/src/services/copilot/create-responses.ts @@ -34,7 +34,7 @@ export const createResponses = async (payload: ResponsesPayload) => { export interface ResponsesPayload { model: string - input: string | Array + input: string | Array stream?: boolean | null temperature?: number | null top_p?: number | null @@ -46,9 +46,13 @@ export interface ResponsesPayload { reasoning_effort?: ModelLevel | null reasoning?: { effort?: ModelLevel + summary?: string } | null + include?: Array | null } +export type ResponseInputItem = ResponseInputMessage | ResponseInputReasoning + export interface ResponseInputMessage { role: "user" | "assistant" | "system" | "tool" | "developer" content: string | Array @@ -57,6 +61,16 @@ export interface ResponseInputMessage { tool_calls?: Array } +export interface ResponseInputReasoning { + type: "reasoning" + encrypted_content: string + id?: string + summary?: Array<{ + type: "summary_text" + text: string + }> +} + export interface ResponseInputContentPart { type: "input_text" | "input_image" text?: string @@ -84,10 +98,16 @@ export interface ResponsesApiResponse { input_tokens?: number output_tokens?: number total_tokens?: number + input_tokens_details?: { + cached_tokens?: number + } } } -export type ResponsesOutputItem = ResponsesOutputMessage | ResponsesFunctionCall +export type ResponsesOutputItem = + | ResponsesOutputMessage + | ResponsesFunctionCall + | ResponsesReasoningItem export interface ResponsesOutputMessage { type: "message" @@ -123,6 +143,16 @@ export interface ResponsesFunctionCall { status?: string } +export interface ResponsesReasoningItem { + type: "reasoning" + id?: string + encrypted_content?: string + summary?: Array<{ + type: string + text?: string + }> +} + function hasVisionInput(input: ResponsesPayload["input"]): boolean { if (!Array.isArray(input)) { return false @@ -130,7 +160,8 @@ function hasVisionInput(input: ResponsesPayload["input"]): boolean { return input.some( (message) => - Array.isArray(message.content) + isResponseInputMessage(message) + && Array.isArray(message.content) && message.content.some((part) => part.type === "input_image"), ) } @@ -140,5 +171,15 @@ function hasAgentInput(input: ResponsesPayload["input"]): boolean { return false } - return input.some((message) => ["assistant", "tool"].includes(message.role)) + return input.some( + (message) => + isResponseInputMessage(message) + && ["assistant", "tool"].includes(message.role), + ) +} + +function isResponseInputMessage( + value: ResponseInputItem, +): value is ResponseInputMessage { + return "role" in value } diff --git a/tests/messages-responses-translation.test.ts b/tests/messages-responses-translation.test.ts new file mode 100644 index 000000000..ad6b93bb7 --- /dev/null +++ b/tests/messages-responses-translation.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, test } from "bun:test" + +import type { AnthropicMessagesPayload } from "~/routes/messages/anthropic-types" +import type { ResponsesApiResponse } from "~/services/copilot/create-responses" + +import { + createResponsesStreamState, + translateResponsesStreamEvent, +} from "~/routes/messages/responses-stream-translation" +import { + THINKING_TEXT, + translateAnthropicToResponses, + translateResponsesToAnthropic, +} from "~/routes/messages/responses-translation" + +describe("Anthropic messages Responses request translation", () => { + test("preserves thinking signatures in responses input", () => { + const payload: AnthropicMessagesPayload = { + model: "claude-sonnet-4.6", + messages: [ + { + role: "assistant", + content: [ + { + type: "thinking", + thinking: "I should reuse the prior reasoning state.", + signature: "encrypted-thought@rs_123", + }, + { + type: "text", + text: "I already checked that.", + }, + ], + }, + { + role: "user", + content: "What about the next step?", + }, + ], + max_tokens: 512, + thinking: { + type: "enabled", + budget_tokens: 2048, + }, + } + + const translated = translateAnthropicToResponses(payload) + expect(translated.include).toEqual(["reasoning.encrypted_content"]) + expect(translated.reasoning).toEqual({ summary: "detailed" }) + expect(Array.isArray(translated.input)).toBe(true) + + if (!Array.isArray(translated.input)) { + throw new TypeError("Expected translated input to be an array") + } + + expect(translated.input[0]).toMatchObject({ + type: "reasoning", + encrypted_content: "encrypted-thought", + id: "rs_123", + summary: [ + { + type: "summary_text", + text: "I should reuse the prior reasoning state.", + }, + ], + }) + expect(translated.input[1]).toMatchObject({ + role: "assistant", + content: "I already checked that.", + }) + expect(translated.input[2]).toMatchObject({ + role: "user", + content: "What about the next step?", + }) + }) +}) + +describe("Anthropic messages Responses response translation", () => { + test("translates responses reasoning output back to anthropic thinking", () => { + const response: ResponsesApiResponse = { + id: "resp_123", + object: "response", + model: "claude-sonnet-4.6", + output: [ + { + type: "reasoning", + id: "rs_123", + encrypted_content: "encrypted-thought", + summary: [ + { + type: "summary_text", + text: "I should reuse the prior reasoning state.", + }, + ], + }, + { + type: "message", + role: "assistant", + content: [ + { + type: "output_text", + text: "Here is the final answer.", + }, + ], + }, + ], + usage: { + input_tokens: 10, + output_tokens: 4, + total_tokens: 14, + }, + } + + const translated = translateResponsesToAnthropic(response) + + expect(translated.content).toEqual([ + { + type: "thinking", + thinking: "I should reuse the prior reasoning state.", + signature: "encrypted-thought@rs_123", + }, + { + type: "text", + text: "Here is the final answer.", + }, + ]) + expect(translated.stop_reason).toBe("end_turn") + }) + + test("streams reasoning summary and signature as anthropic thinking deltas", () => { + const state = createResponsesStreamState() + const events = [ + { + type: "response.created", + response: { + id: "resp_123", + object: "response", + model: "claude-sonnet-4.6", + usage: { + input_tokens: 10, + output_tokens: 0, + }, + }, + }, + { + type: "response.reasoning_summary_text.delta", + output_index: 0, + delta: "Tracing the previous reasoning.", + }, + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "reasoning", + id: "rs_123", + encrypted_content: "encrypted-thought", + }, + }, + { + type: "response.completed", + response: { + id: "resp_123", + object: "response", + model: "claude-sonnet-4.6", + output: [ + { + type: "reasoning", + id: "rs_123", + encrypted_content: "encrypted-thought", + }, + ], + usage: { + input_tokens: 10, + output_tokens: 4, + }, + }, + }, + ].flatMap((event) => translateResponsesStreamEvent(event, state)) + + expect(events[0]).toMatchObject({ + type: "message_start", + message: { + id: "resp_123", + }, + }) + expect(events[1]).toEqual({ + type: "content_block_start", + index: 0, + content_block: { + type: "thinking", + thinking: "", + }, + }) + expect(events[2]).toEqual({ + type: "content_block_delta", + index: 0, + delta: { + type: "thinking_delta", + thinking: "Tracing the previous reasoning.", + }, + }) + expect(events[3]).toEqual({ + type: "content_block_delta", + index: 0, + delta: { + type: "signature_delta", + signature: "encrypted-thought@rs_123", + }, + }) + expect(events.at(-2)).toMatchObject({ + type: "message_delta", + delta: { + stop_reason: "end_turn", + }, + usage: { + input_tokens: 10, + output_tokens: 4, + }, + }) + expect(events.at(-1)).toEqual({ + type: "message_stop", + }) + }) + + test("uses placeholder thinking text when reasoning summary is absent", () => { + const translated = translateResponsesToAnthropic({ + id: "resp_456", + object: "response", + model: "claude-sonnet-4.6", + output: [ + { + type: "reasoning", + id: "rs_456", + encrypted_content: "opaque-thought", + }, + ], + usage: { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2, + }, + }) + + expect(translated.content[0]).toEqual({ + type: "thinking", + thinking: THINKING_TEXT, + signature: "opaque-thought@rs_456", + }) + }) +}) From 5582512891ce3db708d2776c073ba8459747031e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 15:57:51 +0000 Subject: [PATCH 3/5] test: cover reopened reasoning signature streams Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- .../messages/responses-stream-translation.ts | 1 - src/routes/messages/responses-translation.ts | 4 +- tests/messages-responses-translation.test.ts | 45 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/routes/messages/responses-stream-translation.ts b/src/routes/messages/responses-stream-translation.ts index a2ef09f69..28f685d8b 100644 --- a/src/routes/messages/responses-stream-translation.ts +++ b/src/routes/messages/responses-stream-translation.ts @@ -411,7 +411,6 @@ function closeOpenBlock( type: "content_block_stop", index: state.openBlockIndex, }) - state.blockHasDelta.delete(state.openBlockIndex) state.openBlockKey = undefined state.openBlockIndex = undefined } diff --git a/src/routes/messages/responses-translation.ts b/src/routes/messages/responses-translation.ts index fdf20d10c..afd5dad71 100644 --- a/src/routes/messages/responses-translation.ts +++ b/src/routes/messages/responses-translation.ts @@ -216,7 +216,9 @@ function mapUserContent( }) } -function translateThinkingBlock(block: AnthropicThinkingBlock) { +function translateThinkingBlock( + block: AnthropicThinkingBlock & { signature: string }, +) { const { encryptedContent, id } = parseThinkingSignature(block.signature) return { type: "reasoning" as const, diff --git a/tests/messages-responses-translation.test.ts b/tests/messages-responses-translation.test.ts index ad6b93bb7..039847002 100644 --- a/tests/messages-responses-translation.test.ts +++ b/tests/messages-responses-translation.test.ts @@ -126,7 +126,9 @@ describe("Anthropic messages Responses response translation", () => { ]) expect(translated.stop_reason).toBe("end_turn") }) +}) +describe("Anthropic messages Responses stream translation", () => { test("streams reasoning summary and signature as anthropic thinking deltas", () => { const state = createResponsesStreamState() const events = [ @@ -222,6 +224,49 @@ describe("Anthropic messages Responses response translation", () => { }) }) + test("does not inject placeholder thinking text when a signed block reopens", () => { + const state = createResponsesStreamState() + const events = [ + { + type: "response.reasoning_summary_text.delta", + output_index: 0, + delta: "Real reasoning text.", + }, + { + type: "response.output_text.delta", + output_index: 1, + content_index: 0, + delta: "Visible answer.", + }, + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "reasoning", + id: "rs_789", + encrypted_content: "opaque-signature", + }, + }, + ].flatMap((event) => translateResponsesStreamEvent(event, state)) + + const placeholderEvents = events.filter( + (event) => + event.type === "content_block_delta" + && event.delta.type === "thinking_delta" + && event.delta.thinking === THINKING_TEXT, + ) + + expect(placeholderEvents).toHaveLength(0) + expect(events.at(-1)).toEqual({ + type: "content_block_delta", + index: 0, + delta: { + type: "signature_delta", + signature: "opaque-signature@rs_789", + }, + }) + }) + test("uses placeholder thinking text when reasoning summary is absent", () => { const translated = translateResponsesToAnthropic({ id: "resp_456", From eb068258a5302b7494cd83cba2c693dfb5fc68b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:38:48 +0000 Subject: [PATCH 4/5] fix: address responses stream review feedback Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- src/routes/messages/handler.ts | 68 +++++++-- src/routes/messages/non-stream-translation.ts | 2 +- .../messages/responses-stream-translation.ts | 138 ++++++++++++------ src/routes/messages/responses-translation.ts | 4 +- tests/messages-responses-translation.test.ts | 129 +++++++++++++++- 5 files changed, 279 insertions(+), 62 deletions(-) diff --git a/src/routes/messages/handler.ts b/src/routes/messages/handler.ts index f558e4b88..54092778c 100644 --- a/src/routes/messages/handler.ts +++ b/src/routes/messages/handler.ts @@ -34,6 +34,13 @@ import { } from "./responses-translation" import { translateChunkToAnthropicEvents } from "./stream-translation" +const DEBUG_LOG_LENGTH = 400 + +type ResponsesStreamEvent = { + type?: string + [key: string]: unknown +} + export async function handleCompletion(c: Context) { await checkRateLimit(state) @@ -54,7 +61,7 @@ export async function handleCompletion(c: Context) { const responsesPayload = translateAnthropicToResponses(anthropicPayload) consola.debug( "Translated Responses payload:", - JSON.stringify(responsesPayload), + stringifyResponsesDebug(responsesPayload), ) const response = await createResponses(responsesPayload) @@ -62,12 +69,12 @@ export async function handleCompletion(c: Context) { if (isNonStreamingResponse(response)) { consola.debug( "Non-streaming response from Responses API:", - JSON.stringify(response).slice(-400), + stringifyResponsesDebug(response), ) const anthropicResponse = translateResponsesToAnthropic(response) consola.debug( "Translated Anthropic response:", - JSON.stringify(anthropicResponse), + stringifyResponsesDebug(anthropicResponse), ) return c.json(anthropicResponse) } @@ -77,21 +84,22 @@ export async function handleCompletion(c: Context) { const streamState = createResponsesStreamState() for await (const rawEvent of response) { - consola.debug("Responses raw stream event:", JSON.stringify(rawEvent)) if (!rawEvent.data || rawEvent.data === "[DONE]") { continue } - const events = translateResponsesStreamEvent( - JSON.parse(rawEvent.data) as { - type?: string - [key: string]: unknown - }, - streamState, + const parsedEvent = JSON.parse(rawEvent.data) as ResponsesStreamEvent + consola.debug( + "Responses raw stream event:", + stringifyResponsesDebug(parsedEvent), ) + const events = translateResponsesStreamEvent(parsedEvent, streamState) for (const event of events) { - consola.debug("Translated Anthropic event:", JSON.stringify(event)) + consola.debug( + "Translated Anthropic event:", + stringifyResponsesDebug(event), + ) await stream.writeSSE({ event: event.type, data: JSON.stringify(event), @@ -168,3 +176,41 @@ function shouldUseResponsesApi(payload: AnthropicMessagesPayload): boolean { ) ) } + +function stringifyResponsesDebug(value: unknown): string { + const serialized = JSON.stringify( + value, + (key: string, nestedValue: unknown): unknown => { + if ( + key === "encrypted_content" + || key === "signature" + || key === "thinking_signature" + ) { + return "[REDACTED]" + } + + if ( + key === "image_url" + && typeof nestedValue === "string" + && nestedValue.startsWith("data:") + ) { + return "[REDACTED_DATA_URL]" + } + + if ( + typeof nestedValue === "string" + && nestedValue.length > DEBUG_LOG_LENGTH + ) { + return `${nestedValue.slice(0, DEBUG_LOG_LENGTH)}…` + } + + return nestedValue + }, + ) + + if (serialized.length <= DEBUG_LOG_LENGTH) { + return serialized + } + + return `${serialized.slice(0, DEBUG_LOG_LENGTH)}…` +} diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index 30e1506bc..407b2b7d6 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -47,7 +47,7 @@ export function translateToOpenAI( } } -function translateModelName(model: string): string { +export function translateModelName(model: string): string { // Subagent requests use a specific model number which Copilot doesn't support if ( model.startsWith("claude-sonnet-4-") diff --git a/src/routes/messages/responses-stream-translation.ts b/src/routes/messages/responses-stream-translation.ts index 28f685d8b..3cde9e58b 100644 --- a/src/routes/messages/responses-stream-translation.ts +++ b/src/routes/messages/responses-stream-translation.ts @@ -9,16 +9,21 @@ export interface ResponsesStreamState { nextContentBlockIndex: number openBlockKey?: string openBlockIndex?: number - blockIndexByKey: Map - blockHasDelta: Set + blockHasDelta: Set + toolBlockByKey: Map +} + +interface ToolBlockState { + id: string + name: string } export function createResponsesStreamState(): ResponsesStreamState { return { messageStartSent: false, nextContentBlockIndex: 0, - blockIndexByKey: new Map(), blockHasDelta: new Set(), + toolBlockByKey: new Map(), } } @@ -106,9 +111,10 @@ function handleThinkingDelta( } const events: Array = [] + const key = getThinkingKey(parsedEvent.output_index) const blockIndex = openBlock({ state, - key: `thinking:${parsedEvent.output_index}`, + key, contentBlock: { type: "thinking", thinking: "", @@ -124,7 +130,7 @@ function handleThinkingDelta( thinking: parsedEvent.delta, }, }) - state.blockHasDelta.add(blockIndex) + state.blockHasDelta.add(key) return events } @@ -147,9 +153,10 @@ function handleOutputItemDone( } const events: Array = [] + const key = getThinkingKey(parsedEvent.output_index) const blockIndex = openBlock({ state, - key: `thinking:${parsedEvent.output_index}`, + key, contentBlock: { type: "thinking", thinking: "", @@ -157,7 +164,7 @@ function handleOutputItemDone( events, }) - if (!state.blockHasDelta.has(blockIndex)) { + if (!state.blockHasDelta.has(key)) { events.push({ type: "content_block_delta", index: blockIndex, @@ -179,7 +186,7 @@ function handleOutputItemDone( : parsedEvent.item.encrypted_content, }, }) - state.blockHasDelta.add(blockIndex) + state.blockHasDelta.add(key) return events } @@ -200,9 +207,10 @@ function handleTextDelta( } const events: Array = [] + const key = getTextKey(parsedEvent.output_index, parsedEvent.content_index) const blockIndex = openBlock({ state, - key: `text:${parsedEvent.output_index}:${parsedEvent.content_index}`, + key, contentBlock: { type: "text", text: "" }, events, }) @@ -215,7 +223,7 @@ function handleTextDelta( text: parsedEvent.delta, }, }) - state.blockHasDelta.add(blockIndex) + state.blockHasDelta.add(key) return events } @@ -237,9 +245,14 @@ function handleToolAdded( } const events: Array = [] + const key = getToolKey(parsedEvent.output_index) + state.toolBlockByKey.set(key, { + id: parsedEvent.item.call_id, + name: parsedEvent.item.name, + }) openBlock({ state, - key: `tool:${parsedEvent.output_index}`, + key, contentBlock: { type: "tool_use", id: parsedEvent.item.call_id, @@ -265,23 +278,36 @@ function handleToolArgumentsDelta( return [] } - const blockIndex = state.blockIndexByKey.get( - `tool:${parsedEvent.output_index}`, - ) - if (blockIndex === undefined) { + const key = getToolKey(parsedEvent.output_index) + const toolBlock = state.toolBlockByKey.get(key) + if (!toolBlock) { return [] } - return [ - { - type: "content_block_delta", - index: blockIndex, - delta: { - type: "input_json_delta", - partial_json: parsedEvent.delta, - }, + const events: Array = [] + const blockIndex = openBlock({ + state, + key, + contentBlock: { + type: "tool_use", + id: toolBlock.id, + name: toolBlock.name, + input: {}, }, - ] + events, + }) + + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "input_json_delta", + partial_json: parsedEvent.delta, + }, + }) + state.blockHasDelta.add(key) + + return events } function handleToolArgumentsDone( @@ -298,24 +324,35 @@ function handleToolArgumentsDone( return [] } - const blockIndex = state.blockIndexByKey.get( - `tool:${parsedEvent.output_index}`, - ) - if (blockIndex === undefined || state.blockHasDelta.has(blockIndex)) { + const key = getToolKey(parsedEvent.output_index) + const toolBlock = state.toolBlockByKey.get(key) + if (!toolBlock || state.blockHasDelta.has(key)) { return [] } - state.blockHasDelta.add(blockIndex) - return [ - { - type: "content_block_delta", - index: blockIndex, - delta: { - type: "input_json_delta", - partial_json: parsedEvent.arguments, - }, + const events: Array = [] + const blockIndex = openBlock({ + state, + key, + contentBlock: { + type: "tool_use", + id: toolBlock.id, + name: toolBlock.name, + input: {}, }, - ] + events, + }) + + state.blockHasDelta.add(key) + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "input_json_delta", + partial_json: parsedEvent.arguments, + }, + }) + return events } function handleCompleted( @@ -377,17 +414,12 @@ function openBlock(params: { events: Array }): number { const { state, key, contentBlock, events } = params - let blockIndex = state.blockIndexByKey.get(key) - if (blockIndex === undefined) { - blockIndex = state.nextContentBlockIndex - state.nextContentBlockIndex += 1 - state.blockIndexByKey.set(key, blockIndex) - } - - if (state.openBlockKey === key) { - return blockIndex + if (state.openBlockKey === key && state.openBlockIndex !== undefined) { + return state.openBlockIndex } + const blockIndex = state.nextContentBlockIndex + state.nextContentBlockIndex += 1 closeOpenBlock(state, events) events.push({ type: "content_block_start", @@ -415,6 +447,18 @@ function closeOpenBlock( state.openBlockIndex = undefined } +function getThinkingKey(outputIndex: number): string { + return `thinking:${outputIndex}` +} + +function getTextKey(outputIndex: number, contentIndex: number): string { + return `text:${outputIndex}:${contentIndex}` +} + +function getToolKey(outputIndex: number): string { + return `tool:${outputIndex}` +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null } diff --git a/src/routes/messages/responses-translation.ts b/src/routes/messages/responses-translation.ts index afd5dad71..0ac3d004a 100644 --- a/src/routes/messages/responses-translation.ts +++ b/src/routes/messages/responses-translation.ts @@ -22,6 +22,8 @@ import type { AnthropicUserMessage, } from "./anthropic-types" +import { translateModelName } from "./non-stream-translation" + export const THINKING_TEXT = "Thinking..." type InputItem = ResponseInputItem @@ -35,7 +37,7 @@ export function translateAnthropicToResponses( ] return { - model: payload.model, + model: translateModelName(payload.model), input, stream: payload.stream, temperature: payload.temperature, diff --git a/tests/messages-responses-translation.test.ts b/tests/messages-responses-translation.test.ts index 039847002..bed8cb97e 100644 --- a/tests/messages-responses-translation.test.ts +++ b/tests/messages-responses-translation.test.ts @@ -73,6 +73,20 @@ describe("Anthropic messages Responses request translation", () => { content: "What about the next step?", }) }) + + test("normalizes unsupported Claude subagent models on responses path", () => { + const translated = translateAnthropicToResponses({ + model: "claude-sonnet-4-20250110", + messages: [{ role: "user", content: "hi" }], + max_tokens: 64, + thinking: { + type: "enabled", + budget_tokens: 1024, + }, + }) + + expect(translated.model).toBe("claude-sonnet-4") + }) }) describe("Anthropic messages Responses response translation", () => { @@ -128,7 +142,7 @@ describe("Anthropic messages Responses response translation", () => { }) }) -describe("Anthropic messages Responses stream translation", () => { +describe("Anthropic messages Responses reasoning stream translation", () => { test("streams reasoning summary and signature as anthropic thinking deltas", () => { const state = createResponsesStreamState() const events = [ @@ -257,16 +271,127 @@ describe("Anthropic messages Responses stream translation", () => { ) expect(placeholderEvents).toHaveLength(0) + expect(events.at(-2)).toEqual({ + type: "content_block_start", + index: 2, + content_block: { + type: "thinking", + thinking: "", + }, + }) expect(events.at(-1)).toEqual({ type: "content_block_delta", - index: 0, + index: 2, delta: { type: "signature_delta", signature: "opaque-signature@rs_789", }, }) }) +}) + +describe("Anthropic messages Responses tool stream translation", () => { + test("reopens tool blocks with a new index and avoids duplicating done args", () => { + const state = createResponsesStreamState() + const events = [ + { + type: "response.output_item.added", + output_index: 0, + item: { + type: "function_call", + call_id: "call_123", + name: "get_weather", + }, + }, + { + type: "response.function_call_arguments.delta", + output_index: 0, + delta: '{"city":"Bos', + }, + { + type: "response.output_text.delta", + output_index: 1, + content_index: 0, + delta: "Working on it.", + }, + { + type: "response.function_call_arguments.delta", + output_index: 0, + delta: 'ton"}', + }, + { + type: "response.function_call_arguments.done", + output_index: 0, + arguments: '{"city":"Boston"}', + }, + ].flatMap((event) => translateResponsesStreamEvent(event, state)) + + expect(events).toEqual([ + { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "call_123", + name: "get_weather", + input: {}, + }, + }, + { + type: "content_block_delta", + index: 0, + delta: { + type: "input_json_delta", + partial_json: '{"city":"Bos', + }, + }, + { + type: "content_block_stop", + index: 0, + }, + { + type: "content_block_start", + index: 1, + content_block: { + type: "text", + text: "", + }, + }, + { + type: "content_block_delta", + index: 1, + delta: { + type: "text_delta", + text: "Working on it.", + }, + }, + { + type: "content_block_stop", + index: 1, + }, + { + type: "content_block_start", + index: 2, + content_block: { + type: "tool_use", + id: "call_123", + name: "get_weather", + input: {}, + }, + }, + { + type: "content_block_delta", + index: 2, + delta: { + type: "input_json_delta", + partial_json: 'ton"}', + }, + }, + ]) + }) +}) +describe("Anthropic messages Responses placeholder translation", () => { test("uses placeholder thinking text when reasoning summary is absent", () => { const translated = translateResponsesToAnthropic({ id: "resp_456", From a1edfd53ee2de71faa7f5dac492eda9ff71c2a10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:41:24 +0000 Subject: [PATCH 5/5] fix: finalize responses review followups Co-authored-by: Godzilla675 <131464726+Godzilla675@users.noreply.github.com> --- src/routes/chat-completions/responses-translation.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/routes/chat-completions/responses-translation.ts b/src/routes/chat-completions/responses-translation.ts index 01b366a16..b7edfc7e2 100644 --- a/src/routes/chat-completions/responses-translation.ts +++ b/src/routes/chat-completions/responses-translation.ts @@ -432,7 +432,11 @@ function extractToolCalls( continue } - if (typeof item.content === "string" || !Array.isArray(item.content)) { + if ( + item.type !== "message" + || typeof item.content === "string" + || !Array.isArray(item.content) + ) { continue }