From c396212e422e77a11f7a8952880396eb84fa4514 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:44:55 +0200 Subject: [PATCH 01/62] test(vision): pin model-aware reasoning contracts --- tests/vision-reasoning-contract.test.ts | 67 +++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/vision-reasoning-contract.test.ts diff --git a/tests/vision-reasoning-contract.test.ts b/tests/vision-reasoning-contract.test.ts new file mode 100644 index 000000000..e3f08b61e --- /dev/null +++ b/tests/vision-reasoning-contract.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleManagementAPI } from "../src/server/management-api"; +import { listManagementModelRows } from "../src/server/management/model-rows"; +import type { OcxConfig } from "../src/types"; + +/** + * Maintainer takeover regression coverage for #1002. + * + * These contracts intentionally live above the GUI: capability metadata must be present on the + * management model rows, and the management boundary must never persist a reasoning rung a known + * native vision model cannot serve. + */ +describe("vision reasoning capability contracts", () => { + test("native management model rows expose their canonical reasoning ladders", async () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "none", + providers: {}, + }; + + const rows = await listManagementModelRows(config); + const mini = rows.find(row => row.native === true && row.id === "gpt-5.4-mini") as + | { reasoningEfforts?: string[] } + | undefined; + const luna = rows.find(row => row.native === true && row.id === "gpt-5.6-luna") as + | { reasoningEfforts?: string[] } + | undefined; + + expect(mini?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh"]); + expect(luna?.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh", "max"]); + }); + + test("sidecar settings clamp a valid-but-unsupported native effort before persisting", async () => { + const previousHome = process.env.OPENCODEX_HOME; + const isolatedHome = mkdtempSync(join(tmpdir(), "ocx-vision-reasoning-contract-")); + process.env.OPENCODEX_HOME = isolatedHome; + const config = { + port: 10100, + defaultProvider: "none", + providers: {}, + } as OcxConfig; + + try { + const response = await handleManagementAPI( + new Request("http://localhost/api/sidecar-settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ vision: { model: "gpt-5.4-mini", reasoning: "max" } }), + }), + new URL("http://localhost/api/sidecar-settings"), + config, + ); + + expect(response?.status).toBe(200); + const body = await response!.json() as { vision?: { model?: string; reasoning?: string } }; + expect(body.vision).toMatchObject({ model: "gpt-5.4-mini", reasoning: "xhigh" }); + expect((config.visionSidecar as { reasoning?: string } | undefined)?.reasoning).toBe("xhigh"); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(isolatedHome, { recursive: true, force: true }); + } + }); +}); From 0296bdeb17b89b460078e6f55b625c7216199e43 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:50:59 +0200 Subject: [PATCH 02/62] feat(vision): add sidecar reasoning ladder --- src/reasoning-effort.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/reasoning-effort.ts b/src/reasoning-effort.ts index bb7772433..5e97994e1 100644 --- a/src/reasoning-effort.ts +++ b/src/reasoning-effort.ts @@ -19,6 +19,28 @@ export function isCodexReasoningEffort(effort: string): boolean { return CODEX_REASONING_SET.has(effort); } +/** + * Reasoning ladder accepted for the OpenAI vision sidecar. `ultra` is deliberately excluded: + * the vision describer is a single helper call, and `ultra` would be collapsed to `max` by the + * upstream client boundary anyway. + */ +export const VISION_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; +export type VisionReasoningEffort = typeof VISION_REASONING_EFFORTS[number]; + +/** True when `effort` is one of the vision sidecar's supported Responses reasoning levels. */ +export function isVisionReasoningEffort(effort: unknown): effort is VisionReasoningEffort { + return typeof effort === "string" && (VISION_REASONING_EFFORTS as readonly string[]).includes(effort); +} + +/** + * Normalize a persisted/configured vision reasoning value. Invalid values (hand-edited config, + * stale files) degrade to `undefined` so the caller falls back to the documented default instead + * of forwarding an upstream-rejected effort. + */ +export function sanitizeVisionReasoning(effort: unknown): VisionReasoningEffort | undefined { + return isVisionReasoningEffort(effort) ? effort : undefined; +} + /** Position of `effort` in the Codex ladder (low=0 .. ultra=5), or -1 when not a ladder member. */ export function codexEffortRank(effort: string): number { return CODEX_REASONING_ORDER.indexOf(effort); From ae07613e79bcaaec2d1bb2c9c1ffff95acafac11 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:51:30 +0200 Subject: [PATCH 03/62] feat(vision): normalize reasoning by model capability --- src/vision/reasoning.ts | 55 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/vision/reasoning.ts diff --git a/src/vision/reasoning.ts b/src/vision/reasoning.ts new file mode 100644 index 000000000..0c005ea92 --- /dev/null +++ b/src/vision/reasoning.ts @@ -0,0 +1,55 @@ +import { NATIVE_OPENAI_MODELS, nativeReasoningEfforts } from "../codex/catalog"; +import { + VISION_REASONING_EFFORTS, + sanitizeVisionReasoning, + type VisionReasoningEffort, +} from "../reasoning-effort"; + +/** Keep the public config type aligned without replacing the heavily-changed shared types file. */ +declare module "../types" { + interface OcxVisionSidecarConfig { + /** OpenAI Responses reasoning effort used by the vision describer. Default: low. */ + reasoning?: VisionReasoningEffort; + } +} + +const NATIVE_VISION_MODELS = new Set(NATIVE_OPENAI_MODELS); + +/** + * Return the known vision reasoning ladder for a native OpenAI model. + * Unknown/custom models deliberately return undefined so callers stay permissive when reliable + * capability metadata is unavailable. + */ +export function nativeVisionReasoningEfforts(modelId: string): VisionReasoningEffort[] | undefined { + if (!NATIVE_VISION_MODELS.has(modelId)) return undefined; + const advertised = new Set(nativeReasoningEfforts(modelId)); + const supported = VISION_REASONING_EFFORTS.filter(effort => advertised.has(effort)); + return supported.length > 0 ? [...supported] : undefined; +} + +/** + * Normalize one configured effort against a model's known ladder. Invalid enum values degrade to + * undefined; valid values on unknown/custom models are preserved. A known native model clamps to + * the highest supported rung at or below the requested value. + */ +export function normalizeVisionReasoningForModel( + modelId: string, + value: unknown, +): VisionReasoningEffort | undefined { + const requested = sanitizeVisionReasoning(value); + if (!requested) return undefined; + const supported = nativeVisionReasoningEfforts(modelId); + if (!supported || supported.includes(requested)) return requested; + + const requestedRank = VISION_REASONING_EFFORTS.indexOf(requested); + let best = supported[0]; + let bestRank = VISION_REASONING_EFFORTS.indexOf(best); + for (const effort of supported) { + const rank = VISION_REASONING_EFFORTS.indexOf(effort); + if (rank <= requestedRank && rank >= bestRank) { + best = effort; + bestRank = rank; + } + } + return best; +} From dfb0129a654c25af98f04a8d1d194bc0a5974c8f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:51:59 +0200 Subject: [PATCH 04/62] feat(models): expose native reasoning ladders --- src/server/management/model-rows.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index 71c200574..68f494bb5 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -9,7 +9,7 @@ * Bodies are unchanged from their previous home; only `export` was added. */ import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { catalogModelSlug, nativeModelRows, nativeReasoningEfforts, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import type { ExportModel } from "../../clients/config-export"; import { providerContextCap } from "../../providers/context-cap"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; @@ -47,6 +47,7 @@ export async function listManagementModelRows(config: OcxConfig): Promise { From 27dd4864e3a856c7e149947a38e2c2e0ede9bc87 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:52:35 +0200 Subject: [PATCH 05/62] feat(vision): send configured reasoning effort --- src/vision/describe.ts | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/vision/describe.ts b/src/vision/describe.ts index 68744fd90..0b20c42cf 100644 --- a/src/vision/describe.ts +++ b/src/vision/describe.ts @@ -1,4 +1,5 @@ import type { OcxProviderConfig } from "../types"; +import type { VisionReasoningEffort } from "../reasoning-effort"; import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; import { redactSecretString } from "../lib/redact"; @@ -9,22 +10,15 @@ import type { SidecarOutcomeRecorder } from "../web-search/executor"; export interface VisionSettings { model: string; + reasoning: VisionReasoningEffort; timeoutMs: number; } -/** A description, or an `error` string when it couldn't run (caller injects a graceful marker). */ export type DescribeOutcome = { text: string; error?: string }; const ALLOWED_IMAGE_MIME = new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]); -/** ~20 MB — generous enough for screenshots; rejects pathological payloads before forwarding. */ const MAX_IMAGE_BYTES = 20 * 1024 * 1024; -/** - * Validate an image URL before forwarding. Data URLs are checked for an allowed media type and a sane - * decoded size (a malformed/huge/unsupported one would otherwise 400 at the backend or waste tokens). - * Remote https URLs are passed through — the ChatGPT backend fetches them, not this proxy (so there's - * no SSRF surface here). Returns an error string when the URL must be rejected, else null. - */ function validateImageUrl(url: string): string | null { if (url.startsWith("data:")) { const m = /^data:([^;,]+?)(;base64)?,(.*)$/s.exec(url); @@ -41,11 +35,6 @@ function validateImageUrl(url: string): string | null { return "unsupported image URL scheme (expected data: or https:)"; } -/** - * Describe ONE image via a gpt vision model through the ChatGPT forward backend — the path that has - * native image input. Reuses selected forwarded OAuth headers. The user's own request text is - * passed as context so the description is focused. Never throws — returns `{error}` on failure. - */ export async function describeImage( imageUrl: string, detail: string | undefined, @@ -77,9 +66,7 @@ export async function describeImage( "verbatim, and note UI/layout, colors, branding/logos, charts, and notable details. Focus on " + "what's relevant to the user's request. Output only the description.", input: [{ type: "message", role: "user", content }], - reasoning: { effort: "low" }, - // The ChatGPT (codex) backend rejects `max_output_tokens` ("Unsupported parameter"); the - // description is clamped downstream (DESC_MAX_CHARS) instead. + reasoning: { effort: settings.reasoning }, store: false, stream: true, }; @@ -109,8 +96,6 @@ export async function describeImage( } finally { detachBodyGuard(); } - // The backend can return HTTP 200 then stream a `response.failed`/`error` event with no text; - // surface that as a describe error instead of an empty (silently-blank) description. if (!parsed.text.trim() && parsed.error) return { text: "", error: parsed.error }; return { text: parsed.text }; } catch (e) { From c48be174cdd48c48f8299d35921b7bff5f7ae661 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:54:35 +0200 Subject: [PATCH 06/62] feat(vision): apply reasoning at runtime without losing raw-body sync --- src/vision/index.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/vision/index.ts b/src/vision/index.ts index f44b713ec..2af75ae15 100644 --- a/src/vision/index.ts +++ b/src/vision/index.ts @@ -1,8 +1,10 @@ import { createHash } from "node:crypto"; import type { OcxConfig, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent } from "../types"; import { modelInList } from "../types"; +import type { VisionReasoningEffort } from "../reasoning-effort"; import { describeImage, type DescribeOutcome, type VisionSettings } from "./describe"; import { describeImageAnthropic } from "./anthropic-describe"; +import { normalizeVisionReasoningForModel } from "./reasoning"; import type { CodexAuthContext } from "../codex/auth-context"; import { getAccountSet } from "../oauth/store"; import type { ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; @@ -16,6 +18,7 @@ export { describeImageAnthropic, parseAnthropicVisionSSE } from "./anthropic-des const DEFAULT_VISION_MODEL = "gpt-5.4-mini"; const DEFAULT_ANTHROPIC_VISION_MODEL = "claude-sonnet-5"; const DEFAULT_TIMEOUT_MS = 45_000; +const DEFAULT_REASONING: VisionReasoningEffort = "low"; const DEFAULT_MAX_DESCRIPTIONS_PER_TURN = 8; const DESCRIPTION_CACHE_MAX_ENTRIES = 256; export const VISION_DESCRIPTION_CACHE_MAX_BYTES = 1024 * 1024; @@ -242,19 +245,29 @@ export function planVisionSidecar( if (backend === "anthropic") { if (!anthropicSidecar) return undefined; + const model = cfg.model ?? DEFAULT_ANTHROPIC_VISION_MODEL; return { backend, anthropicSidecar, - settings: { model: cfg.model ?? DEFAULT_ANTHROPIC_VISION_MODEL, timeoutMs: cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS }, + settings: { + model, + reasoning: normalizeVisionReasoningForModel(model, cfg.reasoning) ?? DEFAULT_REASONING, + timeoutMs: cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }, maxDescriptionsPerTurn, }; } if (!openAiSidecar) return undefined; + const model = resolveOpenAiVisionModel(config); return { backend, forwardSidecar: openAiSidecar, - settings: { model: resolveOpenAiVisionModel(config), timeoutMs: cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS }, + settings: { + model, + reasoning: normalizeVisionReasoningForModel(model, cfg.reasoning) ?? DEFAULT_REASONING, + timeoutMs: cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS, + }, maxDescriptionsPerTurn, }; } @@ -363,6 +376,7 @@ function descriptionIdentity(job: ImageJob, plan: VisionPlan): { key: string; pe key: JSON.stringify([ plan.backend, plan.settings.model, + ...(plan.backend === "openai" ? [plan.settings.reasoning] : []), job.detail ?? "high", imageHash, sha256(normalizedContext(job.contextText)), @@ -418,7 +432,6 @@ export async function describeImagesInPlace( recordSidecarOutcome?: SidecarOutcomeRecorder, translatorBudget?: TranslatorBudget, ): Promise { - // 1. Gather every image part across messages, each with its own message's text as context. const jobs: ImageJob[] = []; const targets: { msg: OcxMessage; parts: OcxContentPart[] }[] = []; for (const msg of parsed.context.messages) { @@ -440,7 +453,6 @@ export async function describeImagesInPlace( return; } - // 2. Admit misses in source order. Cache hits and same-turn waiters do not consume the cap. const inFlight = new Map>(); const executions: Array<() => Promise> = []; const outcomePromises: Array> = []; @@ -492,7 +504,6 @@ export async function describeImagesInPlace( await runBounded(executions, VISION_CONCURRENCY, execute => execute()); const outcomes = await Promise.all(outcomePromises); - // 3. Rebuild each message, replacing image parts with their descriptions in order. let oi = 0; const descriptions: string[] = []; for (const { msg, parts } of targets) { From d8c5fea1a424140080c7ce295a699a3bebb5c426 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:55:54 +0200 Subject: [PATCH 07/62] refactor(management): preserve current config route base for takeover --- src/server/management/config-routes-base.ts | 427 ++++++++++++++++++++ 1 file changed, 427 insertions(+) create mode 100644 src/server/management/config-routes-base.ts diff --git a/src/server/management/config-routes-base.ts b/src/server/management/config-routes-base.ts new file mode 100644 index 000000000..624836336 --- /dev/null +++ b/src/server/management/config-routes-base.ts @@ -0,0 +1,427 @@ +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import type { CatalogModel } from "../../codex/catalog"; +import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { + DEFAULT_SUBAGENT_MODELS, + codexAutoStartEnabled, + hasOwnProvider, + isValidProviderName, + multiAgentGuidanceEnabled, + providerBaseUrlConfigError, + providerHeadersConfigError, + saveConfigPreservingClaudeCode, +} from "../../config"; +import { + clearLoginState, + getLoginStatus, + isPublicOAuthProvider, + listOAuthProviders, + startLoginFlow, + submitManualLoginCode, + upsertOAuthProvider, +} from "../../oauth"; +import { removeCredential } from "../../oauth/store"; +import { providerDestinationResolvedError } from "../../lib/destination-policy"; +import { isStreamMode } from "../../lib/bun-stream-caps"; +import { shadowSourceModels } from "../../lib/shadow-call"; +import { + configureAppOwnedMemoryBudget, + enforceAppOwnedMemoryBudget, + MAX_APP_OWNED_MEMORY_BUDGET_MB, + MIN_APP_OWNED_MEMORY_BUDGET_MB, + resolveAppOwnedMemoryBudgetBytes, +} from "../../lib/app-owned-memory"; +import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; +import { deriveProviderPresets } from "../../providers/derive"; +import { providerCodexAccountMode } from "../../providers/registry"; +import { routedSlug, slugEquals } from "../../providers/slug-codec"; +import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; +import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { clearThreadAccountMap } from "../../codex/routing"; +import { primeCodexPoolQuotas } from "../../codex/auth-api"; +import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; +import { resolveCodexHomeDir } from "../../codex/home"; +import { readUsageEntries } from "../../usage/log"; +import { getUsageDebugLogEntries } from "../../usage/debug"; +import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; +import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; +import { getProviderRegistryEntry } from "../../providers/registry"; +import { getDebugLogEntries } from "../../lib/debug-log-buffer"; +import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; +import { + clearDebugSettings, + clearDebugSetting, + getDebugSettings, + setDebugSettings, + type DebugFlag, +} from "../../lib/debug-settings"; +import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types"; +import { drainAndShutdown } from "../lifecycle"; +import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; +import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; +import type { PersistedUsageAttempt } from "../../usage/log"; +import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; +import { applySystemEnvToggle } from "../system-env"; +import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache"; +import { runWindowsTrayAction } from "../windows-tray-control"; +import { runStartupInstallAction, type StartupInstallAction } from "../startup-action-control"; +import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime"; + +import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; +import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; +import type { ManagementContext } from "./context"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; + +export async function handleConfigRoutes(ctx: ManagementContext): Promise { + const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx; + if (url.pathname === "/api/config" && req.method === "GET") { + return jsonResponse(safeConfigDTO(config)); + } + + if (url.pathname === "/api/config" && req.method === "PUT") { + return jsonResponse({ error: "Full config PUT is disabled. Use /api/providers POST for provider changes." }, 405); + } + + if (url.pathname === "/api/settings" && req.method === "GET") { + let resolved: ReturnType; + try { + // Full alternative discovery (memoized) so newerAvailable warnings work. + resolved = resolveCodexRuntime(); + } catch { + resolved = { + runtime: { command: "codex", version: null, source: "fallback" }, + failures: [], + }; + } + const lastClamp = loadLastEffortClamp(); + const clampActive = effortClampAppliesToRuntime(lastClamp, resolved.runtime); + const warningParts: string[] = []; + if (resolved.replacedConfigured) { + warningParts.push( + `Preferred Codex runtime is unavailable; using ${displayCodexRuntimePath(resolved.runtime.command)} instead.`, + ); + } else if ( + resolved.runtime.source === "fallback" + && resolved.failures.length > 0 + && !resolved.runtime.version + ) { + warningParts.push("No validated Codex runtime found; falling back to `codex`."); + } + if (clampActive) { + const clampVersion = lastClamp?.runtimeVersion ?? resolved.runtime.version ?? "an older binary"; + warningParts.push( + `Some reasoning effort options were hidden because OpenCodex used Codex ${clampVersion}.${resolved.newerAvailable ? " A newer Codex installation is available." : ""}`, + ); + } else if (resolved.newerAvailable) { + warningParts.push( + `OpenCodex is using an older Codex binary (${resolved.runtime.version ?? "unknown"}). A newer Codex installation is available.`, + ); + } + return jsonResponse({ + // The dashboard renders request-log timestamps. Without this it formats them in the + // BROWSER's zone, so a KST proxy viewed from a UTC browser reports every request nine + // hours off (#725). Carried on settings rather than /api/logs because that route's + // array response has four consumers that would have to change with it. + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, + codexAutoStart: codexAutoStartEnabled(config), + port: config.port, + hostname: config.hostname ?? "127.0.0.1", + streamMode: config.streamMode ?? "auto", + appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256, + startupHealth: await getCachedStartupHealth(config), + codexRuntime: { + path: displayCodexRuntimePath(resolved.runtime.command), + version: resolved.runtime.version, + source: resolved.runtime.source, + newerAvailable: resolved.newerAvailable + ? { + path: displayCodexRuntimePath(resolved.newerAvailable.command), + version: resolved.newerAvailable.version, + } + : null, + catalogClamp: { + active: clampActive, + removedEfforts: clampActive ? (lastClamp?.removedEfforts ?? []) : [], + runtimeVersion: clampActive ? (lastClamp?.runtimeVersion ?? null) : null, + }, + warning: warningParts.length > 0 ? warningParts.join(" ") : null, + }, + }); + } + + if (url.pathname === "/api/startup-health" && req.method === "GET") { + return jsonResponse(await getCachedStartupHealth(config)); + } + + if (url.pathname === "/api/startup-action" && req.method === "POST") { + let body: { action?: unknown; repair?: unknown }; + try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + if (!body || !["install-service", "install-shim"].includes(String(body.action))) { + return jsonResponse({ error: "action must be install-service or install-shim" }, 400); + } + if (body.repair !== undefined && typeof body.repair !== "boolean") { + return jsonResponse({ error: "repair must be a boolean when provided" }, 400); + } + try { + const action = body.action as StartupInstallAction; + const repair = body.repair === true; + const result = await (deps.runStartupInstallAction ?? runStartupInstallAction)(action, { repair }); + invalidateStartupHealthCache(); + return jsonResponse({ ok: true, action, repair, message: result.message }); + } catch (error) { + return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500); + } + } + + if (url.pathname === "/api/windows-tray" && req.method === "GET") { + if (process.platform !== "win32") return jsonResponse({ supported: false, installed: false, running: false, stale: false, summary: `unsupported on ${process.platform}` }); + try { + return jsonResponse(await runWindowsTrayAction("status")); + } catch (error) { + return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500); + } + } + + if (url.pathname === "/api/windows-tray" && req.method === "POST") { + let body: { action?: unknown }; + try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + if (!body || !["install", "start", "stop", "uninstall"].includes(String(body.action))) { + return jsonResponse({ error: "action must be install, start, stop, or uninstall" }, 400); + } + if (process.platform !== "win32") return jsonResponse({ error: "Windows tray is only supported on Windows" }, 400); + try { + const status = await runWindowsTrayAction(body.action as "install" | "start" | "stop" | "uninstall"); + return jsonResponse({ ok: true, status }); + } catch (error) { + return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500); + } + } + + if (url.pathname === "/api/settings" && req.method === "PUT") { + // Each field is optional but at least one must be present; fields are + // validated when present. streamMode-only PUTs must work: Windows/macOS + // memory troubleshooting can use this persisted stream-shape escape hatch + // (a Windows service does not inherit shell env). A stream-shape + // change applies to NEW turns only — the config object is shared by + // reference with the request handlers, no restart needed. + let body: { codexAutoStart?: unknown; streamMode?: unknown; appOwnedMemoryBudgetMb?: unknown }; + try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + if (body.codexAutoStart === undefined && body.streamMode === undefined && body.appOwnedMemoryBudgetMb === undefined) { + return jsonResponse({ error: "provide codexAutoStart, streamMode, or appOwnedMemoryBudgetMb" }, 400); + } + if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") { + return jsonResponse({ error: "codexAutoStart boolean is required" }, 400); + } + if (body.streamMode !== undefined && !isStreamMode(body.streamMode)) { + return jsonResponse({ error: "streamMode must be auto, legacy-tee, or eager-relay" }, 400); + } + if (body.appOwnedMemoryBudgetMb !== undefined && ( + typeof body.appOwnedMemoryBudgetMb !== "number" + || !Number.isInteger(body.appOwnedMemoryBudgetMb) + || body.appOwnedMemoryBudgetMb < MIN_APP_OWNED_MEMORY_BUDGET_MB + || body.appOwnedMemoryBudgetMb > MAX_APP_OWNED_MEMORY_BUDGET_MB + )) { + return jsonResponse({ error: `appOwnedMemoryBudgetMb must be an integer from ${MIN_APP_OWNED_MEMORY_BUDGET_MB} to ${MAX_APP_OWNED_MEMORY_BUDGET_MB}` }, 400); + } + if (typeof body.codexAutoStart === "boolean") { + config.codexAutoStart = body.codexAutoStart; + } + if (body.streamMode !== undefined) { + if (body.streamMode === "auto") { + delete config.streamMode; + } else { + config.streamMode = body.streamMode as "legacy-tee" | "eager-relay"; + } + } + if (typeof body.appOwnedMemoryBudgetMb === "number") { + config.appOwnedMemoryBudgetMb = body.appOwnedMemoryBudgetMb; + } + saveConfigPreservingClaudeCode(config); + if (typeof body.appOwnedMemoryBudgetMb === "number") { + configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(body.appOwnedMemoryBudgetMb)); + enforceAppOwnedMemoryBudget(); + } + invalidateStartupHealthCache(); + return jsonResponse({ + ok: true, + codexAutoStart: codexAutoStartEnabled(config), + streamMode: config.streamMode ?? "auto", + appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256, + startupHealth: await getCachedStartupHealth(config), + }); + } + + if (url.pathname === "/api/diagnostics/project-config" && req.method === "GET") { + const { getCachedProjectConfigDiagnostics } = await import("../../codex/project-config-warnings"); + const { warnings, grouped } = getCachedProjectConfigDiagnostics(); + return jsonResponse({ warnings, grouped }); + } + + if (url.pathname === "/api/sync" && req.method === "POST") { + const { syncModelsToCodex } = await import("../../codex/sync"); + const { attachStaleAppServerHint } = await import("../../codex/app-server-processes"); + const { readRuntimePort, loadConfig } = await import("../../config"); + // Never use the server-captured startup object for a durable integration + // decision. A toggle may have persisted while this process was gathering. + const runtime = readRuntimePort(process.pid); + const result = await syncModelsToCodex(runtime?.port, loadConfig(), null); + const status = result.status === "refused" ? 409 : (result.status === "skipped" || result.ok ? 200 : 500); + return jsonResponse({ + ...attachStaleAppServerHint(result), + ...(result.ok ? {} : { error: result.message }), + }, status); + } + + if (url.pathname === "/api/update/check" && req.method === "GET") { + const { checkForUpdate, normalizeUpdateChannel } = await import("../../update/job"); + const rawTag = url.searchParams.get("tag"); + if (rawTag && rawTag !== "latest" && rawTag !== "preview") { + return jsonResponse({ error: "tag must be latest or preview" }, 400); + } + return jsonResponse(checkForUpdate(normalizeUpdateChannel(rawTag))); + } + + if (url.pathname === "/api/update/run" && req.method === "POST") { + const { normalizeUpdateChannel, startUpdateJob, UpdateJobError } = await import("../../update/job"); + let body: { tag?: unknown; restart?: unknown }; + try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + if (body.tag !== undefined && body.tag !== "latest" && body.tag !== "preview") { + return jsonResponse({ error: "tag must be latest or preview" }, 400); + } + if (body.restart !== undefined && typeof body.restart !== "boolean") { + return jsonResponse({ error: "restart boolean is required" }, 400); + } + try { + return jsonResponse({ ok: true, job: startUpdateJob(normalizeUpdateChannel(body.tag as string | undefined), body.restart !== false) }); + } catch (err) { + if (err instanceof UpdateJobError) { + return jsonResponse({ error: err.message, code: err.code }, err.status); + } + return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 500); + } + } + + if (url.pathname === "/api/update/status" && req.method === "GET") { + const { readUpdateJob } = await import("../../update/job"); + const job = readUpdateJob(url.searchParams.get("jobId")); + if (!job) return jsonResponse({ error: "update job not found" }, 404); + return jsonResponse({ ok: true, job }); + } + + if (url.pathname === "/api/sidecar-settings" && req.method === "GET") { + const ws = config.webSearchSidecar ?? {}; + const vs = config.visionSidecar ?? {}; + return jsonResponse({ + webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend }, + vision: { + model: vs.model ?? "gpt-5.6-luna", + backend: vs.backend, + maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn, + }, + }); + } + + if (url.pathname === "/api/sidecar-settings" && req.method === "PUT") { + let raw: unknown; + try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + // Strict shape (review F2): reject non-object bodies and non-object sections instead of throwing + // on `null` or silently accepting arrays/strings as no-op updates. + if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400); + if (raw.webSearch !== undefined && !isPlainRecord(raw.webSearch)) return jsonResponse({ error: "webSearch must be an object" }, 400); + if (raw.vision !== undefined && !isPlainRecord(raw.vision)) return jsonResponse({ error: "vision must be an object" }, 400); + const body = raw as { + webSearch?: { model?: unknown; backend?: unknown; reasoning?: unknown }; + vision?: { model?: unknown; backend?: unknown; maxDescriptionsPerTurn?: unknown }; + }; + if (body.webSearch && body.webSearch.backend !== undefined && body.webSearch.backend !== null + && body.webSearch.backend !== "openai" && body.webSearch.backend !== "anthropic") { + return jsonResponse({ error: "webSearch.backend must be openai, anthropic, or null" }, 400); + } + if (body.vision && body.vision.backend !== undefined + && body.vision.backend !== null && body.vision.backend !== "openai" && body.vision.backend !== "anthropic") { + return jsonResponse({ error: "vision.backend must be openai, anthropic, or null" }, 400); + } + if (body.vision && body.vision.maxDescriptionsPerTurn !== undefined + && (typeof body.vision.maxDescriptionsPerTurn !== "number" + || !Number.isInteger(body.vision.maxDescriptionsPerTurn) + || body.vision.maxDescriptionsPerTurn <= 0)) { + return jsonResponse({ error: "vision.maxDescriptionsPerTurn must be a positive integer" }, 400); + } + if (body.webSearch) { + config.webSearchSidecar = { ...config.webSearchSidecar }; + if (typeof body.webSearch.model === "string") { + if (body.webSearch.model === "") delete config.webSearchSidecar.model; + else config.webSearchSidecar.model = body.webSearch.model; + } + if (body.webSearch.backend === null) delete config.webSearchSidecar.backend; + else if (body.webSearch.backend === "openai" || body.webSearch.backend === "anthropic") { + config.webSearchSidecar.backend = body.webSearch.backend; + } + if (typeof body.webSearch.reasoning === "string") config.webSearchSidecar.reasoning = body.webSearch.reasoning; + } + if (body.vision) { + config.visionSidecar = { ...config.visionSidecar }; + if (typeof body.vision.model === "string") { + if (body.vision.model === "") delete config.visionSidecar.model; + else config.visionSidecar.model = body.vision.model; + } + if (body.vision.backend === null) delete config.visionSidecar.backend; + else if (body.vision.backend === "openai" || body.vision.backend === "anthropic") { + config.visionSidecar.backend = body.vision.backend; + } + if (typeof body.vision.maxDescriptionsPerTurn === "number") { + config.visionSidecar.maxDescriptionsPerTurn = body.vision.maxDescriptionsPerTurn; + } + } + saveConfigPreservingClaudeCode(config); + const ws = config.webSearchSidecar ?? {}; + const vs = config.visionSidecar ?? {}; + return jsonResponse({ + ok: true, + webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend }, + vision: { + model: vs.model ?? "gpt-5.6-luna", + backend: vs.backend, + maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn, + }, + }); + } + + if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") { + const sci = config.shadowCallIntercept ?? {}; + return jsonResponse({ + enabled: sci.enabled === true, + model: sci.model ?? "", + sourceModels: shadowSourceModels(sci.sourceModels), + }); + } + + if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") { + let raw: unknown; + try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } + if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400); + const body = raw as { enabled?: unknown; model?: unknown }; + if (body.enabled !== undefined && typeof body.enabled !== "boolean") { + return jsonResponse({ error: "enabled must be a boolean" }, 400); + } + if (body.model !== undefined && typeof body.model !== "string") { + return jsonResponse({ error: "model must be a string" }, 400); + } + config.shadowCallIntercept = { ...config.shadowCallIntercept }; + if (typeof body.enabled === "boolean") config.shadowCallIntercept.enabled = body.enabled; + if (typeof body.model === "string") { + if (body.model === "") delete config.shadowCallIntercept.model; + else config.shadowCallIntercept.model = body.model; + } + saveConfigPreservingClaudeCode(config); + const sci = config.shadowCallIntercept; + return jsonResponse({ + ok: true, + enabled: sci.enabled === true, + model: sci.model ?? "", + sourceModels: shadowSourceModels(sci.sourceModels), + }); + } + return null; +} From 2c7c6c9ed14d1db8d18cbeca0c62d65326919334 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:56:35 +0200 Subject: [PATCH 08/62] feat(management): normalize vision reasoning at write boundary --- src/server/management/config-routes.ts | 476 ++++--------------------- 1 file changed, 67 insertions(+), 409 deletions(-) diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 624836336..90637a9fe 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -1,427 +1,85 @@ -import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; -import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; -import { - DEFAULT_SUBAGENT_MODELS, - codexAutoStartEnabled, - hasOwnProvider, - isValidProviderName, - multiAgentGuidanceEnabled, - providerBaseUrlConfigError, - providerHeadersConfigError, - saveConfigPreservingClaudeCode, -} from "../../config"; -import { - clearLoginState, - getLoginStatus, - isPublicOAuthProvider, - listOAuthProviders, - startLoginFlow, - submitManualLoginCode, - upsertOAuthProvider, -} from "../../oauth"; -import { removeCredential } from "../../oauth/store"; -import { providerDestinationResolvedError } from "../../lib/destination-policy"; -import { isStreamMode } from "../../lib/bun-stream-caps"; -import { shadowSourceModels } from "../../lib/shadow-call"; -import { - configureAppOwnedMemoryBudget, - enforceAppOwnedMemoryBudget, - MAX_APP_OWNED_MEMORY_BUDGET_MB, - MIN_APP_OWNED_MEMORY_BUDGET_MB, - resolveAppOwnedMemoryBudgetBytes, -} from "../../lib/app-owned-memory"; -import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; -import { deriveProviderPresets } from "../../providers/derive"; -import { providerCodexAccountMode } from "../../providers/registry"; -import { routedSlug, slugEquals } from "../../providers/slug-codec"; -import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; -import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; -import { clearThreadAccountMap } from "../../codex/routing"; -import { primeCodexPoolQuotas } from "../../codex/auth-api"; -import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; -import { resolveCodexHomeDir } from "../../codex/home"; -import { readUsageEntries } from "../../usage/log"; -import { getUsageDebugLogEntries } from "../../usage/debug"; -import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; -import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; -import { getProviderRegistryEntry } from "../../providers/registry"; -import { getDebugLogEntries } from "../../lib/debug-log-buffer"; -import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; -import { - clearDebugSettings, - clearDebugSetting, - getDebugSettings, - setDebugSettings, - type DebugFlag, -} from "../../lib/debug-settings"; -import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types"; -import { drainAndShutdown } from "../lifecycle"; -import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; -import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; -import type { PersistedUsageAttempt } from "../../usage/log"; -import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; -import { applySystemEnvToggle } from "../system-env"; -import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache"; -import { runWindowsTrayAction } from "../windows-tray-control"; -import { runStartupInstallAction, type StartupInstallAction } from "../startup-action-control"; -import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime"; - -import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; -import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; +import { saveConfigPreservingClaudeCode } from "../../config"; +import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../../reasoning-effort"; +import { normalizeVisionReasoningForModel } from "../../vision/reasoning"; +import { jsonResponse } from "../auth-cors"; import type { ManagementContext } from "./context"; -import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; - -export async function handleConfigRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx; - if (url.pathname === "/api/config" && req.method === "GET") { - return jsonResponse(safeConfigDTO(config)); - } - - if (url.pathname === "/api/config" && req.method === "PUT") { - return jsonResponse({ error: "Full config PUT is disabled. Use /api/providers POST for provider changes." }, 405); - } - - if (url.pathname === "/api/settings" && req.method === "GET") { - let resolved: ReturnType; - try { - // Full alternative discovery (memoized) so newerAvailable warnings work. - resolved = resolveCodexRuntime(); - } catch { - resolved = { - runtime: { command: "codex", version: null, source: "fallback" }, - failures: [], - }; - } - const lastClamp = loadLastEffortClamp(); - const clampActive = effortClampAppliesToRuntime(lastClamp, resolved.runtime); - const warningParts: string[] = []; - if (resolved.replacedConfigured) { - warningParts.push( - `Preferred Codex runtime is unavailable; using ${displayCodexRuntimePath(resolved.runtime.command)} instead.`, - ); - } else if ( - resolved.runtime.source === "fallback" - && resolved.failures.length > 0 - && !resolved.runtime.version - ) { - warningParts.push("No validated Codex runtime found; falling back to `codex`."); - } - if (clampActive) { - const clampVersion = lastClamp?.runtimeVersion ?? resolved.runtime.version ?? "an older binary"; - warningParts.push( - `Some reasoning effort options were hidden because OpenCodex used Codex ${clampVersion}.${resolved.newerAvailable ? " A newer Codex installation is available." : ""}`, - ); - } else if (resolved.newerAvailable) { - warningParts.push( - `OpenCodex is using an older Codex binary (${resolved.runtime.version ?? "unknown"}). A newer Codex installation is available.`, - ); - } - return jsonResponse({ - // The dashboard renders request-log timestamps. Without this it formats them in the - // BROWSER's zone, so a KST proxy viewed from a UTC browser reports every request nine - // hours off (#725). Carried on settings rather than /api/logs because that route's - // array response has four consumers that would have to change with it. - timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, - codexAutoStart: codexAutoStartEnabled(config), - port: config.port, - hostname: config.hostname ?? "127.0.0.1", - streamMode: config.streamMode ?? "auto", - appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256, - startupHealth: await getCachedStartupHealth(config), - codexRuntime: { - path: displayCodexRuntimePath(resolved.runtime.command), - version: resolved.runtime.version, - source: resolved.runtime.source, - newerAvailable: resolved.newerAvailable - ? { - path: displayCodexRuntimePath(resolved.newerAvailable.command), - version: resolved.newerAvailable.version, - } - : null, - catalogClamp: { - active: clampActive, - removedEfforts: clampActive ? (lastClamp?.removedEfforts ?? []) : [], - runtimeVersion: clampActive ? (lastClamp?.runtimeVersion ?? null) : null, - }, - warning: warningParts.length > 0 ? warningParts.join(" ") : null, - }, - }); - } - - if (url.pathname === "/api/startup-health" && req.method === "GET") { - return jsonResponse(await getCachedStartupHealth(config)); - } +import { handleConfigRoutes as handleBaseConfigRoutes } from "./config-routes-base"; - if (url.pathname === "/api/startup-action" && req.method === "POST") { - let body: { action?: unknown; repair?: unknown }; - try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } - if (!body || !["install-service", "install-shim"].includes(String(body.action))) { - return jsonResponse({ error: "action must be install-service or install-shim" }, 400); - } - if (body.repair !== undefined && typeof body.repair !== "boolean") { - return jsonResponse({ error: "repair must be a boolean when provided" }, 400); - } - try { - const action = body.action as StartupInstallAction; - const repair = body.repair === true; - const result = await (deps.runStartupInstallAction ?? runStartupInstallAction)(action, { repair }); - invalidateStartupHealthCache(); - return jsonResponse({ ok: true, action, repair, message: result.message }); - } catch (error) { - return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500); - } - } - - if (url.pathname === "/api/windows-tray" && req.method === "GET") { - if (process.platform !== "win32") return jsonResponse({ supported: false, installed: false, running: false, stale: false, summary: `unsupported on ${process.platform}` }); - try { - return jsonResponse(await runWindowsTrayAction("status")); - } catch (error) { - return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500); - } - } - - if (url.pathname === "/api/windows-tray" && req.method === "POST") { - let body: { action?: unknown }; - try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } - if (!body || !["install", "start", "stop", "uninstall"].includes(String(body.action))) { - return jsonResponse({ error: "action must be install, start, stop, or uninstall" }, 400); - } - if (process.platform !== "win32") return jsonResponse({ error: "Windows tray is only supported on Windows" }, 400); - try { - const status = await runWindowsTrayAction(body.action as "install" | "start" | "stop" | "uninstall"); - return jsonResponse({ ok: true, status }); - } catch (error) { - return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500); - } - } +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} - if (url.pathname === "/api/settings" && req.method === "PUT") { - // Each field is optional but at least one must be present; fields are - // validated when present. streamMode-only PUTs must work: Windows/macOS - // memory troubleshooting can use this persisted stream-shape escape hatch - // (a Windows service does not inherit shell env). A stream-shape - // change applies to NEW turns only — the config object is shared by - // reference with the request handlers, no restart needed. - let body: { codexAutoStart?: unknown; streamMode?: unknown; appOwnedMemoryBudgetMb?: unknown }; - try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } - if (body.codexAutoStart === undefined && body.streamMode === undefined && body.appOwnedMemoryBudgetMb === undefined) { - return jsonResponse({ error: "provide codexAutoStart, streamMode, or appOwnedMemoryBudgetMb" }, 400); - } - if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") { - return jsonResponse({ error: "codexAutoStart boolean is required" }, 400); - } - if (body.streamMode !== undefined && !isStreamMode(body.streamMode)) { - return jsonResponse({ error: "streamMode must be auto, legacy-tee, or eager-relay" }, 400); - } - if (body.appOwnedMemoryBudgetMb !== undefined && ( - typeof body.appOwnedMemoryBudgetMb !== "number" - || !Number.isInteger(body.appOwnedMemoryBudgetMb) - || body.appOwnedMemoryBudgetMb < MIN_APP_OWNED_MEMORY_BUDGET_MB - || body.appOwnedMemoryBudgetMb > MAX_APP_OWNED_MEMORY_BUDGET_MB - )) { - return jsonResponse({ error: `appOwnedMemoryBudgetMb must be an integer from ${MIN_APP_OWNED_MEMORY_BUDGET_MB} to ${MAX_APP_OWNED_MEMORY_BUDGET_MB}` }, 400); - } - if (typeof body.codexAutoStart === "boolean") { - config.codexAutoStart = body.codexAutoStart; - } - if (body.streamMode !== undefined) { - if (body.streamMode === "auto") { - delete config.streamMode; - } else { - config.streamMode = body.streamMode as "legacy-tee" | "eager-relay"; - } - } - if (typeof body.appOwnedMemoryBudgetMb === "number") { - config.appOwnedMemoryBudgetMb = body.appOwnedMemoryBudgetMb; - } - saveConfigPreservingClaudeCode(config); - if (typeof body.appOwnedMemoryBudgetMb === "number") { - configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(body.appOwnedMemoryBudgetMb)); - enforceAppOwnedMemoryBudget(); - } - invalidateStartupHealthCache(); - return jsonResponse({ - ok: true, - codexAutoStart: codexAutoStartEnabled(config), - streamMode: config.streamMode ?? "auto", - appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256, - startupHealth: await getCachedStartupHealth(config), - }); - } +async function sidecarResponseWithReasoning( + response: Response, + ctx: ManagementContext, +): Promise { + if (!response.ok) return response; + let body: unknown; + try { + body = await response.json(); + } catch { + return response; + } + if (!isPlainRecord(body) || !isPlainRecord(body.vision)) return response; + body.vision.reasoning = ctx.config.visionSidecar?.reasoning ?? "low"; + return jsonResponse(body, response.status, ctx.req, ctx.config); +} - if (url.pathname === "/api/diagnostics/project-config" && req.method === "GET") { - const { getCachedProjectConfigDiagnostics } = await import("../../codex/project-config-warnings"); - const { warnings, grouped } = getCachedProjectConfigDiagnostics(); - return jsonResponse({ warnings, grouped }); +/** + * Maintainer wrapper for the current config routes. + * + * The base module is the byte-for-byte `dev` handler. This wrapper owns only the #1002 vision + * reasoning extension so the takeover cannot overwrite newer config-route work while still making + * the management boundary model-aware. + */ +export async function handleConfigRoutes(ctx: ManagementContext): Promise { + const { req, url, config } = ctx; + if (url.pathname !== "/api/sidecar-settings") { + return handleBaseConfigRoutes(ctx); } - if (url.pathname === "/api/sync" && req.method === "POST") { - const { syncModelsToCodex } = await import("../../codex/sync"); - const { attachStaleAppServerHint } = await import("../../codex/app-server-processes"); - const { readRuntimePort, loadConfig } = await import("../../config"); - // Never use the server-captured startup object for a durable integration - // decision. A toggle may have persisted while this process was gathering. - const runtime = readRuntimePort(process.pid); - const result = await syncModelsToCodex(runtime?.port, loadConfig(), null); - const status = result.status === "refused" ? 409 : (result.status === "skipped" || result.ok ? 200 : 500); - return jsonResponse({ - ...attachStaleAppServerHint(result), - ...(result.ok ? {} : { error: result.message }), - }, status); + if (req.method === "GET") { + const response = await handleBaseConfigRoutes(ctx); + return response ? sidecarResponseWithReasoning(response, ctx) : null; } - if (url.pathname === "/api/update/check" && req.method === "GET") { - const { checkForUpdate, normalizeUpdateChannel } = await import("../../update/job"); - const rawTag = url.searchParams.get("tag"); - if (rawTag && rawTag !== "latest" && rawTag !== "preview") { - return jsonResponse({ error: "tag must be latest or preview" }, 400); - } - return jsonResponse(checkForUpdate(normalizeUpdateChannel(rawTag))); - } + if (req.method !== "PUT") return handleBaseConfigRoutes(ctx); - if (url.pathname === "/api/update/run" && req.method === "POST") { - const { normalizeUpdateChannel, startUpdateJob, UpdateJobError } = await import("../../update/job"); - let body: { tag?: unknown; restart?: unknown }; - try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } - if (body.tag !== undefined && body.tag !== "latest" && body.tag !== "preview") { - return jsonResponse({ error: "tag must be latest or preview" }, 400); - } - if (body.restart !== undefined && typeof body.restart !== "boolean") { - return jsonResponse({ error: "restart boolean is required" }, 400); - } - try { - return jsonResponse({ ok: true, job: startUpdateJob(normalizeUpdateChannel(body.tag as string | undefined), body.restart !== false) }); - } catch (err) { - if (err instanceof UpdateJobError) { - return jsonResponse({ error: err.message, code: err.code }, err.status); - } - return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 500); - } + let raw: unknown; + try { + raw = await req.clone().json(); + } catch { + // Preserve the base handler's exact invalid-JSON/body-limit behavior. + return handleBaseConfigRoutes(ctx); } - if (url.pathname === "/api/update/status" && req.method === "GET") { - const { readUpdateJob } = await import("../../update/job"); - const job = readUpdateJob(url.searchParams.get("jobId")); - if (!job) return jsonResponse({ error: "update job not found" }, 404); - return jsonResponse({ ok: true, job }); + const vision = isPlainRecord(raw) && isPlainRecord(raw.vision) ? raw.vision : undefined; + const requestedReasoning = vision?.reasoning; + if (requestedReasoning !== undefined && !isVisionReasoningEffort(requestedReasoning)) { + return jsonResponse( + { error: `vision.reasoning must be ${VISION_REASONING_EFFORTS.join(", ")}` }, + 400, + req, + config, + ); } - if (url.pathname === "/api/sidecar-settings" && req.method === "GET") { - const ws = config.webSearchSidecar ?? {}; - const vs = config.visionSidecar ?? {}; - return jsonResponse({ - webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend }, - vision: { - model: vs.model ?? "gpt-5.6-luna", - backend: vs.backend, - maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn, - }, - }); - } + // Let the current `dev` handler validate and persist every pre-existing field first. No reasoning + // mutation occurs if another field makes the request invalid. + const response = await handleBaseConfigRoutes(ctx); + if (!response || !response.ok) return response; - if (url.pathname === "/api/sidecar-settings" && req.method === "PUT") { - let raw: unknown; - try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } - // Strict shape (review F2): reject non-object bodies and non-object sections instead of throwing - // on `null` or silently accepting arrays/strings as no-op updates. - if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400); - if (raw.webSearch !== undefined && !isPlainRecord(raw.webSearch)) return jsonResponse({ error: "webSearch must be an object" }, 400); - if (raw.vision !== undefined && !isPlainRecord(raw.vision)) return jsonResponse({ error: "vision must be an object" }, 400); - const body = raw as { - webSearch?: { model?: unknown; backend?: unknown; reasoning?: unknown }; - vision?: { model?: unknown; backend?: unknown; maxDescriptionsPerTurn?: unknown }; - }; - if (body.webSearch && body.webSearch.backend !== undefined && body.webSearch.backend !== null - && body.webSearch.backend !== "openai" && body.webSearch.backend !== "anthropic") { - return jsonResponse({ error: "webSearch.backend must be openai, anthropic, or null" }, 400); - } - if (body.vision && body.vision.backend !== undefined - && body.vision.backend !== null && body.vision.backend !== "openai" && body.vision.backend !== "anthropic") { - return jsonResponse({ error: "vision.backend must be openai, anthropic, or null" }, 400); - } - if (body.vision && body.vision.maxDescriptionsPerTurn !== undefined - && (typeof body.vision.maxDescriptionsPerTurn !== "number" - || !Number.isInteger(body.vision.maxDescriptionsPerTurn) - || body.vision.maxDescriptionsPerTurn <= 0)) { - return jsonResponse({ error: "vision.maxDescriptionsPerTurn must be a positive integer" }, 400); - } - if (body.webSearch) { - config.webSearchSidecar = { ...config.webSearchSidecar }; - if (typeof body.webSearch.model === "string") { - if (body.webSearch.model === "") delete config.webSearchSidecar.model; - else config.webSearchSidecar.model = body.webSearch.model; - } - if (body.webSearch.backend === null) delete config.webSearchSidecar.backend; - else if (body.webSearch.backend === "openai" || body.webSearch.backend === "anthropic") { - config.webSearchSidecar.backend = body.webSearch.backend; - } - if (typeof body.webSearch.reasoning === "string") config.webSearchSidecar.reasoning = body.webSearch.reasoning; - } - if (body.vision) { - config.visionSidecar = { ...config.visionSidecar }; - if (typeof body.vision.model === "string") { - if (body.vision.model === "") delete config.visionSidecar.model; - else config.visionSidecar.model = body.vision.model; - } - if (body.vision.backend === null) delete config.visionSidecar.backend; - else if (body.vision.backend === "openai" || body.vision.backend === "anthropic") { - config.visionSidecar.backend = body.vision.backend; - } - if (typeof body.vision.maxDescriptionsPerTurn === "number") { - config.visionSidecar.maxDescriptionsPerTurn = body.vision.maxDescriptionsPerTurn; - } + if (vision && (requestedReasoning !== undefined || vision.model !== undefined)) { + config.visionSidecar = { ...config.visionSidecar }; + const model = config.visionSidecar.model ?? "gpt-5.6-luna"; + const sourceReasoning = requestedReasoning ?? config.visionSidecar.reasoning; + if (sourceReasoning !== undefined) { + const normalized = normalizeVisionReasoningForModel(model, sourceReasoning); + if (normalized !== undefined) config.visionSidecar.reasoning = normalized; + else delete config.visionSidecar.reasoning; + saveConfigPreservingClaudeCode(config); } - saveConfigPreservingClaudeCode(config); - const ws = config.webSearchSidecar ?? {}; - const vs = config.visionSidecar ?? {}; - return jsonResponse({ - ok: true, - webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend }, - vision: { - model: vs.model ?? "gpt-5.6-luna", - backend: vs.backend, - maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn, - }, - }); } - if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") { - const sci = config.shadowCallIntercept ?? {}; - return jsonResponse({ - enabled: sci.enabled === true, - model: sci.model ?? "", - sourceModels: shadowSourceModels(sci.sourceModels), - }); - } - - if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") { - let raw: unknown; - try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); } - if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400); - const body = raw as { enabled?: unknown; model?: unknown }; - if (body.enabled !== undefined && typeof body.enabled !== "boolean") { - return jsonResponse({ error: "enabled must be a boolean" }, 400); - } - if (body.model !== undefined && typeof body.model !== "string") { - return jsonResponse({ error: "model must be a string" }, 400); - } - config.shadowCallIntercept = { ...config.shadowCallIntercept }; - if (typeof body.enabled === "boolean") config.shadowCallIntercept.enabled = body.enabled; - if (typeof body.model === "string") { - if (body.model === "") delete config.shadowCallIntercept.model; - else config.shadowCallIntercept.model = body.model; - } - saveConfigPreservingClaudeCode(config); - const sci = config.shadowCallIntercept; - return jsonResponse({ - ok: true, - enabled: sci.enabled === true, - model: sci.model ?? "", - sourceModels: shadowSourceModels(sci.sourceModels), - }); - } - return null; + return sidecarResponseWithReasoning(response, ctx); } From 910a62182f1c8449bfb7f5fe5c7e7426e381176c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:57:44 +0200 Subject: [PATCH 09/62] feat(gui): add model-aware vision reasoning helpers --- gui/src/pages/dashboard-shared.ts | 45 +++++++++++++++++++------------ 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 6248e19b5..bc8b301c4 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -5,12 +5,6 @@ import type { TKey } from "../i18n/shared"; import type { StartupHealthStatus } from "../startup-health-ui"; export type DashboardSection = "overview" | "providers" | "models"; - -/** - * `#dashboard/update` is the sidebar's action deep link. It is not a tab, so it resolves - * to Overview (where the maintenance panel lives) and separately asks the dashboard to - * open the update dialog. - */ export const DASHBOARD_UPDATE_HASH = "dashboard/update"; export function readDashboardSectionFromHash(): DashboardSection { @@ -20,17 +14,14 @@ export function readDashboardSectionFromHash(): DashboardSection { return "overview"; } -/** True while the location hash is the sidebar update deep link. */ export function hashRequestsUpdateDialog(): boolean { return window.location.hash.replace(/^#\/?/, "") === DASHBOARD_UPDATE_HASH; } -/** Overview is the bare `#dashboard`; the other sections carry a suffix. */ export function dashboardHashForSection(section: DashboardSection): string { return section === "overview" ? "dashboard" : `dashboard/${section}`; } -/** Like readJsonOrThrow, but rejects empty/204 bodies that would otherwise yield undefined. */ export async function requireJson(res: Response, fallbackMessage?: string): Promise { const data = await readJsonOrThrow(res, fallbackMessage); if (data === undefined) throw new Error(fallbackMessage ?? "empty response"); @@ -39,12 +30,11 @@ export async function requireJson(res: Response, fallbackMessage?: string): P export interface HealthData { status: string; version: string; uptime: number } export interface ProviderInfo { name: string; adapter: string; baseUrl: string; defaultModel?: string; hasApiKey: boolean } -export interface ModelInfo { id: string; provider: string; namespaced: string; owned_by?: string } +export interface ModelInfo { id: string; provider: string; namespaced: string; owned_by?: string; reasoningEfforts?: string[] } export interface SettingsData { codexAutoStart: boolean; port: number; hostname: string; - /** IANA zone of the machine running the proxy, used to render log timestamps (#725). */ timeZone?: string; startupHealth?: { status: "native" | "protected" | "at-risk"; @@ -55,11 +45,12 @@ export interface SettingsData { }; } export type SidecarBackend = "openai" | "anthropic"; -export interface SidecarSetting { backend?: SidecarBackend; model: string } +export type VisionReasoning = "low" | "medium" | "high" | "xhigh" | "max"; +export interface SidecarSetting { backend?: SidecarBackend; model: string; reasoning?: VisionReasoning } export interface SidecarData { webSearch: SidecarSetting; vision: SidecarSetting } export interface SidecarPatch { webSearch?: { backend?: SidecarBackend | null; model?: string }; - vision?: { backend?: SidecarBackend | null; model?: string }; + vision?: { backend?: SidecarBackend | null; model?: string; reasoning?: VisionReasoning }; } export interface ShadowCallData { enabled: boolean; model: string; sourceModels?: string[] } export interface UsageSummary30d { summary: { requests: number; totalTokens: number; coverageRatio: number } } @@ -142,15 +133,38 @@ export function updateJobLabel(status: UpdateJobStatus, t: (key: TKey) => string export function mergeSidecarSetting( current: SidecarSetting, - update?: { backend?: SidecarBackend | null; model?: string }, + update?: { backend?: SidecarBackend | null; model?: string; reasoning?: VisionReasoning }, ): SidecarSetting { const merged = { ...current }; if (update?.model !== undefined) merged.model = update.model; if (update?.backend === null) delete merged.backend; else if (update?.backend !== undefined) merged.backend = update.backend; + if (update?.reasoning !== undefined) merged.reasoning = update.reasoning; return merged; } +export const VISION_REASONING_LEVELS: VisionReasoning[] = ["low", "medium", "high", "xhigh", "max"]; + +export function visionReasoningLadder(models: ModelInfo[], modelId: string): VisionReasoning[] { + const model = models.find(m => m.id === modelId); + const ladder = model?.reasoningEfforts; + if (!ladder || ladder.length === 0) return [...VISION_REASONING_LEVELS]; + const supported = VISION_REASONING_LEVELS.filter(effort => ladder.includes(effort)); + return supported.length > 0 ? supported : [...VISION_REASONING_LEVELS]; +} + +export function visionReasoningOptionsFor(ladder: VisionReasoning[], persisted: VisionReasoning): VisionReasoning[] { + return ladder.includes(persisted) ? ladder : [persisted, ...ladder]; +} + +/** Preserve a supported value; otherwise clamp to the highest rung the selected model advertises. */ +export function clampVisionReasoningToLadder( + ladder: VisionReasoning[], + persisted: VisionReasoning, +): VisionReasoning { + return ladder.includes(persisted) ? persisted : ladder[ladder.length - 1]; +} + export function sidecarModelOptions(models: ModelInfo[]) { const out: Array<{ value: string; label: string }> = []; for (const model of models) { @@ -161,7 +175,6 @@ export function sidecarModelOptions(models: ModelInfo[]) { return out; } -/** Options for shadow-call replacement models use the proxy's canonical routing id. */ export function shadowCallModelOptions(models: ModelInfo[], current: string | undefined) { const out = [{ value: "", label: "—" }, ...models.map(model => ({ value: model.namespaced, label: model.namespaced }))]; if (current && !out.some(option => option.value === current)) out.push({ value: current, label: current }); @@ -197,12 +210,10 @@ export function useModalDialog(open: boolean, triggerRef: RefObject { const dialog = dialogRef.current; if (!dialog) return; - if (open) { if (!dialog.open) dialog.showModal(); return; } - if (dialog.open) dialog.close(); focusTriggerQuietly(triggerRef.current); }, [open, triggerRef]); From 9707448d015ebb34c6008609b2cfbe13b37cfddb Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:58:26 +0200 Subject: [PATCH 10/62] feat(gui): add vision reasoning selector --- gui/src/pages/dashboard-overview-sections.tsx | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index 1b74a7021..6560c7efa 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -4,7 +4,7 @@ import { Trans } from "../i18n/provider"; import { Select } from "../ui"; import { formatNamespacedModelId } from "../provider-icons"; import { navigateHash } from "../hash-routing"; -import { EFFORT_CAP_LEVELS, requireJson, shadowCallModelOptions, sidecarBackendForModel, updateJobLabel } from "./dashboard-shared"; +import { EFFORT_CAP_LEVELS, requireJson, shadowCallModelOptions, sidecarBackendForModel, updateJobLabel, visionReasoningLadder, visionReasoningOptionsFor } from "./dashboard-shared"; import { shadowSourceModelBadge } from "./shadow-call-source"; import type { useDashboardData } from "./use-dashboard-data"; @@ -277,6 +277,9 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { sidecar, sidecarSaving, sidecarModels, models, saveSidecar, shadowCall, shadowCallSaving, shadowCallHelpTriggerRef, shadowCallHelpOpen, setShadowCallHelpOpen, saveShadowCall, } = d; + const visionModel = sidecar?.vision.model ?? "gpt-5.6-luna"; + const visionReasoning = sidecar?.vision.reasoning ?? "low"; + const visionLadder = visionReasoningLadder(models, visionModel); return ( <> @@ -317,13 +320,27 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) {
{t("dash.visionSidecar")}
- { + const ladder = visionReasoningLadder(models, model); + // Never persist an effort the new model cannot serve: clamp to its highest rung. + const reasoning = ladder.includes(visionReasoning) ? visionReasoning : ladder[ladder.length - 1]; + void saveSidecar({ vision: { model, backend: sidecarBackendForModel(models, model), reasoning } }); + }} + disabled={!sidecar || sidecarSaving} + label={t("dash.sidecarModel")} + /> + ({ value, label: value }))} - onChange={reasoning => { void saveSidecar({ vision: { reasoning: reasoning as typeof visionReasoning } }); }} + onChange={reasoning => { + void saveSidecar({ + vision: { + model: visionModel, + backend: sidecarBackendForModel(models, visionModel), + reasoning: reasoning as typeof visionReasoning, + }, + }); + }} disabled={!sidecar || sidecarSaving} label={`${t("dash.visionSidecar")} — ${t("dash.injectionEffortLabel")}`} /> @@ -353,4 +362,4 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) {
); -} \ No newline at end of file +} From 5136acd2cf8a9834fc26d618eceef5326f6634a1 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:50:55 +0200 Subject: [PATCH 35/62] test(gui): hide stale unsupported vision effort options --- gui/tests/vision-reasoning-contract.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gui/tests/vision-reasoning-contract.test.ts b/gui/tests/vision-reasoning-contract.test.ts index e1f1ff9c3..607e5cf4b 100644 --- a/gui/tests/vision-reasoning-contract.test.ts +++ b/gui/tests/vision-reasoning-contract.test.ts @@ -3,6 +3,7 @@ import { VISION_REASONING_LEVELS, clampVisionReasoningToLadder, visionReasoningLadder, + visionReasoningOptionsFor, type ModelInfo, } from "../src/pages/dashboard-shared"; @@ -17,6 +18,7 @@ test("vision reasoning uses advertised model ladders and clamps unsupported pers expect(mini).toEqual(["low", "medium", "high", "xhigh"]); expect(clampVisionReasoningToLadder(mini, "max")).toBe("xhigh"); expect(clampVisionReasoningToLadder(mini, "high")).toBe("high"); + expect(visionReasoningOptionsFor(mini, "max")).toEqual(mini); }); test("vision reasoning clamp matches the server for non-prefix ladders", () => { From 56a3fdb3491e4a2ae1f885a11d1bac6ea5c22ced Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:55:46 +0200 Subject: [PATCH 36/62] docs(vision): update canonical sidecar reasoning references --- docs-site/src/content/docs/guides/sidecars.md | 17 +++++++++----- .../docs/guides/vision-sidecar-reasoning.md | 22 ------------------- .../src/content/docs/ja/guides/sidecars.md | 17 +++++++++----- .../ja/guides/vision-sidecar-reasoning.md | 12 ---------- .../docs/ja/reference/configuration/server.md | 3 ++- .../src/content/docs/ko/guides/sidecars.md | 18 ++++++++++----- .../ko/guides/vision-sidecar-reasoning.md | 12 ---------- .../docs/ko/reference/configuration/server.md | 3 ++- .../src/content/docs/ru/guides/sidecars.md | 17 +++++++++----- .../ru/guides/vision-sidecar-reasoning.md | 12 ---------- .../docs/ru/reference/configuration/server.md | 7 ++++-- .../src/content/docs/zh-cn/guides/sidecars.md | 15 ++++++++----- .../zh-cn/guides/vision-sidecar-reasoning.md | 12 ---------- .../zh-cn/reference/configuration/server.md | 3 ++- 14 files changed, 69 insertions(+), 101 deletions(-) delete mode 100644 docs-site/src/content/docs/guides/vision-sidecar-reasoning.md delete mode 100644 docs-site/src/content/docs/ja/guides/vision-sidecar-reasoning.md delete mode 100644 docs-site/src/content/docs/ko/guides/vision-sidecar-reasoning.md delete mode 100644 docs-site/src/content/docs/ru/guides/vision-sidecar-reasoning.md delete mode 100644 docs-site/src/content/docs/zh-cn/guides/vision-sidecar-reasoning.md diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 006287ab9..0a804a7f5 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -76,8 +76,12 @@ persisted legacy `gpt-5.4-mini` value to Luna. If the `visionSidecar.model` fiel the vision execution path still has a `gpt-5.4-mini` code fallback. - Images can come from user, developer, and tool-result messages, including Codex's `view_image`. -- Each image is sent to the configured native vision model with `reasoning.effort: "low"`; its - description replaces the image part inline. +- On the OpenAI path (ChatGPT-login passthrough), each image is sent to the configured vision model + over the Responses endpoint with the selected `reasoning.effort` (`low` by default), and its + description replaces the image part inline. The Anthropic path uses the Messages endpoint with its + own thinking-budget mapping and ignores this OpenAI-specific setting. +- Supported levels depend on the selected provider and model. When you pick a level the model does + not advertise, the Dashboard clamps the saved value to the model's highest supported rung. - Descriptions run with bounded concurrency (3 at a time, input order preserved). User context sent to the describer is capped at 800 characters, and each injected description is capped at 2,000 characters. The request does not send `max_output_tokens`, which the ChatGPT backend rejects. @@ -90,14 +94,17 @@ the vision execution path still has a `gpt-5.4-mini` code fallback. available, the raw image is stripped rather than forwarded to a text-only backend. - `maxDescriptionsPerTurn` (default 8) limits new descriptions per main-model turn. Cache hits and same-turn duplicates do not consume it. Successful `data:` image descriptions are cached by - backend, model, detail, image bytes, and message context; mutable `https:` images are not cached. + backend, model, detail, image bytes, and message context — plus the reasoning effort on OpenAI + keys (Anthropic keys omit it, since that field is ignored there); mutable `https:` images are not + cached. ```json { "visionSidecar": { "enabled": true, - "backend": "anthropic", - "model": "claude-sonnet-5", + "backend": "openai", + "model": "gpt-5.6-luna", + "reasoning": "medium", "maxDescriptionsPerTurn": 8, "timeoutMs": 45000 } diff --git a/docs-site/src/content/docs/guides/vision-sidecar-reasoning.md b/docs-site/src/content/docs/guides/vision-sidecar-reasoning.md deleted file mode 100644 index 8147fae9d..000000000 --- a/docs-site/src/content/docs/guides/vision-sidecar-reasoning.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: "Vision Sidecar Reasoning" -description: Configure OpenAI vision-sidecar reasoning effort safely by model capability. ---- - -The OpenAI vision sidecar can use a configurable reasoning effort when it describes images for a text-only routed model. - -Set `visionSidecar.reasoning` to `low`, `medium`, `high`, `xhigh`, or `max`. The default remains `low`. - -```json -{ - "visionSidecar": { - "backend": "openai", - "model": "gpt-5.6-luna", - "reasoning": "medium" - } -} -``` - -Supported levels depend on the selected model. The Dashboard reads each native model's advertised reasoning ladder and clamps an unavailable saved value to the highest supported rung. The management API and runtime apply the same model-aware normalization, so a direct API call or stale config cannot send a known-unsupported native effort upstream. Unknown/custom models remain permissive when opencodex has no reliable capability metadata. - -Changing the OpenAI reasoning effort creates a distinct image-description cache identity. Anthropic vision ignores this OpenAI-specific setting, so its cache identity does not change. diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md index cc64fc789..843fdadd1 100644 --- a/docs-site/src/content/docs/ja/guides/sidecars.md +++ b/docs-site/src/content/docs/ja/guides/sidecars.md @@ -75,8 +75,12 @@ stall は全体生成 timeout ではありません。SSE 開始前の失敗は - 画像はユーザー、developer、ツール結果メッセージから来ます。Codex の `view_image` 結果も 含まれます。 -- 各画像は設定されたネイティブビジョンモデルに `reasoning.effort: "low"` で渡され、説明が画像 - 部分をインラインに差し替えます。 +- OpenAI パス(ChatGPT ログインパススルー)では、各画像は選択した `reasoning.effort`(デフォルト + `low`)付きで Responses エンドポイント経由で設定済みのビジョンモデルに送信され、説明が画像部分 + をインラインで置き換えます。Anthropic パスは Messages エンドポイントを使い、独自の思考予算 + マッピングで動作し、この OpenAI 固有の設定を無視します。 +- 対応するレベルは、選択したプロバイダーとモデルに依存します。ダッシュボードでモデルが公表して + いないレベルを選ぶと、保存値はそのモデルが対応する最高ラングに切り詰められます。 - 説明は一度に 3 件並列処理し入力順序を維持します。説明モデルに渡すユーザー文脥は 800 文字、注入する画像説明は 1 枚あたり 2,000 文字に制限します。ChatGPT バックエンドが拒否する `max_output_tokens` は送信しません。 @@ -89,14 +93,17 @@ stall は全体生成 timeout ではありません。SSE 開始前の失敗は テキスト専用バックエンドに元画像を送らず削除します。 - `maxDescriptionsPerTurn`(デフォルト 8)はメインモデル 1 ターンで新規実行する説明数を制限します。キャッシュ ヒットと同じターンの重複要求は限度を消費しません。成功した `data:` 画像説明はバックエンド、モデル、 - detail、画像バイト、メッセージ文脈を基準にキャッシュし、変わり得る `https:` 画像はキャッシュしません。 + detail、画像バイト、メッセージ文脈を基準にキャッシュし、OpenAI のキーには推論負荷も含まれます + (Anthropic のキーには含まれません。そこではこのフィールドは無視されるため)。変わり得る + `https:` 画像はキャッシュしません。 ```json { "visionSidecar": { "enabled": true, - "backend": "anthropic", - "model": "claude-sonnet-5", + "backend": "openai", + "model": "gpt-5.6-luna", + "reasoning": "medium", "maxDescriptionsPerTurn": 8, "timeoutMs": 45000 } diff --git a/docs-site/src/content/docs/ja/guides/vision-sidecar-reasoning.md b/docs-site/src/content/docs/ja/guides/vision-sidecar-reasoning.md deleted file mode 100644 index 34df7cae9..000000000 --- a/docs-site/src/content/docs/ja/guides/vision-sidecar-reasoning.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: "Vision サイドカーの推論負荷" -description: OpenAI Vision サイドカーの推論負荷をモデル能力に合わせて安全に設定します。 ---- - -OpenAI の Vision サイドカーは、テキスト専用のルーティングモデル向けに画像を説明するときの推論負荷を設定できます。 - -`visionSidecar.reasoning` には `low`、`medium`、`high`、`xhigh`、`max` を指定できます。既定値は `low` のままです。 - -対応レベルは選択したモデルに依存します。ダッシュボードはネイティブモデルが公開する推論ラダーを使用し、未対応の保存値をそのモデルの最高対応ラングへ切り詰めます。管理 API と実行時にも同じ正規化を行うため、直接の API 呼び出しや古い設定から既知の未対応値が上流へ送られることはありません。信頼できる能力メタデータがないカスタムモデルは制限しません。 - -OpenAI では推論負荷が画像説明キャッシュの識別子に含まれます。Anthropic はこの OpenAI 固有設定を無視するため、Anthropic のキャッシュ識別子には含まれません。 diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index d702ec883..026747378 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -144,9 +144,10 @@ OpenAI バックエンドには、ChatGPT ログインと有効な ChatGPT `forw | `enabled?` | `boolean` |使用可能な場合はオン |マスターイメージと説明のスイッチ。 | | `backend?` | `"openai" \| "anthropic"` |自動 | Web 検索と同じ、明示的優先、人間認証情報を意識した選択。 | | `model?` | `string` |バックエンド依存 | OpenAI の場合は `gpt-5.4-mini`、Anthropic の場合は `claude-sonnet-5`。 | +| `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | OpenAI Responses の推論負荷。Anthropic は無視します。 | | `maxDescriptionsPerTurn?` | `number` | `8` |新しい説明のキャッシュミスはメインターンごとに許可されます。 `0` は通話を無効にします。無効な値にはデフォルトが使用されます。 | | `timeoutMs?` | `number` | `45000` |サイドカーのフェッチタイムアウト。 | -Vision は、プロバイダーの `noVisionModels` のモデルに送信された画像に対してのみアクティブになります。 OpenAI には、検索と同じログイン/転送要件があります。明示的に選択された Anthropic は、使用可能な認証情報がないと失敗します。成功した `data:` 記述では、バックエンド、モデル、詳細、画像バイト、および正規化されたメッセージ コンテキストをキーとした境界付きキャッシュが使用されます。ヒットと同じターンの重複は制限を消費しません。リモート `https:` イメージと失敗した説明、または空の説明はキャッシュされません。 +対応するレベルは、上流プロバイダーの能力と選択したモデルが公表する推論ラダーによって制限されます。 Vision は、プロバイダーの `noVisionModels` のモデルに送信された画像に対してのみアクティブになります。 OpenAI には、検索と同じログイン/転送要件があります。明示的に選択された Anthropic は、使用可能な認証情報がないと失敗します。成功した `data:` 記述では、バックエンド、モデル、詳細、画像バイト、および正規化されたメッセージ コンテキストをキーとした境界付きキャッシュが使用されます。OpenAI のキーには推論負荷も含まれます(Anthropic のキーには含まれません)。ヒットと同じターンの重複は制限を消費しません。リモート `https:` イメージと失敗した説明、または空の説明はキャッシュされません。 Anthropic OAuth サイドカーは、opencodex の既存のクロード コード OAuth フィンガープリントを再利用します。対象のアカウントとワークロードをソークテストします。 diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index 1ea7596bd..17f6f9785 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -76,8 +76,12 @@ stall은 전체 생성 timeout이 아닙니다. SSE가 시작되기 전 실패 - 이미지는 사용자, developer, 도구 결과 메시지에서 올 수 있습니다. Codex의 `view_image` 결과도 포함됩니다. -- 각 이미지는 설정된 네이티브 비전 모델에 `reasoning.effort: "low"`로 전달되고, 설명이 이미지 - 부분을 인라인으로 대체합니다. +- OpenAI 경로(ChatGPT 로그인 패스스루)에서는 각 이미지가 선택한 `reasoning.effort`(기본값 + `low`)와 함께 Responses 엔드포인트로 설정된 비전 모델에 전송되고, 설명이 이미지 부분을 인라인으로 + 대체합니다. Anthropic 경로는 Messages 엔드포인트와 자체 thinking 예산 매핑을 사용하며 이 + OpenAI 전용 설정을 무시합니다. +- 지원되는 수준은 선택한 제공자와 모델에 따라 달라집니다. 대시보드에서 모델이 공개하지 않은 수준을 + 고르면 저장된 값은 모델이 지원하는 최고 단계로 제한됩니다. - 설명은 한 번에 3개씩 병렬 처리하며 입력 순서를 유지합니다. 설명 모델에 전달하는 사용자 문맥은 800자, 주입하는 이미지 설명은 장당 2,000자로 제한합니다. ChatGPT 백엔드가 거부하는 `max_output_tokens`는 보내지 않습니다. @@ -90,15 +94,17 @@ stall은 전체 생성 timeout이 아닙니다. SSE가 시작되기 전 실패 없으면 텍스트 전용 백엔드에 원본 이미지를 보내지 않고 제거합니다. - `maxDescriptionsPerTurn`(기본값 8)은 메인 모델 한 턴에서 새로 실행할 설명 수를 제한합니다. 캐시 적중과 같은 턴의 중복 요청은 한도를 쓰지 않습니다. 성공한 `data:` 이미지 설명은 백엔드, 모델, - detail, 이미지 바이트, 메시지 문맥을 기준으로 캐시하며, 바뀔 수 있는 `https:` 이미지는 캐시하지 - 않습니다. + detail, 이미지 바이트, 메시지 문맥을 기준으로 캐시하며, OpenAI 키에는 추론 강도도 포함됩니다 + (Anthropic 키에는 포함되지 않습니다. 해당 필드는 거기서 무시되기 때문입니다). 바뀔 수 있는 + `https:` 이미지는 캐시하지 않습니다. ```json { "visionSidecar": { "enabled": true, - "backend": "anthropic", - "model": "claude-sonnet-5", + "backend": "openai", + "model": "gpt-5.6-luna", + "reasoning": "medium", "maxDescriptionsPerTurn": 8, "timeoutMs": 45000 } diff --git a/docs-site/src/content/docs/ko/guides/vision-sidecar-reasoning.md b/docs-site/src/content/docs/ko/guides/vision-sidecar-reasoning.md deleted file mode 100644 index a862b09f9..000000000 --- a/docs-site/src/content/docs/ko/guides/vision-sidecar-reasoning.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: "Vision 사이드카 추론 강도" -description: OpenAI Vision 사이드카의 추론 강도를 모델 기능에 맞게 안전하게 설정합니다. ---- - -OpenAI Vision 사이드카는 텍스트 전용 라우팅 모델을 위해 이미지를 설명할 때 사용할 추론 강도를 설정할 수 있습니다. - -`visionSidecar.reasoning`은 `low`, `medium`, `high`, `xhigh`, `max`를 지원하며 기본값은 계속 `low`입니다. - -지원 수준은 선택한 모델에 따라 달라집니다. 대시보드는 네이티브 모델이 공개한 추론 사다리를 읽고 지원되지 않는 저장값을 해당 모델의 최고 지원 단계로 제한합니다. 관리 API와 런타임도 같은 모델 인식 정규화를 적용하므로 직접 API 호출이나 오래된 설정이 알려진 비지원 값을 업스트림으로 보내지 않습니다. 신뢰할 수 있는 기능 메타데이터가 없는 사용자 지정 모델은 제한하지 않습니다. - -OpenAI에서는 추론 강도가 이미지 설명 캐시 식별자에 포함됩니다. Anthropic은 이 OpenAI 전용 설정을 무시하므로 Anthropic 캐시 식별자에는 포함되지 않습니다. diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 3ac1fba3d..ac267cf28 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -144,9 +144,10 @@ OpenAI 백엔드는 ChatGPT 로그인과 활성화된 ChatGPT `forward` provider | `enabled?` | `boolean` | on when usable | 주 이미지 설명 스위치입니다. | | `backend?` | `"openai" \| "anthropic"` | auto | web search와 같은, 명시값 우선 및 Anthropic 자격 증명 인식 선택 방식입니다. | | `model?` | `string` | backend-dependent | OpenAI는 `gpt-5.4-mini`, Anthropic은 `claude-sonnet-5`입니다. | +| `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | OpenAI Responses 추론 강도입니다. Anthropic은 무시합니다. | | `maxDescriptionsPerTurn?` | `number` | `8` | 메인 턴당 허용되는 새 설명 캐시 미스 수입니다. `0`이면 호출이 비활성화되며, 잘못된 값은 기본값을 사용합니다. | | `timeoutMs?` | `number` | `45000` | 사이드카 fetch 제한 시간입니다. | -Vision은 provider의 `noVisionModels`에 속한 모델로 보낸 이미지에만 활성화됩니다. OpenAI는 검색과 같은 로그인/forward 요건을 갖고 있으며, 명시적으로 선택한 Anthropic은 사용할 수 있는 자격 증명이 없으면 닫힌 상태로 실패합니다. 성공한 `data:` 설명은 backend, model, detail, image bytes, 그리고 정규화된 메시지 컨텍스트를 키로 하는 bounded cache를 사용합니다. 히트와 같은 턴의 중복은 한도를 소모하지 않습니다. 원격 `https:` 이미지와 실패했거나 비어 있는 설명은 캐시하지 않습니다. +지원되는 수준은 업스트림 제공자의 역량과 선택한 모델이 공개한 추론 사다리에 따라 제한됩니다. Vision은 provider의 `noVisionModels`에 속한 모델로 보낸 이미지에만 활성화됩니다. OpenAI는 검색과 같은 로그인/forward 요건을 갖고 있으며, 명시적으로 선택한 Anthropic은 사용할 수 있는 자격 증명이 없으면 닫힌 상태로 실패합니다. 성공한 `data:` 설명은 backend, model, detail, image bytes, 그리고 정규화된 메시지 컨텍스트를 키로 하는 bounded cache를 사용합니다. OpenAI 키에는 reasoning effort도 포함됩니다(Anthropic 키에는 없습니다). 히트와 같은 턴의 중복은 한도를 소모하지 않습니다. 원격 `https:` 이미지와 실패했거나 비어 있는 설명은 캐시하지 않습니다. Anthropic OAuth 사이드카는 opencodex의 기존 Claude Code OAuth fingerprint를 재사용합니다. 의도한 계정과 워크로드로 소크 테스트를 수행합니다. diff --git a/docs-site/src/content/docs/ru/guides/sidecars.md b/docs-site/src/content/docs/ru/guides/sidecars.md index 861d4bb05..d09c69dd8 100644 --- a/docs-site/src/content/docs/ru/guides/sidecars.md +++ b/docs-site/src/content/docs/ru/guides/sidecars.md @@ -87,8 +87,13 @@ SSE-событие `response.failed`. - Изображения могут приходить из сообщений пользователя, разработчика и результатов инструментов, включая `view_image` из Codex. -- Каждое изображение отправляется в настроенную нативную vision-модель с - `reasoning.effort: "low"`; полученное описание заменяет часть с изображением на месте. +- На пути OpenAI (passthrough с логином ChatGPT) каждое изображение отправляется в настроенную + vision-модель через endpoint Responses с выбранным `reasoning.effort` (по умолчанию `low`), и + полученное описание заменяет часть с изображением на месте. Путь Anthropic использует endpoint + Messages со своим mapping'ом thinking-бюджета и игнорирует эту специфичную для OpenAI настройку. +- Поддерживаемые уровни зависят от выбранного провайдера и модели. Если в дашборде выбрать уровень, + который модель не заявляет, сохранённое значение ограничивается наивысшим поддерживаемым уровнем + модели. - Описания выполняются с ограниченной параллельностью (по 3 одновременно, порядок входа сохраняется). Пользовательский контекст, передаваемый описывающей модели, ограничен 800 символами, а каждое внедряемое описание — 2 000 символами. Запрос не отправляет @@ -103,14 +108,16 @@ SSE-событие `response.failed`. - `maxDescriptionsPerTurn` (по умолчанию 8) ограничивает число новых описаний за один ход основной модели. Попадания в кэш и дубликаты в рамках того же хода лимит не расходуют. Успешные описания `data:`-изображений кэшируются по бэкенду, модели, детализации, байтам изображения и контексту - сообщения; изменяемые `https:`-изображения не кэшируются. + сообщения; в ключи OpenAI дополнительно входит уровень рассуждений (в ключи Anthropic — нет, + поскольку там это поле игнорируется). Изменяемые `https:`-изображения не кэшируются. ```json { "visionSidecar": { "enabled": true, - "backend": "anthropic", - "model": "claude-sonnet-5", + "backend": "openai", + "model": "gpt-5.6-luna", + "reasoning": "medium", "maxDescriptionsPerTurn": 8, "timeoutMs": 45000 } diff --git a/docs-site/src/content/docs/ru/guides/vision-sidecar-reasoning.md b/docs-site/src/content/docs/ru/guides/vision-sidecar-reasoning.md deleted file mode 100644 index 980103c27..000000000 --- a/docs-site/src/content/docs/ru/guides/vision-sidecar-reasoning.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: "Уровень рассуждений Vision-сайдкара" -description: Безопасная настройка уровня рассуждений OpenAI Vision-сайдкара с учётом возможностей модели. ---- - -Для OpenAI Vision-сайдкара можно настроить уровень рассуждений, используемый при описании изображений для текстовой routed-модели. - -`visionSidecar.reasoning` принимает `low`, `medium`, `high`, `xhigh` или `max`. Значение по умолчанию остаётся `low`. - -Поддерживаемые уровни зависят от выбранной модели. Дашборд читает заявленную лестницу рассуждений нативной модели и ограничивает недоступное сохранённое значение наивысшим поддерживаемым уровнем. Management API и runtime применяют ту же нормализацию, поэтому прямой API-вызов или устаревший конфиг не отправит известный неподдерживаемый уровень upstream. Пользовательские модели без надёжных метаданных возможностей остаются без искусственных ограничений. - -Для OpenAI уровень рассуждений входит в идентификатор кэша описания изображения. Anthropic игнорирует эту специфичную для OpenAI настройку, поэтому его кэш от неё не зависит. diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index 4fd226855..d36fa3383 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -179,14 +179,17 @@ routed-model и hosted-search timeout. Эффективный watchdog мост | `enabled?` | `boolean` | on when usable | Главный переключатель описания изображений. | | `backend?` | `"openai" \| "anthropic"` | auto | Та же логика выбора explicit-first/Anthropic-credential-aware, что и у web search. | | `model?` | `string` | backend-dependent | `gpt-5.4-mini` для OpenAI или `claude-sonnet-5` для Anthropic. | +| `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | Уровень рассуждений OpenAI Responses. Anthropic его игнорирует. | | `maxDescriptionsPerTurn?` | `number` | `8` | Максимум новых промахов description-cache за один main turn. `0` отключает вызовы; некорректные значения возвращают дефолт. | | `timeoutMs?` | `number` | `45000` | Таймаут запроса sidecar'а. | -Vision включается только для изображений, отправленных в модель, входящую в `noVisionModels` её +Поддерживаемые уровни зависят от возможностей вышестоящего провайдера и заявленной лестницы +рассуждений выбранной модели. Vision включается только для изображений, отправленных в модель, входящую в `noVisionModels` её провайдера. У OpenAI требования по login/forward те же, что и у поиска; явный Anthropic без рабочего credential завершается ошибкой. Успешные описания `data:` используют ограниченный cache, ключ которого включает backend, model, detail, bytes изображения и нормализованный message -context. Попадания в cache и дубликаты в пределах одного turn'а не расходуют лимит. Удалённые +context; в ключи OpenAI дополнительно входит reasoning effort (в ключи Anthropic — нет). +Попадания в cache и дубликаты в пределах одного turn'а не расходуют лимит. Удалённые `https:`-изображения, а также пустые и неуспешные описания не кэшируются. Sidecar'ы Anthropic OAuth повторно используют уже существующий OAuth fingerprint Claude Code от diff --git a/docs-site/src/content/docs/zh-cn/guides/sidecars.md b/docs-site/src/content/docs/zh-cn/guides/sidecars.md index 1b49584b1..abec95996 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-cn/guides/sidecars.md @@ -69,8 +69,11 @@ Anthropic OAuth provider。Sidecar 错误会转换成长度受限的工具结果 `visionSidecar.model` 字段完全不存在时,vision 执行路径才会使用代码中的 `gpt-5.4-mini` 回退值。 - 图像可以来自 user、developer 和 tool-result message,也包括 Codex 的 `view_image` 结果。 -- 每张图像会以 `reasoning.effort: "low"` 发送给配置的原生 vision 模型,描述结果会就地替换 - 图像部分。 +- OpenAI 路径(ChatGPT 登录透传)会通过 Responses 端点把每张图像发送给配置的视觉模型,并携带所选 + 的 `reasoning.effort`(默认为 `low`),描述结果就地替换图像部分。Anthropic 路径走 Messages + 端点并使用自己的思考预算映射,会忽略这个 OpenAI 专用设置。 +- 支持的等级取决于所选提供方和模型。在控制台选择模型未公布的等级时,保存的值会被钳制到该模型 + 支持的最高档位。 - 描述任务最多同时处理 3 张图像,并保持输入顺序。发送给描述模型的用户上下文最多 800 个字符, 每张图像注入的描述最多 2,000 个字符。请求不会发送 ChatGPT 后端不支持的 `max_output_tokens`。 @@ -83,14 +86,16 @@ Anthropic OAuth provider。Sidecar 错误会转换成长度受限的工具结果 移除,而不会继续转发给纯文本后端。 - `maxDescriptionsPerTurn`(默认 8)限制每个主模型 turn 的新增描述次数。缓存命中和同一 turn 的重复请求不会消耗配额。成功的 `data:` 图像描述会按后端、模型、detail、图像字节和消息上下文 - 缓存;内容可变的 `https:` 图像不会缓存。 + 缓存;OpenAI 的缓存键还会额外包含推理强度(Anthropic 键不含,因为该字段在那里被忽略)。 + 内容可变的 `https:` 图像不会缓存。 ```json { "visionSidecar": { "enabled": true, - "backend": "anthropic", - "model": "claude-sonnet-5", + "backend": "openai", + "model": "gpt-5.6-luna", + "reasoning": "medium", "maxDescriptionsPerTurn": 8, "timeoutMs": 45000 } diff --git a/docs-site/src/content/docs/zh-cn/guides/vision-sidecar-reasoning.md b/docs-site/src/content/docs/zh-cn/guides/vision-sidecar-reasoning.md deleted file mode 100644 index a92b3a881..000000000 --- a/docs-site/src/content/docs/zh-cn/guides/vision-sidecar-reasoning.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: "Vision 侧车推理强度" -description: 按模型能力安全配置 OpenAI Vision 侧车的推理强度。 ---- - -OpenAI Vision 侧车在为纯文本路由模型描述图像时,可以配置所使用的推理强度。 - -`visionSidecar.reasoning` 支持 `low`、`medium`、`high`、`xhigh` 和 `max`,默认值仍为 `low`。 - -支持的等级取决于所选模型。控制台会读取原生模型公布的推理阶梯,并把不受支持的已保存值限制到该模型支持的最高档位。管理 API 和运行时也使用同一套按模型能力归一化逻辑,因此直接 API 调用或旧配置不会把已知不受支持的原生档位发送到上游。对于没有可靠能力元数据的自定义模型,OpenCodex 保持宽松处理。 - -在 OpenAI 路径中,推理强度会进入图像描述缓存标识。Anthropic 会忽略这个 OpenAI 专用设置,因此 Anthropic 的缓存标识不会因它变化。 diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index e3c530cc1..5da45c40f 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -159,9 +159,10 @@ routed 重放会把主 ChatGPT 认证注入内部请求。Anthropic 后端使用 | `enabled?` | `boolean` | 在可用时启用 | 图像描述总开关。 | | `backend?` | `"openai" \| "anthropic"` | auto | 与 web search 相同的显式优先、感知 Anthropic 凭据的选择方式。 | | `model?` | `string` | 依后端而定 | OpenAI 使用 `gpt-5.4-mini`,Anthropic 使用 `claude-sonnet-5`。 | +| `reasoning?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | `"low"` | OpenAI Responses 推理强度;Anthropic 会忽略该项。 | | `maxDescriptionsPerTurn?` | `number` | `8` | 每个主轮次允许的新增描述缓存未命中次数。`0` 会禁用调用;无效值会使用默认值。 | | `timeoutMs?` | `number` | `45000` | 侧车获取超时。 | -Vision 只会对发送给其提供方 `noVisionModels` 中模型的图像生效。OpenAI 具有与 search 相同的登录/forward 要求;显式选择的 Anthropic 在没有可用凭据时会失败并关闭。成功的 `data:` 描述会使用一个受限缓存,其键由后端、模型、detail、图像字节以及规范化消息上下文组成。命中和同轮重复不会消耗限额。远程 `https:` 图像以及失败或空的描述不会被缓存。 +支持的等级受上游提供方能力与所选模型公布的推理阶梯限制。Vision 只会对发送给其提供方 `noVisionModels` 中模型的图像生效。OpenAI 具有与 search 相同的登录/forward 要求;显式选择的 Anthropic 在没有可用凭据时会失败并关闭。成功的 `data:` 描述会使用一个受限缓存,其键由后端、模型、detail、图像字节以及规范化消息上下文组成;OpenAI 的键还会额外包含推理强度(Anthropic 键不含)。命中和同轮重复不会消耗限额。远程 `https:` 图像以及失败或空的描述不会被缓存。 Anthropic OAuth 侧车会复用 opencodex 现有的 Claude Code OAuth 指纹。请对目标账户和负载进行 soak 测试。 From b048406aadb490e0d45f631aadaed93910ed63d6 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:35:45 +0200 Subject: [PATCH 37/62] test(vision): cover unset model reasoning fallback --- tests/vision-reasoning-contract.test.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/vision-reasoning-contract.test.ts b/tests/vision-reasoning-contract.test.ts index fa88da443..493acebef 100644 --- a/tests/vision-reasoning-contract.test.ts +++ b/tests/vision-reasoning-contract.test.ts @@ -67,6 +67,20 @@ describe("vision reasoning capability contracts", () => { expect(response.status).toBe(200); expect(reasoningOnly.visionSidecar?.reasoning).toBe("xhigh"); + const unsetModel = { + port: 10100, + defaultProvider: "none", + providers: {}, + visionSidecar: { reasoning: "low" }, + } as OcxConfig; + response = await putVision(unsetModel, { reasoning: "max" }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + vision: { model: "gpt-5.4-mini", reasoning: "xhigh" }, + }); + expect(unsetModel.visionSidecar?.model).toBeUndefined(); + expect(unsetModel.visionSidecar?.reasoning).toBe("xhigh"); + const modelOnly = { port: 10100, defaultProvider: "none", @@ -85,8 +99,11 @@ describe("vision reasoning capability contracts", () => { } as OcxConfig; response = await putVision(reset, { model: "" }); expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + vision: { model: "gpt-5.4-mini", reasoning: "xhigh" }, + }); expect(reset.visionSidecar?.model).toBeUndefined(); - expect(reset.visionSidecar?.reasoning).toBe("max"); + expect(reset.visionSidecar?.reasoning).toBe("xhigh"); const custom = { port: 10100, defaultProvider: "none", providers: {} } as OcxConfig; response = await putVision(custom, { model: "custom-vision", reasoning: "max" }); From e8e705f85b5388d509968a85b77b1843f6a8f363 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:36:41 +0200 Subject: [PATCH 38/62] fix(vision): align management fallback with runtime --- src/server/management/config-routes.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index e740bf052..d97ad92d7 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -189,7 +189,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise Date: Sat, 8 Aug 2026 00:37:56 +0200 Subject: [PATCH 39/62] fix(management): restore tray validation message --- src/server/management/config-routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index d97ad92d7..b6b61c685 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -189,7 +189,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise Date: Sat, 8 Aug 2026 00:41:48 +0200 Subject: [PATCH 40/62] fix(vision): normalize CLI reasoning against runtime default --- src/cli/config-command.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 91eed5975..fa7514bb9 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -83,8 +83,11 @@ function validateCandidate(value: unknown): ReturnType Date: Sat, 8 Aug 2026 00:42:18 +0200 Subject: [PATCH 41/62] test(vision): cover CLI default-model normalization --- tests/vision-reasoning-contract.test.ts | 27 ++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tests/vision-reasoning-contract.test.ts b/tests/vision-reasoning-contract.test.ts index 493acebef..e17c55a24 100644 --- a/tests/vision-reasoning-contract.test.ts +++ b/tests/vision-reasoning-contract.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { handleConfigCommand } from "../src/cli/config-command"; import { handleManagementAPI } from "../src/server/management-api"; import { listManagementModelRows } from "../src/server/management/model-rows"; import type { OcxConfig } from "../src/types"; @@ -116,6 +117,30 @@ describe("vision reasoning capability contracts", () => { } }); + test("CLI import normalizes reasoning against the runtime model default", async () => { + const previousHome = process.env.OPENCODEX_HOME; + const isolatedHome = mkdtempSync(join(tmpdir(), "ocx-vision-reasoning-cli-")); + process.env.OPENCODEX_HOME = isolatedHome; + const importPath = join(isolatedHome, "import.json"); + + try { + writeFileSync(importPath, JSON.stringify({ + port: 10100, + defaultProvider: "none", + providers: {}, + visionSidecar: { reasoning: "max" }, + })); + expect(await handleConfigCommand(["import", importPath, "--yes", "--json"])).toBe(0); + const persisted = JSON.parse(readFileSync(join(isolatedHome, "config.json"), "utf8")); + expect(persisted.visionSidecar).toMatchObject({ reasoning: "xhigh" }); + expect(persisted.visionSidecar.model).toBeUndefined(); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(isolatedHome, { recursive: true, force: true }); + } + }); + test("invalid effort is rejected without mutation and unrelated patches preserve reasoning", async () => { const previousHome = process.env.OPENCODEX_HOME; const isolatedHome = mkdtempSync(join(tmpdir(), "ocx-vision-reasoning-invalid-")); From b93b0305618d2d69a926e9429f17120c175c953c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:43:49 +0200 Subject: [PATCH 42/62] fix(gui): keep vision effort edits reasoning-only --- gui/src/pages/dashboard-shared.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 72c2787d0..1462cc052 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -153,6 +153,11 @@ export function mergeSidecarSetting( return merged; } +/** Effort-only edits must not rewrite a custom model or its explicitly selected backend. */ +export function visionReasoningPatch(reasoning: VisionReasoning): SidecarPatch { + return { vision: { reasoning } }; +} + export const VISION_REASONING_LEVELS: VisionReasoning[] = ["low", "medium", "high", "xhigh", "max"]; export function visionReasoningLadder(models: ModelInfo[], modelId: string): VisionReasoning[] { @@ -254,4 +259,4 @@ export function useModalDialog(open: boolean, triggerRef: RefObject Date: Sat, 8 Aug 2026 00:44:01 +0200 Subject: [PATCH 43/62] test(gui): preserve sidecar identity on effort edits --- gui/tests/vision-reasoning-contract.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/gui/tests/vision-reasoning-contract.test.ts b/gui/tests/vision-reasoning-contract.test.ts index 607e5cf4b..b29588486 100644 --- a/gui/tests/vision-reasoning-contract.test.ts +++ b/gui/tests/vision-reasoning-contract.test.ts @@ -4,6 +4,7 @@ import { clampVisionReasoningToLadder, visionReasoningLadder, visionReasoningOptionsFor, + visionReasoningPatch, type ModelInfo, } from "../src/pages/dashboard-shared"; @@ -30,3 +31,7 @@ test("vision reasoning clamp matches the server for non-prefix ladders", () => { test("unknown vision model metadata stays permissive", () => { expect(visionReasoningLadder([], "custom/vision-model")).toEqual(VISION_REASONING_LEVELS); }); + +test("effort-only edits cannot rewrite the selected vision model or backend", () => { + expect(visionReasoningPatch("high")).toEqual({ vision: { reasoning: "high" } }); +}); From 736f68e08034e7efccb5c70a27ec2093f1cfa3b3 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:44:44 +0200 Subject: [PATCH 44/62] fix(gui): preserve vision backend on effort changes --- gui/src/pages/dashboard-overview-sections.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index 11ce43236..a57329323 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -4,7 +4,7 @@ import { Trans } from "../i18n/provider"; import { Select } from "../ui"; import { formatNamespacedModelId } from "../provider-icons"; import { navigateHash } from "../hash-routing"; -import { clampVisionReasoningToLadder, EFFORT_CAP_LEVELS, requireJson, shadowCallModelOptions, sidecarBackendForModel, updateJobLabel, visionReasoningLadder, visionReasoningOptionsFor } from "./dashboard-shared"; +import { clampVisionReasoningToLadder, EFFORT_CAP_LEVELS, requireJson, shadowCallModelOptions, sidecarBackendForModel, updateJobLabel, visionReasoningLadder, visionReasoningOptionsFor, visionReasoningPatch } from "./dashboard-shared"; import { shadowSourceModelBadge } from "./shadow-call-source"; import type { useDashboardData } from "./use-dashboard-data"; @@ -242,7 +242,7 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { sidecar, sidecarSaving, sidecarModels, models, saveSidecar, shadowCall, shadowCallSaving, shadowCallHelpTriggerRef, shadowCallHelpOpen, setShadowCallHelpOpen, saveShadowCall, } = d; - const visionModel = sidecar?.vision.model ?? "gpt-5.6-luna"; + const visionModel = sidecar?.vision.model ?? "gpt-5.4-mini"; const persistedVisionReasoning = sidecar?.vision.reasoning ?? "low"; const visionLadder = visionReasoningLadder(models, visionModel); const visionReasoning = clampVisionReasoningToLadder(visionLadder, persistedVisionReasoning); @@ -302,13 +302,7 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { value={visionReasoning} options={visionReasoningOptionsFor(visionLadder, visionReasoning).map(value => ({ value, label: value }))} onChange={reasoning => { - void saveSidecar({ - vision: { - model: visionModel, - backend: sidecarBackendForModel(models, visionModel), - reasoning: reasoning as typeof visionReasoning, - }, - }); + void saveSidecar(visionReasoningPatch(reasoning as typeof visionReasoning)); }} disabled={!sidecar || sidecarSaving} label={`${t("dash.visionSidecar")} — ${t("dash.injectionEffortLabel")}`} @@ -362,4 +356,4 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) {
); -} +} \ No newline at end of file From f5837653e235e5e36b09a7024f15553db0bbfb84 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:45:38 +0200 Subject: [PATCH 45/62] chore(gui): restore final newline --- gui/src/pages/dashboard-shared.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 1462cc052..db08250e7 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -259,4 +259,4 @@ export function useModalDialog(open: boolean, triggerRef: RefObject Date: Sat, 8 Aug 2026 00:46:17 +0200 Subject: [PATCH 46/62] chore(gui): restore overview final newline --- gui/src/pages/dashboard-overview-sections.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index a57329323..dc8dff049 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -356,4 +356,4 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { ); -} \ No newline at end of file +} From cb6de7434620d8f947cc94932d4bcf867967eed4 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:48:12 +0200 Subject: [PATCH 47/62] fix(vision): report effective reasoning for default model --- src/server/management/config-routes.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index b6b61c685..e2f018f67 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -314,12 +314,14 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise Date: Sat, 8 Aug 2026 00:55:32 +0200 Subject: [PATCH 48/62] fix(vision): treat blank sidecar models as unset --- src/vision/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vision/index.ts b/src/vision/index.ts index 2af75ae15..5c43660d1 100644 --- a/src/vision/index.ts +++ b/src/vision/index.ts @@ -189,7 +189,7 @@ export function resolveVisionBackend( /** Native model used by the OpenAI vision helper, including its bounded default. */ export function resolveOpenAiVisionModel(config: Pick): string { - return config.visionSidecar?.model ?? DEFAULT_VISION_MODEL; + return config.visionSidecar?.model || DEFAULT_VISION_MODEL; } /** A user/developer/toolResult message can carry images (toolResult: e.g. Codex view_image output). */ @@ -245,7 +245,7 @@ export function planVisionSidecar( if (backend === "anthropic") { if (!anthropicSidecar) return undefined; - const model = cfg.model ?? DEFAULT_ANTHROPIC_VISION_MODEL; + const model = cfg.model || DEFAULT_ANTHROPIC_VISION_MODEL; return { backend, anthropicSidecar, From 9c820618253ecdccfbdfa386290e2e16316ad374 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:56:18 +0200 Subject: [PATCH 49/62] fix(vision): normalize blank CLI model as default --- src/cli/config-command.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index fa7514bb9..c926bec0a 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -84,9 +84,9 @@ function validateCandidate(value: unknown): ReturnType Date: Sat, 8 Aug 2026 00:56:54 +0200 Subject: [PATCH 50/62] test(vision): cover blank and stale default-model configs --- tests/vision-reasoning-contract.test.ts | 41 +++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/tests/vision-reasoning-contract.test.ts b/tests/vision-reasoning-contract.test.ts index e17c55a24..e7a5d3dd3 100644 --- a/tests/vision-reasoning-contract.test.ts +++ b/tests/vision-reasoning-contract.test.ts @@ -6,8 +6,16 @@ import { handleConfigCommand } from "../src/cli/config-command"; import { handleManagementAPI } from "../src/server/management-api"; import { listManagementModelRows } from "../src/server/management/model-rows"; import type { OcxConfig } from "../src/types"; +import { resolveOpenAiVisionModel } from "../src/vision"; import { ManagementRequest as Request } from "./helpers/management-auth"; +async function getVision(config: OcxConfig): Promise { + const url = new URL("http://localhost/api/sidecar-settings"); + const response = await handleManagementAPI(new Request(url), url, config); + if (!response) throw new Error("sidecar settings route did not handle request"); + return response; +} + async function putVision(config: OcxConfig, vision: Record): Promise { const url = new URL("http://localhost/api/sidecar-settings"); const response = await handleManagementAPI( @@ -44,6 +52,24 @@ describe("vision reasoning capability contracts", () => { expect(efforts("gpt-5.6-sol")).not.toContain("ultra"); }); + test("management GET reports the effective default model and effort for stale configs", async () => { + for (const model of [undefined, ""] as const) { + const config = { + port: 10100, + defaultProvider: "none", + providers: {}, + visionSidecar: { ...(model === undefined ? {} : { model }), reasoning: "max" }, + } as OcxConfig; + const response = await getVision(config); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + vision: { model: "gpt-5.4-mini", reasoning: "xhigh" }, + }); + // Reads report effective execution state without mutating a hand-edited config in memory. + expect(config.visionSidecar?.reasoning).toBe("max"); + } + }); + test("management normalizes native model/effort pairs on every relevant partial update", async () => { const previousHome = process.env.OPENCODEX_HOME; const isolatedHome = mkdtempSync(join(tmpdir(), "ocx-vision-reasoning-contract-")); @@ -117,7 +143,7 @@ describe("vision reasoning capability contracts", () => { } }); - test("CLI import normalizes reasoning against the runtime model default", async () => { + test("CLI import normalizes reasoning against omitted and blank runtime model defaults", async () => { const previousHome = process.env.OPENCODEX_HOME; const isolatedHome = mkdtempSync(join(tmpdir(), "ocx-vision-reasoning-cli-")); process.env.OPENCODEX_HOME = isolatedHome; @@ -131,9 +157,20 @@ describe("vision reasoning capability contracts", () => { visionSidecar: { reasoning: "max" }, })); expect(await handleConfigCommand(["import", importPath, "--yes", "--json"])).toBe(0); - const persisted = JSON.parse(readFileSync(join(isolatedHome, "config.json"), "utf8")); + let persisted = JSON.parse(readFileSync(join(isolatedHome, "config.json"), "utf8")); expect(persisted.visionSidecar).toMatchObject({ reasoning: "xhigh" }); expect(persisted.visionSidecar.model).toBeUndefined(); + + writeFileSync(importPath, JSON.stringify({ + port: 10100, + defaultProvider: "none", + providers: {}, + visionSidecar: { model: "", reasoning: "max" }, + })); + expect(await handleConfigCommand(["import", importPath, "--yes", "--json"])).toBe(0); + persisted = JSON.parse(readFileSync(join(isolatedHome, "config.json"), "utf8")); + expect(persisted.visionSidecar).toMatchObject({ model: "", reasoning: "xhigh" }); + expect(resolveOpenAiVisionModel({ visionSidecar: persisted.visionSidecar })).toBe("gpt-5.4-mini"); } finally { if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; From 4fb06e027955ae83c2a9d33d786821e5910ff35f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:59:55 +0200 Subject: [PATCH 51/62] docs(sidecars): clarify vision defaults and capability clamps --- docs-site/src/content/docs/guides/sidecars.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/guides/sidecars.md b/docs-site/src/content/docs/guides/sidecars.md index 0a804a7f5..a397f08a9 100644 --- a/docs-site/src/content/docs/guides/sidecars.md +++ b/docs-site/src/content/docs/guides/sidecars.md @@ -70,18 +70,20 @@ failures after response headers have started are delivered as `response.failed` ## Vision sidecar When the routed model is listed in its provider's `noVisionModels` and a request carries an image, -opencodex describes each image **before** the main call and replaces it with text. The Dashboard and -management API present `gpt-5.6-luna` as the current default, and startup migrates an explicitly -persisted legacy `gpt-5.4-mini` value to Luna. If the `visionSidecar.model` field is entirely absent, -the vision execution path still has a `gpt-5.4-mini` code fallback. +opencodex describes each image **before** the main call and replaces it with text. When +`visionSidecar.model` is absent or blank, the OpenAI execution path, Dashboard, and management API +use the `gpt-5.4-mini` fallback. Startup still migrates an explicitly persisted legacy +`gpt-5.4-mini` value to `gpt-5.6-luna`; that migration applies to a stored value, not to an absent +model field. - Images can come from user, developer, and tool-result messages, including Codex's `view_image`. - On the OpenAI path (ChatGPT-login passthrough), each image is sent to the configured vision model over the Responses endpoint with the selected `reasoning.effort` (`low` by default), and its description replaces the image part inline. The Anthropic path uses the Messages endpoint with its own thinking-budget mapping and ignores this OpenAI-specific setting. -- Supported levels depend on the selected provider and model. When you pick a level the model does - not advertise, the Dashboard clamps the saved value to the model's highest supported rung. +- For native models with known capability metadata, unsupported reasoning is normalized to the + highest supported rung at or below the requested level; if none exists, the lowest supported rung + is used. Unknown or custom models remain permissive when reliable capability metadata is absent. - Descriptions run with bounded concurrency (3 at a time, input order preserved). User context sent to the describer is capped at 800 characters, and each injected description is capped at 2,000 characters. The request does not send `max_output_tokens`, which the ChatGPT backend rejects. From caaedb416343eddd8d902793965de98677b9333c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:00:26 +0200 Subject: [PATCH 52/62] docs(ja): align vision defaults and clamp semantics --- docs-site/src/content/docs/ja/guides/sidecars.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/ja/guides/sidecars.md b/docs-site/src/content/docs/ja/guides/sidecars.md index 843fdadd1..376bdabaa 100644 --- a/docs-site/src/content/docs/ja/guides/sidecars.md +++ b/docs-site/src/content/docs/ja/guides/sidecars.md @@ -69,9 +69,10 @@ stall は全体生成 timeout ではありません。SSE 開始前の失敗は ## ビジョンサイドカー ルーティングモデルが該当プロバイダーの `noVisionModels` にありリクエストに画像が来る場合、opencodex は -メイン呼び出し**前に**各画像を説明したテキストに差し替えます。ダッシュボードと管理 API の現在のデフォルト選択は -`gpt-5.6-luna` で、起動時に明示的に保存された既存 `gpt-5.4-mini` 値も Luna にマイグレーションします。 -ただし `visionSidecar.model` フィールド自体がない場合はビジョン実行経路はコードフォールバックの `gpt-5.4-mini` を使います。 +メイン呼び出し**前に**各画像を説明したテキストに差し替えます。`visionSidecar.model` が未設定または空の場合、 +OpenAI 実行経路、ダッシュボード、管理 API は `gpt-5.4-mini` をフォールバックとして使います。起動時には +明示的に保存された旧 `gpt-5.4-mini` 値を引き続き `gpt-5.6-luna` にマイグレーションしますが、この +マイグレーションは保存済みの値だけが対象で、モデルフィールドがない場合には適用されません。 - 画像はユーザー、developer、ツール結果メッセージから来ます。Codex の `view_image` 結果も 含まれます。 @@ -79,8 +80,9 @@ stall は全体生成 timeout ではありません。SSE 開始前の失敗は `low`)付きで Responses エンドポイント経由で設定済みのビジョンモデルに送信され、説明が画像部分 をインラインで置き換えます。Anthropic パスは Messages エンドポイントを使い、独自の思考予算 マッピングで動作し、この OpenAI 固有の設定を無視します。 -- 対応するレベルは、選択したプロバイダーとモデルに依存します。ダッシュボードでモデルが公表して - いないレベルを選ぶと、保存値はそのモデルが対応する最高ラングに切り詰められます。 +- 信頼できる能力メタデータがあるネイティブモデルでは、未対応の推論レベルは要求値以下で最も高い + 対応レベルに正規化されます。該当するレベルがない場合は最も低い対応レベルを使います。能力情報を + 信頼できない不明モデルやカスタムモデルは制限せず、そのまま扱います。 - 説明は一度に 3 件並列処理し入力順序を維持します。説明モデルに渡すユーザー文脥は 800 文字、注入する画像説明は 1 枚あたり 2,000 文字に制限します。ChatGPT バックエンドが拒否する `max_output_tokens` は送信しません。 From 4fbd649c2fa9c6ec88ae057991ed5b74e46aab82 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:02:26 +0200 Subject: [PATCH 53/62] docs(ko): align vision defaults and clamp semantics --- docs-site/src/content/docs/ko/guides/sidecars.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/ko/guides/sidecars.md b/docs-site/src/content/docs/ko/guides/sidecars.md index 17f6f9785..d22e8c66e 100644 --- a/docs-site/src/content/docs/ko/guides/sidecars.md +++ b/docs-site/src/content/docs/ko/guides/sidecars.md @@ -70,9 +70,10 @@ stall은 전체 생성 timeout이 아닙니다. SSE가 시작되기 전 실패 ## 비전 사이드카 라우팅 모델이 해당 프로바이더의 `noVisionModels`에 있고 요청에 이미지가 들어오면, opencodex는 -메인 호출 **전에** 각 이미지를 설명한 텍스트로 바꿉니다. Dashboard와 관리 API의 현재 기본 선택값은 -`gpt-5.6-luna`이며, 시작할 때 명시적으로 저장된 기존 `gpt-5.4-mini` 값도 Luna로 마이그레이션합니다. -다만 `visionSidecar.model` 필드 자체가 없으면 비전 실행 경로는 코드 폴백인 `gpt-5.4-mini`를 씁니다. +메인 호출 **전에** 각 이미지를 설명한 텍스트로 바꿉니다. `visionSidecar.model`이 없거나 빈 값이면 +OpenAI 실행 경로, Dashboard, 관리 API는 `gpt-5.4-mini`를 폴백으로 사용합니다. 시작 시 명시적으로 +저장된 기존 `gpt-5.4-mini` 값은 계속 `gpt-5.6-luna`로 마이그레이션되지만, 이 마이그레이션은 저장된 +값에만 적용되고 모델 필드가 없는 경우에는 적용되지 않습니다. - 이미지는 사용자, developer, 도구 결과 메시지에서 올 수 있습니다. Codex의 `view_image` 결과도 포함됩니다. @@ -80,8 +81,9 @@ stall은 전체 생성 timeout이 아닙니다. SSE가 시작되기 전 실패 `low`)와 함께 Responses 엔드포인트로 설정된 비전 모델에 전송되고, 설명이 이미지 부분을 인라인으로 대체합니다. Anthropic 경로는 Messages 엔드포인트와 자체 thinking 예산 매핑을 사용하며 이 OpenAI 전용 설정을 무시합니다. -- 지원되는 수준은 선택한 제공자와 모델에 따라 달라집니다. 대시보드에서 모델이 공개하지 않은 수준을 - 고르면 저장된 값은 모델이 지원하는 최고 단계로 제한됩니다. +- 신뢰할 수 있는 기능 메타데이터가 있는 네이티브 모델에서는 지원되지 않는 추론 수준을 요청값 이하에서 + 가장 높은 지원 단계로 정규화합니다. 해당 단계가 없으면 가장 낮은 지원 단계를 사용합니다. 신뢰할 수 + 있는 기능 메타데이터가 없는 알 수 없는 모델이나 커스텀 모델은 제한하지 않습니다. - 설명은 한 번에 3개씩 병렬 처리하며 입력 순서를 유지합니다. 설명 모델에 전달하는 사용자 문맥은 800자, 주입하는 이미지 설명은 장당 2,000자로 제한합니다. ChatGPT 백엔드가 거부하는 `max_output_tokens`는 보내지 않습니다. From a1cd5fa14aaa95a5a3ec82b98a2c780bf24fba7d Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:02:54 +0200 Subject: [PATCH 54/62] docs(ru): align vision defaults and clamp semantics --- docs-site/src/content/docs/ru/guides/sidecars.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/ru/guides/sidecars.md b/docs-site/src/content/docs/ru/guides/sidecars.md index d09c69dd8..717707f0c 100644 --- a/docs-site/src/content/docs/ru/guides/sidecars.md +++ b/docs-site/src/content/docs/ru/guides/sidecars.md @@ -80,10 +80,10 @@ SSE-событие `response.failed`. Когда маршрутизируемая модель указана в `noVisionModels` своего провайдера, а запрос содержит изображение, opencodex описывает каждое изображение **до** основного вызова и заменяет его текстом. -Дашборд и API управления показывают `gpt-5.6-luna` как текущее значение по умолчанию, а при запуске -явно сохранённое устаревшее значение `gpt-5.4-mini` мигрирует на Luna. Если поле -`visionSidecar.model` полностью отсутствует, путь выполнения vision всё же имеет зашитый в код -фолбэк `gpt-5.4-mini`. +Если `visionSidecar.model` отсутствует или пуст, путь выполнения OpenAI, дашборд и API управления +используют фолбэк `gpt-5.4-mini`. При запуске явно сохранённое устаревшее значение +`gpt-5.4-mini` по-прежнему мигрирует на `gpt-5.6-luna`; миграция применяется только к сохранённому +значению, а не к отсутствующему полю модели. - Изображения могут приходить из сообщений пользователя, разработчика и результатов инструментов, включая `view_image` из Codex. @@ -91,9 +91,10 @@ SSE-событие `response.failed`. vision-модель через endpoint Responses с выбранным `reasoning.effort` (по умолчанию `low`), и полученное описание заменяет часть с изображением на месте. Путь Anthropic использует endpoint Messages со своим mapping'ом thinking-бюджета и игнорирует эту специфичную для OpenAI настройку. -- Поддерживаемые уровни зависят от выбранного провайдера и модели. Если в дашборде выбрать уровень, - который модель не заявляет, сохранённое значение ограничивается наивысшим поддерживаемым уровнем - модели. +- Для нативных моделей с надёжными метаданными возможностей неподдерживаемый уровень нормализуется + к самому высокому поддерживаемому уровню, не превышающему запрошенный; если такого нет, + используется самый низкий поддерживаемый уровень. Неизвестные и пользовательские модели без + надёжных метаданных остаются без ограничений. - Описания выполняются с ограниченной параллельностью (по 3 одновременно, порядок входа сохраняется). Пользовательский контекст, передаваемый описывающей модели, ограничен 800 символами, а каждое внедряемое описание — 2 000 символами. Запрос не отправляет From 4ca1fd1f2d5b1057544c1abdd0163bfc45af55fc Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:03:22 +0200 Subject: [PATCH 55/62] docs(zh-cn): align vision defaults and clamp semantics --- docs-site/src/content/docs/zh-cn/guides/sidecars.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/zh-cn/guides/sidecars.md b/docs-site/src/content/docs/zh-cn/guides/sidecars.md index abec95996..136d27d2d 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sidecars.md +++ b/docs-site/src/content/docs/zh-cn/guides/sidecars.md @@ -64,16 +64,16 @@ Anthropic OAuth provider。Sidecar 错误会转换成长度受限的工具结果 ## Vision sidecar 当路由模型列在其 provider 的 `noVisionModels` 中,并且请求包含图像时,opencodex 会在主调用 -**之前**描述每张图像,并用文字替换图像。Dashboard 和管理 API 当前显示的默认值是 -`gpt-5.6-luna`,启动时也会把明确保存的旧 `gpt-5.4-mini` 值迁移到 Luna。只有在 -`visionSidecar.model` 字段完全不存在时,vision 执行路径才会使用代码中的 `gpt-5.4-mini` 回退值。 +**之前**描述每张图像,并用文字替换图像。当 `visionSidecar.model` 缺失或为空时,OpenAI 执行路径、 +Dashboard 和管理 API 都使用 `gpt-5.4-mini` 作为回退。启动时仍会把明确保存的旧 +`gpt-5.4-mini` 值迁移到 `gpt-5.6-luna`;该迁移只作用于已保存值,不适用于缺失的 model 字段。 - 图像可以来自 user、developer 和 tool-result message,也包括 Codex 的 `view_image` 结果。 - OpenAI 路径(ChatGPT 登录透传)会通过 Responses 端点把每张图像发送给配置的视觉模型,并携带所选 的 `reasoning.effort`(默认为 `low`),描述结果就地替换图像部分。Anthropic 路径走 Messages 端点并使用自己的思考预算映射,会忽略这个 OpenAI 专用设置。 -- 支持的等级取决于所选提供方和模型。在控制台选择模型未公布的等级时,保存的值会被钳制到该模型 - 支持的最高档位。 +- 对于具有可靠能力元数据的原生模型,不支持的推理等级会归一化到不高于请求值的最高支持档位;如果 + 不存在这样的档位,则使用最低支持档位。对于缺少可靠能力元数据的未知模型或自定义模型,保持宽松处理。 - 描述任务最多同时处理 3 张图像,并保持输入顺序。发送给描述模型的用户上下文最多 800 个字符, 每张图像注入的描述最多 2,000 个字符。请求不会发送 ChatGPT 后端不支持的 `max_output_tokens`。 From 6b1811a0a3dc2c1fed93552875b26e0681ee6e52 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:07:06 +0200 Subject: [PATCH 56/62] feat(i18n): localize vision reasoning levels --- gui/src/i18n/vision-reasoning-labels.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 gui/src/i18n/vision-reasoning-labels.ts diff --git a/gui/src/i18n/vision-reasoning-labels.ts b/gui/src/i18n/vision-reasoning-labels.ts new file mode 100644 index 000000000..2a9d5f43c --- /dev/null +++ b/gui/src/i18n/vision-reasoning-labels.ts @@ -0,0 +1,17 @@ +import type { Locale } from "./shared"; + +export type VisionReasoningLabelLevel = "low" | "medium" | "high" | "xhigh" | "max"; + +const VISION_REASONING_LABELS = { + en: { low: "Low", medium: "Medium", high: "High", xhigh: "Extra high", max: "Maximum" }, + de: { low: "Niedrig", medium: "Mittel", high: "Hoch", xhigh: "Sehr hoch", max: "Maximum" }, + ko: { low: "낮음", medium: "보통", high: "높음", xhigh: "매우 높음", max: "최대" }, + zh: { low: "低", medium: "中", high: "高", xhigh: "极高", max: "最大" }, + ru: { low: "Низкий", medium: "Средний", high: "Высокий", xhigh: "Очень высокий", max: "Максимальный" }, + ja: { low: "低", medium: "中", high: "高", xhigh: "非常に高い", max: "最大" }, +} satisfies Record>; + +/** Localized display label for the wire-level vision reasoning value. */ +export function visionReasoningLabel(locale: Locale, level: VisionReasoningLabelLevel): string { + return VISION_REASONING_LABELS[locale][level]; +} From 621e616fbb37fdbdf5c8b66c0df4dd6bb5076ab6 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:08:05 +0200 Subject: [PATCH 57/62] fix(gui): localize vision reasoning labels --- gui/src/pages/dashboard-overview-sections.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gui/src/pages/dashboard-overview-sections.tsx b/gui/src/pages/dashboard-overview-sections.tsx index dc8dff049..f62403a88 100644 --- a/gui/src/pages/dashboard-overview-sections.tsx +++ b/gui/src/pages/dashboard-overview-sections.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { IconAlert, IconCheck, IconInfo, IconRefresh, IconX } from "../icons"; import { Trans } from "../i18n/provider"; +import { visionReasoningLabel } from "../i18n/vision-reasoning-labels"; import { Select } from "../ui"; import { formatNamespacedModelId } from "../provider-icons"; import { navigateHash } from "../hash-routing"; @@ -238,7 +239,7 @@ export function DashboardMaintenancePanel({ d }: { d: Dash }) { export function DashboardSidecarPanels({ d }: { d: Dash }) { const { - t, settings, settingsSaving, toggleCodexAutoStart, + locale, t, settings, settingsSaving, toggleCodexAutoStart, sidecar, sidecarSaving, sidecarModels, models, saveSidecar, shadowCall, shadowCallSaving, shadowCallHelpTriggerRef, shadowCallHelpOpen, setShadowCallHelpOpen, saveShadowCall, } = d; @@ -300,7 +301,7 @@ export function DashboardSidecarPanels({ d }: { d: Dash }) { />