diff --git a/gui/src/pages/claude-manual-env.ts b/gui/src/pages/claude-manual-env.ts index bb0c2970d..690d0c6e4 100644 --- a/gui/src/pages/claude-manual-env.ts +++ b/gui/src/pages/claude-manual-env.ts @@ -5,7 +5,7 @@ */ export type SidecarBackend = "openai" | "anthropic"; -export interface SidecarOverride { backend?: SidecarBackend; model?: string } +export interface SidecarOverride { backend?: SidecarBackend | null; model?: string } export interface ClaudeManualEnvState { /** diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 5e3074bec..5ee6a9656 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -53,15 +53,25 @@ function isRec(v: unknown): v is Rec { /** Resolve Claude-only sidecar overrides without mutating the shared server config. */ export function buildClaudeReplayConfig(config: OcxConfig): OcxConfig { + const webSearchOverride = config.claudeCode?.webSearchSidecar; + const visionOverride = config.claudeCode?.visionSidecar; + const { backend: webSearchBackend, ...webSearchFields } = webSearchOverride ?? {}; + const { backend: visionBackend, ...visionFields } = visionOverride ?? {}; return { ...config, webSearchSidecar: { ...config.webSearchSidecar, - ...config.claudeCode?.webSearchSidecar, + ...webSearchFields, + ...(webSearchOverride && "backend" in webSearchOverride + ? { backend: webSearchBackend ?? undefined } + : {}), }, visionSidecar: { ...config.visionSidecar, - ...config.claudeCode?.visionSidecar, + ...visionFields, + ...(visionOverride && "backend" in visionOverride + ? { backend: visionBackend ?? undefined } + : {}), }, }; } diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 17f7c00b3..5783de0d9 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1021,8 +1021,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise } const requested = section as { backend?: "openai" | "anthropic" | null; model?: string }; const override: NonNullable = { ...next[field] }; - if (requested.backend === null) delete override.backend; - else if (requested.backend !== undefined) override.backend = requested.backend; + if (requested.backend !== undefined) override.backend = requested.backend; if (requested.model === "") delete override.model; else if (requested.model !== undefined) override.model = requested.model; if (Object.keys(override).length > 0) next[field] = override; diff --git a/src/types.ts b/src/types.ts index 481c15a4b..8a67276b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -476,10 +476,10 @@ export interface OcxClaudeCodeConfig { * definition. Unset inherits the parent session effort. */ subagentEffort?: "low" | "medium" | "high" | "xhigh" | "max"; - /** Claude-originated web-search override. Unset fields inherit the global sidecar settings. */ - webSearchSidecar?: { backend?: "openai" | "anthropic"; model?: string }; - /** Claude-originated vision override. Unset fields inherit the global sidecar settings. */ - visionSidecar?: { backend?: "openai" | "anthropic"; model?: string }; + /** Claude-originated web-search override. Null backend means Auto; an absent field inherits global. */ + webSearchSidecar?: { backend?: "openai" | "anthropic" | null; model?: string }; + /** Claude-originated vision override. Null backend means Auto; an absent field inherits global. */ + visionSidecar?: { backend?: "openai" | "anthropic" | null; model?: string }; /** Persisted Claude Desktop four-family routing profile. */ desktopProfile?: OcxClaudeDesktopProfile; /** Auto-reconcile Desktop 3P config when provider catalog changes. Default: enabled. */ diff --git a/tests/claude-management-api.test.ts b/tests/claude-management-api.test.ts index 4286deeac..169b477c1 100644 --- a/tests/claude-management-api.test.ts +++ b/tests/claude-management-api.test.ts @@ -329,17 +329,17 @@ test("Claude sidecar overrides round-trip, partially update, clear, and reject u expect(loadConfig().claudeCode?.webSearchSidecar).toEqual({ backend: "anthropic", model: "claude-search-2" }); expect(loadConfig().claudeCode?.visionSidecar).toEqual({ backend: "openai", model: "gpt-vision" }); - // null backend is the explicit Auto/inherit transition; empty model deletes only model. + // null backend is explicit Auto; empty model deletes only model. response = await put({ webSearchSidecar: { backend: null }, visionSidecar: { backend: null, model: "" }, }); expect(response.status).toBe(200); - expect(loadConfig().claudeCode?.webSearchSidecar).toEqual({ model: "claude-search-2" }); - expect(loadConfig().claudeCode?.visionSidecar).toBeUndefined(); + expect(loadConfig().claudeCode?.webSearchSidecar).toEqual({ backend: null, model: "claude-search-2" }); + expect(loadConfig().claudeCode?.visionSidecar).toEqual({ backend: null }); get = await fetch(new URL("/api/claude-code", server.url)).then(r => r.json()) as Record; - expect(get.webSearchSidecar).toEqual({ model: "claude-search-2" }); - expect(get.visionSidecar).toBeUndefined(); + expect(get.webSearchSidecar).toEqual({ backend: null, model: "claude-search-2" }); + expect(get.visionSidecar).toEqual({ backend: null }); // null and empty sections both clear the whole override. response = await put({ webSearchSidecar: null, visionSidecar: {} }); diff --git a/tests/claude-sidecar-override.test.ts b/tests/claude-sidecar-override.test.ts index 17130e547..6cc41b29c 100644 --- a/tests/claude-sidecar-override.test.ts +++ b/tests/claude-sidecar-override.test.ts @@ -124,3 +124,21 @@ test("unset Claude overrides inherit the global sidecar backend and model", () = settings: { model: "global-vision" }, }); }); + +test("explicit Claude Auto backends do not inherit global backend choices", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "routed", + providers: { routed, forward }, + webSearchSidecar: { backend: "openai", model: "global-search" }, + visionSidecar: { backend: "openai", model: "global-vision" }, + claudeCode: { + webSearchSidecar: { backend: null, model: "claude-search" }, + visionSidecar: { backend: null, model: "claude-vision" }, + }, + }; + + const effective = buildClaudeReplayConfig(config); + expect(effective.webSearchSidecar).toEqual({ backend: undefined, model: "claude-search" }); + expect(effective.visionSidecar).toEqual({ backend: undefined, model: "claude-vision" }); +});