diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 307afe2a18..051783f6c9 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -39,6 +39,9 @@ export function generateLegacyProviders(): Record { if (entry.responsesBaseUrl) { p.responsesBaseUrl = entry.responsesBaseUrl; } + if (entry.messagesUrl) { + p.messagesUrl = entry.messagesUrl; + } if (entry.requestDefaults) { p.requestDefaults = entry.requestDefaults; } diff --git a/open-sse/config/providers/registry/github/index.ts b/open-sse/config/providers/registry/github/index.ts index 41673dcf7d..e4f6f7e563 100644 --- a/open-sse/config/providers/registry/github/index.ts +++ b/open-sse/config/providers/registry/github/index.ts @@ -12,6 +12,12 @@ export const githubProvider: RegistryEntry = { executor: "github", baseUrl: "https://api.githubcopilot.com/chat/completions", responsesBaseUrl: "https://api.githubcopilot.com/responses", + // Anthropic-native shim: the only Copilot endpoint that surfaces prompt-cache + // token counts (cached_tokens) for Claude models, and avoids round-tripping + // tool_use/tool_result/thinking content blocks through the OpenAI shape. + // Routed via each claude-* model's targetFormat: "claude" below (see + // executors/github.ts buildUrl/buildHeaders). Port of decolua/9router#2608. + messagesUrl: "https://api.githubcopilot.com/v1/messages", authType: "oauth", authHeader: "bearer", // GitHub Copilot is a public device-flow OAuth client: it has a public client_id but @@ -24,16 +30,23 @@ export const githubProvider: RegistryEntry = { }, defaultContextLength: 128000, headers: getGitHubCopilotChatHeaders(), + // All claude-* entries below carry targetFormat: "claude" so chatCore.ts + // translates the request to Anthropic-native shape before the executor ever + // sees it, and the github executor's buildUrl()/buildHeaders() route them at + // messagesUrl (/v1/messages) instead of /chat/completions. Port of + // decolua/9router#2608 (author: yidecode) — see executors/github.ts. models: [ { id: "claude-fable-5", name: "Claude Fable 5", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, }, { id: "claude-opus-4.8-fast", name: "Claude Opus 4.8 (fast mode)", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, unsupportedParams: ["temperature", "top_p", "top_k"], @@ -41,6 +54,7 @@ export const githubProvider: RegistryEntry = { { id: "claude-opus-4.8", name: "Claude Opus 4.8", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, unsupportedParams: ["temperature", "top_p", "top_k"], @@ -48,36 +62,42 @@ export const githubProvider: RegistryEntry = { { id: "claude-opus-4.7", name: "Claude Opus 4.7", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, }, { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, }, { id: "claude-opus-4.5", name: "Claude Opus 4.5", + targetFormat: "claude", contextLength: 200000, maxOutputTokens: 32000, }, { id: "claude-sonnet-5", name: "Claude Sonnet 5", + targetFormat: "claude", contextLength: 1000000, maxOutputTokens: 64000, }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", + targetFormat: "claude", contextLength: 200000, maxOutputTokens: 32000, }, { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", + targetFormat: "claude", contextLength: 200000, maxOutputTokens: 32000, }, diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index acf510ecf9..c6f32b03ec 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -107,6 +107,10 @@ export interface RegistryEntry { /** Override base URL used only for API key validation (e.g., opencode-go validates on zen/v1) */ testKeyBaseUrl?: string; responsesBaseUrl?: string; + /** Anthropic-native /v1/messages endpoint (e.g. GitHub Copilot's shim) used + * for models tagged `targetFormat: "claude"` on an otherwise openai-format + * provider — see registry/github/index.ts. */ + messagesUrl?: string; urlSuffix?: string; urlBuilder?: (base: string, model: string, stream: boolean) => string; authType: string; @@ -174,6 +178,7 @@ export interface LegacyProvider { baseUrl?: string; baseUrls?: string[]; responsesBaseUrl?: string; + messagesUrl?: string; headers?: Record; requestDefaults?: ProviderRequestDefaults; clientId?: string; diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 0058affe32..3d82de7680 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -117,6 +117,7 @@ export type ProviderConfig = { baseUrl?: string; baseUrls?: string[]; responsesBaseUrl?: string; + messagesUrl?: string; chatPath?: string; clientVersion?: string; clientId?: string; diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 5271500d31..64f4bcee6e 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -41,6 +41,16 @@ export class GithubExecutor extends BaseExecutor { buildUrl(model: string, _stream: boolean, _urlIndex = 0) { const targetFormat = getModelTargetFormat("gh", model); + // Claude models: route to Copilot's Anthropic-native /v1/messages shim — the + // only Copilot endpoint that surfaces prompt-cache token counts for Claude and + // avoids a lossy round-trip of tool_use/tool_result/thinking content blocks + // through the OpenAI shape. Driven by the registry's per-model targetFormat + // (see registry/github/index.ts), which chatCore.ts also uses to translate the + // request to Claude shape before the executor ever sees it. + // Port of decolua/9router#2608 (author: yidecode). + if (targetFormat === "claude" && this.config.messagesUrl) { + return this.config.messagesUrl; + } // 9router#102: Copilot Codex models advertise supported_endpoints: ["/responses"] // and 400 on /chat/completions. Route any *-codex id to /responses even when it // isn't in the curated registry, so newly-shipped Codex models work out of the box. @@ -93,6 +103,15 @@ export class GithubExecutor extends BaseExecutor { const sourceBody = body && typeof body === "object" ? body : {}; const modifiedBody = { ...sourceBody }; + // Claude models arrive here already translated to Anthropic-native shape by + // chatCore.ts (registry targetFormat: "claude" — see registry/github/index.ts) + // and are dispatched at /v1/messages (buildUrl above), which behaves like the + // real Anthropic API. None of the /chat/completions-only quirks below apply — + // content-part flattening would destroy native tool_use/tool_result/thinking + // blocks, and the native endpoint (unlike Copilot's /chat/completions) honors + // assistant-message prefill. Port of decolua/9router#2608 (author: yidecode). + const isClaudeNative = getModelTargetFormat("gh", model) === "claude"; + if (Array.isArray(sourceBody.input)) { modifiedBody.input = sanitizeResponsesInputItems(sourceBody.input, false); } @@ -110,7 +129,7 @@ export class GithubExecutor extends BaseExecutor { }); } - if (modifiedBody.response_format && model.toLowerCase().includes("claude")) { + if (!isClaudeNative && modifiedBody.response_format && model.toLowerCase().includes("claude")) { modifiedBody.messages = this.injectResponseFormat( Array.isArray(modifiedBody.messages) ? modifiedBody.messages : [], modifiedBody.response_format @@ -142,8 +161,9 @@ export class GithubExecutor extends BaseExecutor { // the endpoint return: "type has to be either 'image_url' or 'text'" (HTTP 400). // Serialize unknown part types as text, drop empty parts, and collapse to null when // every part is stripped (assistant messages whose only content was tool_calls). - // Port from 9router#220 (fixes 9router#219). - if (Array.isArray(modifiedBody.messages)) { + // Port from 9router#220 (fixes 9router#219). Skipped for the native /v1/messages + // path — those content parts ARE the native Claude shape and must survive intact. + if (!isClaudeNative && Array.isArray(modifiedBody.messages)) { modifiedBody.messages = modifiedBody.messages.map((msg: any) => this.sanitizeChatCompletionsMessage(msg) ); @@ -155,9 +175,10 @@ export class GithubExecutor extends BaseExecutor { // clients such as newest Claude Desktop send a trailing assistant turn as a // prefill seed — the Anthropic API honors it, but Copilot does not. Drop it here, // scoped to the GitHub executor only (the shared translator/contextManager and - // other providers that DO honor prefill are untouched). + // other providers that DO honor prefill are untouched). Skipped for the native + // /v1/messages path, which — like the real Anthropic API — supports prefill. // Port of 9router#2143 (author: Manuel ). - if (Array.isArray(modifiedBody.messages)) { + if (!isClaudeNative && Array.isArray(modifiedBody.messages)) { modifiedBody.messages = this.dropTrailingAssistantPrefill(modifiedBody.messages); } @@ -235,7 +256,8 @@ export class GithubExecutor extends BaseExecutor { buildHeaders( credentials: ProviderCredentials, stream = true, - clientHeaders?: Record | null + clientHeaders?: Record | null, + model?: string ): Record { const token = this.getCopilotToken(credentials) || credentials.accessToken; @@ -258,12 +280,21 @@ export class GithubExecutor extends BaseExecutor { const initiator = clientInitiator === "agent" || clientInitiator === "user" ? clientInitiator : "user"; - return { + const headers: Record = { ...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator), Authorization: `Bearer ${token}`, "x-request-id": crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`, }; + + // Claude models routed to the Anthropic-native /v1/messages shim require the + // anthropic-version header (harmless no-op on /chat/completions and /responses, + // but /v1/messages rejects the request without it). Port of decolua/9router#2608. + if (model && getModelTargetFormat("gh", model) === "claude") { + headers["anthropic-version"] = "2023-06-01"; + } + + return headers; } async refreshCopilotToken(githubAccessToken, log) { diff --git a/tests/unit/github-copilot-claude-native-messages.test.ts b/tests/unit/github-copilot-claude-native-messages.test.ts new file mode 100644 index 0000000000..abe754139a --- /dev/null +++ b/tests/unit/github-copilot-claude-native-messages.test.ts @@ -0,0 +1,131 @@ +// GitHub Copilot exposes an Anthropic-native `/v1/messages` shim alongside its +// OpenAI-shape `/chat/completions` and `/responses` endpoints. Only the native +// shim surfaces prompt-cache token counts (`cached_tokens`) for Claude models — +// /chat/completions silently drops them, and round-tripping Claude tool_use / +// tool_result / thinking content blocks through the OpenAI shape is lossy. +// +// Port of upstream decolua/9router#2608 (author: yidecode), adapted to +// OmniRoute's architecture: instead of the executor doing its own +// translateRequest/translateResponse + manual SSE TransformStream (9router has +// no generic per-model targetFormat mechanism), OmniRoute already has a +// registry-driven `targetFormat` field (see opencode/zen's Qwen entries, +// opencode/go) that makes chatCore.ts translate the request to Claude shape +// *before* the executor ever sees it, and translate the response back +// generically afterwards. So the actual port is: (1) tag the github registry's +// claude-* models with targetFormat:"claude", (2) teach the github executor's +// buildUrl()/buildHeaders() to route those models at the new messagesUrl with +// an anthropic-version header, and (3) gate the executor's /chat/completions-only +// request transforms (content-part flattening, trailing-assistant-prefill drop, +// response_format-as-system-prompt workaround) off for the native path, since +// they either don't apply to Claude-shape bodies or actively corrupt them. + +import test from "node:test"; +import assert from "node:assert/strict"; + +const { GithubExecutor } = await import("../../open-sse/executors/github.ts"); +const { getModelTargetFormat } = await import("../../open-sse/config/providerModels.ts"); + +test("registry: claude-* github models resolve targetFormat 'claude'", () => { + for (const model of ["claude-opus-4.8", "claude-sonnet-4.6", "claude-haiku-4.5"]) { + assert.equal( + getModelTargetFormat("gh", model), + "claude", + `${model} must resolve to the claude target format so chatCore translates natively` + ); + } +}); + +test("registry: non-claude github models keep their existing targetFormat", () => { + assert.equal(getModelTargetFormat("gh", "gpt-5.4"), "openai-responses"); + assert.equal(getModelTargetFormat("gh", "gpt-4o-mini"), null); +}); + +test("buildUrl: claude models route to the native /v1/messages endpoint", () => { + const executor = new GithubExecutor(); + const url = executor.buildUrl("claude-opus-4.8", true); + assert.equal(url, "https://api.githubcopilot.com/v1/messages"); +}); + +test("buildUrl: gpt codex/responses models still route to /responses", () => { + const executor = new GithubExecutor(); + const url = executor.buildUrl("gpt-5.4", true); + assert.match(url, /\/responses$/); +}); + +test("buildUrl: plain gpt models still route to /chat/completions", () => { + const executor = new GithubExecutor(); + const url = executor.buildUrl("gpt-4o-mini", true); + assert.equal(url, executor.config.baseUrl); + assert.match(url, /\/chat\/completions$/); +}); + +test("buildHeaders: claude-native requests carry anthropic-version", () => { + const executor = new GithubExecutor(); + const headers = executor.buildHeaders({ accessToken: "tok" }, true, null, "claude-opus-4.8"); + assert.equal(headers["anthropic-version"], "2023-06-01"); +}); + +test("buildHeaders: non-claude requests do not carry anthropic-version", () => { + const executor = new GithubExecutor(); + const headers = executor.buildHeaders({ accessToken: "tok" }, true, null, "gpt-4o-mini"); + assert.equal(headers["anthropic-version"], undefined); +}); + +test("transformRequest: claude-native path preserves native tool_use/tool_result content blocks", () => { + const executor = new GithubExecutor(); + const body = { + model: "claude-opus-4.8", + system: "you are a helpful assistant", + messages: [ + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "search", input: { q: "hi" } }], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "t1", content: "result text" }], + }, + ], + }; + const result = executor.transformRequest("claude-opus-4.8", body, true, {}); + // Pre-port: sanitizeChatCompletionsMessage flattened every non-text/image_url + // part to {type:"text", text: ...}, destroying the tool_use/tool_result blocks + // Anthropic's native /v1/messages endpoint actually needs. + assert.equal((result.messages[0].content[0] as { type: string }).type, "tool_use"); + assert.equal((result.messages[1].content[0] as { type: string }).type, "tool_result"); +}); + +test("transformRequest: claude-native path keeps a trailing assistant message (prefill)", () => { + const executor = new GithubExecutor(); + const body = { + model: "claude-opus-4.8", + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: "Sure, here is" }, + ], + }; + const result = executor.transformRequest("claude-opus-4.8", body, true, {}); + // Pre-port: dropTrailingAssistantPrefill removed the trailing assistant turn + // because Copilot's /chat/completions rejects prefill — but the native + // /v1/messages endpoint is real Anthropic-compatible and supports it. + assert.equal(result.messages.length, 2); + assert.equal(result.messages[1].role, "assistant"); +}); + +test("transformRequest: non-claude (chat/completions) path still flattens tool_use content and drops prefill", () => { + const executor = new GithubExecutor(); + const body = { + model: "gpt-4o-mini", + messages: [ + { + role: "assistant", + content: [{ type: "tool_use", id: "t1", name: "search", input: {} }], + }, + { role: "user", content: "hi" }, + { role: "assistant", content: "trailing prefill" }, + ], + }; + const result = executor.transformRequest("gpt-4o-mini", body, true, {}); + assert.equal((result.messages[0].content[0] as { type: string }).type, "text"); + assert.equal(result.messages.length, 2, "trailing assistant message must still be dropped"); +}); diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index a565e1a3e6..1e2a307a24 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -89,8 +89,15 @@ test("GitHub Copilot registry reflects the current supported model lineup", () = assert.deepEqual(ids, [...GITHUB_COPILOT_MODEL_ALLOWLIST]); assert.equal(getModelTargetFormat("gh", "gpt-5.3-codex"), "openai-responses"); + // "claude-opus-4.6" is not a real Copilot model id (unlike claude-sonnet-4.6); + // it never appears in the registry, so its target format stays null. assert.equal(getModelTargetFormat("gh", "claude-opus-4.6"), null); - assert.equal(getModelTargetFormat("gh", "claude-opus-4.8-fast"), null); + // Claude models route through Copilot's Anthropic-native /v1/messages shim + // (executors/github.ts) — the only endpoint that surfaces prompt-cache token + // counts for Claude and avoids a lossy tool_use/tool_result round-trip through + // the OpenAI shape. Port of decolua/9router#2608. + assert.equal(getModelTargetFormat("gh", "claude-opus-4.8-fast"), "claude"); + assert.equal(getModelTargetFormat("gh", "claude-sonnet-4.6"), "claude"); assert.equal(getModelTargetFormat("gh", "gemini-3.5-flash"), null); assert.equal(getModelTargetFormat("gh", "kimi-k2.7-code"), null); assert.equal(ids.includes("gpt-4"), false);