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
6 changes: 5 additions & 1 deletion src/routes/chat-completions/responses-translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
1 change: 1 addition & 0 deletions src/routes/messages/anthropic-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export interface AnthropicToolUseBlock {
export interface AnthropicThinkingBlock {
type: "thinking"
thinking: string
signature?: string
}

export type AnthropicUserContentBlock =
Expand Down
125 changes: 125 additions & 0 deletions src/routes/messages/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)

Expand All @@ -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),
})
Comment on lines +61 to +106

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new debug logs in the Responses path print full request/stream payloads, including reasoning.encrypted_content / thinking signatures and potentially user content or base64 images. These values are effectively replayable state and can be sensitive; logging entire events can also be very noisy in production. Consider redacting encrypted_content/signature fields and truncating payloads (similar to existing .slice(-400) usage) or gating these logs behind an explicit verbose/debug flag.

Copilot uses AI. Check for mistakes.
}
}
})
}

const response = await createChatCompletions(openAIPayload)

if (isNonStreaming(response)) {
Expand Down Expand Up @@ -89,3 +160,57 @@ export async function handleCompletion(c: Context) {
const isNonStreaming = (
response: Awaited<ReturnType<typeof createChatCompletions>>,
): response is ChatCompletionResponse => Object.hasOwn(response, "choices")

const isNonStreamingResponse = (
response: Awaited<ReturnType<typeof createResponses>>,
): 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)}…`
}
2 changes: 1 addition & 1 deletion src/routes/messages/non-stream-translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-")
Expand Down
Loading