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 } 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..54092778c 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,8 +24,23 @@ 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" +const DEBUG_LOG_LENGTH = 400 + +type ResponsesStreamEvent = { + type?: string + [key: string]: unknown +} + export async function handleCompletion(c: Context) { await checkRateLimit(state) @@ -38,6 +57,58 @@ export async function handleCompletion(c: Context) { await awaitApproval() } + if (shouldUseResponsesApi(anthropicPayload)) { + const responsesPayload = translateAnthropicToResponses(anthropicPayload) + consola.debug( + "Translated Responses payload:", + stringifyResponsesDebug(responsesPayload), + ) + + const response = await createResponses(responsesPayload) + + if (isNonStreamingResponse(response)) { + consola.debug( + "Non-streaming response from Responses API:", + stringifyResponsesDebug(response), + ) + const anthropicResponse = translateResponsesToAnthropic(response) + consola.debug( + "Translated Anthropic response:", + stringifyResponsesDebug(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) { + if (!rawEvent.data || rawEvent.data === "[DONE]") { + continue + } + + 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:", + stringifyResponsesDebug(event), + ) + await stream.writeSSE({ + event: event.type, + data: JSON.stringify(event), + }) + } + } + }) + } + const response = await createChatCompletions(openAIPayload) if (isNonStreaming(response)) { @@ -89,3 +160,57 @@ 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"), + ) + ) +} + +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 new file mode 100644 index 000000000..3cde9e58b --- /dev/null +++ b/src/routes/messages/responses-stream-translation.ts @@ -0,0 +1,472 @@ +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 + blockHasDelta: Set + toolBlockByKey: Map +} + +interface ToolBlockState { + id: string + name: string +} + +export function createResponsesStreamState(): ResponsesStreamState { + return { + messageStartSent: false, + nextContentBlockIndex: 0, + blockHasDelta: new Set(), + toolBlockByKey: new Map(), + } +} + +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 key = getThinkingKey(parsedEvent.output_index) + const blockIndex = openBlock({ + state, + key, + contentBlock: { + type: "thinking", + thinking: "", + }, + events, + }) + + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "thinking_delta", + thinking: parsedEvent.delta, + }, + }) + state.blockHasDelta.add(key) + 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 key = getThinkingKey(parsedEvent.output_index) + const blockIndex = openBlock({ + state, + key, + contentBlock: { + type: "thinking", + thinking: "", + }, + events, + }) + + if (!state.blockHasDelta.has(key)) { + 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(key) + 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 key = getTextKey(parsedEvent.output_index, parsedEvent.content_index) + const blockIndex = openBlock({ + state, + key, + contentBlock: { type: "text", text: "" }, + events, + }) + + events.push({ + type: "content_block_delta", + index: blockIndex, + delta: { + type: "text_delta", + text: parsedEvent.delta, + }, + }) + state.blockHasDelta.add(key) + 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 = [] + const key = getToolKey(parsedEvent.output_index) + state.toolBlockByKey.set(key, { + id: parsedEvent.item.call_id, + name: parsedEvent.item.name, + }) + openBlock({ + state, + key, + 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 key = getToolKey(parsedEvent.output_index) + const toolBlock = state.toolBlockByKey.get(key) + if (!toolBlock) { + return [] + } + + 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( + parsedEvent: { + output_index?: unknown + arguments?: unknown + }, + state: ResponsesStreamState, +): Array { + if ( + typeof parsedEvent.output_index !== "number" + || typeof parsedEvent.arguments !== "string" + ) { + return [] + } + + const key = getToolKey(parsedEvent.output_index) + const toolBlock = state.toolBlockByKey.get(key) + if (!toolBlock || state.blockHasDelta.has(key)) { + return [] + } + + 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( + 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 + 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", + 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.openBlockKey = undefined + 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 +} + +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..0ac3d004a --- /dev/null +++ b/src/routes/messages/responses-translation.ts @@ -0,0 +1,409 @@ +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" + +import { translateModelName } from "./non-stream-translation" + +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: translateModelName(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 & { signature: string }, +) { + 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..bed8cb97e --- /dev/null +++ b/tests/messages-responses-translation.test.ts @@ -0,0 +1,420 @@ +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?", + }) + }) + + 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", () => { + 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") + }) +}) + +describe("Anthropic messages Responses reasoning stream translation", () => { + 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("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(-2)).toEqual({ + type: "content_block_start", + index: 2, + content_block: { + type: "thinking", + thinking: "", + }, + }) + expect(events.at(-1)).toEqual({ + type: "content_block_delta", + 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", + 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", + }) + }) +})