-
Notifications
You must be signed in to change notification settings - Fork 760
fix(claude): preserve Auto Mode classifier provider affinity and support classifierModel (#1697) #1703
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(claude): preserve Auto Mode classifier provider affinity and support classifierModel (#1697) #1703
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,7 +25,37 @@ 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 /^claude-opus-[45]/.test(stripped); | ||
| } | ||
|
|
||
| /** | ||
| * Explicitly configured classifier route for Claude Code Auto Mode safety checks (#1697). | ||
| * | ||
| * Only OPERATOR-DECLARED targets are used: `classifierModel`, then the ordered | ||
| * `classifierFallbacks`. Both are qualified `provider/model` strings the operator chose, so | ||
| * routing them crosses no boundary the operator did not ask for. | ||
| * | ||
| * Deliberately NOT here: inferring a provider from `claudeCode.model`. That value is the | ||
| * injected/default config slot, not the provider the live session actually selected, so it goes | ||
| * stale the moment the user changes the model picker -- and acting on it would silently move a | ||
| * classifier turn onto a provider with its own privacy and billing consequences. Live session | ||
| * affinity needs the request/session state this function does not have; it is tracked as | ||
| * follow-up work rather than approximated from static config. | ||
| */ | ||
| function configuredClassifierRoute(cc?: OcxClaudeCodeConfig): string | undefined { | ||
| const explicit = typeof cc?.classifierModel === "string" ? cc.classifierModel.trim() : ""; | ||
| if (explicit.length > 0) return explicit; | ||
| if (Array.isArray(cc?.classifierFallbacks)) { | ||
| for (const candidate of cc.classifierFallbacks) { | ||
| if (typeof candidate === "string" && candidate.trim().length > 0) return candidate.trim(); | ||
| } | ||
| } | ||
| return undefined; | ||
|
Comment on lines
+47
to
+55
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Attempt classifier fallbacks after a candidate fails. Line 52 returns the first non-empty fallback before provider availability, model support, adapter compatibility, or upstream execution is known. A disabled provider, unsupported model, or failed first request therefore cannot advance to the next configured target. The current test only confirms first-entry selection. Preserve
The PR objective requires “actual ordered fallback attempts,” while the current implementation performs only first-value selection. 📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /** 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 +77,14 @@ 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 (#1697). Bare classifier checks such as | ||
| // `claude-opus-5` carry no provider, so without this they fall through to defaultProvider -- | ||
| // which may not speak Anthropic at all. Only an operator-declared target is used. | ||
| if (isClaudeClassifierModel(model)) { | ||
| const configured = configuredClassifierRoute(cc); | ||
| if (configured) return configured; | ||
| } | ||
| return model; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2018,12 +2018,30 @@ function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["claudeCod | |
| if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) { | ||
| delete normalized.subagentEffort; | ||
| } | ||
| // A hand-authored config never passes through the management validator, so coerce here too. | ||
| // A malformed classifierFallbacks (a bare string, or an array with non-string entries) would | ||
| // otherwise reach the resolver unchecked. | ||
| if (Object.hasOwn(normalized, "classifierModel")) { | ||
| const value = typeof normalized.classifierModel === "string" ? normalized.classifierModel.trim() : ""; | ||
| if (value.length > 0) normalized.classifierModel = value; | ||
| else delete normalized.classifierModel; | ||
| } | ||
| if (Object.hasOwn(normalized, "classifierFallbacks")) { | ||
| const raw = normalized.classifierFallbacks; | ||
| const kept = Array.isArray(raw) | ||
| ? raw.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map(entry => entry.trim()) | ||
|
Comment on lines
+2029
to
+2032
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| : []; | ||
| if (kept.length > 0) normalized.classifierFallbacks = kept; | ||
| else delete normalized.classifierFallbacks; | ||
| } | ||
| return normalized as OcxConfig["claudeCode"]; | ||
| } | ||
|
|
||
| function normalizeClaudeSubagentEffort(config: OcxConfig, rawParsed: unknown): OcxConfig { | ||
| const rawEffort = rawClaudeSubagentEffort(rawParsed); | ||
| if (rawEffort === undefined || isClaudeSubagentEffort(rawEffort)) return config; | ||
| function normalizeClaudeSubagentEffort(config: OcxConfig, _rawParsed: unknown): OcxConfig { | ||
| // Unconditional. This used to short-circuit when `subagentEffort` was absent or already valid, | ||
| // which meant a config whose ONLY defect was elsewhere in `claudeCode` was never normalized. | ||
| // The specialized subagentEffort WARNING is a separate concern and stays exactly as it is. | ||
| if (!config.claudeCode) return config; | ||
| return { ...config, claudeCode: normalizePersistedClaudeCode(config.claudeCode) }; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1005,6 +1005,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise | |
| smallFastModel: config.claudeCode?.smallFastModel ?? "", | ||
| tierModels: config.claudeCode?.tierModels ?? {}, | ||
| modelMap: config.claudeCode?.modelMap ?? {}, | ||
| classifierModel: config.claudeCode?.classifierModel ?? "", | ||
| classifierFallbacks: config.claudeCode?.classifierFallbacks ?? [], | ||
| systemEnv: config.claudeCode?.systemEnv === true, | ||
| autoConnectSupported: process.platform === "darwin", | ||
| maxContextTokens: config.claudeCode?.maxContextTokens ?? null, | ||
|
|
@@ -1042,7 +1044,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise | |
| return prototype === Object.prototype || prototype === null; | ||
| }; | ||
| if (!isPlainObject(parsedBody)) return jsonResponse({ error: "body must be an object" }, 400); | ||
| const body = parsedBody as { enabled?: unknown; authMode?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown; webSearchSidecar?: unknown; visionSidecar?: unknown }; | ||
| const body = parsedBody as { enabled?: unknown; authMode?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; classifierModel?: unknown; classifierFallbacks?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown; webSearchSidecar?: unknown; visionSidecar?: unknown }; | ||
| for (const field of ["webSearchSidecar", "visionSidecar"] as const) { | ||
| const section = body[field]; | ||
| if (section === undefined || section === null) continue; | ||
|
|
@@ -1182,13 +1184,31 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise | |
| } | ||
| nextFastMode = body.fastMode === null ? undefined : body.fastMode; | ||
| } | ||
| for (const field of ["model", "smallFastModel"] as const) { | ||
| for (const field of ["model", "smallFastModel", "classifierModel"] as const) { | ||
| const value = body[field]; | ||
| if (value === undefined) continue; | ||
| if (typeof value !== "string") return jsonResponse({ error: `${field} must be a string` }, 400); | ||
| if (value.trim() === "") delete next[field]; | ||
| else next[field] = value.trim(); | ||
| } | ||
| if (body.classifierFallbacks !== undefined) { | ||
| if (body.classifierFallbacks === null) { | ||
| delete next.classifierFallbacks; | ||
| } else { | ||
| if (!Array.isArray(body.classifierFallbacks)) { | ||
| return jsonResponse({ error: "classifierFallbacks must be an array of strings, or null" }, 400); | ||
| } | ||
| const list: string[] = []; | ||
| for (const entry of body.classifierFallbacks) { | ||
| if (typeof entry !== "string" || entry.trim() === "") { | ||
| return jsonResponse({ error: "classifierFallbacks entries must be non-empty strings" }, 400); | ||
| } | ||
| list.push(entry.trim()); | ||
| } | ||
| if (list.length > 0) next.classifierFallbacks = list; | ||
| else delete next.classifierFallbacks; | ||
| } | ||
| } | ||
|
Comment on lines
+1187
to
+1211
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Reject classifier targets without a provider qualifier. Line 1172 accepts Use one shared validator for API writes and persisted-config normalization. Require non-empty provider and model components after trimming. Reject invalid API input. Remove invalid hand-authored values. Add regressions for bare values such as
As per path instructions, “Use explicit provider-qualified classifier targets when cross-provider routing is intended,” and “Preserve the provider-qualified target through routing.” 📍 Affects 4 files
🤖 Prompt for AI AgentsSource: Path instructions |
||
| if (body.modelMap !== undefined) { | ||
| if (body.modelMap === null) { | ||
| delete next.modelMap; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Restrict classifier matching to supported classifier identifiers.
Line 30 matches every ID that starts with
claude-opus-4orclaude-opus-5. It therefore classifies unrelated IDs such asclaude-opus-50as Auto Mode classifier requests and rewrites them toclassifierModel. Match only the supported Opus 4/5 classifier forms and their allowed dated variants.src/claude/inbound.ts#L28-L31: replace the prefix match with an explicit supported-identifier matcher.tests/claude-inbound.test.ts#L288-L323: add passthrough regressions for IDs that share the prefix but are not classifier identifiers.The PR objective requires narrower classifier detection.
📍 Affects 2 files
src/claude/inbound.ts#L28-L31(this comment)tests/claude-inbound.test.ts#L288-L323🤖 Prompt for AI Agents