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 @@ -100,6 +100,11 @@ model field.
keys (Anthropic keys omit it, since that field is ignored there); mutable `https:` images are not
cached.

The management API and Dashboard picker now list models that can actually accept image input.
When the matching backend is available, `gpt-5.6-luna` (OpenAI) and `claude-haiku-4-5` (Anthropic)
are always offered as baseline options. `PUT /api/sidecar-settings` rejects a model known to be
text-only, but still accepts an unknown id so custom or ahead-of-catalog names keep working.

```json
{
"visionSidecar": {
Expand Down
18 changes: 18 additions & 0 deletions src/server/management/agent-settings-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ import {
type DebugFlag,
} from "../../lib/debug-settings";
import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
import {
visionCandidateRows,
visionDescriberIsProvablyBlind,
visionDescriberRejection,
} from "./vision-sidecar-options";
import { drainAndShutdown } from "../lifecycle";
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
Expand Down Expand Up @@ -1010,6 +1015,19 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
if (section.model !== undefined && typeof section.model !== "string") {
return jsonResponse({ error: `${field}.model must be a string` }, 400);
}
// Vision override only: reject a model we can prove is blind. Unknown ids stay
// allowed; webSearchSidecar has no vision requirement and is left alone. Shares
// one policy module with /api/sidecar-settings so the two gates cannot drift.
if (field === "visionSidecar" && typeof section.model === "string" && section.model !== "") {
const requested = section.model;
const candidates = await visionCandidateRows(config);
const hint = section.backend === "anthropic" || section.backend === "openai"
? section.backend
: config.claudeCode?.visionSidecar?.backend;
if (visionDescriberIsProvablyBlind(config, requested, candidates, hint)) {
return jsonResponse(visionDescriberRejection("visionSidecar.model", requested, config, candidates), 400);
}
}
}
const next = { ...(config.claudeCode ?? {}) };
for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
Expand Down
59 changes: 51 additions & 8 deletions src/server/management/config-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
import { getProviderRegistryEntry } from "../../providers/registry";
import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../../reasoning-effort";
import { normalizeVisionReasoningForModel } from "../../vision/reasoning";
import { findAnthropicVisionProvider, resolveEffectiveVisionModel, resolveVisionBackend } from "../../vision";
import {
visionCandidateRows,
visionDescriberIsProvablyBlind,
visionDescriberRejection,
visionModelOptionsFor,
} from "./vision-sidecar-options";
import { getDebugLogEntries } from "../../lib/debug-log-buffer";
import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log";
import {
Expand All @@ -80,6 +87,27 @@ import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, C
import type { ManagementContext } from "./context";
import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";

async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{
model: string;
reasoning: string;
models: Awaited<ReturnType<typeof visionModelOptionsFor>>;
}> {
const vs = config.visionSidecar ?? {};
// Match the runtime's one selected Anthropic executor for both backend fallback
// and catalog reachability; resolving it once prevents the two projections drifting.
const anthropicSidecar = findAnthropicVisionProvider(config);
const backend = resolveVisionBackend(vs.backend, anthropicSidecar);
const model = resolveEffectiveVisionModel(config, backend);
const reasoning = normalizeVisionReasoningForModel(model, vs.reasoning) ?? "low";
const models = await visionModelOptionsFor(config, anthropicSidecar);
// Display-only grandfather: a persisted id stays selectable, but the write gate
// remains stricter and rejects a model that is positively proven blind.
if (!models.some(option => option.value === model)) {
models.unshift({ value: model, label: model, backend });
}
return { model, reasoning, models };
}

export async function handleConfigRoutes(ctx: ManagementContext): Promise<Response | null> {
const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
if (url.pathname === "/api/config" && req.method === "GET") {
Expand Down Expand Up @@ -380,16 +408,16 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
if (url.pathname === "/api/sidecar-settings" && req.method === "GET") {
const ws = config.webSearchSidecar ?? {};
const vs = config.visionSidecar ?? {};
const visionModel = vs.model || "gpt-5.4-mini";
const visionReasoning = normalizeVisionReasoningForModel(visionModel, vs.reasoning) ?? "low";
const vision = await sidecarVisionResponseSettings(config);
return jsonResponse({
webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend },
vision: {
model: visionModel,
model: vision.model,
backend: vs.backend,
reasoning: visionReasoning,
reasoning: vision.reasoning,
maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
},
visionModels: vision.models,
});
}

Expand Down Expand Up @@ -422,6 +450,21 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
if (body.vision?.reasoning !== undefined && !isVisionReasoningEffort(body.vision.reasoning)) {
return jsonResponse({ error: `vision.reasoning must be ${VISION_REASONING_EFFORTS.join(", ")}` }, 400);
}
// Reject ONLY a model we can prove is blind. An id nothing knows about stays
// allowed: the operator may be ahead of our catalog, and the runtime never
// required catalog membership (`tests/vision-reasoning-contract.test.ts`
// pins `custom-vision` → 200). The catalog is read ONCE and reused for the
// rejection body, so a 400 cannot cost two provider fetches.
if (body.vision && typeof body.vision.model === "string" && body.vision.model !== "") {
const requested = body.vision.model;
const candidates = await visionCandidateRows(config);
const hint = body.vision.backend === "anthropic" || body.vision.backend === "openai"
? body.vision.backend
: config.visionSidecar?.backend;
if (visionDescriberIsProvablyBlind(config, requested, candidates, hint)) {
return jsonResponse(visionDescriberRejection("vision.model", requested, config, candidates), 400);
}
}

let normalizedVisionReasoning: ReturnType<typeof normalizeVisionReasoningForModel>;
let visionReasoningTouched = false;
Expand Down Expand Up @@ -469,17 +512,17 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
saveConfigPreservingClaudeCode(config);
const ws = config.webSearchSidecar ?? {};
const vs = config.visionSidecar ?? {};
const visionModel = vs.model || "gpt-5.4-mini";
const visionReasoning = normalizeVisionReasoningForModel(visionModel, vs.reasoning) ?? "low";
const vision = await sidecarVisionResponseSettings(config);
return jsonResponse({
ok: true,
webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend },
vision: {
model: visionModel,
model: vision.model,
backend: vs.backend,
reasoning: visionReasoning,
reasoning: vision.reasoning,
maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
},
visionModels: vision.models,
});
}

Expand Down
125 changes: 125 additions & 0 deletions src/server/management/vision-sidecar-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* The one place that decides which models the management API offers as vision
* describers, and which it refuses to persist.
*
* Two routes write a vision sidecar model — `PUT /api/sidecar-settings` and the
* `visionSidecar` override in `PUT /api/claude-code`. They share this module so
* the policy cannot drift: a gate on one route and a stale copy on the other is
* the same as no gate at all.
*/
import type { OcxConfig } from "../../types";
import { findAnthropicVisionProvider, type AnthropicVisionProvider } from "../../vision";
import {
modelAcceptsImageInput,
visionEligibleModelOptions,
type VisionCandidateModel,
type VisionModelOption,
type VisionSidecarBackend,
} from "../../vision/eligibility";
import { listOpenAiForwardSidecarCandidates } from "../../providers/openai-sidecar";
import { listManagementModelRows } from "./model-rows";

/**
* Backends whose executor could actually run: openai forward, anthropic OAuth.
*
* `anthropicSidecar` is REQUIRED rather than defaulted. `findAnthropicVisionProvider`
* reads the OAuth account store from disk, and a default argument made every helper
* in this chain re-resolve it whenever a caller passed an explicit `undefined` —
* which is exactly the no-executor case. Passing it in keeps one read per request.
*/
export function enabledVisionBackends(
config: OcxConfig,
anthropicSidecar: AnthropicVisionProvider | undefined,
): VisionSidecarBackend[] {
const backends: VisionSidecarBackend[] = [];
// The OpenAI describer needs a CANONICAL ChatGPT forward provider, not merely a
// provider keyed "openai" — same predicate the runtime sidecar resolver uses.
if (listOpenAiForwardSidecarCandidates(config).length > 0) backends.push("openai");
if (anthropicSidecar) backends.push("anthropic");
// Neither side resolvable (fresh install, no login): fall back to both so the
// picker is populated rather than empty, matching the permissive-unknown rule.
return backends.length > 0 ? backends : ["openai", "anthropic"];
}

/** Visible catalog rows in the shape the eligibility predicate consumes. */
export async function visionCandidateRows(config: OcxConfig): Promise<VisionCandidateModel[]> {
let rows: Awaited<ReturnType<typeof listManagementModelRows>> = [];
// A catalog outage must not 500 the settings route nor reject a write; with []
// the option list degrades to the baselines, which is the intended floor.
try { rows = await listManagementModelRows(config); } catch { rows = []; }
return rows.filter(row => row.disabled !== true).map(row => ({
provider: row.provider,
id: row.id,
...(row.inputModalities ? { inputModalities: row.inputModalities } : {}),
...(row.native ? { native: true } : {}),
}));
Comment on lines +50 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Limit suggestions to the provider the sidecar executes

In a configuration with an Anthropic OAuth provider plus another Anthropic-compatible provider, these candidates include models from both providers, and visionEligibleModelOptions treats every adapter: "anthropic" row as reachable. However, planVisionSidecar executes only through the provider returned by findAnthropicVisionProvider, so selecting an image-capable private model exposed by the other provider sends that bare ID to the OAuth provider and fails upstream. Build the option list from the actual executable provider's rows rather than every management catalog row.

Useful? React with 👍 / 👎.

}

export function visionModelOptionsFrom(
config: OcxConfig,
candidates: readonly VisionCandidateModel[],
anthropicSidecar: AnthropicVisionProvider | undefined,
): VisionModelOption[] {
return visionEligibleModelOptions(
config,
candidates,
enabledVisionBackends(config, anthropicSidecar),
anthropicSidecar?.providerName,
);
}

/** Convenience for read paths that have no candidate list in hand yet. */
export async function visionModelOptionsFor(
config: OcxConfig,
anthropicSidecar: AnthropicVisionProvider | undefined,
): Promise<VisionModelOption[]> {
return visionModelOptionsFrom(config, await visionCandidateRows(config), anthropicSidecar);
}

/**
* Can we PROVE the requested describer is blind?
*
* Only a positive `false` rejects. An id no source knows stays allowed, because
* the runtime never required catalog membership and an operator may be ahead of
* our tables.
*
* When no catalog row matches, the caller's `backend` is only a HINT, never the
* authority. Trusting it let a client launder a known-blind OpenAI model past the
* gate by claiming `backend: "anthropic"`, since the id is absent from the
* Anthropic table and absence reads as "unknown". Both families are therefore
* consulted and any positive text-only verdict wins. That is safe precisely
* because the two vendor tables share no bare model id, so they can never
* disagree about one.
*/
export function visionDescriberIsProvablyBlind(
config: OcxConfig,
requested: string,
candidates: readonly VisionCandidateModel[],
backendHint: VisionSidecarBackend | undefined,
): boolean {
// Catalog rows can carry operator-authored modalities. A positive claim from one
// must not hide another row or canonical metadata that proves this id is blind.
if (candidates.some(candidate => candidate.id === requested
&& modelAcceptsImageInput(config, candidate) === false)) return true;

const hinted: VisionSidecarBackend = backendHint === "anthropic" ? "anthropic" : "openai";
const probed: VisionSidecarBackend[] = hinted === "anthropic"
? ["anthropic", "openai"]
: ["openai", "anthropic"];
return probed.some(provider => modelAcceptsImageInput(config, { provider, id: requested }) === false);
}

/** The 400 body both routes return, so the two errors cannot diverge either. */
export function visionDescriberRejection(
field: "vision.model" | "visionSidecar.model",
requested: string,
config: OcxConfig,
candidates: readonly VisionCandidateModel[],
): { error: string; allowed: string[] } {
return {
error: `${field} "${requested}" cannot describe images: it has no image input support, or it is a model the vision sidecar describes FOR.`,
// The rejection path is not hot, so it resolves the executor itself rather than
// making every 400 caller thread one through.
allowed: visionModelOptionsFrom(config, candidates, findAnthropicVisionProvider(config)).map(option => option.value),
};
}
13 changes: 11 additions & 2 deletions src/vision/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,16 @@ export function resolveOpenAiVisionModel(config: Pick<OcxConfig, "visionSidecar"
return config.visionSidecar?.model || DEFAULT_VISION_MODEL;
}

/** Effective describer model for the backend `planVisionSidecar` selected. */
export function resolveEffectiveVisionModel(
config: Pick<OcxConfig, "visionSidecar">,
backend: "openai" | "anthropic",
): string {
return backend === "anthropic"
? config.visionSidecar?.model || DEFAULT_ANTHROPIC_VISION_MODEL
: resolveOpenAiVisionModel(config);
}

/** A user/developer/toolResult message can carry images (toolResult: e.g. Codex view_image output). */
function carriesImages(role: string): boolean {
return role === "user" || role === "developer" || role === "toolResult";
Expand Down Expand Up @@ -250,11 +260,11 @@ export function planVisionSidecar(
if (cfg.enabled === false) return undefined;
const anthropicSidecar = findAnthropicVisionProvider(config);
const backend = resolveVisionBackend(cfg.backend, anthropicSidecar);
const model = resolveEffectiveVisionModel(config, backend);
const maxDescriptionsPerTurn = resolveMaxDescriptionsPerTurn(cfg.maxDescriptionsPerTurn);

if (backend === "anthropic") {
if (!anthropicSidecar) return undefined;
const model = cfg.model || DEFAULT_ANTHROPIC_VISION_MODEL;
return {
backend,
anthropicSidecar,
Expand All @@ -268,7 +278,6 @@ export function planVisionSidecar(
}

if (!openAiSidecar) return undefined;
const model = resolveOpenAiVisionModel(config);
return {
backend,
forwardSidecar: openAiSidecar,
Expand Down
Loading
Loading