mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +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>
126 lines
4.6 KiB
TypeScript
126 lines
4.6 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { GithubExecutor } from "../../open-sse/executors/github.ts";
|
|
|
|
// 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." Anthropic clients (e.g. newest Claude
|
|
// Desktop) send a trailing assistant turn as a prefill seed — the Anthropic API honors
|
|
// it, but Copilot 400s. GithubExecutor.dropTrailingAssistantPrefill() strips the
|
|
// trailing assistant message(s) before dispatch, scoped to the github executor only.
|
|
// Port of 9router#2143 (author: Manuel <baslr@users.noreply.github.com>).
|
|
|
|
test("dropTrailingAssistantPrefill drops a single trailing assistant message", () => {
|
|
const executor = new GithubExecutor();
|
|
const messages = [
|
|
{ role: "user", content: "Hi" },
|
|
{ role: "assistant", content: "Here is the answer:" },
|
|
];
|
|
|
|
const out = executor.dropTrailingAssistantPrefill(messages);
|
|
|
|
assert.equal(out.length, 1);
|
|
assert.equal(out[0].role, "user");
|
|
});
|
|
|
|
test("dropTrailingAssistantPrefill drops multiple consecutive trailing assistant messages", () => {
|
|
const executor = new GithubExecutor();
|
|
const messages = [
|
|
{ role: "user", content: "Hi" },
|
|
{ role: "assistant", content: "one" },
|
|
{ role: "assistant", content: "two" },
|
|
];
|
|
|
|
const out = executor.dropTrailingAssistantPrefill(messages);
|
|
|
|
assert.equal(out.length, 1);
|
|
assert.equal(out[0].role, "user");
|
|
});
|
|
|
|
test("dropTrailingAssistantPrefill is a no-op (same reference) when the conversation ends with a user message", () => {
|
|
const executor = new GithubExecutor();
|
|
const messages = [
|
|
{ role: "user", content: "Hi" },
|
|
{ role: "assistant", content: "Hello" },
|
|
{ role: "user", content: "More" },
|
|
];
|
|
|
|
const out = executor.dropTrailingAssistantPrefill(messages);
|
|
|
|
assert.equal(out, messages, "must return the same array reference when nothing changes");
|
|
assert.equal(out.length, 3);
|
|
});
|
|
|
|
test("dropTrailingAssistantPrefill is a no-op when the conversation ends with a tool message", () => {
|
|
const executor = new GithubExecutor();
|
|
const messages = [
|
|
{ role: "user", content: "Hi" },
|
|
{ role: "assistant", content: null, tool_calls: [{ id: "x" }] },
|
|
{ role: "tool", tool_call_id: "x", content: "result" },
|
|
];
|
|
|
|
const out = executor.dropTrailingAssistantPrefill(messages);
|
|
|
|
assert.equal(out, messages, "must return the same array reference when nothing changes");
|
|
assert.equal(out.length, 3);
|
|
assert.equal(out[2].role, "tool");
|
|
});
|
|
|
|
test("dropTrailingAssistantPrefill never empties an assistant-only conversation", () => {
|
|
const executor = new GithubExecutor();
|
|
const messages = [{ role: "assistant", content: "only" }];
|
|
|
|
const out = executor.dropTrailingAssistantPrefill(messages);
|
|
|
|
assert.equal(out.length, 1, "must keep at least one message");
|
|
assert.equal(out[0].role, "assistant");
|
|
});
|
|
|
|
test("dropTrailingAssistantPrefill is null/empty safe", () => {
|
|
const executor = new GithubExecutor();
|
|
|
|
assert.deepEqual(executor.dropTrailingAssistantPrefill([]), []);
|
|
assert.equal(executor.dropTrailingAssistantPrefill(undefined), undefined);
|
|
assert.equal(executor.dropTrailingAssistantPrefill(null), null);
|
|
});
|
|
|
|
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",
|
|
messages: [
|
|
{ role: "user", content: "Hi" },
|
|
{ role: "assistant", content: "Here is the answer:" },
|
|
],
|
|
};
|
|
|
|
const out = executor.transformRequest("claude-sonnet-4", body, false, {});
|
|
|
|
assert.equal(out.messages.length, 1);
|
|
assert.equal(out.messages[0].role, "user");
|
|
});
|
|
|
|
test("GithubExecutor.transformRequest leaves a user-terminated conversation untouched end-to-end", () => {
|
|
const executor = new GithubExecutor();
|
|
const body = {
|
|
model: "claude-sonnet-4.6",
|
|
messages: [
|
|
{ role: "user", content: "Hi" },
|
|
{ role: "assistant", content: "Hello" },
|
|
{ role: "user", content: "More" },
|
|
],
|
|
};
|
|
|
|
const out = executor.transformRequest("claude-sonnet-4.6", body, false, {});
|
|
|
|
assert.equal(out.messages.length, 3);
|
|
assert.equal(out.messages[2].role, "user");
|
|
});
|