From 29668cef1fd6658503a23fdfb90bcab44df70aeb Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Wed, 19 Nov 2025 18:37:15 +0800 Subject: [PATCH 01/11] feat: support copilot reasoning_opaque and reasoning_text --- src/routes/messages/anthropic-types.ts | 1 + src/routes/messages/handler.ts | 1 + src/routes/messages/non-stream-translation.ts | 80 +++-- src/routes/messages/stream-translation.ts | 278 ++++++++++++++---- .../copilot/create-chat-completions.ts | 10 +- tests/anthropic-request.test.ts | 4 +- tests/anthropic-response.test.ts | 2 + 7 files changed, 280 insertions(+), 96 deletions(-) diff --git a/src/routes/messages/anthropic-types.ts b/src/routes/messages/anthropic-types.ts index 881fffcc..03f24d10 100644 --- a/src/routes/messages/anthropic-types.ts +++ b/src/routes/messages/anthropic-types.ts @@ -196,6 +196,7 @@ export interface AnthropicStreamState { messageStartSent: boolean contentBlockIndex: number contentBlockOpen: boolean + thinkingBlockOpen: boolean toolCalls: { [openAIToolIndex: number]: { id: string diff --git a/src/routes/messages/handler.ts b/src/routes/messages/handler.ts index 85dbf624..a40d3f1d 100644 --- a/src/routes/messages/handler.ts +++ b/src/routes/messages/handler.ts @@ -60,6 +60,7 @@ export async function handleCompletion(c: Context) { contentBlockIndex: 0, contentBlockOpen: false, toolCalls: {}, + thinkingBlockOpen: false, } for await (const rawEvent of response) { diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index dc41e638..3b1a491d 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -139,25 +139,26 @@ function handleAssistantMessage( (block): block is AnthropicToolUseBlock => block.type === "tool_use", ) - const textBlocks = message.content.filter( - (block): block is AnthropicTextBlock => block.type === "text", - ) - const thinkingBlocks = message.content.filter( (block): block is AnthropicThinkingBlock => block.type === "thinking", ) - // Combine text and thinking blocks, as OpenAI doesn't have separate thinking blocks - const allTextContent = [ - ...textBlocks.map((b) => b.text), - ...thinkingBlocks.map((b) => b.thinking), - ].join("\n\n") + const allThinkingContent = thinkingBlocks + .filter((b) => b.thinking && b.thinking.length > 0) + .map((b) => b.thinking) + .join("\n\n") + + const signature = thinkingBlocks.find( + (b) => b.signature && b.signature.length > 0, + )?.signature return toolUseBlocks.length > 0 ? [ { role: "assistant", - content: allTextContent || null, + content: mapContent(message.content), + reasoning_text: allThinkingContent, + reasoning_opaque: signature, tool_calls: toolUseBlocks.map((toolUse) => ({ id: toolUse.id, type: "function", @@ -172,6 +173,8 @@ function handleAssistantMessage( { role: "assistant", content: mapContent(message.content), + reasoning_text: allThinkingContent, + reasoning_opaque: signature, }, ] } @@ -191,11 +194,8 @@ function mapContent( const hasImage = content.some((block) => block.type === "image") if (!hasImage) { return content - .filter( - (block): block is AnthropicTextBlock | AnthropicThinkingBlock => - block.type === "text" || block.type === "thinking", - ) - .map((block) => (block.type === "text" ? block.text : block.thinking)) + .filter((block): block is AnthropicTextBlock => block.type === "text") + .map((block) => block.text) .join("\n\n") } @@ -204,12 +204,6 @@ function mapContent( switch (block.type) { case "text": { contentParts.push({ type: "text", text: block.text }) - - break - } - case "thinking": { - contentParts.push({ type: "text", text: block.thinking }) - break } case "image": { @@ -219,7 +213,6 @@ function mapContent( url: `data:${block.source.media_type};base64,${block.source.data}`, }, }) - break } // No default @@ -282,19 +275,19 @@ export function translateToAnthropic( response: ChatCompletionResponse, ): AnthropicResponse { // Merge content from all choices - const allTextBlocks: Array = [] - const allToolUseBlocks: Array = [] - let stopReason: "stop" | "length" | "tool_calls" | "content_filter" | null = - null // default - stopReason = response.choices[0]?.finish_reason ?? stopReason + const assistantContentBlocks: Array = [] + let stopReason = response.choices[0]?.finish_reason ?? null // Process all choices to extract text and tool use blocks for (const choice of response.choices) { const textBlocks = getAnthropicTextBlocks(choice.message.content) + const thingBlocks = getAnthropicThinkBlocks( + choice.message.reasoning_text, + choice.message.reasoning_opaque, + ) const toolUseBlocks = getAnthropicToolUseBlocks(choice.message.tool_calls) - allTextBlocks.push(...textBlocks) - allToolUseBlocks.push(...toolUseBlocks) + assistantContentBlocks.push(...textBlocks, ...thingBlocks, ...toolUseBlocks) // Use the finish_reason from the first choice, or prioritize tool_calls if (choice.finish_reason === "tool_calls" || stopReason === "stop") { @@ -302,14 +295,12 @@ export function translateToAnthropic( } } - // Note: GitHub Copilot doesn't generate thinking blocks, so we don't include them in responses - return { id: response.id, type: "message", role: "assistant", model: response.model, - content: [...allTextBlocks, ...allToolUseBlocks], + content: assistantContentBlocks, stop_reason: mapOpenAIStopReasonToAnthropic(stopReason), stop_sequence: null, usage: { @@ -342,6 +333,31 @@ function getAnthropicTextBlocks( return [] } +function getAnthropicThinkBlocks( + reasoningText: string | null | undefined, + reasoningOpaque: string | null | undefined, +): Array { + if (reasoningText) { + return [ + { + type: "thinking", + thinking: reasoningText, + signature: "", + }, + ] + } + if (reasoningOpaque) { + return [ + { + type: "thinking", + thinking: "", + signature: reasoningOpaque, + }, + ] + } + return [] +} + function getAnthropicToolUseBlocks( toolCalls: Array | undefined, ): Array { diff --git a/src/routes/messages/stream-translation.ts b/src/routes/messages/stream-translation.ts index 55094448..fc6715a9 100644 --- a/src/routes/messages/stream-translation.ts +++ b/src/routes/messages/stream-translation.ts @@ -1,4 +1,8 @@ -import { type ChatCompletionChunk } from "~/services/copilot/create-chat-completions" +import { + type ChatCompletionChunk, + type Choice, + type Delta, +} from "~/services/copilot/create-chat-completions" import { type AnthropicStreamEventData, @@ -16,7 +20,6 @@ function isToolBlockOpen(state: AnthropicStreamState): boolean { ) } -// eslint-disable-next-line max-lines-per-function, complexity export function translateChunkToAnthropicEvents( chunk: ChatCompletionChunk, state: AnthropicStreamState, @@ -30,22 +33,49 @@ export function translateChunkToAnthropicEvents( const choice = chunk.choices[0] const { delta } = choice - if (!state.messageStartSent) { - events.push({ - type: "message_start", - message: { - id: chunk.id, - type: "message", - role: "assistant", - content: [], - model: chunk.model, - stop_reason: null, - stop_sequence: null, + handleMessageStart(state, events, chunk) + + handleThinkingText(delta, state, events) + + handleContent(delta, state, events) + + handleToolCalls(delta, state, events) + + handleFinish(choice, state, { events, chunk }) + + return events +} + +function handleFinish( + choice: Choice, + state: AnthropicStreamState, + context: { + events: Array + chunk: ChatCompletionChunk + }, +) { + const { events, chunk } = context + if (choice.finish_reason && choice.finish_reason.length > 0) { + if (state.contentBlockOpen) { + context.events.push({ + type: "content_block_stop", + index: state.contentBlockIndex, + }) + state.contentBlockOpen = false + } + + events.push( + { + type: "message_delta", + delta: { + stop_reason: mapOpenAIStopReasonToAnthropic(choice.finish_reason), + stop_sequence: null, + }, usage: { input_tokens: (chunk.usage?.prompt_tokens ?? 0) - (chunk.usage?.prompt_tokens_details?.cached_tokens ?? 0), - output_tokens: 0, // Will be updated in message_delta when finished + output_tokens: chunk.usage?.completion_tokens ?? 0, ...(chunk.usage?.prompt_tokens_details?.cached_tokens !== undefined && { cache_read_input_tokens: @@ -53,44 +83,21 @@ export function translateChunkToAnthropicEvents( }), }, }, - }) - state.messageStartSent = true - } - - if (delta.content) { - if (isToolBlockOpen(state)) { - // A tool block was open, so close it before starting a text block. - events.push({ - type: "content_block_stop", - index: state.contentBlockIndex, - }) - state.contentBlockIndex++ - state.contentBlockOpen = false - } - - if (!state.contentBlockOpen) { - events.push({ - type: "content_block_start", - index: state.contentBlockIndex, - content_block: { - type: "text", - text: "", - }, - }) - state.contentBlockOpen = true - } - - events.push({ - type: "content_block_delta", - index: state.contentBlockIndex, - delta: { - type: "text_delta", - text: delta.content, + { + type: "message_stop", }, - }) + ) } +} + +function handleToolCalls( + delta: Delta, + state: AnthropicStreamState, + events: Array, +) { + if (delta.tool_calls && delta.tool_calls.length > 0) { + closeThinkingBlockIfOpen(delta, state, events) - if (delta.tool_calls) { for (const toolCall of delta.tool_calls) { if (toolCall.id && toolCall.function?.name) { // New tool call starting. @@ -141,28 +148,70 @@ export function translateChunkToAnthropicEvents( } } } +} - if (choice.finish_reason) { - if (state.contentBlockOpen) { +function handleContent( + delta: Delta, + state: AnthropicStreamState, + events: Array, +) { + if (delta.content && delta.content.length > 0) { + closeThinkingBlockIfOpen(delta, state, events) + + if (isToolBlockOpen(state)) { + // A tool block was open, so close it before starting a text block. events.push({ type: "content_block_stop", index: state.contentBlockIndex, }) + state.contentBlockIndex++ state.contentBlockOpen = false } - events.push( - { - type: "message_delta", - delta: { - stop_reason: mapOpenAIStopReasonToAnthropic(choice.finish_reason), - stop_sequence: null, + if (!state.contentBlockOpen) { + events.push({ + type: "content_block_start", + index: state.contentBlockIndex, + content_block: { + type: "text", + text: "", }, + }) + state.contentBlockOpen = true + } + + events.push({ + type: "content_block_delta", + index: state.contentBlockIndex, + delta: { + type: "text_delta", + text: delta.content, + }, + }) + } +} + +function handleMessageStart( + state: AnthropicStreamState, + events: Array, + chunk: ChatCompletionChunk, +) { + if (!state.messageStartSent) { + events.push({ + type: "message_start", + message: { + id: chunk.id, + type: "message", + role: "assistant", + content: [], + model: chunk.model, + stop_reason: null, + stop_sequence: null, usage: { input_tokens: (chunk.usage?.prompt_tokens ?? 0) - (chunk.usage?.prompt_tokens_details?.cached_tokens ?? 0), - output_tokens: chunk.usage?.completion_tokens ?? 0, + output_tokens: 0, // Will be updated in message_delta when finished ...(chunk.usage?.prompt_tokens_details?.cached_tokens !== undefined && { cache_read_input_tokens: @@ -170,13 +219,122 @@ export function translateChunkToAnthropicEvents( }), }, }, + }) + state.messageStartSent = true + } +} + +function handleReasoningOpaque( + delta: Delta, + events: Array, + state: AnthropicStreamState, +) { + if (delta.reasoning_opaque && delta.reasoning_opaque.length > 0) { + events.push( + { + type: "content_block_start", + index: state.contentBlockIndex, + content_block: { + type: "thinking", + thinking: "", + }, + }, { - type: "message_stop", + type: "content_block_delta", + index: state.contentBlockIndex, + delta: { + type: "thinking_delta", + thinking: "", + }, + }, + { + type: "content_block_delta", + index: state.contentBlockIndex, + delta: { + type: "signature_delta", + signature: delta.reasoning_opaque, + }, + }, + { + type: "content_block_stop", + index: state.contentBlockIndex, }, ) } +} - return events +function handleThinkingText( + delta: Delta, + state: AnthropicStreamState, + events: Array, +) { + if (delta.reasoning_text && delta.reasoning_text.length > 0) { + if (!state.thinkingBlockOpen) { + events.push({ + type: "content_block_start", + index: state.contentBlockIndex, + content_block: { + type: "thinking", + thinking: "", + }, + }) + state.thinkingBlockOpen = true + } + + events.push({ + type: "content_block_delta", + index: state.contentBlockIndex, + delta: { + type: "thinking_delta", + thinking: delta.reasoning_text, + }, + }) + + if (delta.reasoning_opaque && delta.reasoning_opaque.length > 0) { + events.push( + { + type: "content_block_delta", + index: state.contentBlockIndex, + delta: { + type: "signature_delta", + signature: delta.reasoning_opaque, + }, + }, + { + type: "content_block_stop", + index: state.contentBlockIndex, + }, + ) + state.contentBlockIndex++ + state.thinkingBlockOpen = false + } + } +} + +function closeThinkingBlockIfOpen( + delta: Delta, + state: AnthropicStreamState, + events: Array, +): void { + if (state.thinkingBlockOpen) { + events.push( + { + type: "content_block_delta", + index: state.contentBlockIndex, + delta: { + type: "signature_delta", + signature: "", + }, + }, + { + type: "content_block_stop", + index: state.contentBlockIndex, + }, + ) + state.contentBlockIndex++ + state.thinkingBlockOpen = false + } + handleReasoningOpaque(delta, events, state) } export function translateErrorToAnthropicErrorEvent(): AnthropicStreamEventData { diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index 8534151d..e848b27a 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -69,7 +69,7 @@ export interface ChatCompletionChunk { } } -interface Delta { +export interface Delta { content?: string | null role?: "user" | "assistant" | "system" | "tool" tool_calls?: Array<{ @@ -81,9 +81,11 @@ interface Delta { arguments?: string } }> + reasoning_text?: string | null + reasoning_opaque?: string | null } -interface Choice { +export interface Choice { index: number delta: Delta finish_reason: "stop" | "length" | "tool_calls" | "content_filter" | null @@ -112,6 +114,8 @@ export interface ChatCompletionResponse { interface ResponseMessage { role: "assistant" content: string | null + reasoning_text?: string | null + reasoning_opaque?: string | null tool_calls?: Array } @@ -166,6 +170,8 @@ export interface Message { name?: string tool_calls?: Array tool_call_id?: string + reasoning_text?: string | null + reasoning_opaque?: string | null } export interface ToolCall { diff --git a/tests/anthropic-request.test.ts b/tests/anthropic-request.test.ts index 06c66377..eb1d9b25 100644 --- a/tests/anthropic-request.test.ts +++ b/tests/anthropic-request.test.ts @@ -150,7 +150,7 @@ describe("Anthropic to OpenAI translation logic", () => { const assistantMessage = openAIPayload.messages.find( (m) => m.role === "assistant", ) - expect(assistantMessage?.content).toContain( + expect(assistantMessage?.reasoning_text).toContain( "Let me think about this simple math problem...", ) expect(assistantMessage?.content).toContain("2+2 equals 4.") @@ -188,7 +188,7 @@ describe("Anthropic to OpenAI translation logic", () => { const assistantMessage = openAIPayload.messages.find( (m) => m.role === "assistant", ) - expect(assistantMessage?.content).toContain( + expect(assistantMessage?.reasoning_text).toContain( "I need to call the weather API", ) expect(assistantMessage?.content).toContain( diff --git a/tests/anthropic-response.test.ts b/tests/anthropic-response.test.ts index ecd71aac..e849a02a 100644 --- a/tests/anthropic-response.test.ts +++ b/tests/anthropic-response.test.ts @@ -252,6 +252,7 @@ describe("OpenAI to Anthropic Streaming Response Translation", () => { contentBlockIndex: 0, contentBlockOpen: false, toolCalls: {}, + thinkingBlockOpen: false, } const translatedStream = openAIStream.flatMap((chunk) => translateChunkToAnthropicEvents(chunk, streamState), @@ -352,6 +353,7 @@ describe("OpenAI to Anthropic Streaming Response Translation", () => { contentBlockIndex: 0, contentBlockOpen: false, toolCalls: {}, + thinkingBlockOpen: false, } const translatedStream = openAIStream.flatMap((chunk) => translateChunkToAnthropicEvents(chunk, streamState), From a2467d32e63c979af7e5373ab0868e52c98401fc Mon Sep 17 00:00:00 2001 From: caozhiyuan <568022847@qq.com> Date: Wed, 19 Nov 2025 21:39:07 +0800 Subject: [PATCH 02/11] feat: add signature field to AnthropicThinkingBlock --- src/routes/messages/anthropic-types.ts | 1 + tests/anthropic-request.test.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/src/routes/messages/anthropic-types.ts b/src/routes/messages/anthropic-types.ts index 03f24d10..2fb7849f 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/tests/anthropic-request.test.ts b/tests/anthropic-request.test.ts index eb1d9b25..baed2f6d 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." }, { From 58f7a45c6c43e1a883661c65a372867db0516b37 Mon Sep 17 00:00:00 2001 From: caozhiyuan <568022847@qq.com> Date: Wed, 19 Nov 2025 21:46:20 +0800 Subject: [PATCH 03/11] feat: add idleTimeout configuration for bun server --- src/start.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/start.ts b/src/start.ts index 14abbbdf..171d4ac9 100644 --- a/src/start.ts +++ b/src/start.ts @@ -117,6 +117,9 @@ export async function runServer(options: RunServerOptions): Promise { serve({ fetch: server.fetch as ServerHandler, port: options.port, + bun: { + idleTimeout: 0, + }, }) } From 3fa55199c176ce33e7db60fa5d0f11eb14abd386 Mon Sep 17 00:00:00 2001 From: caozhiyuan <568022847@qq.com> Date: Wed, 19 Nov 2025 21:49:58 +0800 Subject: [PATCH 04/11] feat: enhance reasoning handling in tool calls and change the thinking order when stream=false and exclude reasoning_opaque from token calculation in calculateMessageTokens --- src/lib/tokenizer.ts | 3 +++ src/routes/messages/non-stream-translation.ts | 10 +++---- src/routes/messages/stream-translation.ts | 27 ++++++++++++++++--- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/lib/tokenizer.ts b/src/lib/tokenizer.ts index 8c3eda73..b9ebafad 100644 --- a/src/lib/tokenizer.ts +++ b/src/lib/tokenizer.ts @@ -73,6 +73,9 @@ const calculateMessageTokens = ( const tokensPerName = 1 let tokens = tokensPerMessage for (const [key, value] of Object.entries(message)) { + if (key === "reasoning_opaque") { + continue + } if (typeof value === "string") { tokens += encoder.encode(value).length } diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index 3b1a491d..94a0f7e1 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -287,7 +287,7 @@ export function translateToAnthropic( ) const toolUseBlocks = getAnthropicToolUseBlocks(choice.message.tool_calls) - assistantContentBlocks.push(...textBlocks, ...thingBlocks, ...toolUseBlocks) + assistantContentBlocks.push(...thingBlocks, ...textBlocks, ...toolUseBlocks) // Use the finish_reason from the first choice, or prioritize tool_calls if (choice.finish_reason === "tool_calls" || stopReason === "stop") { @@ -320,7 +320,7 @@ export function translateToAnthropic( function getAnthropicTextBlocks( messageContent: Message["content"], ): Array { - if (typeof messageContent === "string") { + if (typeof messageContent === "string" && messageContent.length > 0) { return [{ type: "text", text: messageContent }] } @@ -337,16 +337,16 @@ function getAnthropicThinkBlocks( reasoningText: string | null | undefined, reasoningOpaque: string | null | undefined, ): Array { - if (reasoningText) { + if (reasoningText && reasoningText.length > 0) { return [ { type: "thinking", thinking: reasoningText, - signature: "", + signature: reasoningOpaque || "", }, ] } - if (reasoningOpaque) { + if (reasoningOpaque && reasoningOpaque.length > 0) { return [ { type: "thinking", diff --git a/src/routes/messages/stream-translation.ts b/src/routes/messages/stream-translation.ts index fc6715a9..9dc2dd52 100644 --- a/src/routes/messages/stream-translation.ts +++ b/src/routes/messages/stream-translation.ts @@ -62,6 +62,8 @@ function handleFinish( index: state.contentBlockIndex, }) state.contentBlockOpen = false + state.contentBlockIndex++ + handleReasoningOpaque(choice.delta, events, state) } events.push( @@ -96,7 +98,9 @@ function handleToolCalls( events: Array, ) { if (delta.tool_calls && delta.tool_calls.length > 0) { - closeThinkingBlockIfOpen(delta, state, events) + closeThinkingBlockIfOpen(state, events) + + handleReasoningOpaqueInToolCalls(state, events, delta) for (const toolCall of delta.tool_calls) { if (toolCall.id && toolCall.function?.name) { @@ -150,13 +154,29 @@ function handleToolCalls( } } +function handleReasoningOpaqueInToolCalls( + state: AnthropicStreamState, + events: Array, + delta: Delta, +) { + if (state.contentBlockOpen) { + events.push({ + type: "content_block_stop", + index: state.contentBlockIndex, + }) + state.contentBlockIndex++ + state.contentBlockOpen = false + } + handleReasoningOpaque(delta, events, state) +} + function handleContent( delta: Delta, state: AnthropicStreamState, events: Array, ) { if (delta.content && delta.content.length > 0) { - closeThinkingBlockIfOpen(delta, state, events) + closeThinkingBlockIfOpen(state, events) if (isToolBlockOpen(state)) { // A tool block was open, so close it before starting a text block. @@ -260,6 +280,7 @@ function handleReasoningOpaque( index: state.contentBlockIndex, }, ) + state.contentBlockIndex++ } } @@ -312,7 +333,6 @@ function handleThinkingText( } function closeThinkingBlockIfOpen( - delta: Delta, state: AnthropicStreamState, events: Array, ): void { @@ -334,7 +354,6 @@ function closeThinkingBlockIfOpen( state.contentBlockIndex++ state.thinkingBlockOpen = false } - handleReasoningOpaque(delta, events, state) } export function translateErrorToAnthropicErrorEvent(): AnthropicStreamEventData { From dfb40d2625a46872ecd4aca99a00dc0ec17b479a Mon Sep 17 00:00:00 2001 From: caozhiyuan <568022847@qq.com> Date: Thu, 20 Nov 2025 07:41:31 +0800 Subject: [PATCH 05/11] feat: conditionally handle reasoningOpaque in handleFinish based on tool block state --- src/routes/messages/stream-translation.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/routes/messages/stream-translation.ts b/src/routes/messages/stream-translation.ts index 9dc2dd52..44b69bf3 100644 --- a/src/routes/messages/stream-translation.ts +++ b/src/routes/messages/stream-translation.ts @@ -57,13 +57,16 @@ function handleFinish( const { events, chunk } = context if (choice.finish_reason && choice.finish_reason.length > 0) { if (state.contentBlockOpen) { + const toolBlockOpen = isToolBlockOpen(state) context.events.push({ type: "content_block_stop", index: state.contentBlockIndex, }) state.contentBlockOpen = false state.contentBlockIndex++ - handleReasoningOpaque(choice.delta, events, state) + if (!toolBlockOpen) { + handleReasoningOpaque(choice.delta, events, state) + } } events.push( From 7657d872e2e31c98ca3baa032cce20c9b720086c Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Thu, 20 Nov 2025 11:02:08 +0800 Subject: [PATCH 06/11] fix: handleReasoningOpaqueInToolCalls add isToolBlockOpen judge --- src/routes/messages/stream-translation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/messages/stream-translation.ts b/src/routes/messages/stream-translation.ts index 44b69bf3..6002d510 100644 --- a/src/routes/messages/stream-translation.ts +++ b/src/routes/messages/stream-translation.ts @@ -162,7 +162,7 @@ function handleReasoningOpaqueInToolCalls( events: Array, delta: Delta, ) { - if (state.contentBlockOpen) { + if (state.contentBlockOpen && !isToolBlockOpen(state)) { events.push({ type: "content_block_stop", index: state.contentBlockIndex, From 7f8187b63bd9509e1b54e17c1d4d13fd9377ad13 Mon Sep 17 00:00:00 2001 From: caozhiyuan <568022847@qq.com> Date: Wed, 3 Dec 2025 14:46:08 +0800 Subject: [PATCH 07/11] feat: support claude model thinking block --- src/routes/messages/non-stream-translation.ts | 53 ++++++++++++++++--- src/routes/messages/stream-translation.ts | 43 ++++++++------- .../copilot/create-chat-completions.ts | 1 + src/services/copilot/get-models.ts | 2 + 4 files changed, 74 insertions(+), 25 deletions(-) diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index 94a0f7e1..29be0d7f 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -1,3 +1,6 @@ +import type { Model } from "~/services/copilot/get-models" + +import { state } from "~/lib/state" import { type ChatCompletionResponse, type ChatCompletionsPayload, @@ -29,11 +32,15 @@ import { mapOpenAIStopReasonToAnthropic } from "./utils" export function translateToOpenAI( payload: AnthropicMessagesPayload, ): ChatCompletionsPayload { + const modelId = translateModelName(payload.model) + const model = state.models?.data.find((m) => m.id === modelId) + const thinkingBudget = getThinkingBudget(payload, model) return { - model: translateModelName(payload.model), + model: modelId, messages: translateAnthropicMessagesToOpenAI( payload.messages, payload.system, + modelId, ), max_tokens: payload.max_tokens, stop: payload.stop_sequences, @@ -43,14 +50,32 @@ export function translateToOpenAI( user: payload.metadata?.user_id, tools: translateAnthropicToolsToOpenAI(payload.tools), tool_choice: translateAnthropicToolChoiceToOpenAI(payload.tool_choice), + thinking_budget: thinkingBudget, } } +function getThinkingBudget( + payload: AnthropicMessagesPayload, + model: Model | undefined, +): number | undefined { + const thinking = payload.thinking + if (model && thinking) { + const maxThinkingBudget = Math.min( + model.capabilities.supports.max_thinking_budget ?? 0, + (model.capabilities.limits.max_output_tokens ?? 0) - 1, + ) + if (maxThinkingBudget > 0 && thinking.budget_tokens !== undefined) { + return Math.min(thinking.budget_tokens, maxThinkingBudget) + } + } + return undefined +} + function translateModelName(model: string): string { // Subagent requests use a specific model number which Copilot doesn't support if (model.startsWith("claude-sonnet-4-")) { return model.replace(/^claude-sonnet-4-.*/, "claude-sonnet-4") - } else if (model.startsWith("claude-opus-")) { + } else if (model.startsWith("claude-opus-4-")) { return model.replace(/^claude-opus-4-.*/, "claude-opus-4") } return model @@ -59,13 +84,14 @@ function translateModelName(model: string): string { function translateAnthropicMessagesToOpenAI( anthropicMessages: Array, system: string | Array | undefined, + modelId: string, ): Array { const systemMessages = handleSystemPrompt(system) const otherMessages = anthropicMessages.flatMap((message) => message.role === "user" ? handleUserMessage(message) - : handleAssistantMessage(message), + : handleAssistantMessage(message, modelId), ) return [...systemMessages, ...otherMessages] @@ -125,6 +151,7 @@ function handleUserMessage(message: AnthropicUserMessage): Array { function handleAssistantMessage( message: AnthropicAssistantMessage, + modelId: string, ): Array { if (!Array.isArray(message.content)) { return [ @@ -139,14 +166,28 @@ function handleAssistantMessage( (block): block is AnthropicToolUseBlock => block.type === "tool_use", ) - const thinkingBlocks = message.content.filter( + let thinkingBlocks = message.content.filter( (block): block is AnthropicThinkingBlock => block.type === "thinking", ) - const allThinkingContent = thinkingBlocks + if (modelId.startsWith("claude")) { + thinkingBlocks = thinkingBlocks.filter( + (b) => + b.thinking + && b.thinking.length > 0 + && b.signature + && b.signature.length > 0 + // gpt signature has @ in it, so filter those out for claude models + && !b.signature.includes("@"), + ) + } + + const thinkingContents = thinkingBlocks .filter((b) => b.thinking && b.thinking.length > 0) .map((b) => b.thinking) - .join("\n\n") + + const allThinkingContent = + thinkingContents.length > 0 ? thinkingContents.join("\n\n") : undefined const signature = thinkingBlocks.find( (b) => b.signature && b.signature.length > 0, diff --git a/src/routes/messages/stream-translation.ts b/src/routes/messages/stream-translation.ts index 6002d510..b492d10f 100644 --- a/src/routes/messages/stream-translation.ts +++ b/src/routes/messages/stream-translation.ts @@ -212,6 +212,30 @@ function handleContent( }, }) } + + // handle for claude model + if ( + delta.content === "" + && delta.reasoning_opaque + && delta.reasoning_opaque.length > 0 + ) { + events.push( + { + type: "content_block_delta", + index: state.contentBlockIndex, + delta: { + type: "signature_delta", + signature: delta.reasoning_opaque, + }, + }, + { + type: "content_block_stop", + index: state.contentBlockIndex, + }, + ) + state.contentBlockIndex++ + state.thinkingBlockOpen = false + } } function handleMessageStart( @@ -313,25 +337,6 @@ function handleThinkingText( thinking: delta.reasoning_text, }, }) - - if (delta.reasoning_opaque && delta.reasoning_opaque.length > 0) { - events.push( - { - type: "content_block_delta", - index: state.contentBlockIndex, - delta: { - type: "signature_delta", - signature: delta.reasoning_opaque, - }, - }, - { - type: "content_block_stop", - index: state.contentBlockIndex, - }, - ) - state.contentBlockIndex++ - state.thinkingBlockOpen = false - } } } diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index e848b27a..2713a80f 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -152,6 +152,7 @@ export interface ChatCompletionsPayload { | { type: "function"; function: { name: string } } | null user?: string | null + thinking_budget?: number } export interface Tool { diff --git a/src/services/copilot/get-models.ts b/src/services/copilot/get-models.ts index 3cfa30af..a7fffe93 100644 --- a/src/services/copilot/get-models.ts +++ b/src/services/copilot/get-models.ts @@ -25,6 +25,8 @@ interface ModelLimits { } interface ModelSupports { + max_thinking_budget?: number + min_thinking_budget?: number tool_calls?: boolean parallel_tool_calls?: boolean dimensions?: boolean From cbe12eb850bd4e65f36c885d8ea68e07789ce522 Mon Sep 17 00:00:00 2001 From: caozhiyuan <568022847@qq.com> Date: Wed, 10 Dec 2025 22:53:10 +0800 Subject: [PATCH 08/11] feat: enhance thinking budget calculation and rename variables for clarity --- src/routes/messages/non-stream-translation.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index 29be0d7f..e5a59a10 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -65,7 +65,11 @@ function getThinkingBudget( (model.capabilities.limits.max_output_tokens ?? 0) - 1, ) if (maxThinkingBudget > 0 && thinking.budget_tokens !== undefined) { - return Math.min(thinking.budget_tokens, maxThinkingBudget) + const budgetTokens = Math.min(thinking.budget_tokens, maxThinkingBudget) + return Math.max( + budgetTokens, + model.capabilities.supports.min_thinking_budget ?? 1024, + ) } } return undefined @@ -322,13 +326,13 @@ export function translateToAnthropic( // Process all choices to extract text and tool use blocks for (const choice of response.choices) { const textBlocks = getAnthropicTextBlocks(choice.message.content) - const thingBlocks = getAnthropicThinkBlocks( + const thinkBlocks = getAnthropicThinkBlocks( choice.message.reasoning_text, choice.message.reasoning_opaque, ) const toolUseBlocks = getAnthropicToolUseBlocks(choice.message.tool_calls) - assistantContentBlocks.push(...thingBlocks, ...textBlocks, ...toolUseBlocks) + assistantContentBlocks.push(...thinkBlocks, ...textBlocks, ...toolUseBlocks) // Use the finish_reason from the first choice, or prioritize tool_calls if (choice.finish_reason === "tool_calls" || stopReason === "stop") { From ebcacb20c5c287972879149595535c50c5d6d0b8 Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Thu, 11 Dec 2025 10:12:41 +0800 Subject: [PATCH 09/11] feat: update Copilot version and API version in api-config; adjust fallback VSCode version --- src/lib/api-config.ts | 4 ++-- src/services/get-vscode-version.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/api-config.ts b/src/lib/api-config.ts index 83bce92a..2006c57c 100644 --- a/src/lib/api-config.ts +++ b/src/lib/api-config.ts @@ -7,11 +7,11 @@ export const standardHeaders = () => ({ accept: "application/json", }) -const COPILOT_VERSION = "0.26.7" +const COPILOT_VERSION = "0.33.5" const EDITOR_PLUGIN_VERSION = `copilot-chat/${COPILOT_VERSION}` const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}` -const API_VERSION = "2025-04-01" +const API_VERSION = "2025-10-01" export const copilotBaseUrl = (state: State) => state.accountType === "individual" ? diff --git a/src/services/get-vscode-version.ts b/src/services/get-vscode-version.ts index 6078f09b..5e3cef79 100644 --- a/src/services/get-vscode-version.ts +++ b/src/services/get-vscode-version.ts @@ -1,4 +1,4 @@ -const FALLBACK = "1.104.3" +const FALLBACK = "1.106.3" export async function getVSCodeVersion() { const controller = new AbortController() From 0d6f7aa99af92a748c4109d1e669442f672cc458 Mon Sep 17 00:00:00 2001 From: "Jeffrey.Cao" Date: Thu, 11 Dec 2025 13:21:38 +0800 Subject: [PATCH 10/11] feat: update Copilot version to 0.35.0 and fallback VSCode version to 1.107.0 --- src/lib/api-config.ts | 2 +- src/services/get-vscode-version.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/api-config.ts b/src/lib/api-config.ts index 2006c57c..7124930e 100644 --- a/src/lib/api-config.ts +++ b/src/lib/api-config.ts @@ -7,7 +7,7 @@ export const standardHeaders = () => ({ accept: "application/json", }) -const COPILOT_VERSION = "0.33.5" +const COPILOT_VERSION = "0.35.0" const EDITOR_PLUGIN_VERSION = `copilot-chat/${COPILOT_VERSION}` const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}` diff --git a/src/services/get-vscode-version.ts b/src/services/get-vscode-version.ts index 5e3cef79..89c39dc0 100644 --- a/src/services/get-vscode-version.ts +++ b/src/services/get-vscode-version.ts @@ -1,4 +1,4 @@ -const FALLBACK = "1.106.3" +const FALLBACK = "1.107.0" export async function getVSCodeVersion() { const controller = new AbortController() From dcafbe1b434e5526f49fd49876896c6026f70d5e Mon Sep 17 00:00:00 2001 From: caozhiyuan <568022847@qq.com> Date: Sat, 13 Dec 2025 09:22:54 +0800 Subject: [PATCH 11/11] fix: simplify copilotBaseUrl logic and correct openai-intent header value --- src/lib/api-config.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/lib/api-config.ts b/src/lib/api-config.ts index 7124930e..5b2c611f 100644 --- a/src/lib/api-config.ts +++ b/src/lib/api-config.ts @@ -14,9 +14,8 @@ const USER_AGENT = `GitHubCopilotChat/${COPILOT_VERSION}` const API_VERSION = "2025-10-01" export const copilotBaseUrl = (state: State) => - state.accountType === "individual" ? - "https://api.githubcopilot.com" - : `https://api.${state.accountType}.githubcopilot.com` + `https://api.${state.accountType}.githubcopilot.com` + export const copilotHeaders = (state: State, vision: boolean = false) => { const headers: Record = { Authorization: `Bearer ${state.copilotToken}`, @@ -25,7 +24,7 @@ export const copilotHeaders = (state: State, vision: boolean = false) => { "editor-version": `vscode/${state.vsCodeVersion}`, "editor-plugin-version": EDITOR_PLUGIN_VERSION, "user-agent": USER_AGENT, - "openai-intent": "conversation-panel", + "openai-intent": "conversation-agent", "x-github-api-version": API_VERSION, "x-request-id": randomUUID(), "x-vscode-user-agent-library-version": "electron-fetch",