Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/lib/context-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ function estimateMessageBytes(msg: Message): number {
}
}
if (msg.tool_call_id) bytes += msg.tool_call_id.length + 20
if (msg.reasoning_text) bytes += msg.reasoning_text.length + 20
if (msg.reasoning_opaque) bytes += msg.reasoning_opaque.length + 20
return bytes
}

Expand Down
17 changes: 16 additions & 1 deletion src/routes/messages/anthropic-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface AnthropicMessagesPayload {
thinking?: {
type: "enabled" | "adaptive"
budget_tokens?: number
display?: "summarized" | "omitted"
}
output_config?: {
effort?: "low" | "medium" | "high" | "xhigh" | "max"
Expand Down Expand Up @@ -82,6 +83,7 @@ export interface AnthropicToolUseBlock {
export interface AnthropicThinkingBlock {
type: "thinking"
thinking: string
signature?: string
}

export type AnthropicUserContentBlock =
Expand All @@ -105,7 +107,19 @@ export interface AnthropicAssistantMessage {
content: string | Array<AnthropicAssistantContentBlock>
}

export type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage
/**
* Mid-conversation system message. Claude Code sends these inside `messages`
* (e.g. SessionStart hook context), sometimes as the trailing entry.
*/
export interface AnthropicSystemMessage {
role: "system"
content: string | Array<AnthropicTextBlock>
}

export type AnthropicMessage =
| AnthropicUserMessage
| AnthropicAssistantMessage
| AnthropicSystemMessage

export interface AnthropicTool {
name: string
Expand Down Expand Up @@ -223,6 +237,7 @@ export interface AnthropicStreamState {
messageStartSent: boolean
contentBlockIndex: number
contentBlockOpen: boolean
thinkingBlockOpen?: boolean
toolCalls: {
[openAIToolIndex: number]: {
id: string
Expand Down
96 changes: 83 additions & 13 deletions src/routes/messages/non-stream-translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import {
type AnthropicMessage,
type AnthropicMessagesPayload,
type AnthropicResponse,
type AnthropicSystemMessage,
type AnthropicTextBlock,
type AnthropicThinkingBlock,
type AnthropicTool,
type AnthropicToolResultBlock,
type AnthropicToolUseBlock,
Expand Down Expand Up @@ -346,15 +348,42 @@ function translateAnthropicMessagesToOpenAI(
): Array<Message> {
const systemMessages = handleSystemPrompt(system)

const otherMessages = anthropicMessages.flatMap((message) =>
message.role === "user" ?
handleUserMessage(message)
: handleAssistantMessage(message),
)
const otherMessages = anthropicMessages.flatMap((message) => {
switch (message.role) {
case "user": {
return handleUserMessage(message)
}
case "system": {
return handleInlineSystemMessage(message)
}
default: {
return handleAssistantMessage(message)
}
}
})

return [...systemMessages, ...otherMessages]
}

/**
* Claude Code sends hook context and reminders as `role: "system"` entries
* inside `messages`, often as the last one. Copilot chat completions has no
* mid-conversation system turn, and treating it as assistant either strips it
* as a prefill or attributes it to the model. Keep it in place as user
* context, which also leaves the cached system-prompt prefix untouched.
*/
function handleInlineSystemMessage(
message: AnthropicSystemMessage,
): Array<Message> {
const text = inlineSystemText(message)
return text ? [{ role: "user", content: text }] : []
}

export function inlineSystemText(message: AnthropicSystemMessage): string {
if (typeof message.content === "string") return message.content
return message.content.map((block) => block.text).join("\n\n")
}

// Reserved keywords that GitHub Copilot API blocks in system prompts
// These will be completely removed from system prompts (case-insensitive)
const RESERVED_KEYWORD_PATTERNS = [
Expand Down Expand Up @@ -443,17 +472,15 @@ function handleAssistantMessage(
(block): block is AnthropicTextBlock => block.type === "text",
)

// Thinking blocks have signed-by-Anthropic semantics that Copilot can't
// verify or replay. Promoting their content to plain text would destroy
// the assistant turn semantics (the model would treat its own private
// reasoning as visible text). Drop them cleanly on the request side.
const allTextContent = textBlocks.map((b) => b.text).join("\n\n")
const reasoning = getPriorReasoning(message.content)

return toolUseBlocks.length > 0 ?
[
{
role: "assistant",
content: allTextContent || null,
...reasoning,
tool_calls: toolUseBlocks.map((toolUse) => ({
id: toolUse.id,
type: "function",
Expand All @@ -468,10 +495,40 @@ function handleAssistantMessage(
{
role: "assistant",
content: mapContent(message.content),
...reasoning,
},
]
}

/**
* Send the model's earlier reasoning back to Copilot the way it arrived:
* `reasoning_text` plus the `reasoning_opaque` signature Copilot issued with
* it (forwarded to Claude Code as the thinking block's signature). Dropping
* it makes Opus re-derive its plan from scratch on every tool-use turn: on
* captured Opus 5.5 xhigh turns that was 3-10x more output tokens.
*
* Only signed blocks are replayed, so reasoning that didn't come from Copilot
* (or a stripped block) is never presented to the model as its own.
*/
function getPriorReasoning(
content: Array<AnthropicAssistantContentBlock>,
): Pick<Message, "reasoning_opaque" | "reasoning_text"> {
const signed = content.filter(
(block): block is AnthropicThinkingBlock =>
block.type === "thinking" && Boolean(block.signature),
)
if (signed.length === 0) return {}

const text = signed
.map((block) => block.thinking)
.filter(Boolean)
.join("\n\n")
return {
...(text && { reasoning_text: text }),
reasoning_opaque: signed[0].signature,
}
}

function mapContent(
content:
| string
Expand Down Expand Up @@ -500,7 +557,7 @@ function mapContent(

break
}
// Thinking blocks dropped — see handleAssistantMessage rationale.
// Thinking blocks travel as reasoning_text/reasoning_opaque, not content.
case "image": {
contentParts.push({
type: "image_url",
Expand Down Expand Up @@ -596,10 +653,12 @@ export function translateToAnthropic(
stopReason = response.choices[0]?.finish_reason ?? stopReason

// Process all choices to extract text and tool use blocks
const allThinkingBlocks: Array<AnthropicThinkingBlock> = []
for (const choice of response.choices) {
const textBlocks = getAnthropicTextBlocks(choice.message.content)
const toolUseBlocks = getAnthropicToolUseBlocks(choice.message.tool_calls)

allThinkingBlocks.push(...getAnthropicThinkingBlocks(choice.message))
allTextBlocks.push(...textBlocks)
allToolUseBlocks.push(...toolUseBlocks)

Expand All @@ -609,14 +668,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: clientModel ?? response.model,
content: [...allTextBlocks, ...allToolUseBlocks],
content: [...allThinkingBlocks, ...allTextBlocks, ...allToolUseBlocks],
stop_reason: mapOpenAIStopReasonToAnthropic(stopReason),
stop_sequence: null,
usage: {
Expand All @@ -633,6 +690,19 @@ export function translateToAnthropic(
}
}

function getAnthropicThinkingBlocks(
message: ChatCompletionResponse["choices"][number]["message"],
): Array<AnthropicThinkingBlock> {
if (!message.reasoning_text && !message.reasoning_opaque) return []
return [
{
type: "thinking",
thinking: message.reasoning_text ?? "",
...(message.reasoning_opaque && { signature: message.reasoning_opaque }),
},
]
}

function getAnthropicTextBlocks(
messageContent: Message["content"],
): Array<AnthropicTextBlock> {
Expand Down
16 changes: 14 additions & 2 deletions src/routes/messages/responses-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
AnthropicUserContentBlock,
} from "./anthropic-types"

import { inlineSystemText } from "./non-stream-translation"
import { translateReasoningEffort } from "./reasoning-effort"

interface SSEStream {
Expand Down Expand Up @@ -144,8 +145,19 @@ function translateSystem(
function translateMessage(
message: AnthropicMessagesPayload["messages"][number],
): Array<ResponsesInputItem> {
if (message.role === "user") return translateUserMessage(message.content)
return translateAssistantMessage(message.content)
switch (message.role) {
case "user": {
return translateUserMessage(message.content)
}
case "system": {
// Mid-conversation system context (see handleInlineSystemMessage).
const text = inlineSystemText(message)
return text ? [{ role: "user", content: text }] : []
}
default: {
return translateAssistantMessage(message.content)
}
}
}

function translateUserMessage(
Expand Down
82 changes: 64 additions & 18 deletions src/routes/messages/stream-translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,62 @@ function isToolBlockOpen(state: AnthropicStreamState): boolean {
)
}

function closeOpenBlock(
state: AnthropicStreamState,
events: Array<AnthropicStreamEventData>,
): void {
if (!state.contentBlockOpen) return
events.push({ type: "content_block_stop", index: state.contentBlockIndex })
state.contentBlockIndex++
state.contentBlockOpen = false
state.thinkingBlockOpen = false
}

function openThinkingBlock(
state: AnthropicStreamState,
events: Array<AnthropicStreamEventData>,
): void {
if (state.thinkingBlockOpen) return
closeOpenBlock(state, events)
events.push({
type: "content_block_start",
index: state.contentBlockIndex,
content_block: { type: "thinking", thinking: "" },
})
state.contentBlockOpen = true
state.thinkingBlockOpen = true
}

/**
* Copilot streams Claude's reasoning as `reasoning_text` deltas, then a single
* `reasoning_opaque` signature. Forward them as an Anthropic thinking block:
* on long high-effort turns this is the only output for minutes, and without
* it Claude Code sees a silent stream (pings don't count as progress).
*/
function translateReasoningDelta(
delta: ChatCompletionChunk["choices"][number]["delta"],
state: AnthropicStreamState,
events: Array<AnthropicStreamEventData>,
): void {
if (delta.reasoning_text) {
openThinkingBlock(state, events)
events.push({
type: "content_block_delta",
index: state.contentBlockIndex,
delta: { type: "thinking_delta", thinking: delta.reasoning_text },
})
}

if (delta.reasoning_opaque) {
openThinkingBlock(state, events)
events.push({
type: "content_block_delta",
index: state.contentBlockIndex,
delta: { type: "signature_delta", signature: delta.reasoning_opaque },
})
}
}

// eslint-disable-next-line max-lines-per-function, complexity
export function translateChunkToAnthropicEvents(
chunk: ChatCompletionChunk,
Expand Down Expand Up @@ -57,15 +113,12 @@ export function translateChunkToAnthropicEvents(
state.messageStartSent = true
}

translateReasoningDelta(delta, state, events)

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 (isToolBlockOpen(state) || state.thinkingBlockOpen) {
// A tool or thinking block was open, so close it before starting text.
closeOpenBlock(state, events)
}

if (!state.contentBlockOpen) {
Expand Down Expand Up @@ -93,16 +146,8 @@ export function translateChunkToAnthropicEvents(
if (delta.tool_calls) {
for (const toolCall of delta.tool_calls) {
if (toolCall.id && toolCall.function?.name) {
// New tool call starting.
if (state.contentBlockOpen) {
// Close any previously open block.
events.push({
type: "content_block_stop",
index: state.contentBlockIndex,
})
state.contentBlockIndex++
state.contentBlockOpen = false
}
// New tool call starting. Close any previously open block.
closeOpenBlock(state, events)

const anthropicBlockIndex = state.contentBlockIndex
state.toolCalls[toolCall.index] = {
Expand Down Expand Up @@ -149,6 +194,7 @@ export function translateChunkToAnthropicEvents(
index: state.contentBlockIndex,
})
state.contentBlockOpen = false
state.thinkingBlockOpen = false
}

events.push(
Expand Down
Loading
Loading