diff --git a/CHANGELOG.md b/CHANGELOG.md index 442e2a572f..d67ed16ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### ✨ New Features - **feat(providers):** add **Yuanbao (web)** as a cookie-session provider ([#6196](https://github.com/diegosouzapw/OmniRoute/issues/6196)) — `yuanbao-web` (Tencent Yuanbao, `yuanbao.tencent.com`) with cookie-only auth (`hy_user`/`hy_token` + public agent id), SSE→OpenAI translation incl. `reasoning_content`, exposing DeepSeek V3/R1 + Hunyuan / Hunyuan-T1. Regression guard: `tests/unit/providers-yuanbao-web.test.ts`. `together-web` was **deferred** (no verifiable web-session endpoint — needs a captured request) and `huggingchat-web` **dropped** (the existing `huggingchat` already is a web-cookie provider). (thanks @chirag127) +- **feat(providers):** route the built-in **agentrouter** through the dynamic Claude-Code wire image ([#6056](https://github.com/diegosouzapw/OmniRoute/issues/6056)) — a small static allow-set (`CC_WIRE_IMAGE_BUILTINS` in `open-sse/services/ccWireImageBuiltins.ts`), consulted by `isClaudeCodeCompatible` / `isClaudeCodeCompatibleProvider` / `applyFingerprint`, makes agentrouter adopt the CC wire-image headers + fingerprint **while guarding the CC baseUrl/auth branches** so it keeps its own registry `baseUrl` and `x-api-key` auth. Regression guard: `tests/unit/agentrouter-cc-wire-image.test.ts` (asserts the wire image is applied AND agentrouter's baseUrl/auth are preserved). Live WAF-acceptance against agentrouter.org is a VPS validation follow-up (Hard Rule #18). ### 🐛 Bug Fixes diff --git a/open-sse/config/providers/registry/agentrouter/index.ts b/open-sse/config/providers/registry/agentrouter/index.ts index a4156d76ca..ebe9d4598f 100644 --- a/open-sse/config/providers/registry/agentrouter/index.ts +++ b/open-sse/config/providers/registry/agentrouter/index.ts @@ -1,14 +1,4 @@ import type { RegistryEntry } from "../../shared.ts"; -import { - getClaudeCliHeaders, - mapStainlessOs, - mapStainlessArch, - ANTHROPIC_BETA_CLAUDE_OAUTH, - ANTHROPIC_VERSION_HEADER, - CLAUDE_CLI_STAINLESS_PACKAGE_VERSION, - CLAUDE_CLI_STAINLESS_RUNTIME_VERSION, - CLAUDE_CLI_USER_AGENT, -} from "../../shared.ts"; export const agentrouterProvider: RegistryEntry = { id: "agentrouter", @@ -19,7 +9,11 @@ export const agentrouterProvider: RegistryEntry = { authType: "apikey", authHeader: "x-api-key", defaultContextLength: 128000, - headers: getClaudeCliHeaders(), + // No static `headers` here: agentrouter now adopts the DYNAMIC Claude-Code + // wire image via CC_WIRE_IMAGE_BUILTINS (#6056) — the fingerprint/headers are + // applied by buildProviderHeaders + applyFingerprint, keeping this entry's + // 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" }, diff --git a/open-sse/services/ccWireImageBuiltins.ts b/open-sse/services/ccWireImageBuiltins.ts new file mode 100644 index 0000000000..870e4f78f1 --- /dev/null +++ b/open-sse/services/ccWireImageBuiltins.ts @@ -0,0 +1,26 @@ +/** + * Built-in provider ids that must adopt the dynamic Claude-Code wire image + * (fingerprint headers/order + system transforms + `?beta=true` chat path) + * WITHOUT inheriting the Claude-Code-Compatible family's default anthropic + * baseUrl / Bearer auth. + * + * These providers keep their own registry `baseUrl` and auth scheme + * (e.g. `agentrouter` → `https://agentrouter.org/v1/messages` + `x-api-key`), + * while the two CC predicates (`isClaudeCodeCompatible` / + * `isClaudeCodeCompatibleProvider`) and `applyFingerprint` treat them as CC + * for the wire-image concerns only. The CC-baseUrl / CC-Bearer branches in + * `buildProviderUrl` / `buildProviderHeaders` are guarded so the registry + * baseUrl + auth are preserved. + * + * Single source of truth — imported by both predicates so they never diverge. + * See issue #6056. + */ +export const CC_WIRE_IMAGE_BUILTINS: ReadonlySet = new Set(["agentrouter"]); + +/** + * True when `provider` is a built-in that adopts the dynamic Claude-Code wire + * image while keeping its own registry baseUrl + auth. + */ +export function usesCcWireImage(provider: unknown): boolean { + return typeof provider === "string" && CC_WIRE_IMAGE_BUILTINS.has(provider); +} diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 4fd5f22711..eff67c4203 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -15,6 +15,7 @@ import { import { applyClaudeCodeCompatibleThinkingDisplay } from "./claudeCodeCompatibleThinkingDisplay.ts"; import { obfuscateInBody } from "./claudeCodeObfuscation.ts"; import { applySystemTransformPipeline, PROVIDER_CC_BRIDGE } from "./systemTransforms.ts"; +import { usesCcWireImage } from "./ccWireImageBuiltins.ts"; import { fixToolPairs, fixToolAdjacency, @@ -95,7 +96,12 @@ function supportsClaudeXHighEffort(model: string | null | undefined): boolean { } export function isClaudeCodeCompatibleProvider(provider: string | null | undefined): boolean { - return typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX); + return ( + (typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX)) || + // Built-in providers (e.g. agentrouter) that adopt the dynamic CC wire image + // while keeping their own registry baseUrl + auth (#6056). + usesCcWireImage(provider) + ); } export function stripAnthropicMessagesSuffix(baseUrl: string | null | undefined): string { diff --git a/open-sse/services/provider.ts b/open-sse/services/provider.ts index 9149c7e70b..9ba0a0acff 100644 --- a/open-sse/services/provider.ts +++ b/open-sse/services/provider.ts @@ -8,6 +8,7 @@ import { } from "./claudeCodeCompatible.ts"; import { getClaudeCodeCompatibleRequestDefaults } from "@/lib/providers/requestDefaults"; import { buildClineHeaders } from "@/shared/utils/clineAuth"; +import { usesCcWireImage } from "./ccWireImageBuiltins.ts"; const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; const OPENAI_COMPATIBLE_DEFAULTS = { @@ -29,7 +30,12 @@ function isAnthropicCompatible(provider) { } export function isClaudeCodeCompatible(provider) { - return typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX); + return ( + (typeof provider === "string" && provider.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX)) || + // Built-in providers (e.g. agentrouter) that adopt the dynamic CC wire image + // while keeping their own registry baseUrl + auth (#6056). + usesCcWireImage(provider) + ); } export function getOpenAICompatibleType( @@ -256,6 +262,15 @@ export function buildProviderUrl( providerSpecificData?: Record | null; } = {} ) { + // Built-in CC-wire-image providers (e.g. agentrouter): keep the registry's + // OWN baseUrl (NOT the CC family's anthropic default) but adopt the CC chat + // path so the request still targets `?beta=true` (#6056). + if (usesCcWireImage(provider)) { + const entry = getRegistryEntry(provider); + const config = getProviderConfig(provider); + const baseUrl = options?.baseUrl || entry?.baseUrl || config.baseUrl; + return joinClaudeCodeCompatibleUrl(baseUrl, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH); + } if (isOpenAICompatible(provider)) { const providerSpecificData = options?.providerSpecificData || null; const apiType = getOpenAICompatibleType(provider, providerSpecificData); @@ -318,12 +333,27 @@ export function buildProviderHeaders(provider, credentials, stream = true, body const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults( credentials?.providerSpecificData ); - return buildClaudeCodeCompatibleHeaders( + const ccHeaders = buildClaudeCodeCompatibleHeaders( token, stream, credentials?.providerSpecificData?.ccSessionId, { redactThinking: ccRequestDefaults.redactThinking === true } ); + // Built-in CC-wire-image providers (e.g. agentrouter): adopt the CC wire + // image headers but keep the registry's OWN auth scheme (e.g. x-api-key) + // instead of the CC family's Bearer auth (#6056). + if (usesCcWireImage(provider)) { + delete ccHeaders["Authorization"]; + const authHeader = entry?.authHeader || "bearer"; + if (authHeader === "x-api-key") { + if (token) ccHeaders["x-api-key"] = token; + } else if (authHeader === "key") { + if (token) ccHeaders["Authorization"] = `Key ${token}`; + } else { + ccHeaders["Authorization"] = `Bearer ${token}`; + } + } + return ccHeaders; } if (isAnthropicCompatible(provider)) { if (credentials.apiKey) { diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 428fe1abef..d760b0f7ac 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -27,64 +27,65 @@ "headers": { "apiKey": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07", - "Anthropic-Dangerous-Direct-Browser-Access": "true", - "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.195 (external, cli)", - "X-App": "cli", + "User-Agent": "claude-cli/2.1.195 (external, sdk-cli)", "X-Stainless-Arch": "", - "X-Stainless-Helper-Method": "stream", "X-Stainless-Lang": "js", - "X-Stainless-Os": "", + "X-Stainless-OS": "MacOS", "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v24.3.0", "X-Stainless-Timeout": "600", - "x-api-key": "" + "accept-encoding": "gzip, deflate, br, zstd", + "anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-version": "2023-06-01", + "x-api-key": "", + "x-app": "cli" }, "nonStream": { - "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07", - "Anthropic-Dangerous-Direct-Browser-Access": "true", - "Anthropic-Version": "2023-06-01", + "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.195 (external, cli)", - "X-App": "cli", + "User-Agent": "claude-cli/2.1.195 (external, sdk-cli)", "X-Stainless-Arch": "", - "X-Stainless-Helper-Method": "stream", "X-Stainless-Lang": "js", - "X-Stainless-Os": "", + "X-Stainless-OS": "MacOS", "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v24.3.0", "X-Stainless-Timeout": "600", - "x-api-key": "" + "accept-encoding": "gzip, deflate, br, zstd", + "anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-version": "2023-06-01", + "x-api-key": "", + "x-app": "cli" }, "oauth": { "Accept": "text/event-stream", - "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07", - "Anthropic-Dangerous-Direct-Browser-Access": "true", - "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.195 (external, cli)", - "X-App": "cli", + "User-Agent": "claude-cli/2.1.195 (external, sdk-cli)", "X-Stainless-Arch": "", - "X-Stainless-Helper-Method": "stream", "X-Stainless-Lang": "js", - "X-Stainless-Os": "", + "X-Stainless-OS": "MacOS", "X-Stainless-Package-Version": "0.94.0", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v24.3.0", "X-Stainless-Timeout": "600", - "x-api-key": "" + "accept-encoding": "gzip, deflate, br, zstd", + "anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-version": "2023-06-01", + "x-api-key": "", + "x-app": "cli" } }, "url": { - "nonStream": "https://agentrouter.org/v1/messages", - "stream": "https://agentrouter.org/v1/messages" + "nonStream": "https://agentrouter.org/v1/messages?beta=true", + "stream": "https://agentrouter.org/v1/messages?beta=true" } }, "agy": { diff --git a/tests/unit/agentrouter-cc-wire-image.test.ts b/tests/unit/agentrouter-cc-wire-image.test.ts new file mode 100644 index 0000000000..1cc334990d --- /dev/null +++ b/tests/unit/agentrouter-cc-wire-image.test.ts @@ -0,0 +1,91 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + isClaudeCodeCompatible, + buildProviderUrl, + buildProviderHeaders, +} from "../../open-sse/services/provider.ts"; +import { isClaudeCodeCompatibleProvider } from "../../open-sse/services/claudeCodeCompatible.ts"; +import { + CC_WIRE_IMAGE_BUILTINS, + usesCcWireImage, +} from "../../open-sse/services/ccWireImageBuiltins.ts"; +import { CLAUDE_CODE_COMPATIBLE_USER_AGENT } from "../../open-sse/services/claudeCodeCompatible.ts"; +import { CLAUDE_CLI_USER_AGENT } from "../../open-sse/config/anthropicHeaders.ts"; +import { applyFingerprint } from "../../open-sse/config/cliFingerprints.ts"; + +// Regression guard for #6056 — the built-in `agentrouter` provider must route +// through the DYNAMIC Claude-Code wire image (fingerprint headers + `?beta=true` +// chat path) while KEEPING its own registry baseUrl + x-api-key auth. + +test("agentrouter is registered in the CC-wire-image built-in allow-set", () => { + assert.ok(CC_WIRE_IMAGE_BUILTINS.has("agentrouter")); + assert.equal(usesCcWireImage("agentrouter"), true); + assert.equal(usesCcWireImage("claude"), false); + assert.equal(usesCcWireImage(null), false); +}); + +test("(a) both CC predicates return true for agentrouter", () => { + assert.equal(isClaudeCodeCompatible("agentrouter"), true); + assert.equal(isClaudeCodeCompatibleProvider("agentrouter"), true); +}); + +test("(a) predicates are unaffected for non-allow-set providers", () => { + // Official Claude OAuth provider must NOT be treated as CC-compatible. + assert.equal(isClaudeCodeCompatible("claude"), false); + assert.equal(isClaudeCodeCompatibleProvider("claude"), false); + // Genuine CC-family providers still match via the prefix. + assert.equal(isClaudeCodeCompatible("anthropic-compatible-cc-foo"), true); + assert.equal(isClaudeCodeCompatibleProvider("anthropic-compatible-cc-foo"), true); +}); + +test("(b) agentrouter outbound headers carry the dynamic CC wire image", () => { + const headers = buildProviderHeaders("agentrouter", { apiKey: "sk-agentrouter" }, true); + + // CC wire image markers (not the static getClaudeCliHeaders() shape). + assert.equal(headers["User-Agent"], CLAUDE_CODE_COMPATIBLE_USER_AGENT); + assert.notEqual(headers["User-Agent"], CLAUDE_CLI_USER_AGENT); + assert.equal(headers["x-app"], "cli"); + assert.equal(headers["anthropic-dangerous-direct-browser-access"], "true"); + assert.ok(headers["anthropic-beta"], "expected the CC anthropic-beta header"); + assert.ok(headers["X-Stainless-Package-Version"], "expected CC X-Stainless anchors"); +}); + +test("(b) applyFingerprint selects the claude-code-compatible fingerprint for agentrouter", () => { + const { headers } = applyFingerprint( + "agentrouter", + buildProviderHeaders("agentrouter", { apiKey: "sk-agentrouter" }, true), + { model: "claude-opus-4-6", messages: [] } + ); + // Fingerprint reordering keeps the CC wire image + the preserved x-api-key auth. + assert.equal(headers["x-api-key"], "sk-agentrouter"); + assert.equal(headers["User-Agent"], CLAUDE_CODE_COMPATIBLE_USER_AGENT); +}); + +test("(c) CRUX: agentrouter keeps its OWN x-api-key auth (NOT CC Bearer)", () => { + const headers = buildProviderHeaders("agentrouter", { apiKey: "sk-agentrouter" }, true); + assert.equal(headers["x-api-key"], "sk-agentrouter"); + assert.equal(headers["Authorization"], undefined); +}); + +test("(c) CRUX: agentrouter keeps its OWN registry baseUrl + ?beta=true", () => { + const url = buildProviderUrl("agentrouter", "claude-opus-4-6", 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")); +}); + +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 + ); + assert.equal(headers["Authorization"], "Bearer sk-foo"); + assert.equal(headers["x-api-key"], undefined); + + const url = buildProviderUrl("anthropic-compatible-cc-foo", "claude-sonnet-4-6", true); + assert.ok(url.includes("api.anthropic.com")); +});