mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
fix(agentrouter): support Claude and Codex protocols
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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?.(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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({
|
||||
|
||||
11
src/shared/constants/codexClient.ts
Normal file
11
src/shared/constants/codexClient.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
139
tests/unit/agentrouter-executor-protocols.test.ts
Normal file
139
tests/unit/agentrouter-executor-protocols.test.ts
Normal 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);
|
||||
});
|
||||
21
tests/unit/agentrouter-live-catalog.test.ts
Normal file
21
tests/unit/agentrouter-live-catalog.test.ts
Normal 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());
|
||||
});
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user