Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ closed. Explicit `xai` requires a usable stored Grok OAuth account and does not
requires both ChatGPT login auth and an enabled `forward` provider.
:::

On file load, a retired OpenCode Free model target in global or `claudeCode` overrides of
`visionSidecar` or `webSearchSidecar` is removed and that sidecar is explicitly disabled, retaining
its other settings. Select a supported target before re-enabling it. Healthy targets and the defaults
for absent sidecar sections are unchanged.

### Additional web-search backends (explicit-only)

Three more web-search backends exist beyond the ChatGPT and Claude paths. Each is
Expand Down
28 changes: 24 additions & 4 deletions gui/src/pages/claude-code-save.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { serializeSidecarOverride } from "./claude-code-sidecar";
import { confirmSidecarOverride, serializeSidecarOverride } from "./claude-code-sidecar";
import type { ClaudeCodeState, MapRow } from "./claude-code-types";
import { newClientId } from "./claude-code-types";

Expand Down Expand Up @@ -118,7 +118,10 @@ export function applyServerRead(current: ClaudeCodeEditState | null, next: Claud
*/
export function savedCopy(serverState: ClaudeCodeState, submitted: ClaudeCodeEditable): ClaudeCodeEditable {
return {
state: { ...serverState, ...pickEditable(submitted.state) },
state: { ...serverState, ...pickEditable(submitted.state),
visionSidecar: confirmSidecarOverride(submitted.state.visionSidecar),
webSearchSidecar: confirmSidecarOverride(submitted.state.webSearchSidecar),
},
rows: Object.entries(normalizedModelMap(submitted.rows)).map(([from, to]) => ({ id: newClientId(), from, to })),
};
}
Expand All @@ -136,6 +139,23 @@ export function acknowledgeSave(current: ClaudeCodeEditState | null, submitted:
const baseline = savedCopy(current.baseline.state, submitted);
const rows = baseline.rows;
const unchanged = claudeCodeDraftKey(current.draft) === claudeCodeDraftKey(submitted);
// Untouched since submission: show what was stored. Edited meanwhile: keep the edits, dirty.
return { draft: unchanged ? { state: current.draft.state, rows } : current.draft, baseline, adoptNextRead: unchanged };
if (unchanged) {
// Untouched since submission: show what was stored.
return { draft: { state: { ...current.draft.state,
visionSidecar: baseline.state.visionSidecar, webSearchSidecar: baseline.state.webSearchSidecar,
}, rows }, baseline, adoptNextRead: true };
}
/*
* Edited while the PUT was out: keep the edits, dirty. A sidecar is still confirmed —
* ending its draft-only activation provenance — when its serialized shape is exactly what
* the Save stored; one edited after submission keeps its marker until its own save lands.
*/
let draft = current.draft;
for (const key of ["visionSidecar", "webSearchSidecar"] as const) {
const stored = JSON.stringify(serializeSidecarOverride(submitted.state[key]));
if (JSON.stringify(serializeSidecarOverride(draft.state[key])) !== stored) continue;
const confirmed = confirmSidecarOverride(draft.state[key]);
if (confirmed !== draft.state[key]) draft = { ...draft, state: { ...draft.state, [key]: confirmed } };
}
return { draft, baseline, adoptNextRead: false };
}
35 changes: 26 additions & 9 deletions gui/src/pages/claude-code-sidecar.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
/**
* Claude Code sidecar override helpers — keep Auto as an in-memory draft so
* users can enter a model before persist. Empty Auto persists only when it
* carries an explicit pool edit.
* carries an explicit activation state or pool edit.
*/

import type { AnthropicInstanceId, SidecarOverride, VisionOverrideBackend } from "./claude-manual-env";

export type SidecarSelectValue = "inherit" | "auto" | VisionOverrideBackend;

// Draft-only provenance follows object spreads but cannot enter JSON or the PUT shape.
const modelEditStartedDisabled = Symbol("modelEditStartedDisabled");
type SidecarDraft = SidecarOverride & { [modelEditStartedDisabled]?: true };

/** A confirmed save starts a new editing baseline; drafts without a pending marker keep their identity. */
export function confirmSidecarOverride(override?: SidecarOverride): SidecarOverride | undefined {
if (!override || !(override as SidecarDraft)[modelEditStartedDisabled]) return override;
const next = { ...override } as SidecarDraft;
delete next[modelEditStartedDisabled];
return next;
}

export type PersistedSidecarOverride = {
backend: VisionOverrideBackend | null;
model: string;
enabled?: boolean;
backend?: VisionOverrideBackend | null;
model?: string;
anthropicInstance?: AnthropicInstanceId | null;
};

Expand Down Expand Up @@ -38,28 +51,32 @@ export function applySidecarPoolChange(override: SidecarOverride | undefined, va
return next;
}

/** Model typing updates the draft in place; empty Auto stays selectable until save. */
/** Model edits preserve activation unless this draft started disabled. */
export function applySidecarModelChange(
override: SidecarOverride | undefined,
model: string,
): SidecarOverride {
return { ...override, model };
const startedDisabled = override?.enabled === false || (override as SidecarDraft | undefined)?.[modelEditStartedDisabled];
return { ...override, model, ...(startedDisabled ? {
enabled: model.trim().length > 0, [modelEditStartedDisabled]: true as const,
} : {}) };
}

/**
* Persist shape for PUT /api/claude-code.
* Empty Auto drafts ({ backend: undefined } / blank model) become null → reload as Inherit.
* Empty Auto drafts become null → Inherit unless an explicit activation state must survive.
*/
export function serializeSidecarOverride(
override?: SidecarOverride,
): PersistedSidecarOverride | null {
if (!override) return null;
const trimmed = (override.model ?? "").trim();
const activation = override.enabled === undefined ? {} : { enabled: override.enabled };
if (!override.backend) {
if (!trimmed && override.anthropicInstance === undefined) return null;
return { backend: null, model: trimmed,
if (!trimmed && override.anthropicInstance === undefined) return override.enabled === undefined ? null : { ...activation, backend: null, model: "" };
return { ...activation, backend: null, model: trimmed,
...(override.anthropicInstance !== undefined ? { anthropicInstance: override.anthropicInstance } : {}) };
}
return { backend: override.backend, model: trimmed,
return { ...activation, backend: override.backend, model: trimmed,
...(override.anthropicInstance !== undefined ? { anthropicInstance: override.anthropicInstance } : {}) };
}
2 changes: 1 addition & 1 deletion gui/src/pages/claude-manual-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface AnthropicPoolOptions {
available: AnthropicInstanceId[];
code?: "anthropic_helper_unavailable";
}
export interface SidecarOverride { backend?: VisionOverrideBackend; model?: string; anthropicInstance?: AnthropicInstanceId | null }
export interface SidecarOverride { enabled?: boolean; backend?: VisionOverrideBackend; model?: string; anthropicInstance?: AnthropicInstanceId | null }

export interface ClaudeManualEnvState {
/**
Expand Down
68 changes: 68 additions & 0 deletions gui/tests/claude-code-save.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
type ClaudeCodeEditable,
} from "../src/pages/claude-code-save";
import { normalizeContextAccounting, type ClaudeCodeState, type MapRow } from "../src/pages/claude-code-types";
import { applySidecarBackendChange, applySidecarModelChange, applySidecarPoolChange } from "../src/pages/claude-code-sidecar";
import type { SidecarOverride } from "../src/pages/claude-manual-env";

const STATE = {
enabled: true,
Expand Down Expand Up @@ -38,6 +40,26 @@ const STATE = {
const row = (from: string, to: string, id = `${from}->${to}`): MapRow => ({ id, from, to });
const editable = (state: Partial<ClaudeCodeState> = {}, rows: MapRow[] = []): ClaudeCodeEditable => ({ state: { ...STATE, ...state }, rows });

test("model-edit activation provenance survives drafts but ends at a confirmed save or revert", () => {
const base = editable({ visionSidecar: { enabled: false } });
const typed = applySidecarModelChange(base.state.visionSidecar, "gpt-5.6-luna");
const submitted = editable({ visionSidecar: applySidecarPoolChange(applySidecarBackendChange(typed, "openai"), "") });
const body = claudeCodeSaveBody(submitted.state, []);
expect(body.visionSidecar).toEqual({ enabled: true, backend: "openai", model: "gpt-5.6-luna" });
expect(Object.getOwnPropertySymbols(body.visionSidecar!)).toHaveLength(0);
expect(JSON.parse(JSON.stringify(body)).visionSidecar).toEqual(body.visionSidecar);
const polled = applyServerRead({ draft: submitted, baseline: base, adoptNextRead: false }, base);
expect(applySidecarModelChange(polled.draft.state.visionSidecar, "").enabled).toBe(false);
expect(revertEditable(submitted.state, base.state).visionSidecar).toEqual({ enabled: false });
const acked = acknowledgeSave({ draft: submitted, baseline: base, adoptNextRead: false }, submitted)!;
// Even if the acknowledging GET fails, the successful PUT is the new activation baseline.
expect(applySidecarModelChange(acked.draft.state.visionSidecar, "").enabled).toBe(true);
const reverted = revertEditable(base.state, acked.baseline.state);
expect(applySidecarModelChange(reverted.visionSidecar, "").enabled).toBe(true);
const fresh = editable({ visionSidecar: { enabled: true, backend: "openai", model: "gpt-5.6-luna" } });
expect(applySidecarModelChange(applyServerRead(acked, fresh).draft.state.visionSidecar, "").enabled).toBe(true);
});

test("tier defaults participate in save, polling preservation and revert", () => {
const tiers = { opus: "combo/opus", sonnet: "combo/sonnet", haiku: "combo/haiku" };
const draft = editable({ tierModels: tiers });
Expand Down Expand Up @@ -148,3 +170,49 @@ test("context accounting is saved, dirties the draft, and older states read as t
expect(normalizeContextAccounting("bogus")).toBe("1m");
expect(normalizeContextAccounting("200k")).toBe("200k");
});

const sidecarTyped = (disabled: SidecarOverride | undefined, model: string) =>
applySidecarBackendChange(applySidecarModelChange(disabled, model), "openai");

test("a stored sidecar is confirmed even when an unrelated field was edited while the PUT was out", () => {
const base = editable({ visionSidecar: { enabled: false }, webSearchSidecar: { enabled: false } });
const vision = sidecarTyped(base.state.visionSidecar, "gpt-5.6-luna");
const web = sidecarTyped(base.state.webSearchSidecar, "claude-haiku-4-5");
const submitted = editable({ visionSidecar: vision, webSearchSidecar: web });
// Only an unrelated field changed mid-flight; the sidecars the server stored are unchanged.
const pending = editable({ visionSidecar: vision, webSearchSidecar: web, authMode: "proxy" });
const acked = acknowledgeSave({ draft: pending, baseline: base, adoptNextRead: false, savePending: true }, submitted)!;
expect(acked.adoptNextRead).toBe(false);
expect(acked.draft.state.authMode).toBe("proxy");
// Confirmed enabled=true: clearing the model afterwards keeps the saved activation.
expect(applySidecarModelChange(acked.draft.state.visionSidecar, "").enabled).toBe(true);
expect(applySidecarModelChange(acked.draft.state.webSearchSidecar, "").enabled).toBe(true);
});

test("a sidecar edited after submission keeps its disabled-start marker until its own save", () => {
const base = editable({ visionSidecar: { enabled: false } });
const typed = sidecarTyped(base.state.visionSidecar, "gpt-5.6-luna");
const submitted = editable({ visionSidecar: typed });
// Cleared mid-flight: the serialized shape no longer matches what the PUT stored.
const cleared = applySidecarModelChange(typed, "");
const acked = acknowledgeSave({ draft: editable({ visionSidecar: cleared }), baseline: base, adoptNextRead: false }, submitted)!;
// The post-submit edit itself is preserved, not overwritten by the confirmed copy.
expect(acked.draft.state.visionSidecar).toBe(cleared);
expect(isClaudeCodeDraftDirty(acked.draft, acked.baseline)).toBe(true);
// Retyping and clearing again still returns to disabled: this draft never reached the server.
expect(applySidecarModelChange(applySidecarModelChange(acked.draft.state.visionSidecar, "gpt-5.6-luna"), "").enabled).toBe(false);
});

test("stored and post-submit-edited sidecars are confirmed independently", () => {
const base = editable({ visionSidecar: { enabled: false }, webSearchSidecar: { enabled: false } });
const vision = sidecarTyped(base.state.visionSidecar, "gpt-5.6-luna");
const web = sidecarTyped(base.state.webSearchSidecar, "claude-haiku-4-5");
const submitted = editable({ visionSidecar: vision, webSearchSidecar: web });
// Vision's model changed mid-flight (still enabled), webSearch did not: each is decided on its own.
const visionRetyped = applySidecarModelChange(vision, "gpt-5.6-astra");
const acked = acknowledgeSave({ draft: editable({ visionSidecar: visionRetyped, webSearchSidecar: web }), baseline: base, adoptNextRead: false }, submitted)!;
expect(applySidecarModelChange(acked.draft.state.webSearchSidecar, "").enabled).toBe(true);
// Vision stays enabled in the draft, so only its retained marker turns a clear back into disabled.
expect(acked.draft.state.visionSidecar).toBe(visionRetyped);
expect(applySidecarModelChange(acked.draft.state.visionSidecar, "").enabled).toBe(false);
});
26 changes: 26 additions & 0 deletions gui/tests/claude-code-sidecar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ test("Empty Auto serializes as null (reloads as Inherit)", () => {
expect(serializeSidecarOverride({})).toBeNull();
});

test("disabled intent survives untouched saves and backend drafts until explicit reset or model choice", () => {
const disabled = { enabled: false };
expect(serializeSidecarOverride(disabled)).toEqual({ enabled: false, backend: null, model: "" });
expect(serializeSidecarOverride({ enabled: true })).toEqual({ enabled: true, backend: null, model: "" });
expect(serializeSidecarOverride(applySidecarBackendChange(disabled, "auto"))).toEqual({ enabled: false, backend: null, model: "" });
expect(serializeSidecarOverride(applySidecarBackendChange(disabled, "inherit"))).toBeNull();
expect(serializeSidecarOverride(applySidecarModelChange(disabled, "gpt-5.6-luna")))
.toEqual({ enabled: true, backend: null, model: "gpt-5.6-luna" });
const typedThenCleared = applySidecarModelChange(applySidecarModelChange(disabled, "gpt-5.6-luna"), " ");
expect(serializeSidecarOverride(typedThenCleared)).toEqual({ enabled: false, backend: null, model: "" });
expect(serializeSidecarOverride({ enabled: false, backend: "openai", model: "gpt-5.6-luna" }))
.toEqual({ enabled: false, backend: "openai", model: "gpt-5.6-luna" });
});

test("Switching back to Inherit clears the override", () => {
const withModel = applySidecarModelChange(
applySidecarBackendChange(undefined, "auto"),
Expand All @@ -42,6 +56,18 @@ test("Switching back to Inherit clears the override", () => {
expect(serializeSidecarOverride(undefined)).toBeNull();
});

test("clearing a model preserves an already-enabled or implicit override", () => {
for (const enabled of [true, undefined]) {
const initial = { enabled, backend: "openai" as const, model: "gpt-5.6-luna" };
const cleared = applySidecarModelChange(initial, " ");
expect(serializeSidecarOverride(cleared)).toEqual({
...(enabled === undefined ? {} : { enabled }), backend: "openai", model: "",
});
expect(serializeSidecarOverride(applySidecarModelChange(applySidecarModelChange(initial, "replacement"), "")))
.toEqual(serializeSidecarOverride(cleared));
}
});

test("Switching Auto ↔ OpenAI ↔ Anthropic preserves the model", () => {
let override = applySidecarModelChange(
applySidecarBackendChange(undefined, "auto"),
Expand Down
11 changes: 11 additions & 0 deletions src/config/removed-opencode-free.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ export function sanitizeRemovedOpenCodeFreeForLoad(parsed: unknown): void {
delete config.syncCodexSubagentDefaults;
delete config.injectionEffort;
}
// These sidecars default on or inherit global settings when absent. Preserve the
// off switch at both supported owners before pruning their retired model target.
for (const owner of [config, record(config.claudeCode)]) {
for (const key of ["visionSidecar", "webSearchSidecar"]) {
const sidecar = record(owner?.[key]);
if (!sidecar || !retired(sidecar.model)) continue;
sidecar.enabled = false;
delete sidecar.model;
changed = true;
}
}
for (const [key, value] of Object.entries(config)) {
if (key === "providers" || !/model|provider|combo|routing|claudeCode|fallback|redirect|compaction|autoReview|advisor|tokenGuardian|images|codexRoleTiers|sidecar|shadowCallIntercept/i.test(key)) continue;
const next = prune(value, key === "claudeCode");
Expand Down
10 changes: 7 additions & 3 deletions src/server/management/agent-settings-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1516,10 +1516,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
visionSidecar: sidecarAnthropicPoolOptions(config, { ...helperVision, backend: helperVisionBackend }, helperParent),
},
...(webSearchOverride && Object.keys(webSearchOverride).length > 0
? { webSearchSidecar: { backend: webSearchOverride.backend, model: webSearchOverride.model, ...(webSearchOverride.anthropicInstance ? { anthropicInstance: webSearchOverride.anthropicInstance } : {}) } }
? { webSearchSidecar: { enabled: webSearchOverride.enabled, backend: webSearchOverride.backend, model: webSearchOverride.model, ...(webSearchOverride.anthropicInstance ? { anthropicInstance: webSearchOverride.anthropicInstance } : {}) } }
: {}),
...(visionOverride && Object.keys(visionOverride).length > 0
? { visionSidecar: { backend: visionOverride.backend, model: visionOverride.model, ...(visionOverride.anthropicInstance ? { anthropicInstance: visionOverride.anthropicInstance } : {}) } }
? { visionSidecar: { enabled: visionOverride.enabled, backend: visionOverride.backend, model: visionOverride.model, ...(visionOverride.anthropicInstance ? { anthropicInstance: visionOverride.anthropicInstance } : {}) } }
: {}),
fastMode: config.fastMode,
forceAvailable: [...visibleNativeSlugs(config), ...filterCatalogVisibleModels(models, config).map(catalogModelSlug)],
Expand Down Expand Up @@ -1673,6 +1673,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
const section = body[field];
if (section === undefined || section === null) continue;
if (!isPlainObject(section)) return jsonResponse({ error: `${field} must be an object or null` }, 400);
if (section.enabled !== undefined && typeof section.enabled !== "boolean") {
return jsonResponse({ error: `${field}.enabled must be a boolean` }, 400);
}
// Both overrides now speak their full unions (roadmap 060 web, 170
// vision revised). Vision's third arm is "routed" (loopback through the
// proxy's own router), never exa: exa is not an LLM, and accepting an
Expand Down Expand Up @@ -1760,8 +1763,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
}
// The per-field validation above guarantees vision only ever carries the two-member
// union; the cast is the loop's shared-shape compromise, not a wider write path.
const requested = section as { backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa" | null; model?: string; anthropicInstance?: AnthropicInstanceId | null };
const requested = section as { enabled?: boolean; backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa" | null; model?: string; anthropicInstance?: AnthropicInstanceId | null };
const override = { ...next[field] } as NonNullable<OcxClaudeCodeConfig[typeof field]>;
if (requested.enabled !== undefined) override.enabled = requested.enabled;
if (requested.anthropicInstance === null) delete override.anthropicInstance;
else if (isAnthropicInstanceId(requested.anthropicInstance)) override.anthropicInstance = requested.anthropicInstance;
if (requested.backend === null) delete override.backend;
Expand Down
Loading
Loading