mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* 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>
132 lines
5.9 KiB
TypeScript
132 lines
5.9 KiB
TypeScript
// 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");
|
|
});
|