From 52cdbfc81adef7e3cc17a5e43a1b17428661fffc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arda=20Karata=C5=9F?= <81361836+ardakrt@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:34:35 +0300 Subject: [PATCH] fix(claude): preserve Auto Mode classifier provider affinity and support classifierModel (#1697) --- src/claude/inbound.ts | 37 +++++++++++++++++++++++++++++++++++- src/router.ts | 11 ++++++++++- src/types.ts | 11 +++++++++++ tests/claude-inbound.test.ts | 36 +++++++++++++++++++++++++++++++++++ tests/router.test.ts | 23 ++++++++++++++++++++++ 5 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 4b72da674..ea97e5e04 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -25,7 +25,23 @@ function isRec(v: unknown): v is Rec { return !!v && typeof v === "object" && !Array.isArray(v); } -/** Alias first, then modelMap: exact id, then date-suffix-stripped (`-\d{8}$`), else passthrough. */ +function isClaudeClassifierModel(model: string): boolean { + const stripped = model.replace(/-\d{8}$/, ""); + return stripped === "claude-opus-5" || stripped === "claude-opus-4" || /^claude-opus-[45]/.test(stripped); +} + +function getClassifierAffinityProvider(mainModel: string | undefined): string | null { + if (!mainModel) return null; + const resolvedMain = resolveAlias(mainModel) ?? mainModel; + const sep = resolvedMain.indexOf("/"); + if (sep > 0) { + const provider = resolvedMain.slice(0, sep); + if (provider !== "native" && provider !== "policy") return provider; + } + return null; +} + +/** Alias first, then modelMap: exact id, then date-suffix-stripped (`-\d{8}$`), then classifier affinity/config, else passthrough. */ export function resolveInboundModel(model: string, cc?: OcxClaudeCodeConfig): string { // Defensive: Desktop/CLI strip the [1m] context-variant marker client-side, but a // leaking build must not break alias decode (devlog 138 — the 1M signal is the @@ -47,6 +63,25 @@ export function resolveInboundModel(model: string, cc?: OcxClaudeCodeConfig): st const stripped = model.replace(/-\d{8}$/, ""); const dateless = map[stripped]; if (typeof dateless === "string" && dateless.length > 0) return dateless; + + // Claude Code Auto Mode classifier routing (issue #1697): + // When Claude Code sends internal bare safety checks (e.g. claude-opus-5), + // preserve session provider affinity or configured classifierModel so requests + // do not fall through to an incompatible defaultProvider. + if (isClaudeClassifierModel(model)) { + if (typeof cc?.classifierModel === "string" && cc.classifierModel.trim().length > 0) { + return cc.classifierModel.trim(); + } + const affinityProvider = getClassifierAffinityProvider(cc?.model); + if (affinityProvider) { + return `${affinityProvider}/${model}`; + } + if (Array.isArray(cc?.classifierFallbacks) && cc.classifierFallbacks.length > 0) { + const firstValid = cc.classifierFallbacks.find(fb => typeof fb === "string" && fb.trim().length > 0); + if (firstValid) return firstValid.trim(); + } + } + return model; } diff --git a/src/router.ts b/src/router.ts index 12edcd532..29945be80 100644 --- a/src/router.ts +++ b/src/router.ts @@ -690,12 +690,21 @@ function routeByKnownModelPattern(config: OcxConfig, modelId: string): RouteResu for (const { providerNames, prefixes } of MODEL_PROVIDER_PATTERNS) { if (prefixes.some(prefix => modelId.startsWith(prefix))) { const matchingProvider = Object.entries(config.providers).find( - ([name]) => providerNames.some(providerName => name === providerName || name.startsWith(`${providerName}-`)) + ([name, prov]) => prov.disabled !== true && providerNames.some(providerName => name === providerName || name.startsWith(`${providerName}-`)) ); if (matchingProvider) { const [provName, prov] = matchingProvider; return routeResult(provName, prov, modelId, "explicit-provider", "model-pattern"); } + if (providerNames.includes("anthropic")) { + const anthropicAdapterProvider = Object.entries(config.providers).find( + ([_, prov]) => prov.disabled !== true && (prov.adapter === "anthropic" || prov.adapter === "anthropic-messages") + ); + if (anthropicAdapterProvider) { + const [provName, prov] = anthropicAdapterProvider; + return routeResult(provName, prov, modelId, "explicit-provider", "model-pattern"); + } + } } } return undefined; diff --git a/src/types.ts b/src/types.ts index f6b2f55ac..6ec52f50e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -432,6 +432,17 @@ export interface OcxClaudeCodeConfig { smallFastModel?: string; /** Inbound model id remaps: exact id first, then date-stripped (`-\d{8}$`). */ modelMap?: Record; + /** + * Explicit classifier model for Claude Code Auto Mode safety checks (e.g. "RelayA/claude-opus-5"). + * When unset, bare classifier requests check modelMap, then same-provider affinity from + * `claudeCode.model`, then compatible Anthropic-adapter providers, and finally fallbacks. + */ + classifierModel?: string; + /** + * Ordered fallback candidates for Claude Code Auto Mode classifier routing when the primary + * classifier route is not available. + */ + classifierFallbacks?: string[]; /** * Inject ANTHROPIC_BASE_URL etc. into the macOS user domain via `launchctl setenv` * so plain `claude` commands route through the proxy without `ocx claude`. Reverted diff --git a/tests/claude-inbound.test.ts b/tests/claude-inbound.test.ts index 74d120aa1..25b8e6173 100644 --- a/tests/claude-inbound.test.ts +++ b/tests/claude-inbound.test.ts @@ -285,6 +285,42 @@ describe("claude inbound translation", () => { expect(resolveInboundModel("anything", undefined)).toBe("anything"); }); + test("Claude Code Auto Mode classifier provider affinity and configuration (#1697)", () => { + // 1. Same-provider affinity from cc.model (e.g. RelayA/claude-fable-5 -> RelayA/claude-opus-5) + const ccWithAffinity = { model: "RelayA/claude-fable-5" }; + expect(resolveInboundModel("claude-opus-5", ccWithAffinity)).toBe("RelayA/claude-opus-5"); + expect(resolveInboundModel("claude-opus-5-20250514", ccWithAffinity)).toBe("RelayA/claude-opus-5-20250514"); + + // 2. Same-provider affinity from aliased cc.model (e.g. claude-ocx-RelayA--claude-fable-5) + const ccWithAliasedModel = { model: "claude-ocx-RelayA--claude-fable-5" }; + expect(resolveInboundModel("claude-opus-5", ccWithAliasedModel)).toBe("RelayA/claude-opus-5"); + + // 3. Explicit classifierModel wins over same-provider affinity + const ccWithExplicitClassifier = { + model: "RelayA/claude-fable-5", + classifierModel: "RelayB/claude-opus-5", + }; + expect(resolveInboundModel("claude-opus-5", ccWithExplicitClassifier)).toBe("RelayB/claude-opus-5"); + + // 4. Explicit modelMap wins over both classifierModel and same-provider affinity + const ccWithModelMap = { + model: "RelayA/claude-fable-5", + classifierModel: "RelayB/claude-opus-5", + modelMap: { "claude-opus-5": "Custom/my-opus-5" }, + }; + expect(resolveInboundModel("claude-opus-5", ccWithModelMap)).toBe("Custom/my-opus-5"); + + // 5. classifierFallbacks resolution when no main model provider is present + const ccWithFallbacks = { + classifierFallbacks: ["RelayC/claude-opus-5", "RelayD/claude-opus-5"], + }; + expect(resolveInboundModel("claude-opus-5", ccWithFallbacks)).toBe("RelayC/claude-opus-5"); + + // 6. Native pseudo-provider in cc.model does not create false affinity + const ccNative = { model: "native/claude-opus-5" }; + expect(resolveInboundModel("claude-opus-5", ccNative)).toBe("claude-opus-5"); + }); + test("error cases: no model, empty messages, bad role, bad tool_result", () => { expect(() => anthropicToResponsesBody({ max_tokens: 1, messages: [{ role: "user", content: "x" }] })).toThrow(AnthropicRequestError); expect(() => anthropicToResponsesBody({ model: "m", max_tokens: 1, messages: [] })).toThrow(AnthropicRequestError); diff --git a/tests/router.test.ts b/tests/router.test.ts index e8ec59c2a..58fa5466f 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -563,4 +563,27 @@ describe("routeModel backfills google wire mode from the registry", () => { }; expect(routeModel(config, "gemini-3-pro").provider.googleMode).toBe("vertex"); }); + + test("routes bare claude-* models to active Anthropic adapter providers instead of incompatible defaultProvider (#1697)", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "deepseek", + providers: { + deepseek: { + adapter: "openai-chat", + baseUrl: "https://api.deepseek.com", + }, + RelayA: { + adapter: "anthropic", + baseUrl: "https://api.anthropic.relay.example/v1", + }, + }, + }; + + const routed = routeModel(config, "claude-opus-5"); + expect(routed.providerName).toBe("RelayA"); + expect(routed.modelId).toBe("claude-opus-5"); + expect(routed.routeKind).toBe("explicit-provider"); + expect(routed.routeReason).toBe("model-pattern"); + }); });