fix(agentrouter): support Claude and Codex protocols

This commit is contained in:
diegosouzapw
2026-08-01 12:18:09 -03:00
parent b38f3a4c02
commit 564c204efe
15 changed files with 286 additions and 69 deletions

View File

@@ -1,4 +1,9 @@
const DEFAULT_CODEX_CLIENT_VERSION = "0.144.1";
import {
DEFAULT_CODEX_CLIENT_VERSION,
getCodexCliRsHeaders as buildCodexCliRsHeaders,
} from "@/shared/constants/codexClient";
export { DEFAULT_CODEX_CLIENT_VERSION } from "@/shared/constants/codexClient";
const DEFAULT_CODEX_USER_AGENT_PLATFORM = "Windows 10.0.26200";
const DEFAULT_CODEX_USER_AGENT_ARCH = "x64";
const CODEX_VERSION_OVERRIDE_ENV = "CODEX_CLIENT_VERSION";
@@ -42,6 +47,10 @@ export function getCodexDefaultHeaders(): Record<string, string> {
};
}
export function getCodexCliRsHeaders(): Record<string, string> {
return buildCodexCliRsHeaders(getCodexClientVersion());
}
export function normalizeCodexSessionId(value: unknown): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();

View File

@@ -19,10 +19,9 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts";
export const FREE_CATALOG_CURATED_AT = "2026-07-22";
export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
{ provider: "agentrouter", modelId: "claude-opus-4-6", displayName: "Claude 4.6 Opus", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agentrouter", modelId: "claude-haiku-4-5-20251001", displayName: "Claude 4.5 Haiku", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agentrouter", modelId: "glm-5.1", displayName: "GLM 5.1", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agentrouter", modelId: "deepseek-v3.2", displayName: "DeepSeek V3.2", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agentrouter", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agentrouter", modelId: "claude-opus-5", displayName: "Claude Opus 5", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agentrouter", modelId: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", monthlyTokens: 0, creditTokens: 200000000, freeType: "one-time-initial", poolKey: "agentrouter", tos: "caution" },
{ provider: "agy", modelId: "claude-opus-4-6-thinking", displayName: "Claude Opus 4.6 (Thinking)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" },
{ provider: "agy", modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6 (Thinking)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" },
{ provider: "agy", modelId: "gemini-3.1-pro-low", displayName: "Gemini 3.1 Pro (Low)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" },

View File

@@ -1,4 +1,5 @@
import type { RegistryEntry } from "../../shared.ts";
import { getCodexCliRsHeaders } from "../../../codexClient.ts";
export const agentrouterProvider: RegistryEntry = {
id: "agentrouter",
@@ -8,6 +9,22 @@ export const agentrouterProvider: RegistryEntry = {
baseUrl: "https://agentrouter.org/v1/messages",
authType: "apikey",
authHeader: "x-api-key",
alternateFormats: [
{
format: "openai",
baseUrl: "https://agentrouter.org/v1/chat/completions",
authHeader: "bearer",
headers: getCodexCliRsHeaders(),
label: "OpenAI-compatible (Codex)",
},
{
format: "openai-responses",
baseUrl: "https://agentrouter.org/v1/responses",
authHeader: "bearer",
headers: getCodexCliRsHeaders(),
label: "OpenAI Responses (Codex)",
},
],
defaultContextLength: 128000,
// No static `headers` here: agentrouter now adopts the DYNAMIC Claude-Code
// wire image via CC_WIRE_IMAGE_BUILTINS (#6056) — the fingerprint/headers are
@@ -15,10 +32,9 @@ export const agentrouterProvider: RegistryEntry = {
// own baseUrl + x-api-key auth. A static fingerprint here would drift and
// trip AgentRouter's WAF ("unauthorized client detected").
models: [
{ id: "claude-opus-4-6", name: "Claude 4.6 Opus" },
{ id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" },
{ id: "glm-5.1", name: "GLM 5.1" },
{ id: "deepseek-v3.2", name: "DeepSeek V3.2" },
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
{ id: "claude-opus-5", name: "Claude Opus 5" },
{ id: "gpt-5.6-sol", name: "GPT-5.6 Sol" },
],
passthroughModels: true,
};

View File

@@ -45,6 +45,7 @@ import {
} from "../services/apiKeyRotator.ts";
import type { KeyHealth } from "../services/apiKeyRotator.ts";
import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts";
import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts";
import {
runWithOnPersist,
getRefreshLeadMs,
@@ -249,7 +250,10 @@ function collectThinkingConfigs(body: unknown): Array<Record<string, unknown>> {
if (!body || typeof body !== "object") return [];
const root = body as Record<string, unknown>;
const configs: Array<Record<string, unknown>> = [];
const envelopes: unknown[] = [root.generationConfig, (root.request as Record<string, unknown> | undefined)?.generationConfig];
const envelopes: unknown[] = [
root.generationConfig,
(root.request as Record<string, unknown> | undefined)?.generationConfig,
];
for (const env of envelopes) {
if (!env || typeof env !== "object") continue;
const tc = (env as Record<string, unknown>).thinkingConfig;
@@ -445,6 +449,12 @@ export class BaseExecutor {
);
}
protected usesClaudeCodeProtocol(credentials: ProviderCredentials | null): boolean {
if (!isClaudeCodeCompatible(this.provider)) return false;
const format = this.resolveAlternate(credentials)?.format;
return format !== "openai" && format !== "openai-responses";
}
/**
* Resolve the effective API key via extra-keys round-robin rotation.
* Mutates `credentials.providerSpecificData.selectedKeyId` on rotation.
@@ -834,15 +844,16 @@ export class BaseExecutor {
);
}
const ccRequestDefaults = isClaudeCodeCompatible(this.provider)
const usesClaudeCodeProtocol = this.usesClaudeCodeProtocol(requestCredentials);
const fingerprintProvider =
usesCcWireImage(this.provider) && !usesClaudeCodeProtocol ? "codex" : this.provider;
const ccRequestDefaults = usesClaudeCodeProtocol
? getClaudeCodeCompatibleRequestDefaults(requestCredentials?.providerSpecificData)
: {};
const shouldForwardExtendedContext =
extendedContext &&
modelSupportsContext1mBeta(model) &&
!isClaudeCodeCompatible(this.provider);
extendedContext && modelSupportsContext1mBeta(model) && !usesClaudeCodeProtocol;
const shouldForwardCcCompatibleContext1m =
isClaudeCodeCompatible(this.provider) &&
usesClaudeCodeProtocol &&
ccRequestDefaults.context1m === true &&
!modelHasNativeContext1m(model);
if (shouldForwardExtendedContext || shouldForwardCcCompatibleContext1m) {
@@ -922,8 +933,8 @@ export class BaseExecutor {
!activeCredentials?.apiKey;
if (
this.provider === "claude" &&
(isClaudeCodeClient || hasClaudeOAuthToken) &&
((this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken)) ||
usesClaudeCodeProtocol) &&
typeof transformedBody === "object" &&
transformedBody !== null
) {
@@ -1200,6 +1211,11 @@ export class BaseExecutor {
if (ccKeysLower.has(key.toLowerCase())) delete headers[key];
}
Object.assign(headers, ccHeaders);
if (usesCcWireImage(this.provider) && usesClaudeCodeProtocol) {
delete headers["Authorization"];
headers["x-api-key"] =
activeCredentials?.apiKey || activeCredentials?.accessToken || "";
}
delete headers["X-Stainless-Helper-Method"];
// OS/arch follow the host running the signed binary. Runtime version
@@ -1246,7 +1262,7 @@ export class BaseExecutor {
// (tool_result must be in immediately next message).
// Only apply for Claude/Claude-compatible — OpenAI allows results
// spread across multiple subsequent messages.
const isClaude = this.provider === "claude" || isClaudeCodeCompatible(this.provider);
const isClaude = this.provider === "claude" || usesClaudeCodeProtocol;
// For Claude, fixToolAdjacency may strip tool_use blocks whose
// tool_result isn't in the next message; re-run fixToolPairs to
// drop any tool_result orphaned by that strip (discussion #2410).
@@ -1265,7 +1281,7 @@ export class BaseExecutor {
// at this final dispatch point — the single chokepoint every Claude
// routing mode (grouped/raw/combo) and the native passthrough share,
// before fingerprinting and CCH signing serialize the body.
if (this.provider === "claude" || isClaudeCodeCompatible(this.provider)) {
if (this.provider === "claude" || usesClaudeCodeProtocol) {
enforceThinkingTemperature(transformedBody as Record<string, unknown>);
}
@@ -1282,7 +1298,7 @@ export class BaseExecutor {
// `contextEditingDisabled` (set by the 400-fallback) suppresses re-injection
// when a fresh `transformedBody` is built for a retry/fallback URL.
if (
(this.provider === "claude" || isClaudeCodeCompatible(this.provider)) &&
(this.provider === "claude" || usesClaudeCodeProtocol) &&
contextEditing?.enabled &&
!contextEditingDisabled
) {
@@ -1298,17 +1314,17 @@ export class BaseExecutor {
let bodyString = JSON.stringify(transformedBody);
const shouldFingerprint =
isCliCompatEnabled(this.provider) ||
isCliCompatEnabled(fingerprintProvider) ||
(this.provider === "claude" && (isClaudeCodeClient || hasClaudeOAuthToken));
if (shouldFingerprint) {
const fingerprinted = applyFingerprint(this.provider, headers, transformedBody);
const fingerprinted = applyFingerprint(fingerprintProvider, headers, transformedBody);
finalHeaders = fingerprinted.headers;
bodyString = fingerprinted.bodyString;
}
// CCH signing — replaces the cch=00000 placeholder in the billing
// header with an xxHash64 integrity token over the serialized body.
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
if (usesClaudeCodeProtocol || this.provider === "claude") {
bodyString = await signRequestBody(bodyString);
}
@@ -1396,7 +1412,7 @@ export class BaseExecutor {
contextEditingDisabled = true;
delete (transformedBody as Record<string, unknown>).context_management;
let retryBody = JSON.stringify(transformedBody);
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
if (usesClaudeCodeProtocol || this.provider === "claude") {
retryBody = await signRequestBody(retryBody);
}
log?.debug?.(
@@ -1435,7 +1451,7 @@ export class BaseExecutor {
thinkingBudgetClampedMax = upstreamMax;
if (clampNestedThinkingBudget(transformedBody, upstreamMax)) {
let retryBody = JSON.stringify(transformedBody);
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
if (usesClaudeCodeProtocol || this.provider === "claude") {
retryBody = await signRequestBody(retryBody);
}
log?.info?.(
@@ -1466,7 +1482,7 @@ export class BaseExecutor {
strippedFields.add(offending);
delete (transformedBody as Record<string, unknown>)[offending];
let retryBody = JSON.stringify(transformedBody);
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
if (usesClaudeCodeProtocol || this.provider === "claude") {
retryBody = await signRequestBody(retryBody);
}
log?.debug?.(
@@ -1491,7 +1507,7 @@ export class BaseExecutor {
addParamToBlocklist(this.provider, autoLearned, model);
delete (transformedBody as Record<string, unknown>)[autoLearned];
let retryBody = JSON.stringify(transformedBody);
if (isClaudeCodeCompatible(this.provider) || this.provider === "claude") {
if (usesClaudeCodeProtocol || this.provider === "claude") {
retryBody = await signRequestBody(retryBody);
}
log?.info?.(

View File

@@ -55,6 +55,7 @@ import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts";
import { resolveZaiUrl } from "./default/zaiFormatOverride.ts";
import { acquireNvidiaConcurrencySlot } from "./default/nvidiaConcurrencyGate.ts";
import { resolveAlibabaProviderBaseUrl } from "@/shared/constants/alibabaProviderRegions";
import { usesCcWireImage } from "../services/ccWireImageBuiltins.ts";
import type { PoolConfig } from "../services/sessionPool/types.ts";
@@ -291,6 +292,10 @@ export class DefaultExecutor extends BaseExecutor {
case "minimax":
case "minimax-cn":
return `${this.config.baseUrl}?beta=true`;
case "agentrouter":
return this.usesClaudeCodeProtocol(credentials)
? `${this.config.baseUrl}?beta=true`
: this.config.baseUrl;
case "gemini":
return `${this.config.baseUrl}/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`;
default: {
@@ -413,7 +418,7 @@ export class DefaultExecutor extends BaseExecutor {
applyClineAuthHeaders(headers, credentials, effectiveKey, clientHeaders, false);
break;
default:
if (isClaudeCodeCompatible(this.provider)) {
if (this.usesClaudeCodeProtocol(credentials)) {
const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults(
credentials?.providerSpecificData
);
@@ -423,6 +428,10 @@ export class DefaultExecutor extends BaseExecutor {
credentials?.providerSpecificData?.ccSessionId,
{ redactThinking: ccRequestDefaults.redactThinking === true }
);
if (usesCcWireImage(this.provider)) {
delete ccHeaders["Authorization"];
ccHeaders["x-api-key"] = effectiveKey || credentials.accessToken || "";
}
// CC nodes are also anthropic-compatible-*, so honor operator custom
// headers here (the early return skips the shared block below).
applyCustomHeaders(ccHeaders, credentials.providerSpecificData?.customHeaders);

View File

@@ -4,8 +4,8 @@
* Keep this leaf dependency-free so server executors, compatibility bridges,
* and client-facing identity presets can share one source of truth.
*/
export const CLAUDE_CODE_CLIENT_VERSION = "2.1.219";
export const CLAUDE_CODE_CLIENT_BUILD_REVISION = "250";
export const CLAUDE_CODE_CLIENT_VERSION = "2.1.220";
export const CLAUDE_CODE_CLIENT_BUILD_REVISION = "1f2";
export const CLAUDE_CODE_CLIENT_BILLING_VERSION = `${CLAUDE_CODE_CLIENT_VERSION}.${CLAUDE_CODE_CLIENT_BUILD_REVISION}`;
export const CLAUDE_CODE_SDK_PACKAGE_VERSION = "0.94.0";
export const CLAUDE_CODE_RUNTIME_VERSION = "v26.3.0";

View File

@@ -15,6 +15,7 @@
*/
import { getClaudeCodeUserAgent } from "./claudeCodeClient";
import { getCodexCliRsHeaders } from "./codexClient";
export interface ClientIdentityProfile {
readonly id: string;
@@ -40,10 +41,7 @@ const CLAUDE_CLI_PROFILE: ClientIdentityProfile = Object.freeze({
const CODEX_CLI_PROFILE: ClientIdentityProfile = Object.freeze({
id: "codex-cli",
label: "Codex CLI",
headers: Object.freeze({
"User-Agent": "codex_cli_rs/0.144.1",
originator: "codex_cli_rs",
}),
headers: Object.freeze(getCodexCliRsHeaders()),
});
const GEMINI_CLI_PROFILE: ClientIdentityProfile = Object.freeze({

View File

@@ -0,0 +1,11 @@
export const DEFAULT_CODEX_CLIENT_VERSION = "0.146.0";
export const CODEX_CLI_RS_ORIGINATOR = "codex_cli_rs";
export function getCodexCliRsHeaders(
version = DEFAULT_CODEX_CLIENT_VERSION
): Record<string, string> {
return {
"User-Agent": `${CODEX_CLI_RS_ORIGINATOR}/${version}`,
originator: CODEX_CLI_RS_ORIGINATOR,
};
}

View File

@@ -56,7 +56,7 @@ test("(b) applyFingerprint selects the claude-code-compatible fingerprint for ag
const { headers } = applyFingerprint(
"agentrouter",
buildProviderHeaders("agentrouter", { apiKey: "sk-agentrouter" }, true),
{ model: "claude-opus-4-6", messages: [] }
{ model: "claude-opus-4-8", messages: [] }
);
// Fingerprint reordering keeps the CC wire image + the preserved x-api-key auth.
assert.equal(headers["x-api-key"], "sk-agentrouter");
@@ -70,7 +70,7 @@ test("(c) CRUX: agentrouter keeps its OWN x-api-key auth (NOT CC Bearer)", () =>
});
test("(c) CRUX: agentrouter keeps its OWN registry baseUrl + ?beta=true", () => {
const url = buildProviderUrl("agentrouter", "claude-opus-4-6", true);
const url = buildProviderUrl("agentrouter", "claude-opus-4-8", true);
assert.equal(url, "https://agentrouter.org/v1/messages?beta=true");
// NOT the CC-family anthropic default baseUrl.
assert.ok(!url.includes("api.anthropic.com"));
@@ -78,11 +78,7 @@ test("(c) CRUX: agentrouter keeps its OWN registry baseUrl + ?beta=true", () =>
test("(c) real CC-family provider still uses the CC default baseUrl + Bearer auth", () => {
// The wire-image guard must NOT leak into genuine anthropic-compatible-cc-* providers.
const headers = buildProviderHeaders(
"anthropic-compatible-cc-foo",
{ apiKey: "sk-foo" },
true
);
const headers = buildProviderHeaders("anthropic-compatible-cc-foo", { apiKey: "sk-foo" }, true);
assert.equal(headers["Authorization"], "Bearer sk-foo");
assert.equal(headers["x-api-key"], undefined);

View File

@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
import { getClaudeCodeUserAgent } from "../../src/shared/constants/claudeCodeClient.ts";
test("AgentRouter default dispatch uses the Claude Code wire image and x-api-key auth", async () => {
const originalFetch = globalThis.fetch;
let captured: { url: string; headers: Headers; body: string } | null = null;
globalThis.fetch = async (url, init = {}) => {
captured = {
url: String(url),
headers: new Headers(init.headers),
body: String(init.body || ""),
};
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
const executor = new DefaultExecutor("agentrouter");
await executor.execute({
model: "claude-opus-4-8",
body: {
model: "claude-opus-4-8",
messages: [{ role: "user", content: "Reply only OK" }],
max_tokens: 8,
},
stream: false,
credentials: { apiKey: "test-agentrouter-key" },
});
} finally {
globalThis.fetch = originalFetch;
}
assert.ok(captured);
assert.equal(captured.url, "https://agentrouter.org/v1/messages?beta=true");
assert.equal(captured.headers.get("x-api-key"), "test-agentrouter-key");
assert.equal(captured.headers.get("authorization"), null);
assert.equal(captured.headers.get("user-agent"), getClaudeCodeUserAgent("cli"));
assert.equal(captured.headers.get("x-app"), "cli");
assert.match(captured.body, /cc_version=[^;]+; cc_entrypoint=cli; cch=(?!00000)[0-9a-f]{5};/);
});
test("AgentRouter OpenAI Chat dispatch uses Codex identity without Claude-only behavior", async () => {
const originalFetch = globalThis.fetch;
let captured: { url: string; headers: Headers; body: Record<string, unknown> } | null = null;
globalThis.fetch = async (url, init = {}) => {
captured = {
url: String(url),
headers: new Headers(init.headers),
body: JSON.parse(String(init.body || "{}")),
};
return new Response(JSON.stringify({ choices: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
const executor = new DefaultExecutor("agentrouter");
await executor.execute({
model: "gpt-5.6-sol",
body: {
model: "gpt-5.6-sol",
messages: [{ role: "user", content: "Reply only OK" }],
max_completion_tokens: 8,
},
stream: false,
credentials: {
apiKey: "test-agentrouter-key",
providerSpecificData: { targetFormat: "openai" },
},
});
} finally {
globalThis.fetch = originalFetch;
}
assert.ok(captured);
assert.equal(captured.url, "https://agentrouter.org/v1/chat/completions");
assert.equal(captured.headers.get("authorization"), "Bearer test-agentrouter-key");
assert.equal(captured.headers.get("x-api-key"), null);
assert.equal(captured.headers.get("user-agent"), "codex_cli_rs/0.146.0");
assert.equal(captured.headers.get("originator"), "codex_cli_rs");
assert.equal(captured.headers.get("x-app"), null);
assert.equal(captured.headers.get("anthropic-version"), null);
assert.equal(captured.body.messages instanceof Array, true);
assert.doesNotMatch(JSON.stringify(captured.body), /x-anthropic-billing-header|cc_version=/);
});
test("AgentRouter OpenAI Responses dispatch uses the Responses endpoint and Codex identity", async () => {
const originalFetch = globalThis.fetch;
let captured: { url: string; headers: Headers; body: Record<string, unknown> } | null = null;
globalThis.fetch = async (url, init = {}) => {
captured = {
url: String(url),
headers: new Headers(init.headers),
body: JSON.parse(String(init.body || "{}")),
};
return new Response(JSON.stringify({ id: "resp_1", object: "response", output: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
const executor = new DefaultExecutor("agentrouter");
await executor.execute({
model: "gpt-5.6-sol",
body: {
model: "gpt-5.6-sol",
input: "Reply only OK",
max_output_tokens: 8,
},
stream: false,
credentials: {
apiKey: "test-agentrouter-key",
providerSpecificData: { targetFormat: "openai-responses" },
},
});
} finally {
globalThis.fetch = originalFetch;
}
assert.ok(captured);
assert.equal(captured.url, "https://agentrouter.org/v1/responses");
assert.equal(captured.headers.get("authorization"), "Bearer test-agentrouter-key");
assert.equal(captured.headers.get("user-agent"), "codex_cli_rs/0.146.0");
assert.equal(captured.headers.get("originator"), "codex_cli_rs");
assert.equal(captured.headers.get("x-app"), null);
assert.equal(captured.headers.get("anthropic-beta"), null);
assert.equal(captured.body.input, "Reply only OK");
assert.equal(captured.body.max_output_tokens, 8);
});

View File

@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
import { FREE_MODEL_BUDGETS } from "../../open-sse/config/freeModelCatalog.ts";
import { getRegistryEntry } from "../../open-sse/config/providerRegistry.ts";
const LIVE_AGENTROUTER_MODEL_IDS = ["claude-opus-4-8", "claude-opus-5", "gpt-5.6-sol"];
test("AgentRouter registry fallback matches the authenticated live catalog", () => {
const ids = getRegistryEntry("agentrouter")
?.models.map((model) => model.id)
.sort();
assert.deepEqual(ids, [...LIVE_AGENTROUTER_MODEL_IDS].sort());
});
test("AgentRouter free-model fallback exposes only currently routable model IDs", () => {
const ids = FREE_MODEL_BUDGETS.filter((model) => model.provider === "agentrouter")
.map((model) => model.modelId)
.sort();
assert.deepEqual(ids, [...LIVE_AGENTROUTER_MODEL_IDS].sort());
});

View File

@@ -41,21 +41,18 @@ test("agentrouter discovery parseResponse maps an OpenAI-style model list", () =
const models = cfg.parseResponse!({
object: "list",
data: [
{ id: "claude-opus-4-6", object: "model", owned_by: "anthropic" },
{ id: "glm-5.1", object: "model", owned_by: "zhipu" },
{ id: "claude-opus-4-8", object: "model", owned_by: "anthropic" },
{ id: "gpt-5.6-sol", object: "model", owned_by: "openai" },
],
});
assert.deepEqual(
models,
[
{ id: "claude-opus-4-6", object: "model", owned_by: "anthropic" },
{ id: "glm-5.1", object: "model", owned_by: "zhipu" },
]
);
assert.deepEqual(models, [
{ id: "claude-opus-4-8", object: "model", owned_by: "anthropic" },
{ id: "gpt-5.6-sol", object: "model", owned_by: "openai" },
]);
// Tolerant of the alternate `models` envelope shape too.
assert.deepEqual(cfg.parseResponse!({ models: [{ id: "deepseek-v3.2" }] }), [
{ id: "deepseek-v3.2" },
assert.deepEqual(cfg.parseResponse!({ models: [{ id: "claude-opus-5" }] }), [
{ id: "claude-opus-5" },
]);
});

View File

@@ -10,9 +10,9 @@ import assert from "node:assert/strict";
import { filterPaidOnlyCandidates } from "../../../open-sse/services/autoCombo/paidModelFilter.ts";
// `agentrouter/claude-opus-4-6` is a documented free model (FREE_MODEL_BUDGETS);
// `agentrouter/claude-opus-4-8` is a documented free model (FREE_MODEL_BUDGETS);
// `openai/gpt-4o` is paid (openai has no documented free models).
const FREE = { provider: "agentrouter", model: "claude-opus-4-6" };
const FREE = { provider: "agentrouter", model: "claude-opus-4-8" };
const PAID = { provider: "openai", model: "gpt-4o" };
test("hidePaidModels OFF (default) returns the pool UNCHANGED (identity, regression guard)", () => {
@@ -27,7 +27,7 @@ test("hidePaidModels ON drops the paid-only backend, keeps the free one", () =>
assert.deepEqual(
result,
[FREE],
"openai/gpt-4o (paid) must be excluded; agentrouter/claude-opus-4-6 (free) kept"
"openai/gpt-4o (paid) must be excluded; agentrouter/claude-opus-4-8 (free) kept"
);
});
@@ -37,7 +37,12 @@ test("hidePaidModels ON with an all-paid pool degrades to an empty pool", () =>
});
test("hidePaidModels ON preserves extra candidate fields on kept entries", () => {
const enriched = { provider: "agentrouter", model: "claude-opus-4-6", connectionId: "abc", extra: 1 };
const enriched = {
provider: "agentrouter",
model: "claude-opus-4-8",
connectionId: "abc",
extra: 1,
};
const result = filterPaidOnlyCandidates([enriched, PAID], true);
assert.deepEqual(result, [enriched], "generic <T> filter must not strip candidate fields");
});

View File

@@ -38,10 +38,10 @@ test("Claude CLI version constants are in lockstep across all 4 sources", () =>
);
});
test("Claude CLI wire versions match the captured 2.1.219 binary", () => {
assert.equal(canonical.CLAUDE_CODE_CLIENT_VERSION, "2.1.219");
assert.equal(canonical.CLAUDE_CODE_CLIENT_BUILD_REVISION, "250");
assert.equal(canonical.CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.219.250");
test("Claude CLI wire versions match the captured 2.1.220 binary", () => {
assert.equal(canonical.CLAUDE_CODE_CLIENT_VERSION, "2.1.220");
assert.equal(canonical.CLAUDE_CODE_CLIENT_BUILD_REVISION, "1f2");
assert.equal(canonical.CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.220.1f2");
assert.equal(canonical.CLAUDE_CODE_SDK_PACKAGE_VERSION, "0.94.0");
assert.equal(canonical.CLAUDE_CODE_RUNTIME_VERSION, "v26.3.0");
assert.equal(
@@ -57,8 +57,9 @@ test("Claude CLI wire versions match the captured 2.1.219 binary", () => {
assert.equal(hdr.CLAUDE_CLI_BILLING_VERSION, canonical.CLAUDE_CODE_CLIENT_BILLING_VERSION);
});
test("Codex client is pinned to the captured 0.144.1 release", () => {
assert.equal(codexCfg.getCodexClientVersion(), "0.144.1");
assert.equal(codexCfg.getCodexUserAgent(), "codex-cli/0.144.1 (Windows 10.0.26200; x64)");
assert.equal(codexCfg.getCodexDefaultHeaders().Version, "0.144.1");
test("Codex client is pinned to the captured 0.146.0 release", () => {
assert.equal(codexCfg.getCodexClientVersion(), "0.146.0");
assert.equal(codexCfg.getCodexUserAgent(), "codex-cli/0.146.0 (Windows 10.0.26200; x64)");
assert.equal(codexCfg.getCodexDefaultHeaders().Version, "0.146.0");
assert.equal(codexCfg.getCodexCliRsHeaders()["User-Agent"], "codex_cli_rs/0.146.0");
});

View File

@@ -39,11 +39,11 @@ test("getClientIdentityProfileHeaders: unknown profile id falls back to no heade
test("getClientIdentityProfileHeaders: known CLI profiles expose their preset headers", () => {
const claudeCli = getClientIdentityProfileHeaders("claude-cli");
assert.equal(claudeCli["User-Agent"], "claude-cli/2.1.219 (external, cli)");
assert.equal(claudeCli["User-Agent"], "claude-cli/2.1.220 (external, cli)");
assert.equal(claudeCli["X-App"], "cli");
const codexCli = getClientIdentityProfileHeaders("codex-cli");
assert.equal(codexCli["User-Agent"], "codex_cli_rs/0.144.1");
assert.equal(codexCli["User-Agent"], "codex_cli_rs/0.146.0");
assert.equal(codexCli.originator, "codex_cli_rs");
const geminiCli = getClientIdentityProfileHeaders("gemini-cli");
@@ -55,7 +55,7 @@ test("getClientIdentityProfileHeaders: returns a fresh mutable copy (catalog sta
headers["User-Agent"] = "tampered";
assert.equal(
CLIENT_IDENTITY_PROFILES["claude-cli"].headers["User-Agent"],
"claude-cli/2.1.219 (external, cli)"
"claude-cli/2.1.220 (external, cli)"
);
});
@@ -80,7 +80,7 @@ test("a selected profile's headers land in providerSpecificData.customHeaders",
customHeaders: { ...profileHeaders, "X-Operator-Set": "keep-me" },
};
assert.equal(providerSpecificData.customHeaders["User-Agent"], "codex_cli_rs/0.144.1");
assert.equal(providerSpecificData.customHeaders["User-Agent"], "codex_cli_rs/0.146.0");
assert.equal(providerSpecificData.customHeaders.originator, "codex_cli_rs");
assert.equal(providerSpecificData.customHeaders["X-Operator-Set"], "keep-me");
});
@@ -100,7 +100,7 @@ test("profile headers merged into customHeaders survive applyCustomHeaders sanit
true
) as Record<string, string>;
assert.equal(headers["User-Agent"], "claude-cli/2.1.219 (external, cli)");
assert.equal(headers["User-Agent"], "claude-cli/2.1.220 (external, cli)");
assert.equal(headers["X-App"], "cli");
assert.equal(headers["Authorization"], "Bearer test-key");
});