From 882e298ddeb9b0da739af373965f55e724402a3e Mon Sep 17 00:00:00 2001 From: rejectliu Date: Wed, 19 Nov 2025 16:00:36 +0800 Subject: [PATCH 01/13] update compose config --- .env.example | 1 + .gitignore | 4 +++- docker-compose.yml | 9 +++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..6e237e5f5 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +GH_TOKEN= diff --git a/.gitignore b/.gitignore index 577a4f199..73ebed921 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,6 @@ node_modules/ .eslintcache # build output -dist/ \ No newline at end of file +dist/ + +.env diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..ffed9d5f0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: "3.8" +services: + copilot-api: + build: . + ports: + - "4141:4141" + environment: + - GH_TOKEN=${GH_TOKEN} + restart: unless-stopped From 6cb662de9e1cf3ab23a79dfaa1165fd9bf5aeda6 Mon Sep 17 00:00:00 2001 From: rejectliu Date: Wed, 19 Nov 2025 16:04:55 +0800 Subject: [PATCH 02/13] update compose config --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index ffed9d5f0..c1ce596de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ version: "3.8" services: copilot-api: + container_name: copilot-api build: . ports: - "4141:4141" From 3b0bdf1a37377bff006fbeb4596775a398a09404 Mon Sep 17 00:00:00 2001 From: rejectliu Date: Sun, 14 Dec 2025 22:15:09 +0800 Subject: [PATCH 03/13] =?UTF-8?q?docker=20=E6=94=AF=E6=8C=81=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96token=E5=88=B0=E5=AE=B9=E5=99=A8=E5=86=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c1ce596de..f3bd18691 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,10 @@ services: build: . ports: - "4141:4141" - environment: - - GH_TOKEN=${GH_TOKEN} + env_file: .env + volumes: + - copilot-data:/root/.local/share/copilot-api restart: unless-stopped + +volumes: + copilot-data: From 67bc65ccf494828ab72dbc5c51217f93fbe67620 Mon Sep 17 00:00:00 2001 From: rejectliu Date: Mon, 15 Dec 2025 08:06:11 +0800 Subject: [PATCH 04/13] remove .env --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index f3bd18691..1b1f502fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,6 @@ services: build: . ports: - "4141:4141" - env_file: .env volumes: - copilot-data:/root/.local/share/copilot-api restart: unless-stopped From 64b67e3824b0b039dda2cf98034b5b5a985f96c8 Mon Sep 17 00:00:00 2001 From: rejectliu Date: Mon, 15 Dec 2025 08:06:35 +0800 Subject: [PATCH 05/13] remove version --- docker-compose.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 1b1f502fd..61d744e2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: "3.8" services: copilot-api: container_name: copilot-api From b89199d5e0172731f36847ff0bf28d98f47a94f8 Mon Sep 17 00:00:00 2001 From: rejectliu Date: Sat, 3 Jan 2026 12:24:39 +0800 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20=E5=86=85=E9=83=A8=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=E4=BD=BF=E7=94=A8=E6=B5=81=E5=BC=8F=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E9=95=BF=E6=97=B6=E9=97=B4=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 createChatCompletionsStream 函数,强制使用流式模式 - 非流式请求改为收集流式数据块后合并返回 - 解决 ECONNRESET 超时问题 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- bun.lock | 1 + src/routes/chat-completions/handler.ts | 185 ++++++++++++++++-- .../copilot/create-chat-completions.ts | 40 +++- 3 files changed, 211 insertions(+), 15 deletions(-) diff --git a/bun.lock b/bun.lock index 20e895e7f..9ece87578 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "copilot-api", diff --git a/src/routes/chat-completions/handler.ts b/src/routes/chat-completions/handler.ts index 04a5ae9ed..265bd692b 100644 --- a/src/routes/chat-completions/handler.ts +++ b/src/routes/chat-completions/handler.ts @@ -1,3 +1,4 @@ +import type { ServerSentEventMessage } from "fetch-event-stream" import type { Context } from "hono" import consola from "consola" @@ -9,7 +10,8 @@ import { state } from "~/lib/state" import { getTokenCount } from "~/lib/tokenizer" import { isNullish } from "~/lib/utils" import { - createChatCompletions, + createChatCompletionsStream, + type ChatCompletionChunk, type ChatCompletionResponse, type ChatCompletionsPayload, } from "~/services/copilot/create-chat-completions" @@ -47,22 +49,177 @@ export async function handleCompletion(c: Context) { consola.debug("Set max_tokens to:", JSON.stringify(payload.max_tokens)) } - const response = await createChatCompletions(payload) + // 记录客户端是否请求流式响应 + const clientWantsStream = payload.stream === true - if (isNonStreaming(response)) { - consola.debug("Non-streaming response:", JSON.stringify(response)) - return c.json(response) + // 内部始终使用流式模式,避免长时间请求超时导致 ECONNRESET + const response = await createChatCompletionsStream(payload) + + // 如果客户端请求流式响应,直接透传 + if (clientWantsStream) { + consola.debug("Streaming response") + return streamSSE(c, async (stream) => { + for await (const chunk of response) { + consola.debug("Streaming chunk:", JSON.stringify(chunk)) + await stream.writeSSE(chunk as SSEMessage) + } + }) } - consola.debug("Streaming response") - return streamSSE(c, async (stream) => { - for await (const chunk of response) { - consola.debug("Streaming chunk:", JSON.stringify(chunk)) - await stream.writeSSE(chunk as SSEMessage) + // 客户端请求非流式响应,收集流式数据块并合并 + consola.debug("Collecting stream chunks for non-streaming response") + const nonStreamResponse = await collectStreamToResponse(response) + consola.debug("Non-streaming response:", JSON.stringify(nonStreamResponse)) + return c.json(nonStreamResponse) +} + +type FinishReason = "stop" | "length" | "tool_calls" | "content_filter" + +type ToolCallAccumulator = { + id: string + type: "function" + function: { name: string; arguments: string } +} + +type StreamAccumulator = { + id: string + model: string + created: number + systemFingerprint?: string + finishReason: FinishReason + content: string + toolCalls: Map + usage?: ChatCompletionResponse["usage"] +} + +/** + * 将流式响应的数据块合并为非流式响应格式 + */ +async function collectStreamToResponse( + stream: AsyncGenerator, +): Promise { + const accumulator = createAccumulator() + + for await (const chunk of stream) { + if (!chunk.data) continue + if (chunk.data === "[DONE]") break + + const parsed = parseChunkDataOrLog(chunk.data) + if (!parsed) continue + applyChunkToAccumulator(parsed, accumulator) + } + + return buildResponse(accumulator) +} + +function createAccumulator(): StreamAccumulator { + return { + id: "", + model: "", + created: 0, + finishReason: "stop", + content: "", + toolCalls: new Map(), + } +} + +function parseChunkDataOrLog(data: unknown): ChatCompletionChunk | null { + if (typeof data !== "string") return null + + try { + return JSON.parse(data) as ChatCompletionChunk + } catch (error) { + consola.debug("Failed to parse SSE chunk data", { + dataPreview: data.slice(0, 500), + error, + }) + return null + } +} + +function applyChunkToAccumulator( + parsed: ChatCompletionChunk, + accumulator: StreamAccumulator, +) { + if (!accumulator.id && parsed.id) accumulator.id = parsed.id + if (!accumulator.model && parsed.model) accumulator.model = parsed.model + if (!accumulator.created && parsed.created) + accumulator.created = parsed.created + if (!accumulator.systemFingerprint && parsed.system_fingerprint) { + accumulator.systemFingerprint = parsed.system_fingerprint + } + if (parsed.usage) accumulator.usage = parsed.usage + + const choice = parsed.choices.at(0) + if (!choice) return + + if (choice.finish_reason) accumulator.finishReason = choice.finish_reason + + if (typeof choice.delta.content === "string") { + accumulator.content += choice.delta.content + } + + if (choice.delta.tool_calls) { + mergeToolCalls(accumulator.toolCalls, choice.delta.tool_calls) + } +} + +function mergeToolCalls( + toolCalls: Map, + deltas: NonNullable< + ChatCompletionChunk["choices"][number]["delta"]["tool_calls"] + >, +) { + for (const delta of deltas) { + const existing = toolCalls.get(delta.index) + if (!existing) { + toolCalls.set(delta.index, { + id: delta.id ?? "", + type: "function", + function: { + name: delta.function?.name ?? "", + arguments: delta.function?.arguments ?? "", + }, + }) + continue + } + + if (!existing.id && delta.id) existing.id = delta.id + if (!existing.function.name && delta.function?.name) { + existing.function.name = delta.function.name } - }) + if (delta.function?.arguments) { + existing.function.arguments += delta.function.arguments + } + } } -const isNonStreaming = ( - response: Awaited>, -): response is ChatCompletionResponse => Object.hasOwn(response, "choices") +function buildResponse(accumulator: StreamAccumulator): ChatCompletionResponse { + const response: ChatCompletionResponse = { + id: accumulator.id, + object: "chat.completion", + created: accumulator.created, + model: accumulator.model, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: accumulator.content || null, + ...(accumulator.toolCalls.size > 0 && { + tool_calls: Array.from(accumulator.toolCalls.values()), + }), + }, + logprobs: null, + finish_reason: accumulator.finishReason, + }, + ], + } + + if (accumulator.systemFingerprint) { + response.system_fingerprint = accumulator.systemFingerprint + } + if (accumulator.usage) response.usage = accumulator.usage + + return response +} diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index 8534151da..ca2270ce3 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -1,5 +1,5 @@ import consola from "consola" -import { events } from "fetch-event-stream" +import { events, type ServerSentEventMessage } from "fetch-event-stream" import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config" import { HTTPError } from "~/lib/error" @@ -46,6 +46,44 @@ export const createChatCompletions = async ( return (await response.json()) as ChatCompletionResponse } +/** + * 强制使用流式模式的版本,返回类型始终是 AsyncGenerator + * 用于避免非流式请求的超时问题 + */ +export const createChatCompletionsStream = async ( + payload: Omit, +): Promise> => { + if (!state.copilotToken) throw new Error("Copilot token not found") + + const enableVision = payload.messages.some( + (x) => + typeof x.content !== "string" + && x.content?.some((x) => x.type === "image_url"), + ) + + const isAgentCall = payload.messages.some((msg) => + ["assistant", "tool"].includes(msg.role), + ) + + const headers: Record = { + ...copilotHeaders(state, enableVision), + "X-Initiator": isAgentCall ? "agent" : "user", + } + + const response = await fetch(`${copilotBaseUrl(state)}/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ ...payload, stream: true }), + }) + + if (!response.ok) { + consola.error("Failed to create chat completions", response) + throw new HTTPError("Failed to create chat completions", response) + } + + return events(response) +} + // Streaming types export interface ChatCompletionChunk { From 3733486aa2e32fbed4fd93a3cd0d8ab77d974efe Mon Sep 17 00:00:00 2001 From: rejectliu Date: Sat, 3 Jan 2026 12:39:27 +0800 Subject: [PATCH 07/13] log to inform non-stream --- src/routes/chat-completions/handler.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/routes/chat-completions/handler.ts b/src/routes/chat-completions/handler.ts index 265bd692b..ee7e7b536 100644 --- a/src/routes/chat-completions/handler.ts +++ b/src/routes/chat-completions/handler.ts @@ -66,6 +66,10 @@ export async function handleCompletion(c: Context) { }) } + consola.info( + "Client requested non-streaming response; using upstream streaming and collecting chunks", + ) + // 客户端请求非流式响应,收集流式数据块并合并 consola.debug("Collecting stream chunks for non-streaming response") const nonStreamResponse = await collectStreamToResponse(response) From daba1725a5308519a3000734507140b4ba334257 Mon Sep 17 00:00:00 2001 From: rejectliu Date: Sat, 3 Jan 2026 12:43:09 +0800 Subject: [PATCH 08/13] Revert "log to inform non-stream" This reverts commit 3733486aa2e32fbed4fd93a3cd0d8ab77d974efe. --- src/routes/chat-completions/handler.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/routes/chat-completions/handler.ts b/src/routes/chat-completions/handler.ts index ee7e7b536..265bd692b 100644 --- a/src/routes/chat-completions/handler.ts +++ b/src/routes/chat-completions/handler.ts @@ -66,10 +66,6 @@ export async function handleCompletion(c: Context) { }) } - consola.info( - "Client requested non-streaming response; using upstream streaming and collecting chunks", - ) - // 客户端请求非流式响应,收集流式数据块并合并 consola.debug("Collecting stream chunks for non-streaming response") const nonStreamResponse = await collectStreamToResponse(response) From ae8f9d03d4e529ae0dcaf84eb0a596e6e5650f02 Mon Sep 17 00:00:00 2001 From: rejectliu Date: Wed, 28 Jan 2026 11:07:50 +0800 Subject: [PATCH 09/13] feat: add manual token refresh command Add `refresh-token` CLI command and `/token/refresh` API endpoint to manually refresh Copilot token without restarting the container. Usage: `docker compose exec copilot-api refresh-token` Co-Authored-By: Claude (claude-opus-4.5) --- Dockerfile | 4 +++- entrypoint.sh | 18 +++++++++++------- refresh-token | 5 +++++ src/main.ts | 9 ++++++++- src/refresh-token.ts | 37 +++++++++++++++++++++++++++++++++++++ src/routes/token/route.ts | 16 ++++++++++++++++ 6 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 refresh-token create mode 100644 src/refresh-token.ts diff --git a/Dockerfile b/Dockerfile index 1265220ef..39be38106 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,5 +21,7 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget --spider -q http://localhost:4141/ || exit 1 COPY entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +COPY refresh-token /usr/local/bin/refresh-token +RUN chmod +x /entrypoint.sh /usr/local/bin/refresh-token + ENTRYPOINT ["/entrypoint.sh"] diff --git a/entrypoint.sh b/entrypoint.sh index dfe63c902..cc1c741ce 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,9 +1,13 @@ #!/bin/sh -if [ "$1" = "--auth" ]; then - # Run auth command - exec bun run dist/main.js auth -else - # Default command - exec bun run dist/main.js start -g "$GH_TOKEN" "$@" -fi +case "$1" in + --auth) + exec bun run dist/main.js auth + ;; + refresh-token) + exec bun run dist/main.js refresh-token + ;; + *) + exec bun run dist/main.js start -g "$GH_TOKEN" "$@" + ;; +esac diff --git a/refresh-token b/refresh-token new file mode 100644 index 000000000..20d14123f --- /dev/null +++ b/refresh-token @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +echo "Refreshing Copilot token..." +bun run /app/dist/main.js refresh-token "$@" diff --git a/src/main.ts b/src/main.ts index 4f6ca784b..afe174d54 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import { defineCommand, runMain } from "citty" import { auth } from "./auth" import { checkUsage } from "./check-usage" import { debug } from "./debug" +import { refreshToken } from "./refresh-token" import { start } from "./start" const main = defineCommand({ @@ -13,7 +14,13 @@ const main = defineCommand({ description: "A wrapper around GitHub Copilot API to make it OpenAI compatible, making it usable for other tools.", }, - subCommands: { auth, start, "check-usage": checkUsage, debug }, + subCommands: { + auth, + start, + "check-usage": checkUsage, + debug, + "refresh-token": refreshToken, + }, }) await runMain(main) diff --git a/src/refresh-token.ts b/src/refresh-token.ts new file mode 100644 index 000000000..1a8a956ab --- /dev/null +++ b/src/refresh-token.ts @@ -0,0 +1,37 @@ +import { defineCommand } from "citty" +import consola from "consola" + +export const refreshToken = defineCommand({ + meta: { + name: "refresh-token", + description: "Manually refresh the Copilot token via API", + }, + args: { + port: { + type: "string", + alias: "p", + default: "4141", + description: "The port the server is running on", + }, + }, + async run({ args }) { + const port = args.port + const url = `http://localhost:${port}/token/refresh` + + try { + const response = await fetch(url, { method: "POST" }) + const data = await response.json() + + if (response.ok && data.success) { + consola.success("Token refreshed successfully") + } else { + consola.error("Failed to refresh token:", data.error || "Unknown error") + process.exit(1) + } + } catch (error) { + consola.error("Failed to connect to server:", error) + consola.info("Make sure the server is running on port", port) + process.exit(1) + } + }, +}) diff --git a/src/routes/token/route.ts b/src/routes/token/route.ts index dd0456d9a..1b1ae41f1 100644 --- a/src/routes/token/route.ts +++ b/src/routes/token/route.ts @@ -1,6 +1,7 @@ import { Hono } from "hono" import { state } from "~/lib/state" +import { getCopilotToken } from "~/services/github/get-copilot-token" export const tokenRoute = new Hono() @@ -14,3 +15,18 @@ tokenRoute.get("/", (c) => { return c.json({ error: "Failed to fetch token", token: null }, 500) } }) + +tokenRoute.post("/refresh", async (c) => { + try { + const { token } = await getCopilotToken() + state.copilotToken = token + console.log("Copilot token manually refreshed") + return c.json({ + success: true, + message: "Token refreshed successfully", + }) + } catch (error) { + console.error("Error refreshing token:", error) + return c.json({ error: "Failed to refresh token", success: false }, 500) + } +}) From 39c7c03cb71a1f9051d75dda841ce30cc82da416 Mon Sep 17 00:00:00 2001 From: rejectliu Date: Thu, 29 Jan 2026 15:27:52 +0800 Subject: [PATCH 10/13] feat: auto refresh token on 401 with 1 hour cooldown When a request fails with 401 (token expired), automatically attempt to refresh the Copilot token and retry the request. Includes a 1 hour cooldown to prevent repeated refresh attempts if the error is caused by other issues. Co-Authored-By: Claude (claude-opus-4.5) --- src/lib/token.ts | 29 +++++++++ .../copilot/create-chat-completions.ts | 59 ++++++++++--------- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/src/lib/token.ts b/src/lib/token.ts index fc8d2785f..4b6b49549 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -12,6 +12,35 @@ import { state } from "./state" const readGithubToken = () => fs.readFile(PATHS.GITHUB_TOKEN_PATH, "utf8") +const REFRESH_COOLDOWN_MS = 60 * 60 * 1000 // 1 hour +let lastRefreshAttempt = 0 + +export async function refreshCopilotTokenOnError(): Promise { + const now = Date.now() + if (now - lastRefreshAttempt < REFRESH_COOLDOWN_MS) { + consola.warn( + "Token refresh on error skipped: cooldown not elapsed (1 hour limit)", + ) + return false + } + + lastRefreshAttempt = now + consola.info("Attempting to refresh Copilot token due to request error") + + try { + const { token } = await getCopilotToken() + state.copilotToken = token + consola.info("Copilot token refreshed successfully after error") + if (state.showToken) { + consola.info("Refreshed Copilot token:", token) + } + return true + } catch (error) { + consola.error("Failed to refresh Copilot token on error:", error) + return false + } +} + const writeGithubToken = (token: string) => fs.writeFile(PATHS.GITHUB_TOKEN_PATH, token) diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index ca2270ce3..8c915209c 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -4,10 +4,12 @@ import { events, type ServerSentEventMessage } from "fetch-event-stream" import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config" import { HTTPError } from "~/lib/error" import { state } from "~/lib/state" +import { refreshCopilotTokenOnError } from "~/lib/token" -export const createChatCompletions = async ( +async function doFetch( payload: ChatCompletionsPayload, -) => { + streamOverride?: boolean, +) { if (!state.copilotToken) throw new Error("Copilot token not found") const enableVision = payload.messages.some( @@ -16,23 +18,39 @@ export const createChatCompletions = async ( && x.content?.some((x) => x.type === "image_url"), ) - // Agent/user check for X-Initiator header - // Determine if any message is from an agent ("assistant" or "tool") const isAgentCall = payload.messages.some((msg) => ["assistant", "tool"].includes(msg.role), ) - // Build headers and add X-Initiator const headers: Record = { ...copilotHeaders(state, enableVision), "X-Initiator": isAgentCall ? "agent" : "user", } - const response = await fetch(`${copilotBaseUrl(state)}/chat/completions`, { + const body = + streamOverride !== undefined ? + { ...payload, stream: streamOverride } + : payload + + return fetch(`${copilotBaseUrl(state)}/chat/completions`, { method: "POST", headers, - body: JSON.stringify(payload), + body: JSON.stringify(body), }) +} + +export const createChatCompletions = async ( + payload: ChatCompletionsPayload, +) => { + let response = await doFetch(payload) + + if (response.status === 401) { + consola.warn("Got 401, attempting token refresh") + const refreshed = await refreshCopilotTokenOnError() + if (refreshed) { + response = await doFetch(payload) + } + } if (!response.ok) { consola.error("Failed to create chat completions", response) @@ -53,29 +71,16 @@ export const createChatCompletions = async ( export const createChatCompletionsStream = async ( payload: Omit, ): Promise> => { - if (!state.copilotToken) throw new Error("Copilot token not found") - - const enableVision = payload.messages.some( - (x) => - typeof x.content !== "string" - && x.content?.some((x) => x.type === "image_url"), - ) - - const isAgentCall = payload.messages.some((msg) => - ["assistant", "tool"].includes(msg.role), - ) + let response = await doFetch(payload as ChatCompletionsPayload, true) - const headers: Record = { - ...copilotHeaders(state, enableVision), - "X-Initiator": isAgentCall ? "agent" : "user", + if (response.status === 401) { + consola.warn("Got 401, attempting token refresh") + const refreshed = await refreshCopilotTokenOnError() + if (refreshed) { + response = await doFetch(payload as ChatCompletionsPayload, true) + } } - const response = await fetch(`${copilotBaseUrl(state)}/chat/completions`, { - method: "POST", - headers, - body: JSON.stringify({ ...payload, stream: true }), - }) - if (!response.ok) { consola.error("Failed to create chat completions", response) throw new HTTPError("Failed to create chat completions", response) From 9a296d30436ea62653b51c74cf7197b31cbb6659 Mon Sep 17 00:00:00 2001 From: rejectliu Date: Sat, 28 Feb 2026 20:36:16 +0800 Subject: [PATCH 11/13] feat: centralize model name normalization and add model aliases Extract model alias mapping into a dedicated module (model-normalization.ts) to unify model name handling across the codebase. This enables Claude Code to work with versioned model names (e.g. claude-opus-4-6[1M]) by mapping them to their canonical Copilot model IDs. Also fixes a bug in count-tokens-handler where the pre-normalization model name was used for model lookup. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/model-normalization.ts | 39 +++++++++++++++++++ src/routes/messages/count-tokens-handler.ts | 2 +- src/routes/messages/non-stream-translation.ts | 13 +------ src/routes/models/route.ts | 33 +++++++++++----- .../copilot/create-chat-completions.ts | 14 +++++-- src/start.ts | 10 +++-- 6 files changed, 83 insertions(+), 28 deletions(-) create mode 100644 src/lib/model-normalization.ts diff --git a/src/lib/model-normalization.ts b/src/lib/model-normalization.ts new file mode 100644 index 000000000..f5f8602a4 --- /dev/null +++ b/src/lib/model-normalization.ts @@ -0,0 +1,39 @@ +const modelAliases: Record = { + "claude-opus-4-6[1M]": "claude-opus-4.6-1m", + "claude-opus-4-6": "claude-opus-4.6-1m", + "claude-sonnet-4-6": "claude-sonnet-4.6", +} + +const reverseModelAliases = new Map>() +for (const [alias, canonical] of Object.entries(modelAliases)) { + const aliases = reverseModelAliases.get(canonical) ?? [] + aliases.push(alias) + reverseModelAliases.set(canonical, aliases) +} + +export function normalizeModelName(modelId: string): string { + return modelAliases[modelId] ?? modelId +} + +export function getModelAliases(modelId: string): Array { + return reverseModelAliases.get(modelId) ?? [] +} + +export function expandModelIdsWithAliases( + modelIds: Array, +): Array { + const expandedModelIds: Array = [] + const seenModelIds = new Set() + + for (const modelId of modelIds) { + for (const variant of [modelId, ...getModelAliases(modelId)]) { + if (seenModelIds.has(variant)) { + continue + } + seenModelIds.add(variant) + expandedModelIds.push(variant) + } + } + + return expandedModelIds +} diff --git a/src/routes/messages/count-tokens-handler.ts b/src/routes/messages/count-tokens-handler.ts index 2ec849cb8..939cee317 100644 --- a/src/routes/messages/count-tokens-handler.ts +++ b/src/routes/messages/count-tokens-handler.ts @@ -20,7 +20,7 @@ export async function handleCountTokens(c: Context) { const openAIPayload = translateToOpenAI(anthropicPayload) const selectedModel = state.models?.data.find( - (model) => model.id === anthropicPayload.model, + (model) => model.id === openAIPayload.model, ) if (!selectedModel) { diff --git a/src/routes/messages/non-stream-translation.ts b/src/routes/messages/non-stream-translation.ts index dc41e6382..dd22038f6 100644 --- a/src/routes/messages/non-stream-translation.ts +++ b/src/routes/messages/non-stream-translation.ts @@ -1,3 +1,4 @@ +import { normalizeModelName } from "~/lib/model-normalization" import { type ChatCompletionResponse, type ChatCompletionsPayload, @@ -30,7 +31,7 @@ export function translateToOpenAI( payload: AnthropicMessagesPayload, ): ChatCompletionsPayload { return { - model: translateModelName(payload.model), + model: normalizeModelName(payload.model), messages: translateAnthropicMessagesToOpenAI( payload.messages, payload.system, @@ -46,16 +47,6 @@ export function translateToOpenAI( } } -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-")) { - return model.replace(/^claude-opus-4-.*/, "claude-opus-4") - } - return model -} - function translateAnthropicMessagesToOpenAI( anthropicMessages: Array, system: string | Array | undefined, diff --git a/src/routes/models/route.ts b/src/routes/models/route.ts index 5254e2af7..1a3b6e42e 100644 --- a/src/routes/models/route.ts +++ b/src/routes/models/route.ts @@ -1,6 +1,10 @@ import { Hono } from "hono" import { forwardError } from "~/lib/error" +import { + expandModelIdsWithAliases, + normalizeModelName, +} from "~/lib/model-normalization" import { state } from "~/lib/state" import { cacheModels } from "~/lib/utils" @@ -13,15 +17,26 @@ modelRoutes.get("/", async (c) => { await cacheModels() } - const models = state.models?.data.map((model) => ({ - id: model.id, - object: "model", - type: "model", - created: 0, // No date available from source - created_at: new Date(0).toISOString(), // No date available from source - owned_by: model.vendor, - display_name: model.name, - })) + const modelById = new Map( + state.models?.data.map((model) => [model.id, model]), + ) + const modelIds = expandModelIdsWithAliases( + state.models?.data.map((model) => model.id) ?? [], + ) + const models = modelIds.flatMap((modelId) => { + const sourceModel = modelById.get(normalizeModelName(modelId)) + if (!sourceModel) return [] + + return { + id: modelId, + object: "model", + type: "model", + created: 0, // No date available from source + created_at: new Date(0).toISOString(), // No date available from source + owned_by: sourceModel.vendor, + display_name: sourceModel.name, + } + }) return c.json({ object: "list", diff --git a/src/services/copilot/create-chat-completions.ts b/src/services/copilot/create-chat-completions.ts index 8c915209c..e7cecf3a3 100644 --- a/src/services/copilot/create-chat-completions.ts +++ b/src/services/copilot/create-chat-completions.ts @@ -3,6 +3,7 @@ import { events, type ServerSentEventMessage } from "fetch-event-stream" import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config" import { HTTPError } from "~/lib/error" +import { normalizeModelName } from "~/lib/model-normalization" import { state } from "~/lib/state" import { refreshCopilotTokenOnError } from "~/lib/token" @@ -12,13 +13,18 @@ async function doFetch( ) { if (!state.copilotToken) throw new Error("Copilot token not found") - const enableVision = payload.messages.some( + const normalizedPayload: ChatCompletionsPayload = { + ...payload, + model: normalizeModelName(payload.model), + } + + const enableVision = normalizedPayload.messages.some( (x) => typeof x.content !== "string" && x.content?.some((x) => x.type === "image_url"), ) - const isAgentCall = payload.messages.some((msg) => + const isAgentCall = normalizedPayload.messages.some((msg) => ["assistant", "tool"].includes(msg.role), ) @@ -29,8 +35,8 @@ async function doFetch( const body = streamOverride !== undefined ? - { ...payload, stream: streamOverride } - : payload + { ...normalizedPayload, stream: streamOverride } + : normalizedPayload return fetch(`${copilotBaseUrl(state)}/chat/completions`, { method: "POST", diff --git a/src/start.ts b/src/start.ts index 14abbbdff..9fe1a8caf 100644 --- a/src/start.ts +++ b/src/start.ts @@ -6,6 +6,7 @@ import consola from "consola" import { serve, type ServerHandler } from "srvx" import invariant from "tiny-invariant" +import { expandModelIdsWithAliases } from "./lib/model-normalization" import { ensurePaths } from "./lib/paths" import { initProxyFromEnv } from "./lib/proxy" import { generateEnvScript } from "./lib/shell" @@ -59,9 +60,12 @@ export async function runServer(options: RunServerOptions): Promise { await setupCopilotToken() await cacheModels() + const availableModelIds = expandModelIdsWithAliases( + state.models?.data.map((model) => model.id) ?? [], + ) consola.info( - `Available models: \n${state.models?.data.map((model) => `- ${model.id}`).join("\n")}`, + `Available models: \n${availableModelIds.map((modelId) => `- ${modelId}`).join("\n")}`, ) const serverUrl = `http://localhost:${options.port}` @@ -73,7 +77,7 @@ export async function runServer(options: RunServerOptions): Promise { "Select a model to use with Claude Code", { type: "select", - options: state.models.data.map((model) => model.id), + options: availableModelIds, }, ) @@ -81,7 +85,7 @@ export async function runServer(options: RunServerOptions): Promise { "Select a small model to use with Claude Code", { type: "select", - options: state.models.data.map((model) => model.id), + options: availableModelIds, }, ) From 7b4ed3169d38d5db29b5603c368f1baa4615f320 Mon Sep 17 00:00:00 2001 From: rejectliu Date: Sat, 28 Feb 2026 20:36:55 +0800 Subject: [PATCH 12/13] feat: add claude-haiku-4-5 as alias for claude-haiku-4.5 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/model-normalization.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/model-normalization.ts b/src/lib/model-normalization.ts index f5f8602a4..8a807401e 100644 --- a/src/lib/model-normalization.ts +++ b/src/lib/model-normalization.ts @@ -2,6 +2,7 @@ const modelAliases: Record = { "claude-opus-4-6[1M]": "claude-opus-4.6-1m", "claude-opus-4-6": "claude-opus-4.6-1m", "claude-sonnet-4-6": "claude-sonnet-4.6", + "claude-haiku-4-5": "claude-haiku-4.5", } const reverseModelAliases = new Map>() From f4b06ff9729b51951304e7a8c53935967abc48b4 Mon Sep 17 00:00:00 2001 From: rejectliu Date: Sat, 28 Feb 2026 20:38:54 +0800 Subject: [PATCH 13/13] docs: explain why claude-opus-4-6 maps to 1m variant Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/model-normalization.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/model-normalization.ts b/src/lib/model-normalization.ts index 8a807401e..e3bf67d3f 100644 --- a/src/lib/model-normalization.ts +++ b/src/lib/model-normalization.ts @@ -1,3 +1,7 @@ +// When configuring claude-opus-4-6[1M] in Claude Code, the [1M] suffix only +// activates the client-side 1M context window. The actual model name sent in +// requests is still claude-opus-4-6. So to enable 1M context support, we must +// map claude-opus-4-6 to claude-opus-4.6-1m (not claude-opus-4.6). const modelAliases: Record = { "claude-opus-4-6[1M]": "claude-opus-4.6-1m", "claude-opus-4-6": "claude-opus-4.6-1m",