Skip to content
Open
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
327 changes: 327 additions & 0 deletions src/lib/responses-translation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
// Translates between the Chat Completions dialect this project speaks
// everywhere (both /chat/completions and the Anthropic-compatible
// /v1/messages route funnel through createChatCompletions) and Copilot's
// Responses API, which is the only endpoint some models accept requests on
// (see Model["supported_endpoints"] in services/copilot/get-models.ts).
// Keeping the translation here - rather than in the routes/handlers - means
// every existing caller of createChatCompletions keeps working unchanged.

import type {
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsPayload,
Message,
TextPart,
ToolCall,
} from "~/services/copilot/create-chat-completions"
import type {
ResponseContentPart,
ResponseInputItem,
ResponseMessageItem,
ResponsesPayload,
ResponsesResult,
ResponseStreamEvent,
} from "~/services/copilot/create-responses"

export function chatPayloadToResponsesPayload(
payload: ChatCompletionsPayload,
): ResponsesPayload {
const input: Array<ResponseInputItem> = []

for (const message of payload.messages) {
if (message.role === "tool") {
input.push({
type: "function_call_output",
call_id: message.tool_call_id ?? "",
output: contentToText(message.content),
})
continue
}

if (message.tool_calls && message.tool_calls.length > 0) {
if (message.content) {
input.push(toMessageItem(message))
}
for (const toolCall of message.tool_calls) {
input.push({
type: "function_call",
call_id: toolCall.id,
name: toolCall.function.name,
arguments: toolCall.function.arguments,
})
}
continue
}

input.push(toMessageItem(message))
}

return {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve or reject n.

ChatCompletionsPayload permits n, but this translated payload does not carry it. responsesResultToChatCompletion() always creates one choice. A rerouted request with n: 2 silently returns one choice.

Reject n values other than 1 before routing, or implement compatible fan-out behavior.

model: payload.model,
input,
max_output_tokens: payload.max_tokens,
temperature: payload.temperature,
top_p: payload.top_p,
stream: payload.stream,
tools: payload.tools?.map((tool) => ({
type: "function" as const,
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
})),
tool_choice: translateToolChoice(payload.tool_choice),
}
}

function toMessageItem(message: Message): ResponseMessageItem {
return {
type: "message",
role: message.role as ResponseMessageItem["role"],
content: contentToParts(message.content, message.role),
}
}

function contentToParts(
content: Message["content"],
role: Message["role"],
): Array<ResponseContentPart> {
const textType: "input_text" | "output_text" =
role === "assistant" ? "output_text" : "input_text"

if (content === null) return []
if (typeof content === "string") {
return content.length > 0 ? [{ type: textType, text: content }] : []
}

return content.map((part): ResponseContentPart => {
if (part.type === "image_url") {
return { type: "input_image", image_url: part.image_url.url }
}
return { type: textType, text: part.text }
})
}

function contentToText(content: Message["content"]): string {
if (content === null) return ""
if (typeof content === "string") return content
return content
.filter((part): part is TextPart => part.type === "text")
.map((part) => part.text)
.join("")
}

function finalFinishReason(
hasToolCalls: boolean,
incomplete: boolean,
): "stop" | "length" | "tool_calls" {
if (hasToolCalls) return "tool_calls"
if (incomplete) return "length"
return "stop"
}

function translateToolChoice(
toolChoice: ChatCompletionsPayload["tool_choice"],
): ResponsesPayload["tool_choice"] {
if (!toolChoice || typeof toolChoice === "string") return toolChoice
return { type: "function", name: toolChoice.function.name }
}

export function responsesResultToChatCompletion(
result: ResponsesResult,
): ChatCompletionResponse {
let content: string | null = null
const toolCalls: Array<ToolCall> = []

for (const item of result.output) {
if (item.type === "message") {
const text = item.content.map((part) => part.text).join("")
content = (content ?? "") + text
} else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- outline ---'
ast-grep outline src/lib/responses-translation.ts
printf '%s\n' '--- target file ---'
cat -n src/lib/responses-translation.ts
printf '%s\n' '--- related response item declarations and usages ---'
rg -n -C 4 'function_call|output_text|Response.*Output|output_item|item\.type|responses-translation' src package.json README.md 2>/dev/null | head -n 300

Repository: ericc-ch/copilot-api

Length of output: 25206


🏁 Script executed:

pwd; sed -n '110,170p' src/lib/responses-translation.ts; rg -n -C 3 'function_call|output_text|item.type' src/lib src 2>/dev/null | head -n 200

Repository: ericc-ch/copilot-api

Length of output: 12971


🏁 Script executed:

set -eu
cat -n src/lib/responses-translation.ts
printf '\n--- related symbols ---\n'
rg -n -C 4 'function_call|output_text|Response.*Output|output_item|item\.type|responses-translation' src package.json README.md 2>/dev/null | head -n 300

Repository: ericc-ch/copilot-api

Length of output: 24412


🏁 Script executed:

set -eu
printf '%s\n' '--- create-responses.ts ---'
cat -n src/services/copilot/create-responses.ts
printf '%s\n' '--- create-chat-completions.ts relevant sections ---'
sed -n '1,180p' src/services/copilot/create-chat-completions.ts

Repository: ericc-ch/copilot-api

Length of output: 11350


🏁 Script executed:

set -eu
cat -n src/services/copilot/create-responses.ts
sed -n '1,180p' src/services/copilot/create-chat-completions.ts

Repository: ericc-ch/copilot-api

Length of output: 11269


Handle only function_call output items. The non-streaming /responses body is cast without runtime validation, so responsesResultToChatCompletion can receive output types outside ResponseOutputItem. Its else branch converts every non-message item into a tool call. A reasoning item can therefore produce a malformed tool call with missing call_id, name, and arguments, and incorrectly set finish_reason to tool_calls. Check item.type === "function_call" before adding a tool call, then ignore or translate other output types.

toolCalls.push({
id: item.call_id,
type: "function",
function: { name: item.name, arguments: item.arguments },
})
}
}

const finishReason = finalFinishReason(
toolCalls.length > 0,
result.status === "incomplete",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Do not translate failed Responses as stop.

Both call sites only distinguish incomplete. For the modeled status: "failed" and response.failed cases, finalFinishReason() returns "stop". Non-streaming callers receive a successful completion, and streaming callers receive a normal terminal chunk followed by [DONE].

Propagate failed Responses through the existing error path instead of emitting a successful completion.

Also applies to: 278-278

)

return {
id: result.id,
object: "chat.completion",
created: result.created_at,
model: result.model,
choices: [
{
index: 0,
message: {
role: "assistant",
content,
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
},
logprobs: null,
finish_reason: finishReason,
},
],
...(result.usage && {
usage: {
prompt_tokens: result.usage.input_tokens,
completion_tokens: result.usage.output_tokens,
total_tokens: result.usage.total_tokens,
...(result.usage.input_tokens_details?.cached_tokens !== undefined && {
prompt_tokens_details: {
cached_tokens: result.usage.input_tokens_details.cached_tokens,
},
}),
},
}),
}
}

interface StreamAccumulator {
id: string
created: number
model: string
toolCallIndexByItemId: Map<string, number>
nextToolIndex: number
}

interface ChunkDelta {
role?: "assistant"
content?: string
tool_calls?: Array<{
index: number
id?: string
type?: "function"
function?: { name?: string; arguments?: string }
}>
}

interface ChunkOptions {
finishReason?: ChatCompletionChunk["choices"][number]["finish_reason"]
usage?: ChatCompletionChunk["usage"]
}

function chatChunk(
acc: StreamAccumulator,
delta: ChunkDelta,
options: ChunkOptions = {},
): ChatCompletionChunk {
const { finishReason = null, usage } = options
return {
id: acc.id,
object: "chat.completion.chunk",
created: acc.created,
model: acc.model,
choices: [{ index: 0, delta, finish_reason: finishReason, logprobs: null }],
...(usage && { usage }),
}
}

function translateResponseStreamEvent(
event: ResponseStreamEvent,
acc: StreamAccumulator,
): Array<ChatCompletionChunk> {
switch (event.type) {
case "response.created":
case "response.in_progress": {
const { response } = event
acc.id = response.id
acc.created = response.created_at
acc.model = response.model
return event.type === "response.created" ?
[chatChunk(acc, { role: "assistant", content: "" })]
: []
}
case "response.output_text.delta": {
return [chatChunk(acc, { content: event.delta })]
}
case "response.output_item.added": {
const { item } = event
if (item.type !== "function_call") return []

const index = acc.nextToolIndex
acc.nextToolIndex += 1
acc.toolCallIndexByItemId.set(item.id, index)
return [
chatChunk(acc, {
tool_calls: [
{
index,
id: item.call_id,
type: "function",
function: { name: item.name, arguments: "" },
},
],
}),
]
}
case "response.function_call_arguments.delta": {
const index = acc.toolCallIndexByItemId.get(event.item_id)
if (index === undefined) return []
return [
chatChunk(acc, {
tool_calls: [{ index, function: { arguments: event.delta } }],
}),
]
}
case "response.completed":
case "response.incomplete":
case "response.failed": {
const { response } = event
const finishReason = finalFinishReason(
acc.toolCallIndexByItemId.size > 0,
event.type === "response.incomplete",
)
const usage = response.usage && {
prompt_tokens: response.usage.input_tokens,
completion_tokens: response.usage.output_tokens,
total_tokens: response.usage.total_tokens,
}
return [chatChunk(acc, {}, { finishReason, usage })]
}
default: {
return []
}
}
}

const isTerminalResponseEvent = (type: string) =>
type === "response.completed"
|| type === "response.incomplete"
|| type === "response.failed"

export async function* translateResponsesStream(
source: AsyncIterable<{ data?: string; event?: string }>,
): AsyncGenerator<{ data: string }> {
const acc: StreamAccumulator = {
id: "resp-stream",
created: Math.floor(Date.now() / 1000),
model: "",
toolCallIndexByItemId: new Map(),
nextToolIndex: 0,
}

for await (const rawEvent of source) {
if (!rawEvent.data || rawEvent.data === "[DONE]") continue

let parsed: ResponseStreamEvent
try {
parsed = JSON.parse(rawEvent.data) as ResponseStreamEvent
} catch {
continue
}

for (const chunk of translateResponseStreamEvent(parsed, acc)) {
yield { data: JSON.stringify(chunk) }
}

if (isTerminalResponseEvent(parsed.type)) {
yield { data: "[DONE]" }
}
}
}
44 changes: 44 additions & 0 deletions src/services/copilot/create-chat-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,57 @@ import { events } from "fetch-event-stream"

import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config"
import { HTTPError } from "~/lib/error"
import {
chatPayloadToResponsesPayload,
responsesResultToChatCompletion,
translateResponsesStream,
} from "~/lib/responses-translation"
import { state } from "~/lib/state"

import type { Model } from "./get-models"

import { createResponses, type ResponsesResult } from "./create-responses"

// Some models (the GPT reasoning family: gpt-5.x/6.x, gpt-6-astra,
// mai-code-1.1-flash, ...) are Responses-API-only on Copilot's side -
// /chat/completions rejects them with "not accessible via the
// /chat/completions endpoint", regardless of payload shape. Model.
// supported_endpoints (from GET /models) says so upfront; when it's
// present and excludes /chat/completions, reroute through /responses and
// translate the result back, so every existing caller of this function -
// both the /chat/completions route and the Anthropic-compatible
// /v1/messages route - keeps working unchanged.
const supportsChatCompletionsEndpoint = (model: Model | undefined) =>
!model?.supported_endpoints
|| model.supported_endpoints.includes("/chat/completions")

const supportsResponsesEndpoint = (model: Model | undefined) =>
model?.supported_endpoints?.includes("/responses") ?? false

const isResponsesNonStreaming = (
result: Awaited<ReturnType<typeof createResponses>>,
): result is ResponsesResult => Object.hasOwn(result, "output")

export const createChatCompletions = async (
payload: ChatCompletionsPayload,
) => {
if (!state.copilotToken) throw new Error("Copilot token not found")

const selectedModel = state.models?.data.find((m) => m.id === payload.model)

if (
!supportsChatCompletionsEndpoint(selectedModel)
&& supportsResponsesEndpoint(selectedModel)
) {
consola.debug(
`Model ${payload.model} is Responses-API-only, rerouting through /responses`,
)
const result = await createResponses(chatPayloadToResponsesPayload(payload))
return isResponsesNonStreaming(result) ?
responsesResultToChatCompletion(result)
: translateResponsesStream(result)
}

const enableVision = payload.messages.some(
(x) =>
typeof x.content !== "string"
Expand Down
Loading