mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
feat(sse): route GitHub Copilot Claude models through native /v1/messages (#7223)
* feat(sse): route GitHub Copilot Claude models through native /v1/messages GitHub Copilot's /chat/completions and /responses endpoints never surface prompt-cache token counts (cached_tokens) for Claude models, and round-tripping Claude tool_use/tool_result/thinking content blocks through the OpenAI shape is lossy. Copilot also exposes an Anthropic-native /v1/messages shim that reports cached_tokens correctly and accepts native content blocks as-is. Tag each github registry claude-* model with targetFormat: "claude" so chatCore.ts translates the request to Anthropic-native shape before the executor ever sees it (the same mechanism opencode/zen's Qwen entries and opencode/go already use), and teach the github executor's buildUrl() / buildHeaders() to dispatch those models at the new messagesUrl (api.githubcopilot.com/v1/messages) with the required anthropic-version header. transformRequest() now skips its /chat/completions-only quirks (content-part flattening, trailing-assistant-prefill drop, the response_format-as-system-prompt workaround) for the native path — the first would destroy native tool_use/tool_result blocks, the prefill drop is unnecessary because the real Anthropic API supports assistant prefill, and the response_format workaround is superseded by the generic openai-to-claude translator's own JSON-mode handling. Co-authored-by: luoyide <ydhome.code@gmail.com> Inspired-by: https://github.com/decolua/9router/pull/2608 * chore(changelog): fragment for #7223 * fix(sse): green PR #7223 CI — complexity ratchet + stale test expectations - Extract applyChatCompletionsOnlyQuirks() and resolveInitiatorHeader() out of GithubExecutor.transformRequest()/buildHeaders() so the two methods drop back under the complexity/cognitive-complexity ratchets (2058/891 -> 2056/890, matching the frozen baseline). No behavior change — same guards, just relocated. - Update 4 pre-existing unit tests that hard-coded now-native claude-* Copilot ids (claude-sonnet-4.5/4.6) to exercise the /chat/completions legacy path via an unregistered id (claude-sonnet-4), matching the sibling test already using that pattern. These ids now intentionally route to the native /v1/messages shim added by this PR, which correctly skips the /chat/completions-only workarounds these tests were built to verify — the native path's own coverage lives in github-copilot-claude-native-messages.test.ts. - Split the routing invariant test (copilot-gemini-claude-route-no-responses.test.ts) into a Claude case (expects /v1/messages) and a Gemini case (still expects /chat/completions), reflecting the intentional routing change. --------- Co-authored-by: luoyide <ydhome.code@gmail.com>
This commit is contained in:
committed by
GitHub
parent
0f10225f1d
commit
fea1d54e6d
@@ -0,0 +1 @@
|
||||
- **feat(sse):** GitHub Copilot Claude models now route through Copilot's native `/v1/messages` endpoint (prompt-cache token counts, no more lossy tool-call round-trip). (thanks @yidecode)
|
||||
@@ -39,6 +39,9 @@ export function generateLegacyProviders(): Record<string, LegacyProvider> {
|
||||
if (entry.responsesBaseUrl) {
|
||||
p.responsesBaseUrl = entry.responsesBaseUrl;
|
||||
}
|
||||
if (entry.messagesUrl) {
|
||||
p.messagesUrl = entry.messagesUrl;
|
||||
}
|
||||
if (entry.requestDefaults) {
|
||||
p.requestDefaults = entry.requestDefaults;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<string, string>;
|
||||
requestDefaults?: ProviderRequestDefaults;
|
||||
clientId?: string;
|
||||
|
||||
@@ -117,6 +117,7 @@ export type ProviderConfig = {
|
||||
baseUrl?: string;
|
||||
baseUrls?: string[];
|
||||
responsesBaseUrl?: string;
|
||||
messagesUrl?: string;
|
||||
chatPath?: string;
|
||||
clientVersion?: string;
|
||||
clientId?: string;
|
||||
|
||||
@@ -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,14 +129,6 @@ export class GithubExecutor extends BaseExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
if (modifiedBody.response_format && model.toLowerCase().includes("claude")) {
|
||||
modifiedBody.messages = this.injectResponseFormat(
|
||||
Array.isArray(modifiedBody.messages) ? modifiedBody.messages : [],
|
||||
modifiedBody.response_format
|
||||
);
|
||||
delete modifiedBody.response_format;
|
||||
}
|
||||
|
||||
if (Array.isArray(modifiedBody.tools) && modifiedBody.tools.length > 128) {
|
||||
modifiedBody.tools = modifiedBody.tools.slice(0, 128);
|
||||
}
|
||||
@@ -136,29 +147,13 @@ export class GithubExecutor extends BaseExecutor {
|
||||
delete modifiedBody.temperature;
|
||||
}
|
||||
|
||||
// GitHub Copilot /chat/completions only accepts {type:'text'} or {type:'image_url'}
|
||||
// content parts. Clients like Cursor IDE pass through Anthropic-shape parts
|
||||
// (tool_use, tool_result, thinking) untouched when using Claude models, which makes
|
||||
// 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)) {
|
||||
modifiedBody.messages = modifiedBody.messages.map((msg: any) =>
|
||||
this.sanitizeChatCompletionsMessage(msg)
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub Copilot's /chat/completions endpoint rejects a conversation that ends
|
||||
// with an assistant message: "This model does not support assistant message
|
||||
// prefill. The conversation must end with a user message." (HTTP 400). Anthropic
|
||||
// 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).
|
||||
// Port of 9router#2143 (author: Manuel <baslr@users.noreply.github.com>).
|
||||
if (Array.isArray(modifiedBody.messages)) {
|
||||
modifiedBody.messages = this.dropTrailingAssistantPrefill(modifiedBody.messages);
|
||||
// The quirks below (response_format-as-system-prompt, content-part flattening,
|
||||
// trailing-assistant-prefill drop) are all workarounds for /chat/completions-only
|
||||
// limitations. They either don't apply to Claude-shape bodies or actively corrupt
|
||||
// them, so they are skipped entirely for the native /v1/messages path. Port of
|
||||
// decolua/9router#2608 (author: yidecode) — see class doc comment above.
|
||||
if (!isClaudeNative) {
|
||||
this.applyChatCompletionsOnlyQuirks(model, modifiedBody);
|
||||
}
|
||||
|
||||
// Config-driven strip of params unsupported by the target provider/model.
|
||||
@@ -171,6 +166,46 @@ export class GithubExecutor extends BaseExecutor {
|
||||
return modifiedBody;
|
||||
}
|
||||
|
||||
// GitHub Copilot's /chat/completions endpoint has several quirks that the native
|
||||
// /v1/messages shim doesn't share — extracted from transformRequest so the native
|
||||
// path (the common case for Claude models going forward) doesn't pay their branch
|
||||
// cost. Mutates modifiedBody in place.
|
||||
private applyChatCompletionsOnlyQuirks(model: string, modifiedBody): void {
|
||||
// Claude models on /chat/completions don't support response_format — inject the
|
||||
// instruction as a system message instead. Port from 9router (see
|
||||
// injectResponseFormat above).
|
||||
if (modifiedBody.response_format && model.toLowerCase().includes("claude")) {
|
||||
modifiedBody.messages = this.injectResponseFormat(
|
||||
Array.isArray(modifiedBody.messages) ? modifiedBody.messages : [],
|
||||
modifiedBody.response_format
|
||||
);
|
||||
delete modifiedBody.response_format;
|
||||
}
|
||||
|
||||
if (!Array.isArray(modifiedBody.messages)) return;
|
||||
|
||||
// GitHub Copilot /chat/completions only accepts {type:'text'} or {type:'image_url'}
|
||||
// content parts. Clients like Cursor IDE pass through Anthropic-shape parts
|
||||
// (tool_use, tool_result, thinking) untouched when using Claude models, which makes
|
||||
// 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).
|
||||
modifiedBody.messages = modifiedBody.messages.map((msg: any) =>
|
||||
this.sanitizeChatCompletionsMessage(msg)
|
||||
);
|
||||
|
||||
// GitHub Copilot's /chat/completions endpoint rejects a conversation that ends
|
||||
// with an assistant message: "This model does not support assistant message
|
||||
// prefill. The conversation must end with a user message." (HTTP 400). Anthropic
|
||||
// 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).
|
||||
// Port of 9router#2143 (author: Manuel <baslr@users.noreply.github.com>).
|
||||
modifiedBody.messages = this.dropTrailingAssistantPrefill(modifiedBody.messages);
|
||||
}
|
||||
|
||||
private sanitizeChatCompletionsMessage(msg: any): any {
|
||||
if (!msg || typeof msg !== "object") return msg;
|
||||
// String content and missing content (e.g. assistant w/ only tool_calls) pass through.
|
||||
@@ -235,17 +270,38 @@ export class GithubExecutor extends BaseExecutor {
|
||||
buildHeaders(
|
||||
credentials: ProviderCredentials,
|
||||
stream = true,
|
||||
clientHeaders?: Record<string, string> | null
|
||||
clientHeaders?: Record<string, string> | null,
|
||||
model?: string
|
||||
): Record<string, string> {
|
||||
const token = this.getCopilotToken(credentials) || credentials.accessToken;
|
||||
const initiator = this.resolveInitiatorHeader(clientHeaders);
|
||||
|
||||
// Forward the client's x-initiator header when present. OpenCode and other
|
||||
// Copilot-aware clients use this to distinguish user-initiated turns
|
||||
// (x-initiator: user) from autonomous tool-call continuations
|
||||
// (x-initiator: agent). GitHub Copilot's billing treats "agent" turns as
|
||||
// free, so forwarding the value avoids burning a premium request on every
|
||||
// tool-call round-trip. Fall back to "user" when the header is absent to
|
||||
// preserve the existing default behaviour.
|
||||
const headers: Record<string, string> = {
|
||||
...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;
|
||||
}
|
||||
|
||||
// Forward the client's x-initiator header when present. OpenCode and other
|
||||
// Copilot-aware clients use this to distinguish user-initiated turns
|
||||
// (x-initiator: user) from autonomous tool-call continuations
|
||||
// (x-initiator: agent). GitHub Copilot's billing treats "agent" turns as
|
||||
// free, so forwarding the value avoids burning a premium request on every
|
||||
// tool-call round-trip. Falls back to "user" when the header is absent to
|
||||
// preserve the existing default behaviour. Extracted from buildHeaders so
|
||||
// header assembly stays the one place that reads it.
|
||||
private resolveInitiatorHeader(clientHeaders?: Record<string, string> | null): string {
|
||||
let clientInitiator = clientHeaders?.["x-initiator"] || clientHeaders?.["X-Initiator"];
|
||||
if (!clientInitiator && clientHeaders) {
|
||||
for (const key in clientHeaders) {
|
||||
@@ -255,15 +311,7 @@ export class GithubExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
}
|
||||
const initiator =
|
||||
clientInitiator === "agent" || clientInitiator === "user" ? clientInitiator : "user";
|
||||
|
||||
return {
|
||||
...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator),
|
||||
Authorization: `Bearer ${token}`,
|
||||
"x-request-id":
|
||||
crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
};
|
||||
return clientInitiator === "agent" || clientInitiator === "user" ? clientInitiator : "user";
|
||||
}
|
||||
|
||||
async refreshCopilotToken(githubAccessToken, log) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { RegistryModel } from "../../open-sse/config/providerRegistry.ts";
|
||||
|
||||
const CHAT_URL = "https://api.githubcopilot.com/chat/completions";
|
||||
const RESPONSES_URL = "https://api.githubcopilot.com/responses";
|
||||
const MESSAGES_URL = "https://api.githubcopilot.com/v1/messages";
|
||||
|
||||
function getGithubModel(modelId: string): RegistryModel {
|
||||
const model = PROVIDER_MODELS["gh"]?.find((entry) => entry.id === modelId);
|
||||
@@ -35,7 +36,7 @@ function getGithubModel(modelId: string): RegistryModel {
|
||||
}
|
||||
|
||||
describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9router#1536)", () => {
|
||||
it("routes registered Claude/Gemini Copilot models to chat/completions", () => {
|
||||
it("routes registered Claude Copilot models to the native /v1/messages shim (port decolua/9router#2608)", () => {
|
||||
const exec = new GithubExecutor();
|
||||
for (const id of [
|
||||
"claude-haiku-4.5",
|
||||
@@ -43,14 +44,18 @@ describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9rout
|
||||
"claude-sonnet-4.6",
|
||||
"claude-sonnet-5",
|
||||
"claude-fable-5",
|
||||
"claude-opus-4.6",
|
||||
"claude-opus-4.7",
|
||||
"claude-opus-4.8",
|
||||
"claude-opus-4.8-fast",
|
||||
"claude-opus-4.5",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3.5-flash",
|
||||
]) {
|
||||
assert.equal(exec.buildUrl(id, false), MESSAGES_URL, `${id} must route to /v1/messages`);
|
||||
}
|
||||
});
|
||||
|
||||
it("routes registered Gemini Copilot models to chat/completions", () => {
|
||||
const exec = new GithubExecutor();
|
||||
for (const id of ["gemini-3.1-pro-preview", "gemini-3.5-flash"]) {
|
||||
assert.equal(exec.buildUrl(id, false), CHAT_URL, `${id} must route to chat/completions`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -87,15 +87,21 @@ test("dropTrailingAssistantPrefill is null/empty safe", () => {
|
||||
|
||||
test("GithubExecutor.transformRequest drops the trailing assistant prefill end-to-end", () => {
|
||||
const executor = new GithubExecutor();
|
||||
// Use an unregistered claude-* id so getModelTargetFormat("gh", ...) resolves
|
||||
// to null and this stays on the /chat/completions path this test targets.
|
||||
// Registered claude-* ids (e.g. "claude-sonnet-4.6") now carry
|
||||
// targetFormat:"claude" (native /v1/messages, which supports prefill — port
|
||||
// of decolua/9router#2608, see github-copilot-claude-native-messages.test.ts)
|
||||
// and intentionally skip this drop.
|
||||
const body = {
|
||||
model: "claude-sonnet-4.6",
|
||||
model: "claude-sonnet-4",
|
||||
messages: [
|
||||
{ role: "user", content: "Hi" },
|
||||
{ role: "assistant", content: "Here is the answer:" },
|
||||
],
|
||||
};
|
||||
|
||||
const out = executor.transformRequest("claude-sonnet-4.6", body, false, {});
|
||||
const out = executor.transformRequest("claude-sonnet-4", body, false, {});
|
||||
|
||||
assert.equal(out.messages.length, 1);
|
||||
assert.equal(out.messages[0].role, "user");
|
||||
|
||||
@@ -161,7 +161,13 @@ test("GithubExecutor.transformRequest sanitizes Anthropic-shape content parts (t
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.transformRequest("claude-sonnet-4.6", body, true, {});
|
||||
// Use an unregistered claude-* id (not "claude-sonnet-4.6"/etc.) so
|
||||
// getModelTargetFormat("gh", ...) resolves to null and this stays on the
|
||||
// /chat/completions path this test targets. Registered claude-* ids now
|
||||
// carry targetFormat:"claude" (native /v1/messages — port of
|
||||
// decolua/9router#2608, see github-copilot-claude-native-messages.test.ts)
|
||||
// and intentionally skip this sanitization.
|
||||
const result = executor.transformRequest("claude-sonnet-4", body, true, {});
|
||||
|
||||
// user message keeps text + image_url parts untouched
|
||||
assert.equal(result.messages[0].content[0].type, "text");
|
||||
|
||||
131
tests/unit/github-copilot-claude-native-messages.test.ts
Normal file
131
tests/unit/github-copilot-claude-native-messages.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -32,7 +32,13 @@ test("T27: Claude + response_format=json_object injects system instruction and s
|
||||
response_format: { type: "json_object" },
|
||||
};
|
||||
|
||||
const transformed = executor.transformRequest("claude-sonnet-4.5", request, false, {});
|
||||
// Use an unregistered claude-* id so getModelTargetFormat("gh", ...) resolves
|
||||
// to null and this stays on the /chat/completions path this test targets.
|
||||
// Registered claude-* ids (e.g. "claude-sonnet-4.5") now carry
|
||||
// targetFormat:"claude" (native /v1/messages, which doesn't need this
|
||||
// response_format-as-system-prompt workaround — port of decolua/9router#2608,
|
||||
// see github-copilot-claude-native-messages.test.ts) and intentionally skip it.
|
||||
const transformed = executor.transformRequest("claude-sonnet-4", request, false, {});
|
||||
|
||||
assert.equal(transformed.response_format, undefined);
|
||||
assert.equal(transformed.messages[0].role, "system");
|
||||
|
||||
Reference in New Issue
Block a user