diff --git a/devlog/_plan/260915_workflow_budget_window/000_unit.md b/devlog/_plan/260915_workflow_budget_window/000_unit.md new file mode 100644 index 0000000000..9cf76c0b30 --- /dev/null +++ b/devlog/_plan/260915_workflow_budget_window/000_unit.md @@ -0,0 +1,97 @@ +# 260915 — the root workflow budget outlives the task it was sized for + +## What happened + +A Codex session spent several hours dispatching subagents. Every dispatch failed, +across three unrelated providers, with a 429 that reads as a provider rate limit. +The obvious readings were all wrong: not the model, not the account, not the +upstream. The refusal came from this proxy. + +The reproduction is one line. With the same request body, a probe carrying the +long-running session id in `x-codex-parent-thread-id` was refused, and a probe +carrying a freshly invented root id was served. Restarting the proxy served both. +That is the whole diagnosis: the ceiling is per root, held in process memory, and +the session had reached it. + +## Why the ceiling fired + +`DEFAULT_WORKFLOW_BUDGET_POLICY` (`src/lib/workflow-budget.ts`) caps a root at 256 +physical sends and 64 distinct children. `workflowSendCeilingReached` compares +`state.sends >= policy.maxPhysicalSends`, and `state.sends` is **cumulative for the +life of the process**. The root id is `x-codex-parent-thread-id`, which for Codex is +the session. So the cap is not a fan-out guard on a long session; it is an expiry. + +The comment that justifies it says a per-request cap "cannot bound a fan-out that +sends once per child seven hundred times". That is a **burst** concern, and a burst +is bounded by a rate. A lifetime total cannot tell seven hundred sends in a minute +from two hundred and fifty-six sends spread over four hours, and it refuses both. +The second one is ordinary work. + +Two things made it expensive to diagnose rather than merely annoying. The refusal +is a 429 that an operator reads as an upstream rate limit, so the first hours went +to providers and accounts. And there is no way out except restarting the proxy: +`resetWorkflowBudgetsForTest` exists, the name says who it is for, and +`workflowBudgetSnapshot` is never exposed, so the state that decided the refusal is +invisible from outside the process. + +## The rule + +> A root budget bounds a **rate**, and says so. A ceiling that fires is a local +> decision an operator can see, name and clear without restarting the proxy. + +## Roadmap + +| Doc | Work phase | Outcome | +| --- | --- | --- | +| `010_windowed_ceilings.md` | wfb | Sends and distinct children are counted over a bounded window, so a long session is never refused for work it did hours ago while a burst inside one window still is | +| `020_legible_refusal.md` | wfc | The refusal names the ceiling that fired, is marked as a proxy decision rather than an upstream one, and the root budget can be read and cleared through the management API | + +## Write scope + +Permitted: `src/lib/workflow-budget.ts`, the workflow call sites in +`src/server/responses/core.ts` and `src/server/index.ts`, the management read and +mutation surface under `src/server/management/`, `src/server/request-log.ts` for the +refusal provenance, their tests, and this unit. + +## Verification posture + +Local suite, typecheck, install and GUI build are **not run** for this unit by +explicit instruction. Proof is hosted CI at the exact final head SHA and nothing +else. Pushes use `--no-verify`. + +## What would make this fail + +Raising the numbers instead of fixing the shape. A bigger lifetime total is the +same defect further away: it still refuses a session for work it finished hours +ago, and it still cannot be seen or cleared. The window is the change; the numbers +are a consequence of it. + + +## Reproducing it + +Both probes carry the same body and differ only in the root id. Against a proxy +whose process has been up long enough for a session to reach the ceiling: + +```bash +BODY='{"model":"gpt-5.6-terra","input":[{"role":"user","content":[{"type":"input_text","text":"ok"}]}],"max_output_tokens":16,"stream":true}' + +# the long-running session's own root: refused +curl -s -o /dev/null -w '%{http_code}\n' -N -X POST http://127.0.0.1:10100/v1/responses \ + -H 'Content-Type: application/json' -H 'Accept: text/event-stream' \ + -H "x-codex-parent-thread-id: " -d "$BODY" + +# any root the process has not seen: served +curl -s -o /dev/null -w '%{http_code}\n' -N -X POST http://127.0.0.1:10100/v1/responses \ + -H 'Content-Type: application/json' -H 'Accept: text/event-stream' \ + -H "x-codex-parent-thread-id: probe-$(date +%s)" -d "$BODY" +``` + +Two answers from one proxy, one body and one upstream, separated only by which +root the request claims. That is what rules out the provider, the account and the +model in a single step, and it is the check to run first the next time a fan-out +starts failing for no visible reason. + +After a restart both return 200, which is the other half of the diagnosis: the +ceiling is process-memory only, so the evidence disappears the moment anyone tries +the obvious remedy. + diff --git a/devlog/_plan/260915_workflow_budget_window/010_windowed_ceilings.md b/devlog/_plan/260915_workflow_budget_window/010_windowed_ceilings.md new file mode 100644 index 0000000000..394364115c --- /dev/null +++ b/devlog/_plan/260915_workflow_budget_window/010_windowed_ceilings.md @@ -0,0 +1,52 @@ +# 010 — wfb: count sends and children over a window, not over a lifetime + +## Today + +```ts +export function workflowSendCeilingReached(rootId, policy) { + const state = roots.get(rootId); + return state !== undefined && state.sends >= policy.maxPhysicalSends; +} +``` + +`state.sends` only ever grows. `state.children` is a Set that only ever gains +members. Neither has a clock. A root that made 256 sends in its first hour is +refused for the rest of the process even if it sends nothing for a day. + +## The change + +Keep the counters, add a window. A root records its sends as timestamped buckets +and the ceiling compares the count **inside the window** against +`maxPhysicalSends`. Distinct children get the same treatment: a child seen once, +hours ago, and never again should not hold a slot forever. + +The default window has to be argued for rather than picked. 256 sends is the +number already in the tree and it was chosen against a fan-out, so the window is +the interval over which that fan-out would be abusive. A ten-minute window keeps +the original intent — seven hundred sends in a minute is still refused several +times over — while an ordinary session that averages well under a send every two +seconds never approaches it. + +`maxConcurrentChildren` stays as it is. Concurrency is already instantaneous; it +has no lifetime problem to fix. + +## What must not change + +An unconfigured install must not see a refusal it would not have seen before. +Windowing only ever admits more, never less, for the same traffic — the count +inside a window is bounded by the lifetime count — so this direction is safe by +construction. Say so in a test rather than trusting the argument. + +The eviction rules from #4546 stay: a root is evicted only when it is both +inactive and not exhausted, and a full table refuses rather than laundering a +fan-out into a fresh allowance. A windowed root that has aged out of its window is +no longer exhausted, which is exactly the state that makes it evictable again. + +## Acceptance + +1. A root at the ceiling is admitted once its window rolls, without a restart. +2. A burst inside one window is still refused at the same count as before. +3. Distinct children age out of the window the same way sends do. +4. Bucket storage per root is bounded; a root that sends forever does not grow + forever. + diff --git a/devlog/_plan/260915_workflow_budget_window/020_legible_refusal.md b/devlog/_plan/260915_workflow_budget_window/020_legible_refusal.md new file mode 100644 index 0000000000..78a1897cf4 --- /dev/null +++ b/devlog/_plan/260915_workflow_budget_window/020_legible_refusal.md @@ -0,0 +1,50 @@ +# 020 — wfc: a refusal an operator can read, name and clear + +## Today + +The refusal is a 429 with `workflow_budget_exhausted` and a sentence about the +task's send budget. Two problems, and the first one cost hours. + +A 429 from a proxy that also forwards provider 429s is ambiguous. There is nothing +on the record that says which one this was, so the first move is always to go look +at the provider. This is the same defect #4639 fixed for the synthetic 503: a +locally generated refusal presented under a field an operator reads as upstream. +The fix there was provenance on the record, and it applies unchanged here. + +The second is that there is no way out. `resetWorkflowBudgetsForTest` is named for +its audience and `workflowBudgetSnapshot` has no caller outside the module, so the +state that decided the refusal cannot be read and cannot be cleared except by +restarting the proxy — which drops every other root's accounting with it. + +## The change + +Name the ceiling. The denial type already distinguishes +`workflow-sends-exhausted` from `workflow-children-exhausted` and the rest; carry +that through to the error body and onto the request log instead of collapsing it +into one sentence. + +Mark it local. The request log gains the same origin treatment #4639 introduced, so +a proxy refusal and an upstream 429 are distinguishable on the record and on the +management read surface. + +Expose and allow clearing. `GET` the root's budget through the management API so an +operator can see a ceiling approaching rather than discovering it, and allow a +bounded, recorded clear of one root. Clearing one root is not the same as +restarting: it is scoped, it is logged, and it leaves every other root's accounting +intact. + +## What must not change + +The clear is an operator action on the operator's own proxy, not a path a request +can take. It goes through the management surface, which already requires a +dashboard session or the admin token, and it must not be reachable from the data +plane. A fan-out cannot be allowed to clear its own ceiling — that would make the +budget a suggestion, which is the failure #4546 spent a release removing. + +## Acceptance + +1. The refusal body and the request log name which ceiling fired. +2. The record marks the refusal as proxy-origin, distinguishable from an upstream 429. +3. An operator can read one root's budget and clear it through the management API. +4. The clear is scoped to one root, is recorded, and is not reachable from the data plane. + diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 58eb9792ae..e6f9844c1f 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -172,7 +172,7 @@ does not change `main`/`preview` review rules or allow direct pushes, force-push ## Adding a provider to the catalog -All provider pickers and seeds derive from the canonical registry (`src/providers/registry.ts`): +All provider pickers and seeds derive from the canonical registry (`src/providers/registry/entries-extended.ts`): ```ts { diff --git a/docs-site/src/content/docs/fr/contributing.md b/docs-site/src/content/docs/fr/contributing.md index 34d6548d77..345d31359f 100644 --- a/docs-site/src/content/docs/fr/contributing.md +++ b/docs-site/src/content/docs/fr/contributing.md @@ -160,7 +160,7 @@ du dépôt et des chemins sensibles du point de vue de la sécurité est déclar ## Ajout d'un fournisseur au catalogue -Tous les sélecteurs de fournisseurs et les graines proviennent du registre canonique (`src/providers/registry.ts`) : +Tous les sélecteurs de fournisseurs et les graines proviennent du registre canonique (`src/providers/registry/entries-extended.ts`) : ```ts { diff --git a/docs-site/src/content/docs/ja/contributing.md b/docs-site/src/content/docs/ja/contributing.md index ebada118d0..115d182ef6 100644 --- a/docs-site/src/content/docs/ja/contributing.md +++ b/docs-site/src/content/docs/ja/contributing.md @@ -123,7 +123,7 @@ Go ネイティブポートを担っていた `dev2-go` は廃止し、2 本の ## カタログにプロバイダーを追加 -すべてのプロバイダー選択肢と seed は canonical レジストリ(`src/providers/registry.ts`)から派生します。 +すべてのプロバイダー選択肢と seed は canonical レジストリ(`src/providers/registry/entries-extended.ts`)から派生します。 ```ts { diff --git a/docs-site/src/content/docs/ko/contributing.md b/docs-site/src/content/docs/ko/contributing.md index 24642bdf81..f6eee2bc16 100644 --- a/docs-site/src/content/docs/ko/contributing.md +++ b/docs-site/src/content/docs/ko/contributing.md @@ -122,7 +122,7 @@ Go 네이티브 포트를 담당했던 `dev2-go`는 정리했고, 두 라인을 ## 카탈로그에 프로바이더 추가하기 -모든 프로바이더 선택기와 seed는 canonical registry(`src/providers/registry.ts`)에서 파생됩니다. +모든 프로바이더 선택기와 seed는 canonical registry(`src/providers/registry/entries-extended.ts`)에서 파생됩니다. ```ts { diff --git a/docs-site/src/content/docs/ru/contributing.md b/docs-site/src/content/docs/ru/contributing.md index b926b32e04..a1857f6821 100644 --- a/docs-site/src/content/docs/ru/contributing.md +++ b/docs-site/src/content/docs/ru/contributing.md @@ -124,7 +124,7 @@ Pull request'ы с ребейзом приветствуются: ребейз ## Добавление провайдера в каталог Все селекторы провайдеров и seed-данные выводятся из канонического реестра -(`src/providers/registry.ts`): +(`src/providers/registry/entries-extended.ts`): ```ts { diff --git a/docs-site/src/content/docs/tr/contributing.md b/docs-site/src/content/docs/tr/contributing.md index 66eb9293b9..cd00f95262 100644 --- a/docs-site/src/content/docs/tr/contributing.md +++ b/docs-site/src/content/docs/tr/contributing.md @@ -186,7 +186,7 @@ sahipliği `.github/CODEOWNERS` dosyasında bildirilmiştir. ## Kataloğa sağlayıcı ekleme Tüm sağlayıcı seçicileri ve tohumları kurallı kayıt defterinden -(`src/providers/registry.ts`) türetilir: +(`src/providers/registry/entries-extended.ts`) türetilir: ```ts { diff --git a/docs-site/src/content/docs/zh-cn/contributing.md b/docs-site/src/content/docs/zh-cn/contributing.md index 7adccef691..a25ea4fcb1 100644 --- a/docs-site/src/content/docs/zh-cn/contributing.md +++ b/docs-site/src/content/docs/zh-cn/contributing.md @@ -113,7 +113,7 @@ bun run release:watch # 观察最新的 Release workflow run ## 向目录中添加 provider -所有 provider picker 与 seed 都来自 canonical registry(`src/providers/registry.ts`): +所有 provider picker 与 seed 都来自 canonical registry(`src/providers/registry/entries-extended.ts`): ```ts { diff --git a/docs-site/src/content/docs/zh-tw/contributing.md b/docs-site/src/content/docs/zh-tw/contributing.md index 97880fe4f2..86931c1ff6 100644 --- a/docs-site/src/content/docs/zh-tw/contributing.md +++ b/docs-site/src/content/docs/zh-tw/contributing.md @@ -131,7 +131,7 @@ bun run release:watch # 觀察最新的 Release workflow run ## 向目錄中新增 provider -所有 provider picker 與 seed 都來自 canonical registry(`src/providers/registry.ts`): +所有 provider picker 與 seed 都來自 canonical registry(`src/providers/registry/entries-extended.ts`): ```ts { diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 51902ee0b8..8503210e46 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -1,1462 +1,50 @@ import { hasShrinkableOpenAIChatImages, normalizeOpenAIChatImages } from "./openai-chat-images"; import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base"; -import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types"; -import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types"; +import { modelInList } from "../types"; import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort"; -import { registryEntryForProviderDestination } from "../providers/registry"; import { debugProviderDiagnostic } from "../lib/debug"; import { sseFieldValue } from "../lib/sse-decoder"; import { isDebugEnabled } from "../lib/debug-settings"; -import { isCyberPolicyCode } from "../lib/errors"; -import { redactSecretString } from "../lib/redact"; -import { contentPartsToText } from "./image"; -import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation"; -import { identifyRoutedModel } from "./identity"; -import { peekReasoningForCall } from "../responses/reasoning-replay-cache"; -import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge"; +import { frameAgentRouterMessages } from "./agentrouter"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing"; import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../providers/vercel-gateway-routing"; -import { - canForwardForeignServiceTierForChatModel, - fastPolicyForModel, - supportsServiceTierForModel, -} from "../providers/service-tier"; -import { - canonicalFastTierMarker, - createAdapterTierMetadata, - decideTier, - type AdapterTierMetadata, - type ResolvedFastPolicy, -} from "../providers/fastwire"; -import { openaiChatCompletionsUrl } from "./openai-chat-url"; -import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "./responses-tool-schema"; -import { agentRouterDefaultHeaders, frameAgentRouterMessages } from "./agentrouter"; -import { - isXaiSchemaTarget, - lookupLocalJsonPointer, - normalizeXaiToolParameters, -} from "./xai-tool-schema"; +import { fastPolicyForModel } from "../providers/service-tier"; +import { createAdapterTierMetadata, decideTier, type AdapterTierMetadata } from "../providers/fastwire"; import { isTranslatorBudgetExceededError, retainTranslatedEventBatch, TRANSLATOR_MAX_SSE_EVENT_BYTES, type TranslatorBudget, } from "../lib/translator-budget"; - -// Providers may opt into stripping one trailing "[...]" group from the wire model id. -// Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211; -// unflagged OpenAI-compatible providers and the Anthropic adapter keep ids verbatim. -export function stripBracketedModelSuffix(modelId: string): string { - const suffixEnd = modelId.trimEnd().length; - if (suffixEnd === 0 || modelId[suffixEnd - 1] !== "]") return modelId; - - let suffixStart = -1; - for (let i = suffixEnd - 2; i >= 0 && modelId[i] !== "]"; i--) { - if (modelId[i] === "[") suffixStart = i; - } - return suffixStart === -1 ? modelId : modelId.slice(0, suffixStart); -} - -const CHAT_PASSTHROUGH_FIELDS = [ - "audio", - "frequency_penalty", - "logit_bias", - "logprobs", - "max_completion_tokens", - "max_tokens", - "metadata", - "modalities", - "n", - "prediction", - "presence_penalty", - "reasoning_effort", - "response_format", - "seed", - "stop", - "store", - "temperature", - "tool_choice", - "tools", - "top_logprobs", - "top_p", - "user", - "web_search_options", -] as const; - -function openAIChatTransport(provider: OcxProviderConfig): { - url: string; - headers: Record; - hasCredential: boolean; -} { - const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0; - if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) { - throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`); - } - const headers: Record = { - "Content-Type": "application/json", - ...agentRouterDefaultHeaders(provider.baseUrl, provider.headers), - }; - if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`; - if (provider.headers) Object.assign(headers, provider.headers); - // A configured relative path wins, mirroring how the Responses adapter honours - // `responsesPath`. An upstream can serve both wires under different prefixes, and a - // per-model wire override only swaps the adapter, so without this the opted-in Chat - // request would be sent to the Responses base with `/chat/completions` appended. - const url = provider.chatCompletionsPath === undefined - ? openaiChatCompletionsUrl(provider.baseUrl) - : `${provider.baseUrl.replace(/\/$/, "")}${provider.chatCompletionsPath}`; - return { url, headers, hasCredential }; -} - -/** - * The translated Chat route has no video mapping: this adapter does not implement one, - * and the marker records that fact so the payload is not dropped in silence. - * - * The wording is deliberately about opencodex's own translation, not the provider or - * model. An earlier revision said "unsupported by this provider", which attributed an - * opencodex mapping limit to upstream capability the proxy has not established. Native - * Chat passthrough and Google inline video are unaffected by this route. - */ -const VIDEO_UNSUPPORTED_MARKER = "[video omitted: the translated Chat route has no video mapping]"; - -/** - * Build a provider request from an inbound Chat Completions body without translating it - * through the Responses contract. This is deliberately a whitelist: Chat-only caller - * fields retain their exact wire representation, while provider capability gates remain - * centralized beside the ordinary openai-chat adapter. - */ -export function buildOpenAIChatPassthroughRequest( - provider: OcxProviderConfig, - rawBody: Record, - modelId: string, - stream: boolean, - fastPolicy: ResolvedFastPolicy = fastPolicyForModel(provider, modelId, undefined, "chat"), - fastMode?: boolean, -): AdapterRequest { - const { url, headers, hasCredential } = openAIChatTransport(provider); - - const body: Record = { - model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId, - messages: frameAgentRouterMessages(provider.baseUrl, rawBody.messages), - stream, - }; - for (const field of CHAT_PASSTHROUGH_FIELDS) { - if (rawBody[field] !== undefined) body[field] = rawBody[field]; - } - const rawEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; - if (modelInList(provider.noReasoningModels, modelId) || rawEfforts?.length === 0) { - delete body.reasoning_effort; - } - - const openRouterRouting = resolveOpenRouterRouting(provider, modelId); - if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); - const vercelRouting = resolveVercelGatewayRouting(provider, modelId); - if (vercelRouting) body.provider = vercelGatewayProviderPayload(vercelRouting); - - if (modelInList(provider.noTemperatureModels, modelId)) delete body.temperature; - if (modelInList(provider.noTopPModels, modelId)) delete body.top_p; - if (modelInList(provider.noPenaltyModels, modelId)) { - delete body.presence_penalty; - delete body.frequency_penalty; - } - // Exact match, unlike the gates above: `noStructuredOutputModels` is documented as - // "only an exact requested-model match omits the field" (#1424), and the Responses - // ingress enforces exactly that. A prefix match here would strip response_format from - // `:` siblings the operator never opted out, silently returning prose. - if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format; - // Narrower neighbour: the model takes `json_object` but rejects `json_schema`. Downgrade - // rather than drop, so a caller that asked for JSON still gets JSON. The type check also - // makes the kill switch above win without an else — after its `delete` there is no type - // left to match. - const passthroughFormat = body.response_format; - if (provider.noJsonSchemaModels?.includes(modelId) - && typeof passthroughFormat === "object" && passthroughFormat !== null - && (passthroughFormat as { type?: unknown }).type === "json_schema") { - body.response_format = { type: "json_object" }; - } - - // Run the same complete Fast policy as the translated Chat path, including explicit - // fastMode and foreign-tier handling. On inherited canonical Fast, the passthrough still - // retains the caller's exact spelling; forced Fast uses the policy-owned wire value. - const callerTier = typeof rawBody.service_tier === "string" ? rawBody.service_tier : undefined; - const tierDecision = decideTier(fastPolicy, fastMode, callerTier); - if (tierDecision.kind === "set") { - body.service_tier = fastMode === undefined && canonicalFastTierMarker(callerTier) !== undefined - ? callerTier - : tierDecision.value; - } else if (tierDecision.kind === "forward-caller" && rawBody.service_tier !== undefined) { - body.service_tier = rawBody.service_tier; - } - if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) { - body.prompt_cache_key = rawBody.prompt_cache_key; - } - if (Array.isArray(rawBody.tools) && rawBody.tools.length > 0) { - if (provider.parallelToolCalls === true) { - body.parallel_tool_calls = rawBody.parallel_tool_calls !== false; - } else if (provider.parallelToolCalls === false - && (provider.baseUrl === "https://integrate.api.nvidia.com/v1" || provider.pinParallelToolCallsFalse === true)) { - body.parallel_tool_calls = false; - } - } - if (stream) { - const callerOptions = rawBody.stream_options !== null - && typeof rawBody.stream_options === "object" - && !Array.isArray(rawBody.stream_options) - ? rawBody.stream_options as Record - : {}; - body.stream_options = { ...callerOptions, include_usage: true }; - } else if (rawBody.stream_options !== undefined) { - body.stream_options = rawBody.stream_options; - } - - const bodyJson = JSON.stringify(body); - - if (isDebugEnabled()) { - let host = "upstream"; - try { host = new URL(url).host; } catch { /* keep fallback */ } - debugProviderDiagnostic("openai-chat", "passthrough-request", { - host, - model: body.model, - stream, - messageCount: Array.isArray(body.messages) ? body.messages.length : 0, - toolCount: Array.isArray(body.tools) ? body.tools.length : 0, - hasCredential, - bodyBytes: Buffer.byteLength(bodyJson, "utf8"), - }); - } - - return { url, method: "POST", headers, body: bodyJson }; -} - -// 260715 (issue #126): surface upstream error detail through the web-search sidecar loop. -// loop.ts only appends a suffix to "Provider error N" when the adapter exposes -// formatErrorBody; without it, strict OpenAI-compatible backends (NVIDIA NIM pydantic -// validation, "This model only supports single tool-calls at once!", etc.) were reduced -// to a bare status code. JSON-only extraction: recognized string fields are returned, -// HTML/non-JSON bodies yield "" so raw markup is never echoed to the client. -export function formatOpenAIChatErrorBody(status: number, _headers: Headers, payloadText: string): string { - let parsed: unknown; - try { - parsed = JSON.parse(payloadText); - } catch { - return ""; - } - const detail = extractErrorDetail(parsed); - if (!detail) return ""; - return redactSecretString(detail).slice(0, 400); -} - -function extractErrorDetail(parsed: unknown): string | undefined { - if (typeof parsed === "string") return parsed.trim() || undefined; - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; - const obj = parsed as Record; - const err = obj.error; - if (typeof err === "string" && err.trim()) return err.trim(); - if (err !== null && typeof err === "object" && !Array.isArray(err)) { - const msg = (err as Record).message; - if (typeof msg === "string" && msg.trim()) return msg.trim(); - } - const det = obj.detail; - if (typeof det === "string" && det.trim()) return det.trim(); - if (Array.isArray(det)) { - const msgs = det - .map(item => (item !== null && typeof item === "object" && typeof (item as Record).msg === "string" - ? ((item as Record).msg as string).trim() - : "")) - .filter(m => m.length > 0); - if (msgs.length > 0) return msgs.join("; "); - } - if (typeof obj.message === "string" && obj.message.trim()) return obj.message.trim(); - if (typeof obj.title === "string" && obj.title.trim()) return obj.title.trim(); - return undefined; -} - -function unwrapChatCompletionPayload(json: Record): Record { - if ((json.error !== undefined && json.error !== null) || Array.isArray(json.choices)) return json; - const data = json.data; - return data !== null && typeof data === "object" && !Array.isArray(data) - ? data as Record - : json; -} - -interface OpenAIChatError { - message?: unknown; - code?: unknown; - type?: unknown; - status?: unknown; - metadata?: unknown; -} - -function safeUpstreamRequestId(metadata: unknown): string | undefined { - if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) return undefined; - const record = metadata as Record; - const value = record.request_id ?? record.requestId; - if (typeof value !== "string") return undefined; - const requestId = value.trim(); - return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(requestId) - && redactSecretString(requestId) === requestId - ? requestId - : undefined; -} - -function upstreamErrorEvent( - error: unknown, - usage?: OcxUsage, -): Extract { - const details = error !== null && typeof error === "object" && !Array.isArray(error) - ? error as OpenAIChatError - : undefined; - const rawMessage = typeof error === "string" - ? error.trim() || "upstream error" - : typeof details?.message === "string" ? details.message : "upstream error"; - const safeMessage = redactSecretString(rawMessage); - const requestId = safeUpstreamRequestId(details?.metadata); - const message = requestId !== undefined && !safeMessage.includes(requestId) - ? `${safeMessage} (request ID: ${requestId})` - : safeMessage; - const code = typeof details?.code === "string" - ? details.code - : typeof details?.code === "number" && Number.isFinite(details.code) && Number.isInteger(details.code) - ? String(details.code) - : undefined; - const errorType = typeof details?.type === "string" ? details.type : undefined; - const codeStatus = typeof details?.code === "number" - && Number.isInteger(details.code) - && details.code >= 100 - && details.code <= 599 - ? details.code - : undefined; - const status = isCyberPolicyCode(code) - ? 400 - : typeof details?.status === "number" && Number.isInteger(details.status) - ? details.status - : codeStatus; - return { - type: "error", - message, - ...(usage !== undefined ? { usage } : {}), - ...(code !== undefined ? { code } : {}), - ...(errorType !== undefined ? { errorType } : {}), - ...(status !== undefined ? { status } : {}), - }; -} - -function stopReasonFor(finishReason: unknown): "max_tokens" | "content_filter" | undefined { - return finishReason === "length" - ? "max_tokens" - : finishReason === "content_filter" - ? "content_filter" - : undefined; -} - -function reasoningTextFrom(record: Record): string | undefined { - return typeof record.reasoning_content === "string" && record.reasoning_content.length > 0 - ? record.reasoning_content - : typeof record.reasoning === "string" && record.reasoning.length > 0 - ? record.reasoning - : undefined; -} - -interface ReasoningDetailSegment { - key: string; - text: string; -} - -/** - * Structured `reasoning_details` array (MiniMax M-series with `reasoning_split`). - * Each segment's key scopes cumulative-snapshot tracking: upstream repeats the - * full text-so-far under a stable `id`/`index` instead of sending increments. - */ -function reasoningDetailSegmentsFrom(record: Record): ReasoningDetailSegment[] { - const raw = record.reasoning_details; - if (!Array.isArray(raw)) return []; - const segments: ReasoningDetailSegment[] = []; - for (let i = 0; i < raw.length; i++) { - const item: unknown = raw[i]; - if (!isRecord(item)) continue; - if (typeof item.text !== "string" || item.text.length === 0) continue; - const key = typeof item.id === "string" && item.id.length > 0 - ? `id:${item.id}` - : typeof item.index === "number" - ? `i:${item.index}` - : `n:${i}`; - segments.push({ key, text: item.text }); - } - return segments; -} - -/** Single-segment `reasoning_details` entry for replaying preserved reasoning (MiniMax wire shape). */ -function reasoningDetailSegmentForWire(text: string): Record { - return { type: "reasoning.text", id: "reasoning-text-1", format: "MiniMax-response-v1", index: 0, text }; -} - -function invalidChoicesEvent(usage?: OcxUsage): Extract { - return { - type: "error", - message: "upstream response contained invalid choices", - ...(usage !== undefined ? { usage } : {}), - }; -} - -function invalidToolCallsEvent( - rawToolCalls: unknown, - mode: "stream" | "response", - usage?: OcxUsage, - diagnosticOverride?: InvalidToolCallDiagnostic, -): Extract { - // The streamed accumulator knows things a rescan cannot: which field on which pending call - // was actually rejected. Without the override, a stream carrying accepted padding on call 0 - // and a real defect on call 1 blames call 0, because the stateless scan stops at the first - // structurally odd value it sees. - const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode); - const detail = diagnostic - ? ` (${diagnostic.reason}${diagnostic.callIndex !== undefined ? `; callIndex=${diagnostic.callIndex}` : ""}; valueType=${diagnostic.valueType})` - : ""; - return { - type: "error", - status: 502, - errorType: "upstream_error", - message: `upstream response contained invalid tool calls${detail}`, - ...(usage !== undefined ? { usage } : {}), - }; -} - -/** - * A streamed tool call is only dispatchable once the upstream has named the function. - * - * The OpenAI streaming convention puts `function.name` in the first chunk for a tool-call - * index and leaves later chunks carrying only `arguments` deltas, so a stream that never - * sends a name is non-conforming for every provider rather than quirky for one. The - * reference implementations accumulate such a call with an empty name and let the caller - * fail; we sit at the boundary where it would become a Codex tool-call contract event, so - * the equivalent is to refuse to emit it. - * - * Failing closed rather than dropping is deliberate, and matches #1325: a claimed tool call - * that silently disappears can leave the matching result orphaned on the next turn. Naming - * it ourselves is worse still — the id is synthesizable because it is an opaque correlation - * handle, but a function name is a guess at intent. - */ -function unnamedToolCallEvent(usage?: OcxUsage): Extract { - return { - type: "error", - message: "upstream streamed a tool call without a function name — cannot dispatch", - ...(usage !== undefined ? { usage } : {}), - }; -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -type InvalidToolCallReason = - | "tool_calls_not_array" - | "tool_call_not_object" - | "tool_call_id_invalid" - | "tool_call_function_not_object" - | "tool_call_function_name_invalid" - | "tool_call_function_name_blank" - | "tool_call_function_arguments_invalid"; - -type InvalidToolCallDiagnostic = { - reason: InvalidToolCallReason; - callIndex?: number; - valueType: string; -}; - -type InvalidFieldShape = - | { - kind: "object"; - knownKeys: string[]; - knownFieldTypes: Record; - hasUnknownKeys: boolean; - } - | { - kind: "array"; - length: number; - }; - -const SAFE_TOOL_CALL_SHAPE_KEYS = [ - "name", - "type", - "value", - "function", - "arguments", - "id", - "index", -] as const; -const SAFE_TOOL_CALL_SHAPE_KEY_SET = new Set(SAFE_TOOL_CALL_SHAPE_KEYS); - -function structuralValueType(value: unknown): string { - return value === null ? "null" : Array.isArray(value) ? "array" : typeof value; -} - -function invalidToolCallField(rawToolCalls: unknown, diagnostic: InvalidToolCallDiagnostic): unknown { - if (diagnostic.reason === "tool_calls_not_array") return rawToolCalls; - if (!Array.isArray(rawToolCalls) || diagnostic.callIndex === undefined) return undefined; - - const rawToolCall = rawToolCalls[diagnostic.callIndex]; - if (diagnostic.reason === "tool_call_not_object") return rawToolCall; - if (!isRecord(rawToolCall)) return undefined; - if (diagnostic.reason === "tool_call_function_not_object") return rawToolCall.function; - - const rawFunction = rawToolCall.function; - switch (diagnostic.reason) { - case "tool_call_id_invalid": - return rawToolCall.id; - case "tool_call_function_name_invalid": - return isRecord(rawFunction) ? rawFunction.name : undefined; - case "tool_call_function_arguments_invalid": - return isRecord(rawFunction) ? rawFunction.arguments : undefined; - default: - return undefined; - } -} - -function fingerprintInvalidField(value: unknown): InvalidFieldShape | undefined { - if (Array.isArray(value)) return { kind: "array", length: value.length }; - if (!isRecord(value)) return undefined; - - const knownKeys: string[] = []; - const knownFieldTypes: Record = {}; - for (const key of SAFE_TOOL_CALL_SHAPE_KEYS) { - if (!Object.hasOwn(value, key)) continue; - knownKeys.push(key); - knownFieldTypes[key] = structuralValueType(value[key]); - } - - let hasUnknownKeys = false; - for (const key of Object.keys(value)) { - if (!SAFE_TOOL_CALL_SHAPE_KEY_SET.has(key)) { - hasUnknownKeys = true; - break; - } - } - return { kind: "object", knownKeys, knownFieldTypes, hasUnknownKeys }; -} - -/** - * Streamed string fields are absent when null or undefined (#1731): OpenAI-compatible - * streamers repeat already-sent `id`/`name`/`arguments` as null on continuation deltas. - * The accumulator and this diagnostic share this predicate so they cannot disagree about - * which delta was the invalid one. - */ -function isInvalidStreamStringField(value: unknown): boolean { - return value != null && typeof value !== "string"; -} - -/** - * Explain only the rejected wire shape, never its values. This diagnostic exists so provider - * compatibility can be tightened from evidence without retaining tool arguments or credentials. - */ -function diagnoseInvalidToolCalls( - rawToolCalls: unknown, - mode: "stream" | "response", -): InvalidToolCallDiagnostic | undefined { - if (!Array.isArray(rawToolCalls)) { - return { reason: "tool_calls_not_array", valueType: rawToolCalls === null ? "null" : typeof rawToolCalls }; - } - for (let callIndex = 0; callIndex < rawToolCalls.length; callIndex++) { - const rawToolCall = rawToolCalls[callIndex]; - if (!isRecord(rawToolCall)) { - return { - reason: "tool_call_not_object", - callIndex, - valueType: rawToolCall === null ? "null" : Array.isArray(rawToolCall) ? "array" : typeof rawToolCall, - }; - } - if (mode === "stream") { - // The streamed path validates the pieces it is about to store (#1531): a present - // `function` must be a record, and a present `name`/`arguments`/`id` must be a string. - // Blank names are caught later at flush, not here, so they are not diagnosed on this - // branch. Describe exactly that boundary rather than tightening compatibility in a - // diagnostic change. - // #1731: "present" means the same thing here as in the accumulator — null and undefined - // are both absent, because some OpenAI-compatible streamers repeat already-sent fields - // as null on continuation deltas. A separate predicate here would diagnose accepted - // padding as the failure and point compatibility work at the wrong delta. - const streamFunction = (rawToolCall as { function?: unknown }).function; - if (streamFunction !== undefined && streamFunction !== null) { - if (!isRecord(streamFunction)) { - return { - reason: "tool_call_function_not_object", - callIndex, - valueType: Array.isArray(streamFunction) ? "array" : typeof streamFunction, - }; - } - if (isInvalidStreamStringField(streamFunction.name)) { - return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof streamFunction.name }; - } - if (isInvalidStreamStringField(streamFunction.arguments)) { - return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof streamFunction.arguments }; - } - } - if (isInvalidStreamStringField(rawToolCall.id)) { - return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id }; - } - continue; - } - // Precedence must mirror the buffered validator below, or a payload with more than one - // problem is reported under the wrong reason and sends compatibility work after the wrong - // shape. That validator checks the `function` container first (`!isRecord(rawToolCall) || - // !isRecord(rawToolCall.function)`), then id/name/arguments types together, and only then - // the blank name. - if (!isRecord(rawToolCall.function)) { - return { - reason: "tool_call_function_not_object", - callIndex, - valueType: rawToolCall.function === null ? "null" : Array.isArray(rawToolCall.function) ? "array" : typeof rawToolCall.function, - }; - } - if (typeof rawToolCall.id !== "string") { - return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id }; - } - if (typeof rawToolCall.function.name !== "string") { - return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof rawToolCall.function.name }; - } - if (typeof rawToolCall.function.arguments !== "string") { - return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof rawToolCall.function.arguments }; - } - // Last, matching the validator: #1531 also rejects a blank or whitespace-only name here, - // because such a call cannot select a dispatch target. Reporting it as `name_invalid` - // would claim a type problem for a correctly-typed value, so it gets its own code. - if (rawToolCall.function.name.trim().length === 0) { - return { reason: "tool_call_function_name_blank", callIndex, valueType: "string" }; - } - } - return undefined; -} - -function logInvalidToolCalls( - mode: "stream" | "response", - rawToolCalls: unknown, - diagnosticOverride?: InvalidToolCallDiagnostic, -): void { - if (!isDebugEnabled()) return; - const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode); - if (!diagnostic) return; - const fieldShape = fingerprintInvalidField(invalidToolCallField(rawToolCalls, diagnostic)); - debugProviderDiagnostic("openai-chat", "invalid-tool-calls", { - mode, - ...diagnostic, - ...(fieldShape ? { fieldShape } : {}), - }); -} - -function developerSystemText(message: OcxMessage): string | undefined { - if (message.role !== "developer") return undefined; - if (typeof message.content === "string") return message.content; - if (message.content.some(part => part.type === "image")) return undefined; - return message.content.map(part => (part as OcxTextContent).text).join(""); -} - -function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean { - try { - return new URL(provider.baseUrl).hostname === "api.openai.com"; - } catch { - return false; - } -} - -/** - * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool" - * content is text-only on every chat provider, so these ride in a follow-up user message instead of - * being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https - * URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64. - */ -function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty = false): string { - // An empty content array is a present-but-empty result; `contentPartsToText` would - // otherwise fall back to the "[image]" marker and hide the emptiness from the model. - if (annotateEmpty && Array.isArray(content) && content.length === 0) return EMPTY_TOOL_OUTPUT_ANNOTATION; - if (typeof content === "string") { - if (annotateEmpty && content.trim() === "") return EMPTY_TOOL_OUTPUT_ANNOTATION; - return content; - } - const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join(""); - // A whitespace-only text-part array is the array twin of a blank string; the - // shared emptiness contract (same module as the Responses adapter) annotates it - // instead of forwarding whitespace the model silently accepts. Image parts and - // any other non-text part keep the array non-empty. - if (annotateEmpty && isWhitespaceOnlyTextPartArray(content)) { - return EMPTY_TOOL_OUTPUT_ANNOTATION; - } - if (text) { - const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length; - return `${text}${"[image]".repeat(untransportableImages)}`; - } - return contentPartsToText(content); -} - -function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[] { - if (typeof content === "string") return []; - const parts: unknown[] = []; - for (const p of content) { - if (p.type !== "image" || !p.imageUrl) continue; - parts.push({ type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }); - } - return parts; -} - -function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] { - const out: unknown[] = []; - const { context, options } = parsed; - const replayCacheScope = parsed._reasoningReplayScope; - - interface PendingToolCall { id: string; name: string } - let pendingToolCalls: PendingToolCall[] = []; - let deferredBarrierMessages: unknown[] = []; - let pendingToolResultImageParts: unknown[] = []; - let mintedIdSeq = 0; - const seenWireCallIds = new Set(); - - const mintCallId = (): string => { - let id = ""; - do { - id = `call_ocx_minted_${++mintedIdSeq}`; - } while (seenWireCallIds.has(id)); - seenWireCallIds.add(id); - return id; - }; - - const releaseDeferredBarriers = (): void => { - if (deferredBarrierMessages.length === 0) return; - out.push(...deferredBarrierMessages); - deferredBarrierMessages = []; - }; - - const flushToolResultImages = (): void => { - if (pendingToolResultImageParts.length === 0) return; - out.push({ - role: "user", - content: [ - { type: "text", text: "[ocx] image output from the preceding tool result(s):" }, - ...pendingToolResultImageParts, - ], - }); - pendingToolResultImageParts = []; - }; - - const flushPendingToolCalls = (): void => { - if (pendingToolCalls.length === 0) return; - for (const call of pendingToolCalls) { - out.push({ - role: "tool", - tool_call_id: call.id, - content: `[ocx] no tool result was recorded for "${call.name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`, - }); - } - pendingToolCalls = []; - flushToolResultImages(); - releaseDeferredBarriers(); - }; - - const nativeOpenAI = isNativeOpenAIChatTarget(provider); - // Hoisting a newly appended reminder rewrites the reusable prompt prefix. - // Keep this compatibility exception on the destination/model tested with OCG. - const chronologicalSystem = parsed.modelId === "deepseek-v4.1-flash" - && registryEntryForProviderDestination(provider)?.id === "opencode-go"; - const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider) - ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice) - : undefined; - const developerSystemParts = nativeOpenAI || chronologicalSystem - ? [] - : context.messages - .map(developerSystemText) - .filter((part): part is string => part !== undefined && part.length > 0); - const systemParts = [ - ...(context.systemPrompt ?? []), - ...developerSystemParts, - ...(toolCatalogNudge ? [toolCatalogNudge] : []), - ]; - if (systemParts.length > 0) { - const wireModelId = provider.modelSuffixBracketStrip - ? stripBracketedModelSuffix(parsed.modelId) - : parsed.modelId; - const sys = identifyRoutedModel(systemParts.join("\n\n"), wireModelId); - out.push({ role: "system", content: sys }); - } - - for (const msg of context.messages) { - switch (msg.role) { - case "user": - case "developer": { - const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[]; - const hasImages = parts?.some(p => p.type === "image") ?? false; - let chatMsg: Record; - if (msg.role === "developer" && !hasImages) { - if (!nativeOpenAI && !chronologicalSystem) break; - const text = typeof msg.content === "string" - ? msg.content - : parts!.map(p => (p as OcxTextContent).text).join(""); - // A non-text timeline part (video, for example) serializes to nothing here. - // The generic path drops such a message; the chronological exception must not - // turn it into an empty system message that some upstreams reject. - if (!nativeOpenAI && text.length === 0) break; - chatMsg = { role: nativeOpenAI ? "developer" : "system", content: text }; - } else if (typeof msg.content === "string") { - chatMsg = { role: "user", content: msg.content }; - } else if (!hasImages) { - // A video part has no `text`, so joining it produced "" and the whole message - // was dropped: a video-only or text-plus-video turn vanished silently. OpenAI's - // Chat Completions wire has no video content part, so state the omission - // instead of losing it. Scoped to this adapter's wire, not a claim about video - // support in general — native Chat passthrough and Google inline video are - // unaffected. - chatMsg = { - role: "user", - content: parts!.map(p => (p.type === "video" - ? VIDEO_UNSUPPORTED_MARKER - : (p as OcxTextContent).text)).join(""), - }; - } else { - const chatParts = parts!.map(p => { - if (p.type === "image") { - return { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }; - } - // Previously this produced { type: "text", text: undefined } for a video - // part — a malformed part, worse than a drop because it can fail upstream - // schema validation. - if (p.type === "video") return { type: "text", text: VIDEO_UNSUPPORTED_MARKER }; - return { type: "text", text: (p as OcxTextContent).text }; - }); - chatMsg = { role: "user", content: chatParts }; - } - if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg); - else out.push(chatMsg); - break; - } - case "assistant": { - const aMsg = msg as OcxAssistantMessage; - const textParts = aMsg.content.filter(p => p.type === "text") as OcxTextContent[]; - const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[]; - const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[]; - const chatMsg: Record = { role: "assistant" }; - if (textParts.length > 0) chatMsg.content = textParts.map(p => p.text).join(""); - let reasoningContent = thinkingParts.map(p => p.thinking).join(""); - if ( - reasoningContent.length === 0 - && toolCalls.length > 0 - && modelInList(provider.preserveReasoningContentModels, parsed.modelId) - ) { - const cached = toolCalls - .map(tc => (tc.id ? peekReasoningForCall(tc.id, replayCacheScope) : undefined)) - .filter((text): text is string => typeof text === "string" && text.length > 0); - // Parallel calls share one preceding reasoning block, which is - // recorded under every call id — join unique texts only. - if (cached.length > 0) { - reasoningContent = [...new Set(cached)].join("\n"); - } else if (modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)) { - // Fallback (extends #950, closes #1193): the replay cache is - // bounded (64 entries / 256 KiB / 1 h TTL) and always misses on - // long sessions, and some tool rounds carry no recorded reasoning - // at all. DeepSeek thinking mode rejects ANY tool_call assistant - // message missing reasoning_content with HTTP 400, so inject a - // minimal placeholder rather than emit a bare continuation the - // upstream will reject. Scoped to requiresReasoningPlaceholderModels - // (defaulting to the preserve list): preserve-listed providers with - // toggleable thinking (MiniMax low effort) opt out with `[]` so - // non-thinking histories are never given a fabricated placeholder. - reasoningContent = " "; - } - } - if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) { - // MiniMax's interleaved-thinking contract requires the structured - // reasoning_details array back on the next turn; a reasoning_content - // string is the native-format pass-back the docs mark unsupported. - if (modelInList(provider.reasoningDetailsModels, parsed.modelId)) { - chatMsg.reasoning_details = [reasoningDetailSegmentForWire(reasoningContent)]; - } else { - chatMsg.reasoning_content = reasoningContent; - } - } - const hasReplayedReasoning = chatMsg.reasoning_content !== undefined || chatMsg.reasoning_details !== undefined; - if (chatMsg.content === undefined && toolCalls.length === 0 && !hasReplayedReasoning) break; - flushPendingToolCalls(); - const wireToolCalls = toolCalls.map(tc => { - let id = tc.id; - if (!id) id = mintCallId(); - else seenWireCallIds.add(id); - return { tc, id }; - }); - if (wireToolCalls.length > 0) { - chatMsg.tool_calls = wireToolCalls.map(({ tc, id }) => ({ - id, - type: "function", - function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) }, - })); - if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider); - } - if (hasReplayedReasoning && chatMsg.content === undefined && chatMsg.tool_calls === undefined) { - chatMsg.content = emptyAssistantContent(provider); - } - out.push(chatMsg); - pendingToolCalls = wireToolCalls.map(({ tc, id }) => ({ id, name: namespacedToolName(tc.namespace, tc.name) })); - break; - } - case "toolResult": { - let toolCallId = msg.toolCallId; - const matchIdx = toolCallId ? pendingToolCalls.findIndex(c => c.id === toolCallId) : -1; - if (matchIdx >= 0 && toolCallId) { - out.push({ - role: "tool", - tool_call_id: toolCallId, - content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true), - }); - pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content)); - pendingToolCalls.splice(matchIdx, 1); - if (pendingToolCalls.length === 0) { - flushToolResultImages(); - releaseDeferredBarriers(); - } - } else { - if (!toolCallId) toolCallId = `call_orphan_${out.length}`; - flushPendingToolCalls(); - const name = safeToolName(msg.toolName); - const cachedReasoning = - toolCallId && modelInList(provider.preserveReasoningContentModels, parsed.modelId) - ? peekReasoningForCall(toolCallId, replayCacheScope) - : undefined; - // Same fallback as the main-assistant path: never emit a bare orphan - // tool_call continuation on a thinking-mode provider — inject a - // placeholder when the replay cache missed (the bounded cache can - // always miss on long sessions), or DeepSeek thinking mode 400s. - // Gate on the preserve list too: reasoning_content is only ever - // serialized for preserve-listed models, so a requires-only custom - // entry must not fabricate it on this path (P2 on #1205). - // `||` (not `??`): the cache never stores empty strings, but treat a - // falsy hit as a miss so the placeholder still fires. - const orphanReasoning = - cachedReasoning - || (modelInList(provider.preserveReasoningContentModels, parsed.modelId) - && modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId) - ? " " - : undefined); - const orphanReasoningFields: Record = !orphanReasoning - ? {} - : modelInList(provider.reasoningDetailsModels, parsed.modelId) - ? { reasoning_details: [reasoningDetailSegmentForWire(orphanReasoning)] } - : { reasoning_content: orphanReasoning }; - out.push({ - role: "assistant", - content: emptyAssistantContent(provider), - ...orphanReasoningFields, - tool_calls: [{ - id: toolCallId, - type: "function", - function: { name, arguments: "{}" }, - }], - }); - seenWireCallIds.add(toolCallId); - out.push({ - role: "tool", - tool_call_id: toolCallId, - content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true), - }); - pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content)); - flushToolResultImages(); - } - break; - } - } - } - - flushPendingToolCalls(); - releaseDeferredBarriers(); - return out; -} - -function safeToolName(name: string | undefined): string { - const raw = name && name.trim().length > 0 ? name : "tool_result"; - const sanitized = raw.replace(/[^A-Za-z0-9_-]/g, "_"); - return sanitized; -} - -const ZEN_SCHEMA_MAP_KEYS = new Set(["properties", "$defs", "definitions"]); -const ZEN_DROPPED_SCHEMA_KEYS = new Set(["encrypted"]); - -function sanitizeZenSchemaMap(value: unknown): unknown { - if (!value || typeof value !== "object" || Array.isArray(value)) return sanitizeZenToolParameters(value); - const out: Record = {}; - for (const [name, child] of Object.entries(value as Record)) { - out[name] = sanitizeZenToolParameters(child); - } - return out; -} - -function sanitizeZenToolParameters(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sanitizeZenToolParameters); - if (!value || typeof value !== "object") return value; - const input = value as Record; - const out: Record = {}; - for (const [key, child] of Object.entries(input)) { - if (ZEN_DROPPED_SCHEMA_KEYS.has(key)) continue; - if (key === "required" && Array.isArray(child) && child.length === 0) continue; - if (key === "type" && Array.isArray(child)) { - const nonNull = child.filter(entry => entry !== "null"); - if (child.includes("null")) out.nullable = true; - if (nonNull.length > 0) out.type = nonNull[0]; - continue; - } - out[key] = ZEN_SCHEMA_MAP_KEYS.has(key) ? sanitizeZenSchemaMap(child) : sanitizeZenToolParameters(child); - } - return out; -} - -function ensureZenRootObjectSchema(schema: unknown): Record { - const obj = schema && typeof schema === "object" && !Array.isArray(schema) - ? schema as Record - : {}; - const compositionKeys = ["oneOf", "anyOf", "allOf"] as const; - const hasComposition = compositionKeys.some(key => Array.isArray(obj[key])); - const rootType = obj.type; - const rootObjectType = rootType === "object" || (Array.isArray(rootType) && rootType.includes("object")); - if (!hasComposition) { - const base = sanitizeZenToolParameters(obj) as Record; - return rootObjectType && base.type === "object" ? base : { ...base, type: "object" }; - } - - const props: Record = {}; - const required = new Set(); - if (obj.properties && typeof obj.properties === "object") { - Object.assign(props, sanitizeZenSchemaMap(obj.properties) as Record); - } - if (Array.isArray(obj.required)) { - for (const entry of obj.required) if (typeof entry === "string") required.add(entry); - } - for (const key of compositionKeys) { - const variants = obj[key]; - if (!Array.isArray(variants)) continue; - const mergeRequired = key === "allOf"; - for (const variant of variants) { - if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue; - const rec = variant as Record; - if (rec.properties && typeof rec.properties === "object") { - Object.assign(props, sanitizeZenSchemaMap(rec.properties) as Record); - } - if (mergeRequired && Array.isArray(rec.required)) { - for (const entry of rec.required) if (typeof entry === "string") required.add(entry); - } - } - } - - const merged = sanitizeZenToolParameters(obj) as Record; - delete merged.oneOf; - delete merged.anyOf; - delete merged.allOf; - merged.type = "object"; - if (Object.keys(props).length > 0) merged.properties = props; - if (required.size > 0) merged.required = [...required]; - return merged; -} - -function shouldSanitizeZenToolParameters(provider: OcxProviderConfig): boolean { - const baseUrl = provider.baseUrl.replace(/\/+$/, ""); - return baseUrl === "https://opencode.ai/zen/v1" - || baseUrl === "https://opencode.ai/zen/go/v1"; -} - -/** Azure Model Router (and Gemini-in-the-pool) 400s Codex MCP schemas whose root is a union. */ -const AZURE_CHAT_FORBIDDEN_ROOT_KEYS = ["oneOf", "anyOf", "allOf", "enum", "const", "not"] as const; - -function isAzureOpenAiChatTarget(provider: OcxProviderConfig): boolean { - try { - const host = new URL(provider.baseUrl).hostname.toLowerCase(); - return host.endsWith(".openai.azure.com") - || host.endsWith(".cognitiveservices.azure.com") - || host.endsWith(".services.ai.azure.com") - || host.endsWith(".ai.azure.com"); - } catch { - return false; - } -} - -/** - * Azure Foundry Model Router validates every function schema against the strictest model in - * the pool (Gemini-shaped): root must be {type:"object"} with no oneOf/anyOf/allOf/enum/ - * const/not. Codex App MCP tools such as mcp__codex_app__automation_update ship a root - * union, which 400s the whole turn. Flatten like Zen, then strip leftover forbidden keys. - */ -function sanitizeAzureChatToolParameters(parameters: unknown): Record { - const root = ensureZenRootObjectSchema(parameters); - for (const key of AZURE_CHAT_FORBIDDEN_ROOT_KEYS) delete root[key]; - root.type = "object"; - if (!root.properties || typeof root.properties !== "object" || Array.isArray(root.properties)) { - root.properties = {}; - } - return root; -} - -// Moonshot validates function schemas against a draft-07 reading of `$ref`, where the -// keyword stands alone and siblings are ignored. It rejects the whole request rather -// than ignoring them: "not a valid moonshot flavored json schema ... when using $ref, -// type should be defined in the referenced schema instead of the parent schema". -const MOONSHOT_SCHEMA_HOSTNAMES = new Set([ - "api.kimi.com", - "api.moonshot.ai", - "api.moonshot.cn", -]); - -function isMoonshotSchemaTarget(provider: OcxProviderConfig): boolean { - try { - return MOONSHOT_SCHEMA_HOSTNAMES.has(new URL(provider.baseUrl).hostname); - } catch { - return false; - } -} - -const VOLCENGINE_ARK_HOSTNAMES = new Set([ - "ark.cn-beijing.volces.com", - "ark.ap-southeast.volces.com", -]); - -function isVolcengineArkPaygChatTarget(provider: OcxProviderConfig): boolean { - try { - const url = new URL(provider.baseUrl); - const pathname = url.pathname.replace(/\/+$/, "") || "/"; - return VOLCENGINE_ARK_HOSTNAMES.has(url.hostname) && pathname === "/api/v3"; - } catch { - return false; - } -} - -function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] { - return isVolcengineArkPaygChatTarget(provider) ? [{ type: "text", text: "" }] : ""; -} - -function ensureRootObjectType(parameters: unknown): Record { - if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { - return { type: "object", properties: {} }; - } - const obj = parameters as Record; - if (obj.type === "object") return obj; - return { ...obj, type: "object" }; -} - -function isXaiObjectSchema(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -/** - * JSON Schema 2020-12 makes `$ref` an in-place applicator: siblings stay in force and are - * combined with the referenced schema. Moonshot enforces the older draft-07 reading where - * `$ref` must stand alone, and 400s the entire request when a node carries both. Codex's own - * deferred tool catalog emits exactly that shape (zod-to-json-schema deduplicates into - * `$defs.__schema*` nodes that keep `type`/`minLength`/`format` beside the `$ref`), so the - * schema is not something a user can fix from configuration — see issue #2673. - * - * Inline the referenced schema underneath the node's own keywords, which is what 2020-12 says - * the node means, then drop `$ref`. Constraints reach the model instead of being stripped. - * The `$defs` bag is preserved: a bare `$ref` (no siblings) is already legal for Moonshot and - * is left pointing at its definition rather than expanded, which keeps recursive schemas finite. - */ -function moonshotRefTargetKeys(node: Record): string[] { - return Object.keys(node).filter(key => key !== "$ref"); -} - -/** - * Inlining duplicates the target, so a schema referencing one large definition from many - * sibling-carrying nodes can multiply. Bound the total expansions and fall back to a bare - * `$ref` once the budget is spent: still valid for Moonshot, just without the node's own - * narrowing keywords. Mirrors the node budget in google-tool-schema.ts. - */ -const MOONSHOT_MAX_REF_EXPANSIONS = 512; - -/** - * Expansion count alone does not bound the walk: a deeply nested ref-free schema, or one - * large definition repeated across many nodes, still recurses to exhaustion or amplifies the - * emitted output. Depth and node budgets close both, and mirror google-tool-schema.ts. - */ -const MOONSHOT_MAX_SCHEMA_DEPTH = 64; -const MOONSHOT_MAX_SCHEMA_NODES = 4_096; - -/** - * Assertion keywords whose meaning under a `$ref` is CONJUNCTION, not replacement. A node - * carrying `required: ["b"]` beside a target requiring `["a"]` means both are required; - * letting the sibling win emitted a schema that no longer described the tool. - */ -function unionRequired(target: unknown, sibling: unknown): unknown { - if (!Array.isArray(target) || !Array.isArray(sibling)) return sibling; - const seen = new Set(); - const out: unknown[] = []; - for (const name of [...target, ...sibling]) { - if (seen.has(name)) continue; - seen.add(name); - out.push(name); - } - return out; -} - -/** - * Keywords whose values are DATA, not schemas. - * - * Recursing into them rewrote user data: an `enum` listing a literal object that happens - * to carry a `"$ref"` string had that key stripped as if it were a schema reference, so a - * value the tool declared as legal silently changed shape. These are copied through. - */ -const MOONSHOT_DATA_VALUED_KEYWORDS = new Set(["enum", "const", "default", "examples"]); - -/** - * Numeric assertions whose intersection is a bound, and which direction tightens. - * - * `$ref` under 2020-12 is an in-place applicator: the node and its target BOTH apply, so - * the emitted schema must be their INTERSECTION. The previous code overwrote the target - * with the node and called that "the narrower reading", which holds only when the node - * happens to be narrower. A node declaring `minLength: 1` beside a target declaring - * `minLength: 5` shipped `minLength: 1` - a contract weaker than either side asked for, - * emitted silently, which is the same failure mode the `required` composition fixed for - * set-valued keywords. - * - * "max" means the surviving value is the larger of the two (lower bounds), "min" the - * smaller (upper bounds). A keyword absent from this table keeps the overwrite: for - * `type`, `format`, `description` and friends there is no ordering to intersect along, - * and the node is the more specific statement. - */ -const MOONSHOT_BOUND_KEYWORDS: Record = { - minLength: "max", - minItems: "max", - minProperties: "max", - minimum: "max", - exclusiveMinimum: "max", - maxLength: "min", - maxItems: "min", - maxProperties: "min", - maximum: "min", - exclusiveMaximum: "min", -}; - -/** - * Intersect one numeric bound. Either side being absent or non-finite yields the other, - * because an unstated bound constrains nothing - returning `undefined` there would drop - * a constraint the remaining side genuinely made. - */ -function intersectBound(target: unknown, sibling: unknown, direction: "max" | "min"): unknown { - const a = typeof target === "number" && Number.isFinite(target) ? target : null; - const b = typeof sibling === "number" && Number.isFinite(sibling) ? sibling : null; - if (a === null) return b === null ? sibling : sibling; - if (b === null) return target; - return direction === "max" ? Math.max(a, b) : Math.min(a, b); -} - -/** - * Compose two `properties` maps. A property named in BOTH the referenced target and the - * node is the same conjunction problem `required` had: letting the sibling win discards - * the target's constraints for that member. Merge the two member schemas so neither side - * loses its keywords. Shared member bounds are the same conjunction one level down, - * and nested object members recurse through this helper instead of replacing the target. - */ -function composeProperties( - target: Record, - sibling: Record, -): Record { - const combined: Record = Object.create(null) as Record; - for (const [name, sub] of Object.entries(target)) combined[name] = sub; - for (const [name, sub] of Object.entries(sibling)) { - const existing = combined[name]; - if (isXaiObjectSchema(existing) && isXaiObjectSchema(sub)) { - const member: Record = Object.create(null) as Record; - for (const [k, v] of Object.entries(existing)) member[k] = v; - for (const [k, v] of Object.entries(sub)) { - if (k === "required") { - member[k] = unionRequired(member[k], v); - continue; - } - if (k === "properties" && isXaiObjectSchema(member[k]) && isXaiObjectSchema(v)) { - member[k] = composeProperties(member[k] as Record, v); - continue; - } - const boundDirection = MOONSHOT_BOUND_KEYWORDS[k]; - if (boundDirection && k in member) { - member[k] = intersectBound(member[k], v, boundDirection); - continue; - } - member[k] = v; - } - combined[name] = member; - continue; - } - combined[name] = sub; - } - return combined; -} - -interface MoonshotNormalizeState { - activeRefs: Set; - remainingExpansions: number; - remainingNodes: number; -} - -function normalizeMoonshotSchemaNode( - node: unknown, - root: Record, - state: MoonshotNormalizeState, - depth = 0, -): unknown { - if (Array.isArray(node)) { - if (depth >= MOONSHOT_MAX_SCHEMA_DEPTH) return []; - return node.map(item => normalizeMoonshotSchemaNode(item, root, state, depth + 1)); - } - if (!isXaiObjectSchema(node)) return node; - - // Fail closed for this node rather than emitting a partially weakened schema: an empty - // object is the one shape that asserts nothing it cannot back up. - if (depth >= MOONSHOT_MAX_SCHEMA_DEPTH || state.remainingNodes <= 0) return {}; - state.remainingNodes -= 1; - - const ref = node.$ref; - const hasSiblings = moonshotRefTargetKeys(node).length > 0; - - if (typeof ref === "string" && hasSiblings) { - // A cycle cannot be inlined. Keeping the bare `$ref` is the lossy-but-valid fallback: - // Moonshot accepts it, and the alternative (dropping the ref) would erase the recursion. - if (state.activeRefs.has(ref) || state.remainingExpansions <= 0) return { $ref: ref }; - - const target = lookupLocalJsonPointer(root, ref); - if (isXaiObjectSchema(target)) { - state.remainingExpansions -= 1; - state.activeRefs.add(ref); - const resolvedTarget = normalizeMoonshotSchemaNode(target, root, state, depth + 1); - state.activeRefs.delete(ref); - const merged: Record = Object.create(null) as Record; - if (isXaiObjectSchema(resolvedTarget)) { - for (const [key, value] of Object.entries(resolvedTarget)) merged[key] = value; - } - // "Alongside the target" is conjunction, not replacement. For most keywords the node - // narrows the target and overwriting is the narrower reading, but `required` and - // `properties` are set-valued: letting the sibling win DROPPED the target's own - // members, so a tool requiring `a` beside a node requiring `b` shipped requiring only - // `b`. Those two compose; everything else keeps the narrowing overwrite. - for (const [key, value] of Object.entries(node)) { - if (key === "$ref") continue; - if (MOONSHOT_DATA_VALUED_KEYWORDS.has(key)) { - merged[key] = value; - continue; - } - const normalized = normalizeMoonshotSchemaNode(value, root, state, depth + 1); - if (key === "required") { - merged[key] = unionRequired(merged[key], normalized); - continue; - } - if (key === "properties" && isXaiObjectSchema(merged[key]) && isXaiObjectSchema(normalized)) { - merged[key] = composeProperties(merged[key] as Record, normalized); - continue; - } - // Numeric bounds intersect rather than overwrite: both the node and its target - // apply, so the surviving bound is the stricter of the two in whichever direction - // that keyword tightens. - const boundDirection = MOONSHOT_BOUND_KEYWORDS[key]; - if (boundDirection && key in merged) { - merged[key] = intersectBound(merged[key], normalized, boundDirection); - continue; - } - merged[key] = normalized; - } - return merged; - } - - // Unresolvable pointer: a remote ref, a malformed path, or a non-object target. Dropping - // the ref and keeping the siblings silently discards whatever the reference constrained, - // which is the one outcome we cannot detect downstream. A bare `$ref` is lossy in the - // other direction - it loses the node's own keywords - but it preserves the identity of - // what was asked for, and Moonshot accepts it. - return { $ref: ref }; - } - - const out: Record = Object.create(null) as Record; - for (const [key, value] of Object.entries(node)) { - out[key] = key === "$ref" || MOONSHOT_DATA_VALUED_KEYWORDS.has(key) - ? value - : normalizeMoonshotSchemaNode(value, root, state, depth + 1); - } - return out; -} - -function normalizeMoonshotToolParameters(parameters: unknown): Record { - const rooted = ensureRootObjectType(parameters); - const normalized = normalizeMoonshotSchemaNode(rooted, rooted, { - activeRefs: new Set(), - remainingExpansions: MOONSHOT_MAX_REF_EXPANSIONS, - remainingNodes: MOONSHOT_MAX_SCHEMA_NODES, - }); - return isXaiObjectSchema(normalized) ? normalized : rooted; -} - -function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined { - if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined; - const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools)); - if (tools.length === 0) return undefined; - const xaiTarget = isXaiSchemaTarget(provider); - const moonshotTarget = !xaiTarget && isMoonshotSchemaTarget(provider); - const formatted = tools.flatMap(t => { - const normalized = xaiTarget - ? normalizeXaiToolParameters(t.parameters) - : moonshotTarget - ? normalizeMoonshotToolParameters(t.parameters) - : ensureRootObjectType(t.parameters); - const parameters = stripUnicodePropertyPatterns(stripResponsesOnlyEncryptedMarker(normalized)); - - if (parameters === undefined) return []; - return [{ - type: "function", - function: { - name: namespacedToolName(t.namespace, t.name), - ...(t.description ? { description: t.description } : {}), - parameters, - ...(t.strict !== undefined ? { strict: t.strict } : {}), - }, - }]; - }); - return formatted.length > 0 ? formatted : undefined; -} - -function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined { - const base = toolsToChatFormat(parsed, provider); - const azureChat = isAzureOpenAiChatTarget(provider); - const zenChat = shouldSanitizeZenToolParameters(provider); - if (!base || (!zenChat && !azureChat)) return base; - return base.map(tool => { - if (!tool || typeof tool !== "object") return tool; - const functionDef = (tool as { function?: Record }).function; - if (!functionDef || typeof functionDef !== "object") return tool; - const parameters = azureChat - ? sanitizeAzureChatToolParameters(functionDef.parameters ?? {}) - : ensureZenRootObjectSchema(functionDef.parameters ?? {}); - const nextFunction: Record = { ...functionDef, parameters }; - // strict: true plus a flattened schema is rejected by Gemini-in-the-pool routers. - if (azureChat) delete nextFunction.strict; - return { - ...tool, - function: nextFunction, - }; - }); -} - -function toolChoiceToChatFormat( - tc: OcxParsedRequest["options"]["toolChoice"], - tools: OcxParsedRequest["context"]["tools"], - provider: OcxProviderConfig, -): unknown { - if (!tc) return undefined; - if (isAllowedToolChoice(tc)) { - if (tc.mode === "required" && tc.allowedTools.length === 1 && isNativeOpenAIChatTarget(provider)) { - return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.allowedTools[0]) } }; - } - return tc.mode === "required" ? "required" : "auto"; - } - if (tc === "auto" || tc === "none" || tc === "required") return tc; - if ("name" in tc) return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } }; - return undefined; -} - -function usageFromOpenAIChat(usage: Record | undefined): OcxUsage | undefined { - if (!usage) return undefined; - const promptDetails = usage.prompt_tokens_details as Record | undefined; - const completionDetails = usage.completion_tokens_details as Record | undefined; - return { - inputTokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0, - outputTokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0, - ...(promptDetails?.cached_tokens !== undefined ? { cachedInputTokens: promptDetails.cached_tokens } : {}), - ...(completionDetails?.reasoning_tokens !== undefined ? { reasoningOutputTokens: completionDetails.reasoning_tokens } : {}), - }; -} +import { + isInvalidStreamStringField, + isRecord, + logInvalidToolCalls, + type InvalidToolCallDiagnostic, +} from "./openai-chat/tool-call-validation"; +import { + invalidChoicesEvent, + invalidToolCallsEvent, + reasoningDetailSegmentsFrom, + reasoningTextFrom, + stopReasonFor, + unnamedToolCallEvent, + usageFromOpenAIChat, +} from "./openai-chat/response-events"; +import { + formatOpenAIChatErrorBody, + OpenAIChatError, + unwrapChatCompletionPayload, + upstreamErrorEvent, +} from "./openai-chat/errors"; +import { messagesToChatFormat } from "./openai-chat/messages"; +import { isNativeOpenAIChatTarget, openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire"; +import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema"; + +export { stripBracketedModelSuffix } from "./openai-chat/wire"; +export { buildOpenAIChatPassthroughRequest } from "./openai-chat/passthrough"; +export { formatOpenAIChatErrorBody } from "./openai-chat/errors"; function resolveMaxTokens(provider: OcxProviderConfig, parsed: OcxParsedRequest): number | undefined { return parsed.options.maxOutputTokens diff --git a/src/adapters/openai-chat/errors.ts b/src/adapters/openai-chat/errors.ts new file mode 100644 index 0000000000..622bd1b054 --- /dev/null +++ b/src/adapters/openai-chat/errors.ts @@ -0,0 +1,116 @@ +import { isCyberPolicyCode } from "../../lib/errors"; +import { redactSecretString } from "../../lib/redact"; +import type { AdapterEvent, OcxUsage } from "../../types"; + +// 260715 (issue #126): surface upstream error detail through the web-search sidecar loop. +// loop.ts only appends a suffix to "Provider error N" when the adapter exposes +// formatErrorBody; without it, strict OpenAI-compatible backends (NVIDIA NIM pydantic +// validation, "This model only supports single tool-calls at once!", etc.) were reduced +// to a bare status code. JSON-only extraction: recognized string fields are returned, +// HTML/non-JSON bodies yield "" so raw markup is never echoed to the client. +export function formatOpenAIChatErrorBody(status: number, _headers: Headers, payloadText: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(payloadText); + } catch { + return ""; + } + const detail = extractErrorDetail(parsed); + if (!detail) return ""; + return redactSecretString(detail).slice(0, 400); +} + +function extractErrorDetail(parsed: unknown): string | undefined { + if (typeof parsed === "string") return parsed.trim() || undefined; + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + const obj = parsed as Record; + const err = obj.error; + if (typeof err === "string" && err.trim()) return err.trim(); + if (err !== null && typeof err === "object" && !Array.isArray(err)) { + const msg = (err as Record).message; + if (typeof msg === "string" && msg.trim()) return msg.trim(); + } + const det = obj.detail; + if (typeof det === "string" && det.trim()) return det.trim(); + if (Array.isArray(det)) { + const msgs = det + .map(item => (item !== null && typeof item === "object" && typeof (item as Record).msg === "string" + ? ((item as Record).msg as string).trim() + : "")) + .filter(m => m.length > 0); + if (msgs.length > 0) return msgs.join("; "); + } + if (typeof obj.message === "string" && obj.message.trim()) return obj.message.trim(); + if (typeof obj.title === "string" && obj.title.trim()) return obj.title.trim(); + return undefined; +} + +export function unwrapChatCompletionPayload(json: Record): Record { + if ((json.error !== undefined && json.error !== null) || Array.isArray(json.choices)) return json; + const data = json.data; + return data !== null && typeof data === "object" && !Array.isArray(data) + ? data as Record + : json; +} + +export interface OpenAIChatError { + message?: unknown; + code?: unknown; + type?: unknown; + status?: unknown; + metadata?: unknown; +} + +export function safeUpstreamRequestId(metadata: unknown): string | undefined { + if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) return undefined; + const record = metadata as Record; + const value = record.request_id ?? record.requestId; + if (typeof value !== "string") return undefined; + const requestId = value.trim(); + return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(requestId) + && redactSecretString(requestId) === requestId + ? requestId + : undefined; +} + +export function upstreamErrorEvent( + error: unknown, + usage?: OcxUsage, +): Extract { + const details = error !== null && typeof error === "object" && !Array.isArray(error) + ? error as OpenAIChatError + : undefined; + const rawMessage = typeof error === "string" + ? error.trim() || "upstream error" + : typeof details?.message === "string" ? details.message : "upstream error"; + const safeMessage = redactSecretString(rawMessage); + const requestId = safeUpstreamRequestId(details?.metadata); + const message = requestId !== undefined && !safeMessage.includes(requestId) + ? `${safeMessage} (request ID: ${requestId})` + : safeMessage; + const code = typeof details?.code === "string" + ? details.code + : typeof details?.code === "number" && Number.isFinite(details.code) && Number.isInteger(details.code) + ? String(details.code) + : undefined; + const errorType = typeof details?.type === "string" ? details.type : undefined; + const codeStatus = typeof details?.code === "number" + && Number.isInteger(details.code) + && details.code >= 100 + && details.code <= 599 + ? details.code + : undefined; + const status = isCyberPolicyCode(code) + ? 400 + : typeof details?.status === "number" && Number.isInteger(details.status) + ? details.status + : codeStatus; + return { + type: "error", + message, + ...(usage !== undefined ? { usage } : {}), + ...(code !== undefined ? { code } : {}), + ...(errorType !== undefined ? { errorType } : {}), + ...(status !== undefined ? { status } : {}), + }; +} diff --git a/src/adapters/openai-chat/messages.ts b/src/adapters/openai-chat/messages.ts new file mode 100644 index 0000000000..8e88dabc6b --- /dev/null +++ b/src/adapters/openai-chat/messages.ts @@ -0,0 +1,346 @@ +import { isNativeOpenAIChatTarget, stripBracketedModelSuffix } from "./wire"; +import { reasoningDetailSegmentForWire } from "./response-events"; +import { isVolcengineArkPaygChatTarget } from "./tool-schema"; +import { contentPartsToText } from "../image"; +import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "../empty-tool-output-annotation"; +import { identifyRoutedModel } from "../identity"; +import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "../tool-catalog-nudge"; +import { registryEntryForProviderDestination } from "../../providers/registry"; +import { peekReasoningForCall } from "../../responses/reasoning-replay-cache"; +import type { OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall } from "../../types"; +import { modelInList, namespacedToolName } from "../../types"; + +/** + * The translated Chat route has no video mapping: this adapter does not implement one, + * and the marker records that fact so the payload is not dropped in silence. + * + * The wording is deliberately about opencodex's own translation, not the provider or + * model. An earlier revision said "unsupported by this provider", which attributed an + * opencodex mapping limit to upstream capability the proxy has not established. Native + * Chat passthrough and Google inline video are unaffected by this route. + */ +const VIDEO_UNSUPPORTED_MARKER = "[video omitted: the translated Chat route has no video mapping]"; + +export function developerSystemText(message: OcxMessage): string | undefined { + if (message.role !== "developer") return undefined; + if (typeof message.content === "string") return message.content; + if (message.content.some(part => part.type === "image")) return undefined; + return message.content.map(part => (part as OcxTextContent).text).join(""); +} + +/** + * Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool" + * content is text-only on every chat provider, so these ride in a follow-up user message instead of + * being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https + * URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64. + */ +export function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty = false): string { + // An empty content array is a present-but-empty result; `contentPartsToText` would + // otherwise fall back to the "[image]" marker and hide the emptiness from the model. + if (annotateEmpty && Array.isArray(content) && content.length === 0) return EMPTY_TOOL_OUTPUT_ANNOTATION; + if (typeof content === "string") { + if (annotateEmpty && content.trim() === "") return EMPTY_TOOL_OUTPUT_ANNOTATION; + return content; + } + const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join(""); + // A whitespace-only text-part array is the array twin of a blank string; the + // shared emptiness contract (same module as the Responses adapter) annotates it + // instead of forwarding whitespace the model silently accepts. Image parts and + // any other non-text part keep the array non-empty. + if (annotateEmpty && isWhitespaceOnlyTextPartArray(content)) { + return EMPTY_TOOL_OUTPUT_ANNOTATION; + } + if (text) { + const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length; + return `${text}${"[image]".repeat(untransportableImages)}`; + } + return contentPartsToText(content); +} + +export function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[] { + if (typeof content === "string") return []; + const parts: unknown[] = []; + for (const p of content) { + if (p.type !== "image" || !p.imageUrl) continue; + parts.push({ type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }); + } + return parts; +} + +export function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] { + const out: unknown[] = []; + const { context, options } = parsed; + const replayCacheScope = parsed._reasoningReplayScope; + + interface PendingToolCall { id: string; name: string } + let pendingToolCalls: PendingToolCall[] = []; + let deferredBarrierMessages: unknown[] = []; + let pendingToolResultImageParts: unknown[] = []; + let mintedIdSeq = 0; + const seenWireCallIds = new Set(); + + const mintCallId = (): string => { + let id = ""; + do { + id = `call_ocx_minted_${++mintedIdSeq}`; + } while (seenWireCallIds.has(id)); + seenWireCallIds.add(id); + return id; + }; + + const releaseDeferredBarriers = (): void => { + if (deferredBarrierMessages.length === 0) return; + out.push(...deferredBarrierMessages); + deferredBarrierMessages = []; + }; + + const flushToolResultImages = (): void => { + if (pendingToolResultImageParts.length === 0) return; + out.push({ + role: "user", + content: [ + { type: "text", text: "[ocx] image output from the preceding tool result(s):" }, + ...pendingToolResultImageParts, + ], + }); + pendingToolResultImageParts = []; + }; + + const flushPendingToolCalls = (): void => { + if (pendingToolCalls.length === 0) return; + for (const call of pendingToolCalls) { + out.push({ + role: "tool", + tool_call_id: call.id, + content: `[ocx] no tool result was recorded for "${call.name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`, + }); + } + pendingToolCalls = []; + flushToolResultImages(); + releaseDeferredBarriers(); + }; + + const nativeOpenAI = isNativeOpenAIChatTarget(provider); + // Hoisting a newly appended reminder rewrites the reusable prompt prefix. + // Keep this compatibility exception on the destination/model tested with OCG. + const chronologicalSystem = parsed.modelId === "deepseek-v4.1-flash" + && registryEntryForProviderDestination(provider)?.id === "opencode-go"; + const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider) + ? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice) + : undefined; + const developerSystemParts = nativeOpenAI || chronologicalSystem + ? [] + : context.messages + .map(developerSystemText) + .filter((part): part is string => part !== undefined && part.length > 0); + const systemParts = [ + ...(context.systemPrompt ?? []), + ...developerSystemParts, + ...(toolCatalogNudge ? [toolCatalogNudge] : []), + ]; + if (systemParts.length > 0) { + const wireModelId = provider.modelSuffixBracketStrip + ? stripBracketedModelSuffix(parsed.modelId) + : parsed.modelId; + const sys = identifyRoutedModel(systemParts.join("\n\n"), wireModelId); + out.push({ role: "system", content: sys }); + } + + for (const msg of context.messages) { + switch (msg.role) { + case "user": + case "developer": { + const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[]; + const hasImages = parts?.some(p => p.type === "image") ?? false; + let chatMsg: Record; + if (msg.role === "developer" && !hasImages) { + if (!nativeOpenAI && !chronologicalSystem) break; + const text = typeof msg.content === "string" + ? msg.content + : parts!.map(p => (p as OcxTextContent).text).join(""); + // A non-text timeline part (video, for example) serializes to nothing here. + // The generic path drops such a message; the chronological exception must not + // turn it into an empty system message that some upstreams reject. + if (!nativeOpenAI && text.length === 0) break; + chatMsg = { role: nativeOpenAI ? "developer" : "system", content: text }; + } else if (typeof msg.content === "string") { + chatMsg = { role: "user", content: msg.content }; + } else if (!hasImages) { + // A video part has no `text`, so joining it produced "" and the whole message + // was dropped: a video-only or text-plus-video turn vanished silently. OpenAI's + // Chat Completions wire has no video content part, so state the omission + // instead of losing it. Scoped to this adapter's wire, not a claim about video + // support in general — native Chat passthrough and Google inline video are + // unaffected. + chatMsg = { + role: "user", + content: parts!.map(p => (p.type === "video" + ? VIDEO_UNSUPPORTED_MARKER + : (p as OcxTextContent).text)).join(""), + }; + } else { + const chatParts = parts!.map(p => { + if (p.type === "image") { + return { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }; + } + // Previously this produced { type: "text", text: undefined } for a video + // part — a malformed part, worse than a drop because it can fail upstream + // schema validation. + if (p.type === "video") return { type: "text", text: VIDEO_UNSUPPORTED_MARKER }; + return { type: "text", text: (p as OcxTextContent).text }; + }); + chatMsg = { role: "user", content: chatParts }; + } + if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg); + else out.push(chatMsg); + break; + } + case "assistant": { + const aMsg = msg as OcxAssistantMessage; + const textParts = aMsg.content.filter(p => p.type === "text") as OcxTextContent[]; + const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[]; + const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[]; + const chatMsg: Record = { role: "assistant" }; + if (textParts.length > 0) chatMsg.content = textParts.map(p => p.text).join(""); + let reasoningContent = thinkingParts.map(p => p.thinking).join(""); + if ( + reasoningContent.length === 0 + && toolCalls.length > 0 + && modelInList(provider.preserveReasoningContentModels, parsed.modelId) + ) { + const cached = toolCalls + .map(tc => (tc.id ? peekReasoningForCall(tc.id, replayCacheScope) : undefined)) + .filter((text): text is string => typeof text === "string" && text.length > 0); + // Parallel calls share one preceding reasoning block, which is + // recorded under every call id — join unique texts only. + if (cached.length > 0) { + reasoningContent = [...new Set(cached)].join("\n"); + } else if (modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)) { + // Fallback (extends #950, closes #1193): the replay cache is + // bounded (64 entries / 256 KiB / 1 h TTL) and always misses on + // long sessions, and some tool rounds carry no recorded reasoning + // at all. DeepSeek thinking mode rejects ANY tool_call assistant + // message missing reasoning_content with HTTP 400, so inject a + // minimal placeholder rather than emit a bare continuation the + // upstream will reject. Scoped to requiresReasoningPlaceholderModels + // (defaulting to the preserve list): preserve-listed providers with + // toggleable thinking (MiniMax low effort) opt out with `[]` so + // non-thinking histories are never given a fabricated placeholder. + reasoningContent = " "; + } + } + if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) { + // MiniMax's interleaved-thinking contract requires the structured + // reasoning_details array back on the next turn; a reasoning_content + // string is the native-format pass-back the docs mark unsupported. + if (modelInList(provider.reasoningDetailsModels, parsed.modelId)) { + chatMsg.reasoning_details = [reasoningDetailSegmentForWire(reasoningContent)]; + } else { + chatMsg.reasoning_content = reasoningContent; + } + } + const hasReplayedReasoning = chatMsg.reasoning_content !== undefined || chatMsg.reasoning_details !== undefined; + if (chatMsg.content === undefined && toolCalls.length === 0 && !hasReplayedReasoning) break; + flushPendingToolCalls(); + const wireToolCalls = toolCalls.map(tc => { + let id = tc.id; + if (!id) id = mintCallId(); + else seenWireCallIds.add(id); + return { tc, id }; + }); + if (wireToolCalls.length > 0) { + chatMsg.tool_calls = wireToolCalls.map(({ tc, id }) => ({ + id, + type: "function", + function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) }, + })); + if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider); + } + if (hasReplayedReasoning && chatMsg.content === undefined && chatMsg.tool_calls === undefined) { + chatMsg.content = emptyAssistantContent(provider); + } + out.push(chatMsg); + pendingToolCalls = wireToolCalls.map(({ tc, id }) => ({ id, name: namespacedToolName(tc.namespace, tc.name) })); + break; + } + case "toolResult": { + let toolCallId = msg.toolCallId; + const matchIdx = toolCallId ? pendingToolCalls.findIndex(c => c.id === toolCallId) : -1; + if (matchIdx >= 0 && toolCallId) { + out.push({ + role: "tool", + tool_call_id: toolCallId, + content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true), + }); + pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content)); + pendingToolCalls.splice(matchIdx, 1); + if (pendingToolCalls.length === 0) { + flushToolResultImages(); + releaseDeferredBarriers(); + } + } else { + if (!toolCallId) toolCallId = `call_orphan_${out.length}`; + flushPendingToolCalls(); + const name = safeToolName(msg.toolName); + const cachedReasoning = + toolCallId && modelInList(provider.preserveReasoningContentModels, parsed.modelId) + ? peekReasoningForCall(toolCallId, replayCacheScope) + : undefined; + // Same fallback as the main-assistant path: never emit a bare orphan + // tool_call continuation on a thinking-mode provider — inject a + // placeholder when the replay cache missed (the bounded cache can + // always miss on long sessions), or DeepSeek thinking mode 400s. + // Gate on the preserve list too: reasoning_content is only ever + // serialized for preserve-listed models, so a requires-only custom + // entry must not fabricate it on this path (P2 on #1205). + // `||` (not `??`): the cache never stores empty strings, but treat a + // falsy hit as a miss so the placeholder still fires. + const orphanReasoning = + cachedReasoning + || (modelInList(provider.preserveReasoningContentModels, parsed.modelId) + && modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId) + ? " " + : undefined); + const orphanReasoningFields: Record = !orphanReasoning + ? {} + : modelInList(provider.reasoningDetailsModels, parsed.modelId) + ? { reasoning_details: [reasoningDetailSegmentForWire(orphanReasoning)] } + : { reasoning_content: orphanReasoning }; + out.push({ + role: "assistant", + content: emptyAssistantContent(provider), + ...orphanReasoningFields, + tool_calls: [{ + id: toolCallId, + type: "function", + function: { name, arguments: "{}" }, + }], + }); + seenWireCallIds.add(toolCallId); + out.push({ + role: "tool", + tool_call_id: toolCallId, + content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true), + }); + pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content)); + flushToolResultImages(); + } + break; + } + } + } + + flushPendingToolCalls(); + releaseDeferredBarriers(); + return out; +} + +export function safeToolName(name: string | undefined): string { + const raw = name && name.trim().length > 0 ? name : "tool_result"; + const sanitized = raw.replace(/[^A-Za-z0-9_-]/g, "_"); + return sanitized; +} + +export function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] { + return isVolcengineArkPaygChatTarget(provider) ? [{ type: "text", text: "" }] : ""; +} diff --git a/src/adapters/openai-chat/passthrough.ts b/src/adapters/openai-chat/passthrough.ts new file mode 100644 index 0000000000..f7682b7a62 --- /dev/null +++ b/src/adapters/openai-chat/passthrough.ts @@ -0,0 +1,146 @@ +import { openAIChatTransport, stripBracketedModelSuffix } from "./wire"; +import type { AdapterRequest } from "../base"; +import { frameAgentRouterMessages } from "../agentrouter"; +import { openRouterProviderPayload, resolveOpenRouterRouting } from "../../providers/openrouter-routing"; +import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../../providers/vercel-gateway-routing"; +import { fastPolicyForModel } from "../../providers/service-tier"; +import { canonicalFastTierMarker, decideTier, type ResolvedFastPolicy } from "../../providers/fastwire"; +import { debugProviderDiagnostic } from "../../lib/debug"; +import { isDebugEnabled } from "../../lib/debug-settings"; +import { modelRecordValue } from "../../reasoning-effort"; +import { modelInList, type OcxProviderConfig } from "../../types"; + +const CHAT_PASSTHROUGH_FIELDS = [ + "audio", + "frequency_penalty", + "logit_bias", + "logprobs", + "max_completion_tokens", + "max_tokens", + "metadata", + "modalities", + "n", + "prediction", + "presence_penalty", + "reasoning_effort", + "response_format", + "seed", + "stop", + "store", + "temperature", + "tool_choice", + "tools", + "top_logprobs", + "top_p", + "user", + "web_search_options", +] as const; + +/** + * Build a provider request from an inbound Chat Completions body without translating it + * through the Responses contract. This is deliberately a whitelist: Chat-only caller + * fields retain their exact wire representation, while provider capability gates remain + * centralized beside the ordinary openai-chat adapter. + */ +export function buildOpenAIChatPassthroughRequest( + provider: OcxProviderConfig, + rawBody: Record, + modelId: string, + stream: boolean, + fastPolicy: ResolvedFastPolicy = fastPolicyForModel(provider, modelId, undefined, "chat"), + fastMode?: boolean, +): AdapterRequest { + const { url, headers, hasCredential } = openAIChatTransport(provider); + + const body: Record = { + model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId, + messages: frameAgentRouterMessages(provider.baseUrl, rawBody.messages), + stream, + }; + for (const field of CHAT_PASSTHROUGH_FIELDS) { + if (rawBody[field] !== undefined) body[field] = rawBody[field]; + } + const rawEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; + if (modelInList(provider.noReasoningModels, modelId) || rawEfforts?.length === 0) { + delete body.reasoning_effort; + } + + const openRouterRouting = resolveOpenRouterRouting(provider, modelId); + if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); + const vercelRouting = resolveVercelGatewayRouting(provider, modelId); + if (vercelRouting) body.provider = vercelGatewayProviderPayload(vercelRouting); + + if (modelInList(provider.noTemperatureModels, modelId)) delete body.temperature; + if (modelInList(provider.noTopPModels, modelId)) delete body.top_p; + if (modelInList(provider.noPenaltyModels, modelId)) { + delete body.presence_penalty; + delete body.frequency_penalty; + } + // Exact match, unlike the gates above: `noStructuredOutputModels` is documented as + // "only an exact requested-model match omits the field" (#1424), and the Responses + // ingress enforces exactly that. A prefix match here would strip response_format from + // `:` siblings the operator never opted out, silently returning prose. + if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format; + // Narrower neighbour: the model takes `json_object` but rejects `json_schema`. Downgrade + // rather than drop, so a caller that asked for JSON still gets JSON. The type check also + // makes the kill switch above win without an else — after its `delete` there is no type + // left to match. + const passthroughFormat = body.response_format; + if (provider.noJsonSchemaModels?.includes(modelId) + && typeof passthroughFormat === "object" && passthroughFormat !== null + && (passthroughFormat as { type?: unknown }).type === "json_schema") { + body.response_format = { type: "json_object" }; + } + + // Run the same complete Fast policy as the translated Chat path, including explicit + // fastMode and foreign-tier handling. On inherited canonical Fast, the passthrough still + // retains the caller's exact spelling; forced Fast uses the policy-owned wire value. + const callerTier = typeof rawBody.service_tier === "string" ? rawBody.service_tier : undefined; + const tierDecision = decideTier(fastPolicy, fastMode, callerTier); + if (tierDecision.kind === "set") { + body.service_tier = fastMode === undefined && canonicalFastTierMarker(callerTier) !== undefined + ? callerTier + : tierDecision.value; + } else if (tierDecision.kind === "forward-caller" && rawBody.service_tier !== undefined) { + body.service_tier = rawBody.service_tier; + } + if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) { + body.prompt_cache_key = rawBody.prompt_cache_key; + } + if (Array.isArray(rawBody.tools) && rawBody.tools.length > 0) { + if (provider.parallelToolCalls === true) { + body.parallel_tool_calls = rawBody.parallel_tool_calls !== false; + } else if (provider.parallelToolCalls === false + && (provider.baseUrl === "https://integrate.api.nvidia.com/v1" || provider.pinParallelToolCallsFalse === true)) { + body.parallel_tool_calls = false; + } + } + if (stream) { + const callerOptions = rawBody.stream_options !== null + && typeof rawBody.stream_options === "object" + && !Array.isArray(rawBody.stream_options) + ? rawBody.stream_options as Record + : {}; + body.stream_options = { ...callerOptions, include_usage: true }; + } else if (rawBody.stream_options !== undefined) { + body.stream_options = rawBody.stream_options; + } + + const bodyJson = JSON.stringify(body); + + if (isDebugEnabled()) { + let host = "upstream"; + try { host = new URL(url).host; } catch { /* keep fallback */ } + debugProviderDiagnostic("openai-chat", "passthrough-request", { + host, + model: body.model, + stream, + messageCount: Array.isArray(body.messages) ? body.messages.length : 0, + toolCount: Array.isArray(body.tools) ? body.tools.length : 0, + hasCredential, + bodyBytes: Buffer.byteLength(bodyJson, "utf8"), + }); + } + + return { url, method: "POST", headers, body: bodyJson }; +} diff --git a/src/adapters/openai-chat/response-events.ts b/src/adapters/openai-chat/response-events.ts new file mode 100644 index 0000000000..4738af8896 --- /dev/null +++ b/src/adapters/openai-chat/response-events.ts @@ -0,0 +1,117 @@ +import { diagnoseInvalidToolCalls, isRecord, type InvalidToolCallDiagnostic } from "./tool-call-validation"; +import type { AdapterEvent, OcxUsage } from "../../types"; + +export function stopReasonFor(finishReason: unknown): "max_tokens" | "content_filter" | undefined { + return finishReason === "length" + ? "max_tokens" + : finishReason === "content_filter" + ? "content_filter" + : undefined; +} + +export function reasoningTextFrom(record: Record): string | undefined { + return typeof record.reasoning_content === "string" && record.reasoning_content.length > 0 + ? record.reasoning_content + : typeof record.reasoning === "string" && record.reasoning.length > 0 + ? record.reasoning + : undefined; +} + +export interface ReasoningDetailSegment { + key: string; + text: string; +} + +/** + * Structured `reasoning_details` array (MiniMax M-series with `reasoning_split`). + * Each segment's key scopes cumulative-snapshot tracking: upstream repeats the + * full text-so-far under a stable `id`/`index` instead of sending increments. + */ +export function reasoningDetailSegmentsFrom(record: Record): ReasoningDetailSegment[] { + const raw = record.reasoning_details; + if (!Array.isArray(raw)) return []; + const segments: ReasoningDetailSegment[] = []; + for (let i = 0; i < raw.length; i++) { + const item: unknown = raw[i]; + if (!isRecord(item)) continue; + if (typeof item.text !== "string" || item.text.length === 0) continue; + const key = typeof item.id === "string" && item.id.length > 0 + ? `id:${item.id}` + : typeof item.index === "number" + ? `i:${item.index}` + : `n:${i}`; + segments.push({ key, text: item.text }); + } + return segments; +} + +/** Single-segment `reasoning_details` entry for replaying preserved reasoning (MiniMax wire shape). */ +export function reasoningDetailSegmentForWire(text: string): Record { + return { type: "reasoning.text", id: "reasoning-text-1", format: "MiniMax-response-v1", index: 0, text }; +} + +export function invalidChoicesEvent(usage?: OcxUsage): Extract { + return { + type: "error", + message: "upstream response contained invalid choices", + ...(usage !== undefined ? { usage } : {}), + }; +} + +export function invalidToolCallsEvent( + rawToolCalls: unknown, + mode: "stream" | "response", + usage?: OcxUsage, + diagnosticOverride?: InvalidToolCallDiagnostic, +): Extract { + // The streamed accumulator knows things a rescan cannot: which field on which pending call + // was actually rejected. Without the override, a stream carrying accepted padding on call 0 + // and a real defect on call 1 blames call 0, because the stateless scan stops at the first + // structurally odd value it sees. + const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode); + const detail = diagnostic + ? ` (${diagnostic.reason}${diagnostic.callIndex !== undefined ? `; callIndex=${diagnostic.callIndex}` : ""}; valueType=${diagnostic.valueType})` + : ""; + return { + type: "error", + status: 502, + errorType: "upstream_error", + message: `upstream response contained invalid tool calls${detail}`, + ...(usage !== undefined ? { usage } : {}), + }; +} + +/** + * A streamed tool call is only dispatchable once the upstream has named the function. + * + * The OpenAI streaming convention puts `function.name` in the first chunk for a tool-call + * index and leaves later chunks carrying only `arguments` deltas, so a stream that never + * sends a name is non-conforming for every provider rather than quirky for one. The + * reference implementations accumulate such a call with an empty name and let the caller + * fail; we sit at the boundary where it would become a Codex tool-call contract event, so + * the equivalent is to refuse to emit it. + * + * Failing closed rather than dropping is deliberate, and matches #1325: a claimed tool call + * that silently disappears can leave the matching result orphaned on the next turn. Naming + * it ourselves is worse still — the id is synthesizable because it is an opaque correlation + * handle, but a function name is a guess at intent. + */ +export function unnamedToolCallEvent(usage?: OcxUsage): Extract { + return { + type: "error", + message: "upstream streamed a tool call without a function name — cannot dispatch", + ...(usage !== undefined ? { usage } : {}), + }; +} + +export function usageFromOpenAIChat(usage: Record | undefined): OcxUsage | undefined { + if (!usage) return undefined; + const promptDetails = usage.prompt_tokens_details as Record | undefined; + const completionDetails = usage.completion_tokens_details as Record | undefined; + return { + inputTokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0, + outputTokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0, + ...(promptDetails?.cached_tokens !== undefined ? { cachedInputTokens: promptDetails.cached_tokens } : {}), + ...(completionDetails?.reasoning_tokens !== undefined ? { reasoningOutputTokens: completionDetails.reasoning_tokens } : {}), + }; +} diff --git a/src/adapters/openai-chat/tool-call-validation.ts b/src/adapters/openai-chat/tool-call-validation.ts new file mode 100644 index 0000000000..b90ef939e1 --- /dev/null +++ b/src/adapters/openai-chat/tool-call-validation.ts @@ -0,0 +1,200 @@ +import { debugProviderDiagnostic } from "../../lib/debug"; +import { isDebugEnabled } from "../../lib/debug-settings"; + +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +type InvalidToolCallReason = + | "tool_calls_not_array" + | "tool_call_not_object" + | "tool_call_id_invalid" + | "tool_call_function_not_object" + | "tool_call_function_name_invalid" + | "tool_call_function_name_blank" + | "tool_call_function_arguments_invalid"; + +export type InvalidToolCallDiagnostic = { + reason: InvalidToolCallReason; + callIndex?: number; + valueType: string; +}; + +type InvalidFieldShape = + | { + kind: "object"; + knownKeys: string[]; + knownFieldTypes: Record; + hasUnknownKeys: boolean; + } + | { + kind: "array"; + length: number; + }; + +const SAFE_TOOL_CALL_SHAPE_KEYS = [ + "name", + "type", + "value", + "function", + "arguments", + "id", + "index", +] as const; +const SAFE_TOOL_CALL_SHAPE_KEY_SET = new Set(SAFE_TOOL_CALL_SHAPE_KEYS); + +function structuralValueType(value: unknown): string { + return value === null ? "null" : Array.isArray(value) ? "array" : typeof value; +} + +function invalidToolCallField(rawToolCalls: unknown, diagnostic: InvalidToolCallDiagnostic): unknown { + if (diagnostic.reason === "tool_calls_not_array") return rawToolCalls; + if (!Array.isArray(rawToolCalls) || diagnostic.callIndex === undefined) return undefined; + + const rawToolCall = rawToolCalls[diagnostic.callIndex]; + if (diagnostic.reason === "tool_call_not_object") return rawToolCall; + if (!isRecord(rawToolCall)) return undefined; + if (diagnostic.reason === "tool_call_function_not_object") return rawToolCall.function; + + const rawFunction = rawToolCall.function; + switch (diagnostic.reason) { + case "tool_call_id_invalid": + return rawToolCall.id; + case "tool_call_function_name_invalid": + return isRecord(rawFunction) ? rawFunction.name : undefined; + case "tool_call_function_arguments_invalid": + return isRecord(rawFunction) ? rawFunction.arguments : undefined; + default: + return undefined; + } +} + +function fingerprintInvalidField(value: unknown): InvalidFieldShape | undefined { + if (Array.isArray(value)) return { kind: "array", length: value.length }; + if (!isRecord(value)) return undefined; + + const knownKeys: string[] = []; + const knownFieldTypes: Record = {}; + for (const key of SAFE_TOOL_CALL_SHAPE_KEYS) { + if (!Object.hasOwn(value, key)) continue; + knownKeys.push(key); + knownFieldTypes[key] = structuralValueType(value[key]); + } + + let hasUnknownKeys = false; + for (const key of Object.keys(value)) { + if (!SAFE_TOOL_CALL_SHAPE_KEY_SET.has(key)) { + hasUnknownKeys = true; + break; + } + } + return { kind: "object", knownKeys, knownFieldTypes, hasUnknownKeys }; +} + +/** + * Streamed string fields are absent when null or undefined (#1731): OpenAI-compatible + * streamers repeat already-sent `id`/`name`/`arguments` as null on continuation deltas. + * The accumulator and this diagnostic share this predicate so they cannot disagree about + * which delta was the invalid one. + */ +export function isInvalidStreamStringField(value: unknown): boolean { + return value != null && typeof value !== "string"; +} + +/** + * Explain only the rejected wire shape, never its values. This diagnostic exists so provider + * compatibility can be tightened from evidence without retaining tool arguments or credentials. + */ +export function diagnoseInvalidToolCalls( + rawToolCalls: unknown, + mode: "stream" | "response", +): InvalidToolCallDiagnostic | undefined { + if (!Array.isArray(rawToolCalls)) { + return { reason: "tool_calls_not_array", valueType: rawToolCalls === null ? "null" : typeof rawToolCalls }; + } + for (let callIndex = 0; callIndex < rawToolCalls.length; callIndex++) { + const rawToolCall = rawToolCalls[callIndex]; + if (!isRecord(rawToolCall)) { + return { + reason: "tool_call_not_object", + callIndex, + valueType: rawToolCall === null ? "null" : Array.isArray(rawToolCall) ? "array" : typeof rawToolCall, + }; + } + if (mode === "stream") { + // The streamed path validates the pieces it is about to store (#1531): a present + // `function` must be a record, and a present `name`/`arguments`/`id` must be a string. + // Blank names are caught later at flush, not here, so they are not diagnosed on this + // branch. Describe exactly that boundary rather than tightening compatibility in a + // diagnostic change. + // #1731: "present" means the same thing here as in the accumulator — null and undefined + // are both absent, because some OpenAI-compatible streamers repeat already-sent fields + // as null on continuation deltas. A separate predicate here would diagnose accepted + // padding as the failure and point compatibility work at the wrong delta. + const streamFunction = (rawToolCall as { function?: unknown }).function; + if (streamFunction !== undefined && streamFunction !== null) { + if (!isRecord(streamFunction)) { + return { + reason: "tool_call_function_not_object", + callIndex, + valueType: Array.isArray(streamFunction) ? "array" : typeof streamFunction, + }; + } + if (isInvalidStreamStringField(streamFunction.name)) { + return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof streamFunction.name }; + } + if (isInvalidStreamStringField(streamFunction.arguments)) { + return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof streamFunction.arguments }; + } + } + if (isInvalidStreamStringField(rawToolCall.id)) { + return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id }; + } + continue; + } + // Precedence must mirror the buffered validator below, or a payload with more than one + // problem is reported under the wrong reason and sends compatibility work after the wrong + // shape. That validator checks the `function` container first (`!isRecord(rawToolCall) || + // !isRecord(rawToolCall.function)`), then id/name/arguments types together, and only then + // the blank name. + if (!isRecord(rawToolCall.function)) { + return { + reason: "tool_call_function_not_object", + callIndex, + valueType: rawToolCall.function === null ? "null" : Array.isArray(rawToolCall.function) ? "array" : typeof rawToolCall.function, + }; + } + if (typeof rawToolCall.id !== "string") { + return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id }; + } + if (typeof rawToolCall.function.name !== "string") { + return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof rawToolCall.function.name }; + } + if (typeof rawToolCall.function.arguments !== "string") { + return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof rawToolCall.function.arguments }; + } + // Last, matching the validator: #1531 also rejects a blank or whitespace-only name here, + // because such a call cannot select a dispatch target. Reporting it as `name_invalid` + // would claim a type problem for a correctly-typed value, so it gets its own code. + if (rawToolCall.function.name.trim().length === 0) { + return { reason: "tool_call_function_name_blank", callIndex, valueType: "string" }; + } + } + return undefined; +} + +export function logInvalidToolCalls( + mode: "stream" | "response", + rawToolCalls: unknown, + diagnosticOverride?: InvalidToolCallDiagnostic, +): void { + if (!isDebugEnabled()) return; + const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode); + if (!diagnostic) return; + const fieldShape = fingerprintInvalidField(invalidToolCallField(rawToolCalls, diagnostic)); + debugProviderDiagnostic("openai-chat", "invalid-tool-calls", { + mode, + ...diagnostic, + ...(fieldShape ? { fieldShape } : {}), + }); +} diff --git a/src/adapters/openai-chat/tool-schema.ts b/src/adapters/openai-chat/tool-schema.ts new file mode 100644 index 0000000000..c056a6a043 --- /dev/null +++ b/src/adapters/openai-chat/tool-schema.ts @@ -0,0 +1,477 @@ +import { isNativeOpenAIChatTarget } from "./wire"; +import { isXaiSchemaTarget, lookupLocalJsonPointer, normalizeXaiToolParameters } from "../xai-tool-schema"; +import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "../responses-tool-schema"; +import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../../types"; +import type { OcxParsedRequest, OcxProviderConfig } from "../../types"; + +const ZEN_SCHEMA_MAP_KEYS = new Set(["properties", "$defs", "definitions"]); +const ZEN_DROPPED_SCHEMA_KEYS = new Set(["encrypted"]); + +function sanitizeZenSchemaMap(value: unknown): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) return sanitizeZenToolParameters(value); + const out: Record = {}; + for (const [name, child] of Object.entries(value as Record)) { + out[name] = sanitizeZenToolParameters(child); + } + return out; +} + +function sanitizeZenToolParameters(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sanitizeZenToolParameters); + if (!value || typeof value !== "object") return value; + const input = value as Record; + const out: Record = {}; + for (const [key, child] of Object.entries(input)) { + if (ZEN_DROPPED_SCHEMA_KEYS.has(key)) continue; + if (key === "required" && Array.isArray(child) && child.length === 0) continue; + if (key === "type" && Array.isArray(child)) { + const nonNull = child.filter(entry => entry !== "null"); + if (child.includes("null")) out.nullable = true; + if (nonNull.length > 0) out.type = nonNull[0]; + continue; + } + out[key] = ZEN_SCHEMA_MAP_KEYS.has(key) ? sanitizeZenSchemaMap(child) : sanitizeZenToolParameters(child); + } + return out; +} + +function ensureZenRootObjectSchema(schema: unknown): Record { + const obj = schema && typeof schema === "object" && !Array.isArray(schema) + ? schema as Record + : {}; + const compositionKeys = ["oneOf", "anyOf", "allOf"] as const; + const hasComposition = compositionKeys.some(key => Array.isArray(obj[key])); + const rootType = obj.type; + const rootObjectType = rootType === "object" || (Array.isArray(rootType) && rootType.includes("object")); + if (!hasComposition) { + const base = sanitizeZenToolParameters(obj) as Record; + return rootObjectType && base.type === "object" ? base : { ...base, type: "object" }; + } + + const props: Record = {}; + const required = new Set(); + if (obj.properties && typeof obj.properties === "object") { + Object.assign(props, sanitizeZenSchemaMap(obj.properties) as Record); + } + if (Array.isArray(obj.required)) { + for (const entry of obj.required) if (typeof entry === "string") required.add(entry); + } + for (const key of compositionKeys) { + const variants = obj[key]; + if (!Array.isArray(variants)) continue; + const mergeRequired = key === "allOf"; + for (const variant of variants) { + if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue; + const rec = variant as Record; + if (rec.properties && typeof rec.properties === "object") { + Object.assign(props, sanitizeZenSchemaMap(rec.properties) as Record); + } + if (mergeRequired && Array.isArray(rec.required)) { + for (const entry of rec.required) if (typeof entry === "string") required.add(entry); + } + } + } + + const merged = sanitizeZenToolParameters(obj) as Record; + delete merged.oneOf; + delete merged.anyOf; + delete merged.allOf; + merged.type = "object"; + if (Object.keys(props).length > 0) merged.properties = props; + if (required.size > 0) merged.required = [...required]; + return merged; +} + +function shouldSanitizeZenToolParameters(provider: OcxProviderConfig): boolean { + const baseUrl = provider.baseUrl.replace(/\/+$/, ""); + return baseUrl === "https://opencode.ai/zen/v1" + || baseUrl === "https://opencode.ai/zen/go/v1"; +} + +/** Azure Model Router (and Gemini-in-the-pool) 400s Codex MCP schemas whose root is a union. */ +const AZURE_CHAT_FORBIDDEN_ROOT_KEYS = ["oneOf", "anyOf", "allOf", "enum", "const", "not"] as const; + +function isAzureOpenAiChatTarget(provider: OcxProviderConfig): boolean { + try { + const host = new URL(provider.baseUrl).hostname.toLowerCase(); + return host.endsWith(".openai.azure.com") + || host.endsWith(".cognitiveservices.azure.com") + || host.endsWith(".services.ai.azure.com") + || host.endsWith(".ai.azure.com"); + } catch { + return false; + } +} + +/** + * Azure Foundry Model Router validates every function schema against the strictest model in + * the pool (Gemini-shaped): root must be {type:"object"} with no oneOf/anyOf/allOf/enum/ + * const/not. Codex App MCP tools such as mcp__codex_app__automation_update ship a root + * union, which 400s the whole turn. Flatten like Zen, then strip leftover forbidden keys. + */ +function sanitizeAzureChatToolParameters(parameters: unknown): Record { + const root = ensureZenRootObjectSchema(parameters); + for (const key of AZURE_CHAT_FORBIDDEN_ROOT_KEYS) delete root[key]; + root.type = "object"; + if (!root.properties || typeof root.properties !== "object" || Array.isArray(root.properties)) { + root.properties = {}; + } + return root; +} + +// Moonshot validates function schemas against a draft-07 reading of `$ref`, where the +// keyword stands alone and siblings are ignored. It rejects the whole request rather +// than ignoring them: "not a valid moonshot flavored json schema ... when using $ref, +// type should be defined in the referenced schema instead of the parent schema". +const MOONSHOT_SCHEMA_HOSTNAMES = new Set([ + "api.kimi.com", + "api.moonshot.ai", + "api.moonshot.cn", +]); + +function isMoonshotSchemaTarget(provider: OcxProviderConfig): boolean { + try { + return MOONSHOT_SCHEMA_HOSTNAMES.has(new URL(provider.baseUrl).hostname); + } catch { + return false; + } +} + +const VOLCENGINE_ARK_HOSTNAMES = new Set([ + "ark.cn-beijing.volces.com", + "ark.ap-southeast.volces.com", +]); + +export function isVolcengineArkPaygChatTarget(provider: OcxProviderConfig): boolean { + try { + const url = new URL(provider.baseUrl); + const pathname = url.pathname.replace(/\/+$/, "") || "/"; + return VOLCENGINE_ARK_HOSTNAMES.has(url.hostname) && pathname === "/api/v3"; + } catch { + return false; + } +} + +function ensureRootObjectType(parameters: unknown): Record { + if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) { + return { type: "object", properties: {} }; + } + const obj = parameters as Record; + if (obj.type === "object") return obj; + return { ...obj, type: "object" }; +} + +function isXaiObjectSchema(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +/** + * JSON Schema 2020-12 makes `$ref` an in-place applicator: siblings stay in force and are + * combined with the referenced schema. Moonshot enforces the older draft-07 reading where + * `$ref` must stand alone, and 400s the entire request when a node carries both. Codex's own + * deferred tool catalog emits exactly that shape (zod-to-json-schema deduplicates into + * `$defs.__schema*` nodes that keep `type`/`minLength`/`format` beside the `$ref`), so the + * schema is not something a user can fix from configuration — see issue #2673. + * + * Inline the referenced schema underneath the node's own keywords, which is what 2020-12 says + * the node means, then drop `$ref`. Constraints reach the model instead of being stripped. + * The `$defs` bag is preserved: a bare `$ref` (no siblings) is already legal for Moonshot and + * is left pointing at its definition rather than expanded, which keeps recursive schemas finite. + */ +function moonshotRefTargetKeys(node: Record): string[] { + return Object.keys(node).filter(key => key !== "$ref"); +} + +/** + * Inlining duplicates the target, so a schema referencing one large definition from many + * sibling-carrying nodes can multiply. Bound the total expansions and fall back to a bare + * `$ref` once the budget is spent: still valid for Moonshot, just without the node's own + * narrowing keywords. Mirrors the node budget in google-tool-schema.ts. + */ +const MOONSHOT_MAX_REF_EXPANSIONS = 512; + +/** + * Expansion count alone does not bound the walk: a deeply nested ref-free schema, or one + * large definition repeated across many nodes, still recurses to exhaustion or amplifies the + * emitted output. Depth and node budgets close both, and mirror google-tool-schema.ts. + */ +const MOONSHOT_MAX_SCHEMA_DEPTH = 64; +const MOONSHOT_MAX_SCHEMA_NODES = 4_096; + +/** + * Assertion keywords whose meaning under a `$ref` is CONJUNCTION, not replacement. A node + * carrying `required: ["b"]` beside a target requiring `["a"]` means both are required; + * letting the sibling win emitted a schema that no longer described the tool. + */ +function unionRequired(target: unknown, sibling: unknown): unknown { + if (!Array.isArray(target) || !Array.isArray(sibling)) return sibling; + const seen = new Set(); + const out: unknown[] = []; + for (const name of [...target, ...sibling]) { + if (seen.has(name)) continue; + seen.add(name); + out.push(name); + } + return out; +} + +/** + * Keywords whose values are DATA, not schemas. + * + * Recursing into them rewrote user data: an `enum` listing a literal object that happens + * to carry a `"$ref"` string had that key stripped as if it were a schema reference, so a + * value the tool declared as legal silently changed shape. These are copied through. + */ +const MOONSHOT_DATA_VALUED_KEYWORDS = new Set(["enum", "const", "default", "examples"]); + +/** + * Numeric assertions whose intersection is a bound, and which direction tightens. + * + * `$ref` under 2020-12 is an in-place applicator: the node and its target BOTH apply, so + * the emitted schema must be their INTERSECTION. The previous code overwrote the target + * with the node and called that "the narrower reading", which holds only when the node + * happens to be narrower. A node declaring `minLength: 1` beside a target declaring + * `minLength: 5` shipped `minLength: 1` - a contract weaker than either side asked for, + * emitted silently, which is the same failure mode the `required` composition fixed for + * set-valued keywords. + * + * "max" means the surviving value is the larger of the two (lower bounds), "min" the + * smaller (upper bounds). A keyword absent from this table keeps the overwrite: for + * `type`, `format`, `description` and friends there is no ordering to intersect along, + * and the node is the more specific statement. + */ +const MOONSHOT_BOUND_KEYWORDS: Record = { + minLength: "max", + minItems: "max", + minProperties: "max", + minimum: "max", + exclusiveMinimum: "max", + maxLength: "min", + maxItems: "min", + maxProperties: "min", + maximum: "min", + exclusiveMaximum: "min", +}; + +/** + * Intersect one numeric bound. Either side being absent or non-finite yields the other, + * because an unstated bound constrains nothing - returning `undefined` there would drop + * a constraint the remaining side genuinely made. + */ +function intersectBound(target: unknown, sibling: unknown, direction: "max" | "min"): unknown { + const a = typeof target === "number" && Number.isFinite(target) ? target : null; + const b = typeof sibling === "number" && Number.isFinite(sibling) ? sibling : null; + if (a === null) return b === null ? sibling : sibling; + if (b === null) return target; + return direction === "max" ? Math.max(a, b) : Math.min(a, b); +} + +/** + * Compose two `properties` maps. A property named in BOTH the referenced target and the + * node is the same conjunction problem `required` had: letting the sibling win discards + * the target's constraints for that member. Merge the two member schemas so neither side + * loses its keywords. Shared member bounds are the same conjunction one level down, + * and nested object members recurse through this helper instead of replacing the target. + */ +function composeProperties( + target: Record, + sibling: Record, +): Record { + const combined: Record = Object.create(null) as Record; + for (const [name, sub] of Object.entries(target)) combined[name] = sub; + for (const [name, sub] of Object.entries(sibling)) { + const existing = combined[name]; + if (isXaiObjectSchema(existing) && isXaiObjectSchema(sub)) { + const member: Record = Object.create(null) as Record; + for (const [k, v] of Object.entries(existing)) member[k] = v; + for (const [k, v] of Object.entries(sub)) { + if (k === "required") { + member[k] = unionRequired(member[k], v); + continue; + } + if (k === "properties" && isXaiObjectSchema(member[k]) && isXaiObjectSchema(v)) { + member[k] = composeProperties(member[k] as Record, v); + continue; + } + const boundDirection = MOONSHOT_BOUND_KEYWORDS[k]; + if (boundDirection && k in member) { + member[k] = intersectBound(member[k], v, boundDirection); + continue; + } + member[k] = v; + } + combined[name] = member; + continue; + } + combined[name] = sub; + } + return combined; +} + +interface MoonshotNormalizeState { + activeRefs: Set; + remainingExpansions: number; + remainingNodes: number; +} + +function normalizeMoonshotSchemaNode( + node: unknown, + root: Record, + state: MoonshotNormalizeState, + depth = 0, +): unknown { + if (Array.isArray(node)) { + if (depth >= MOONSHOT_MAX_SCHEMA_DEPTH) return []; + return node.map(item => normalizeMoonshotSchemaNode(item, root, state, depth + 1)); + } + if (!isXaiObjectSchema(node)) return node; + + // Fail closed for this node rather than emitting a partially weakened schema: an empty + // object is the one shape that asserts nothing it cannot back up. + if (depth >= MOONSHOT_MAX_SCHEMA_DEPTH || state.remainingNodes <= 0) return {}; + state.remainingNodes -= 1; + + const ref = node.$ref; + const hasSiblings = moonshotRefTargetKeys(node).length > 0; + + if (typeof ref === "string" && hasSiblings) { + // A cycle cannot be inlined. Keeping the bare `$ref` is the lossy-but-valid fallback: + // Moonshot accepts it, and the alternative (dropping the ref) would erase the recursion. + if (state.activeRefs.has(ref) || state.remainingExpansions <= 0) return { $ref: ref }; + + const target = lookupLocalJsonPointer(root, ref); + if (isXaiObjectSchema(target)) { + state.remainingExpansions -= 1; + state.activeRefs.add(ref); + const resolvedTarget = normalizeMoonshotSchemaNode(target, root, state, depth + 1); + state.activeRefs.delete(ref); + const merged: Record = Object.create(null) as Record; + if (isXaiObjectSchema(resolvedTarget)) { + for (const [key, value] of Object.entries(resolvedTarget)) merged[key] = value; + } + // "Alongside the target" is conjunction, not replacement. For most keywords the node + // narrows the target and overwriting is the narrower reading, but `required` and + // `properties` are set-valued: letting the sibling win DROPPED the target's own + // members, so a tool requiring `a` beside a node requiring `b` shipped requiring only + // `b`. Those two compose; everything else keeps the narrowing overwrite. + for (const [key, value] of Object.entries(node)) { + if (key === "$ref") continue; + if (MOONSHOT_DATA_VALUED_KEYWORDS.has(key)) { + merged[key] = value; + continue; + } + const normalized = normalizeMoonshotSchemaNode(value, root, state, depth + 1); + if (key === "required") { + merged[key] = unionRequired(merged[key], normalized); + continue; + } + if (key === "properties" && isXaiObjectSchema(merged[key]) && isXaiObjectSchema(normalized)) { + merged[key] = composeProperties(merged[key] as Record, normalized); + continue; + } + // Numeric bounds intersect rather than overwrite: both the node and its target + // apply, so the surviving bound is the stricter of the two in whichever direction + // that keyword tightens. + const boundDirection = MOONSHOT_BOUND_KEYWORDS[key]; + if (boundDirection && key in merged) { + merged[key] = intersectBound(merged[key], normalized, boundDirection); + continue; + } + merged[key] = normalized; + } + return merged; + } + + // Unresolvable pointer: a remote ref, a malformed path, or a non-object target. Dropping + // the ref and keeping the siblings silently discards whatever the reference constrained, + // which is the one outcome we cannot detect downstream. A bare `$ref` is lossy in the + // other direction - it loses the node's own keywords - but it preserves the identity of + // what was asked for, and Moonshot accepts it. + return { $ref: ref }; + } + + const out: Record = Object.create(null) as Record; + for (const [key, value] of Object.entries(node)) { + out[key] = key === "$ref" || MOONSHOT_DATA_VALUED_KEYWORDS.has(key) + ? value + : normalizeMoonshotSchemaNode(value, root, state, depth + 1); + } + return out; +} + +function normalizeMoonshotToolParameters(parameters: unknown): Record { + const rooted = ensureRootObjectType(parameters); + const normalized = normalizeMoonshotSchemaNode(rooted, rooted, { + activeRefs: new Set(), + remainingExpansions: MOONSHOT_MAX_REF_EXPANSIONS, + remainingNodes: MOONSHOT_MAX_SCHEMA_NODES, + }); + return isXaiObjectSchema(normalized) ? normalized : rooted; +} + +export function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined { + if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined; + const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools)); + if (tools.length === 0) return undefined; + const xaiTarget = isXaiSchemaTarget(provider); + const moonshotTarget = !xaiTarget && isMoonshotSchemaTarget(provider); + const formatted = tools.flatMap(t => { + const normalized = xaiTarget + ? normalizeXaiToolParameters(t.parameters) + : moonshotTarget + ? normalizeMoonshotToolParameters(t.parameters) + : ensureRootObjectType(t.parameters); + const parameters = stripUnicodePropertyPatterns(stripResponsesOnlyEncryptedMarker(normalized)); + + if (parameters === undefined) return []; + return [{ + type: "function", + function: { + name: namespacedToolName(t.namespace, t.name), + ...(t.description ? { description: t.description } : {}), + parameters, + ...(t.strict !== undefined ? { strict: t.strict } : {}), + }, + }]; + }); + return formatted.length > 0 ? formatted : undefined; +} + +export function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined { + const base = toolsToChatFormat(parsed, provider); + const azureChat = isAzureOpenAiChatTarget(provider); + const zenChat = shouldSanitizeZenToolParameters(provider); + if (!base || (!zenChat && !azureChat)) return base; + return base.map(tool => { + if (!tool || typeof tool !== "object") return tool; + const functionDef = (tool as { function?: Record }).function; + if (!functionDef || typeof functionDef !== "object") return tool; + const parameters = azureChat + ? sanitizeAzureChatToolParameters(functionDef.parameters ?? {}) + : ensureZenRootObjectSchema(functionDef.parameters ?? {}); + const nextFunction: Record = { ...functionDef, parameters }; + // strict: true plus a flattened schema is rejected by Gemini-in-the-pool routers. + if (azureChat) delete nextFunction.strict; + return { + ...tool, + function: nextFunction, + }; + }); +} + +export function toolChoiceToChatFormat( + tc: OcxParsedRequest["options"]["toolChoice"], + tools: OcxParsedRequest["context"]["tools"], + provider: OcxProviderConfig, +): unknown { + if (!tc) return undefined; + if (isAllowedToolChoice(tc)) { + if (tc.mode === "required" && tc.allowedTools.length === 1 && isNativeOpenAIChatTarget(provider)) { + return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.allowedTools[0]) } }; + } + return tc.mode === "required" ? "required" : "auto"; + } + if (tc === "auto" || tc === "none" || tc === "required") return tc; + if ("name" in tc) return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } }; + return undefined; +} diff --git a/src/adapters/openai-chat/wire.ts b/src/adapters/openai-chat/wire.ts new file mode 100644 index 0000000000..077bd4bc7c --- /dev/null +++ b/src/adapters/openai-chat/wire.ts @@ -0,0 +1,50 @@ +import { agentRouterDefaultHeaders } from "../agentrouter"; +import { openaiChatCompletionsUrl } from "../openai-chat-url"; +import type { OcxProviderConfig } from "../../types"; + +// Providers may opt into stripping one trailing "[...]" group from the wire model id. +// Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211; +// unflagged OpenAI-compatible providers and the Anthropic adapter keep ids verbatim. +export function stripBracketedModelSuffix(modelId: string): string { + const suffixEnd = modelId.trimEnd().length; + if (suffixEnd === 0 || modelId[suffixEnd - 1] !== "]") return modelId; + + let suffixStart = -1; + for (let i = suffixEnd - 2; i >= 0 && modelId[i] !== "]"; i--) { + if (modelId[i] === "[") suffixStart = i; + } + return suffixStart === -1 ? modelId : modelId.slice(0, suffixStart); +} + +export function openAIChatTransport(provider: OcxProviderConfig): { + url: string; + headers: Record; + hasCredential: boolean; +} { + const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0; + if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) { + throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`); + } + const headers: Record = { + "Content-Type": "application/json", + ...agentRouterDefaultHeaders(provider.baseUrl, provider.headers), + }; + if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`; + if (provider.headers) Object.assign(headers, provider.headers); + // A configured relative path wins, mirroring how the Responses adapter honours + // `responsesPath`. An upstream can serve both wires under different prefixes, and a + // per-model wire override only swaps the adapter, so without this the opted-in Chat + // request would be sent to the Responses base with `/chat/completions` appended. + const url = provider.chatCompletionsPath === undefined + ? openaiChatCompletionsUrl(provider.baseUrl) + : `${provider.baseUrl.replace(/\/$/, "")}${provider.chatCompletionsPath}`; + return { url, headers, hasCredential }; +} + +export function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean { + try { + return new URL(provider.baseUrl).hostname === "api.openai.com"; + } catch { + return false; + } +} diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 8f92f2c8c8..c996aab96f 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -1,96 +1,5 @@ -import { CODEX_ACCOUNT_LOG_LABEL_RE } from "./account-label"; -import { poolQuotaHistoryIdentity } from "./account-store"; -import { estimateCodexQuotaCapacity, insufficientCodexCapacity, type CodexCapacityResult } from "./quota-capacity"; -import { readUsageSnapshotForManagement } from "../usage/log"; -import { capturePoolQuotaWriter } from "./account-store"; -import type { PoolQuotaWriter } from "./quota-types"; -import { getAccountQuotaHistory, isValidWhamHistoryObservation } from "./quota"; -import { - ConfigMutationLockError, - loadConfig, - mutatePersistedConfig, - saveConfigPreservingClaudeCode, - withConfigMutationLockSync, -} from "../config"; -import { codexAccountLogLabel, withCodexAccountLogLabel } from "./account-label"; -import { - getCodexAccountCredential, - getValidCodexToken, - isCodexAccountGenerationLive, - forceRefreshCodexPoolToken, - markCodexAccountValidated, - markCodexAccountValidationFailed, - readCodexAccountRecord, - saveCodexAccountCredential, - CodexCredentialGenerationConflictError, - CodexCredentialRefreshLockTimeoutError, - CodexCredentialRefreshBusyError, - CodexCredentialRefreshStaleError, - TokenRefreshError, -} from "./account-store"; -import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; -import { - appendDefaultCodexAccountNamespace, - codexAccountPickerEnabled, -} from "./account-namespaces"; -import { - catalogRefreshIsPending, - normalizeCatalogDisposition, -} from "./catalog-refresh-status"; -import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause"; -import { - clearCodexAccountPin, - getCodexAccountPriority, - isCodexAccountPriorityKey, - pinnedCodexAccountId, - setCodexAccountPin, - setCodexAccountPriority, -} from "./account-priority"; -import { - claimDueCodexQuotaRecoveryProbes, - codexQuotaScopeForModel, - claimManualResetCooldowns, - settleManualResetCooldown, - type ManualResetCooldownClaim, - type ManualResetRefreshLineage, - clearCodexAccountCooldown, - clearThreadAccountMapForAccount, - getEffectiveActiveCodexAccountId, - isEffectiveCodexAccountPinned, - isCodexAccountPlanExcluded, - reconcileCodexActiveAfterExclusion, - resetCodexRoutingForManualSelection, - settleCodexQuotaRecoveryProbe, -} from "./routing"; -import { - DEFAULT_ACCOUNT_PRIORITY, - MAX_ACCOUNT_PRIORITY, - MIN_ACCOUNT_PRIORITY, - normalizeAccountPoolStickyLimit, - normalizeCodexAccountPoolStrategy, - parseAccountPoolStickyLimit, - parseCodexAccountPoolStrategy, - parseAccountPriority, -} from "./pool-rotation"; -import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; -import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "./plan"; export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision"; export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; -import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; -import { - clearAccountQuota, - getAccountQuota, - isCompleteCodexQuotaRecoverySnapshot, - isCodexQuotaExhausted, - listAccountQuotas, - parseMainPolicyUsageQuota, - parseUsageQuota, - setAccountQuotaFromParsed, - updateAccountQuota, - withoutRetiredCodexQuota, - type StoredAccountQuota, - type WhamUsageResponse, -} from "./quota"; export { applyAccountQuotaFromUpstreamHeaders, clearAccountQuota, @@ -99,3036 +8,36 @@ export { setAccountQuotaFromParsed, updateAccountQuota, } from "./quota"; -import { extractAccountId } from "../oauth/chatgpt"; -import { - getMainAccountPlan, getValidMainAccountToken, isMainAccountTokenVerifiablyLive, - MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan, -} from "./main-account"; -import { captureConfigGeneration, registerStateSweepAfterTick } from "../lib/state-store-sweeper"; -import { reconcileLiveStateStores } from "../lib/state-store-registrations"; -import { - captureMainAccountIdentityGeneration, - clearMainAccountInfoCache, - getMainAccountCredentialPresence, - getMainAccountInfoCache, - getMainQuotaCredentialGeneration, - isMainAccountIdentityGenerationLive, - isMainQuotaWriterLive, - type MainQuotaWriter, - matchesMainQuotaCredential, - observeMainQuotaCredential, - setMainAccountCredentialPresence, - setMainAccountInfoCache, - type MainAccountInfo, -} from "./main-account-cache"; export { clearMainAccountInfoCache } from "./main-account-cache"; -import type { CodexQuotaRefreshOutcome } from "./quota-refresh-outcome"; -import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock"; -import { observeMainReserveRevocation } from "./reserve-availability"; -import { emailMaskingEnabled, projectEmail } from "../lib/privacy"; -import { codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "./warmup"; export { maskEmail } from "../lib/privacy"; -import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../types"; -import type { CatalogDisposition } from "./convergence-types"; -import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; -import { providerCodexAccountMode } from "../providers/registry"; -import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBody } from "../lib/bounded-body"; -import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort"; -import { - oauthAccountHealthFields, - projectCodexAccountHealth, - type OAuthAccountHealth, - type OAuthHealthLabel, -} from "../oauth/health"; -import { - CODEX_ACCOUNT_ID_RE, - hasLegacyMainCodexPoolAccount, - isSelectableCodexPoolAccount, - isValidCodexAccountId, -} from "./account-id"; -import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match"; -import { - markManualResetCreditOperationAmbiguous, - openManualResetCreditOperation, - settleManualResetCreditOperation, -} from "./reset-credit-operation-ledger"; -import { isCodexResetCreditOperationId } from "./reset-credit-recovery"; -import { ResourceAdmissionError, type AdmissionLease } from "../lib/admission"; -import { tryAcquireNativeMainProfileClaim } from "./native-main-admission"; -import { withNativeMainSharedClaim } from "./native-main-claim"; -import { resolveNativeProfileContext } from "./native-profile-store"; -import { NativeProfileError } from "./native-profile-types"; -import { WHAM_REQUEST_TIMEOUT_MS } from "./quota-recovery-timing"; -import { - claimQuotaRecovery, - quotaRecoveryTerminalFor, - releaseQuotaRecovery, - settleQuotaRecovery, - settleQuotaRecoveryTerminal, -} from "./quota-401-recovery"; - -function isNativeMainClaimUnavailable(error: unknown): error is NativeProfileError { - return error instanceof NativeProfileError - && (error.code === "NATIVE_MAIN_CLAIM_BUSY" || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE"); -} - -function withNativeMainCredentialClaim(operation: () => Promise): Promise { - return withNativeMainSharedClaim(resolveNativeProfileContext(), operation); -} - -function jsonResponse(data: unknown, status = 200): Response { - return new Response(JSON.stringify(data), { - status, - headers: { "Content-Type": "application/json" }, - }); -} - -function nativeMainProfileBusyResponse(): Response { - const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); - response.headers.set("Retry-After", "1"); - return response; -} - -const CODEX_CREDENTIAL_PERSISTENCE_ERROR = "Account was saved, but credential setup did not complete. Reauthenticate or remove the account."; -const CODEX_CREDENTIAL_PERSISTENCE_CODE = "codex_credential_persistence_failed"; - -const MAX_CODEX_LOGIN_STATE_ROWS = 32; -const CODEX_LOGIN_TERMINAL_TTL_MS = 300_000; -interface CodexLoginStateRow { - status: string; - startedAt: number; - accountId?: string; - email?: string; - error?: string; - code?: string; - needsReauth?: boolean; - catalogRefreshPending?: boolean; - validationPending?: boolean; - doneAt?: number; -} -const codexAuthLoginState = new Map(); -export class CodexLoginStateBusyError extends ResourceAdmissionError { - constructor() { super("codex_login_state_rows", MAX_CODEX_LOGIN_STATE_ROWS); this.name = "CodexLoginStateBusyError"; } -} - -function setCodexLoginState(flowId: string, patch: Partial): void { - const row = codexAuthLoginState.get(flowId); - if (row) Object.assign(row, patch); -} - -function pruneCodexLoginState(now = Date.now()): void { - for (const [id, row] of codexAuthLoginState) { - if (row.doneAt !== undefined && now - row.doneAt >= CODEX_LOGIN_TERMINAL_TTL_MS) codexAuthLoginState.delete(id); - } - while (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) { - const terminal = [...codexAuthLoginState].filter(([, row]) => row.doneAt !== undefined) - .sort((a, b) => (a[1].doneAt ?? 0) - (b[1].doneAt ?? 0))[0]; - if (!terminal) break; - codexAuthLoginState.delete(terminal[0]); - } -} - -function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null { - if (!isValidCodexAccountId(accountId)) return null; - return (config.codexAccounts ?? []) - .find(account => account.id === accountId && isSelectableCodexPoolAccount(account)) ?? null; -} - -function codexAccountPersistenceConflict( - config: OcxConfig, - accountId: string, - mode: "create" | "reauth", -): string | undefined { - if (mode === "reauth") { - return configuredPoolAccount(config, accountId) - ? undefined - : "Pool account was removed while login was in progress. Add it again as a new account."; - } - const namespaceCollision = codexAccountIdNamespaceCollisionError(config.codexAccountNamespaces, accountId); - if (namespaceCollision) return namespaceCollision; - return (config.codexAccounts ?? []).some(account => account.id === accountId) - || Boolean(getCodexAccountCredential(accountId)) - ? `Account id already exists: ${accountId}` - : undefined; -} - -function quotaForPlan | StoredAccountQuota | null>( - quota: T, - plan: unknown, -): T | null { - const visible = withoutRetiredCodexQuota(quota); - if (!visible || !isThirtyDayOnlyCodexPlan(plan)) return visible; - const quotaWindows = visible; - return { - ...(quotaWindows.monthlyPercent !== undefined ? { monthlyPercent: quotaWindows.monthlyPercent } : {}), - ...(quotaWindows.monthlyResetAt !== undefined ? { monthlyResetAt: quotaWindows.monthlyResetAt } : {}), - // A 30-day plan can still carry a burst window, and it blocks the account on its own. - // Dropping it here would show a healthy card for an account upstream is refusing (#1791). - ...(quotaWindows.shortPercent !== undefined ? { shortPercent: quotaWindows.shortPercent } : {}), - ...(quotaWindows.shortResetAt !== undefined ? { shortResetAt: quotaWindows.shortResetAt } : {}), - ...(quotaWindows.shortWindowSeconds !== undefined ? { shortWindowSeconds: quotaWindows.shortWindowSeconds } : {}), - ...(quotaWindows.customWindows !== undefined ? { customWindows: quotaWindows.customWindows } : {}), - ...(quotaWindows.resetCredits !== undefined ? { resetCredits: quotaWindows.resetCredits } : {}), - ...("updatedAt" in quotaWindows ? { updatedAt: quotaWindows.updatedAt } : {}), - } as T; -} - -/** - * Last reset-credit count this process parsed for the main account, tagged with the - * physical ChatGPT account it was read from. - * - * It is deliberately memory-only. The quota store is keyed by the stable `__main__` - * ALIAS, and `~/.codex/auth.json` can be swapped for another account while the proxy is - * not running — `reconcileMainCodexAccountRuntimeState` only purges alias-keyed state - * when it observes the id CHANGE, and its first observation after a restart has nothing - * to compare against. A disk-hydrated `__main__` entry can therefore belong to the - * previous login, so filling the DTO from it would show one account's tickets on - * another's card. Pool accounts have no such hole because their store key IS the account - * id. Binding the value to `requestAccountId` keeps the fill honest: after a restart the - * badge simply waits for the first usage response that carries the summary. - */ -let mainResetCreditsProvenance: { accountId: string; credits: number } | null = null; - -function rememberMainResetCredits(accountId: string | null, credits: number | undefined): void { - if (accountId === null || credits === undefined) return; - mainResetCreditsProvenance = { accountId, credits }; -} - -/** Forget the remembered count when the physical main identity is no longer the same. */ -function mainResetCreditsForCurrentIdentity(): number | undefined { - if (!mainResetCreditsProvenance) return undefined; - const currentAccountId = getMainChatgptAccountId(); - if (currentAccountId === null) return undefined; - if (currentAccountId !== mainResetCreditsProvenance.accountId) { - mainResetCreditsProvenance = null; - return undefined; - } - return mainResetCreditsProvenance.credits; -} - -/** - * The main account is the only account whose DTO quota comes from the raw WHAM parse - * result instead of the merged store: `poolAccountDto` serializes what - * `commitPoolQuotaResponse` read back out of `getAccountQuota()`, while the main DTO - * spreads `mainInfo.quota` directly. `/wham/usage` carries `rate_limit_reset_credits` - * only intermittently, and the store exists to bridge that gap - * (`setAccountQuotaFromParsed` carries an existing `resetCredits` forward when the new - * snapshot omits it), so the main card lost its ticket badge on every response that - * happened to omit the summary while pool cards kept theirs. - * - * Only `resetCredits` is carried, deliberately, and only from an identity-tagged - * in-process observation rather than the alias-keyed store. The window fields have - * *clearing* semantics — a monthly-only snapshot must drop a stale weekly value (#382) — - * so reinstating the whole stored object would resurrect a window the parse meant to - * clear whenever the store write was refused by generation gating. A freshly parsed value - * always wins, including `0`: zero is defined, so it never takes the fill branch. - */ -function mainQuotaWithCarriedResetCredits( - parsed: Omit, -): StoredAccountQuota { - const carried = parsed.resetCredits === undefined - ? mainResetCreditsForCurrentIdentity() - : undefined; - return { - ...parsed, - ...(carried !== undefined ? { resetCredits: carried } : {}), - updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(), - }; -} - -/** - * Why an account needs the operator. `missing_credential`, `refresh_failed`, and - * `quota_unauthorized` are the three causes this surface tells apart on its own. `unauthorized` - * and `forbidden` exist because the shared health projection may return them; today - * `projectCodexAccountHealth` only ever produces `refresh_failed`, so accepting the full union - * keeps this field correct if that projection widens rather than silently dropping a reason. - */ -export type CodexAccountReauthReason = - | "missing_credential" - | "refresh_failed" - | "quota_unauthorized" - | "unauthorized" - | "forbidden"; - -function poolAccountDto( - config: OcxConfig, - account: CodexAccount, - quotaResult: PoolQuotaResult, - hasCredential: boolean, - paused: boolean, - priority: number, - maskEmails: boolean, -): CodexAuthAccountDto { - const plan = codexPlanValue(account.plan); - const quota = quotaForPlan(quotaResult.quota, plan); - const runtimeReauth = isAccountNeedsReauth(account.id); - const needsReauth = !hasCredential || quotaResult.needsReauth || runtimeReauth; - const health = projectCodexAccountHealth({ accountId: account.id, needsReauth }); - // `needsReauth` is an OR of three independent causes plus a persisted verdict resolved inside the - // health projection. Emitting only the boolean is what left #4212's reporter guessing which - // account took their model away and why, so name the cause they actually have to act on. - const reauthReason: CodexAccountReauthReason | undefined = !hasCredential - ? "missing_credential" - : runtimeReauth - ? "refresh_failed" - : quotaResult.needsReauth - ? "quota_unauthorized" - : health.status === "reauth_required" ? health.reason : undefined; - return { - id: account.id, - email: projectEmail(account.email, maskEmails) ?? account.email, - ...(account.alias !== undefined ? { alias: account.alias } : {}), - ...(plan !== undefined ? { plan } : {}), - logLabel: codexAccountLogLabel(account), - isMain: false, - paused, - priority, - quota: quota ? { ...quota } : null, - needsReauth: needsReauth || health.status === "reauth_required", - ...(reauthReason !== undefined ? { reauthReason } : {}), - ...(isCodexAccountPlanExcluded(config, account.id) ? { - selectionExcludedReason: "plan_excluded" as const, - selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan), - } : {}), - hasCredential, - ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}), - ...oauthAccountHealthFields("codex", account.id, health), - }; -} - -interface ResetCreditAuth { - isMain: boolean; - accessToken: string; - chatgptAccountId: string; - nativeMainLease?: AdmissionLease; - nativeMainSharedClaimHeld?: true; - poolGeneration?: number; - mainProof?: MainResetQuotaProof; -} - -async function withResetCreditAuth( - runtimeConfig: OcxConfig, - accountId: string, - operation: (auth: ResetCreditAuth) => Promise, -): Promise<{ ok: true; value: T } | { ok: false; response: Response }> { - if (accountId === MAIN_CODEX_ACCOUNT_ID) { - if (hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { - return { ok: false, response: jsonResponse({ error: "Remove the legacy __main__ pool row before using the Desktop account" }, 409) }; - } - const nativeMainLease = tryAcquireNativeMainProfileClaim(); - if (!nativeMainLease) return { ok: false, response: nativeMainProfileBusyResponse() }; - try { - try { - return await withNativeMainCredentialClaim(async () => { - const tokens = readCodexTokens(); - if (!tokens) { - return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) }; - } - reconcileMainCodexAccountRuntimeState(); - const physicalId = extractAccountId(tokens.id_token, tokens.access_token) ?? tokens.account_id; - const writer = physicalId === tokens.account_id - ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) : undefined; - return { - ok: true, - value: await operation({ - isMain: true, - ...(writer ? { mainProof: { writer, credentialGeneration: getMainQuotaCredentialGeneration() } } : {}), - accessToken: tokens.access_token, - chatgptAccountId: tokens.account_id, - nativeMainLease, - nativeMainSharedClaimHeld: true, - }), - }; - }); - } catch (error) { - if (isNativeMainClaimUnavailable(error)) { - return { ok: false, response: nativeMainProfileBusyResponse() }; - } - throw error; - } - } finally { - nativeMainLease.release(); - } - } - if (!isValidCodexAccountId(accountId)) { - return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) }; - } - if (!configuredPoolAccount(runtimeConfig, accountId)) { - return { ok: false, response: jsonResponse({ error: "Unknown Codex account" }, 404) }; - } - const cred = await getValidCodexToken(accountId); - return { - ok: true, - value: await operation({ - isMain: false, - poolGeneration: cred.generation, - accessToken: cred.accessToken, - chatgptAccountId: cred.chatgptAccountId, - }), - }; -} - -function safeResetCreditsDto(input: unknown): { credits: { granted_at: string; expires_at: string }[]; available_count?: number } { - const obj = typeof input === "object" && input !== null ? input as Record : {}; - const rawCredits = Array.isArray(obj.credits) ? obj.credits : []; - const credits = rawCredits.flatMap((raw): { granted_at: string; expires_at: string }[] => { - if (typeof raw !== "object" || raw === null) return []; - const credit = raw as Record; - return typeof credit.granted_at === "string" && typeof credit.expires_at === "string" - ? [{ granted_at: credit.granted_at, expires_at: credit.expires_at }] - : []; - }); - const rawAvailable = (obj.rate_limit_reset_credits as { available_count?: unknown } | null | undefined)?.available_count - ?? obj.available_count; - return { - credits, - ...(typeof rawAvailable === "number" && Number.isFinite(rawAvailable) ? { available_count: rawAvailable } : {}), - }; -} - -function safeResetCreditConsumeDto(input: unknown): { code: string } { - const obj = typeof input === "object" && input !== null ? input as Record : {}; - return { code: typeof obj.code === "string" ? obj.code : "unknown" }; -} - -/** - * Background reset-credit access for the auto-redeemer (#822). Goes through the same - * account/lease wrapper as the management routes, but takes a caller-owned - * `redeem_request_id` so a journaled id can be replayed idempotently after a crash. - * Throws on any auth or upstream failure; the caller treats a throw on consume as ambiguous. - */ -export function createResetCreditWhamClient(config: OcxConfig, accountId: string): { - inspect: () => Promise<{ credits: { granted_at: string; expires_at: string }[] }>; - consume: (redeemRequestId: string) => Promise<{ code: string }>; -} { - const run = async (operation: (auth: ResetCreditAuth) => Promise): Promise => { - const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, operation); - if (result.ok) return result.value; - throw new Error(`reset-credit auth unavailable (${result.response.status})`); - }; - return { - inspect: () => run(async auth => { - const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", { - headers: { Authorization: `Bearer ${auth.accessToken}`, "ChatGPT-Account-Id": auth.chatgptAccountId }, - signal: AbortSignal.timeout(8000), - }); - if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); } - const parsed = await readResetCreditJson(resp, AbortSignal.timeout(8000)); - if (!parsed.ok) throw new Error("invalid upstream reset-credit response"); - return { credits: safeResetCreditsDto(parsed.value).credits }; - }), - consume: redeemRequestId => run(async auth => { - const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", { - method: "POST", - headers: { - Authorization: `Bearer ${auth.accessToken}`, - "ChatGPT-Account-Id": auth.chatgptAccountId, - "Content-Type": "application/json", - }, - body: JSON.stringify({ redeem_request_id: redeemRequestId }), - signal: AbortSignal.timeout(10_000), - }); - if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); } - return safeResetCreditConsumeDto(await resp.json()); - }), - }; -} - -type ResetCreditJsonRead = - | { ok: true; value: unknown } - | { ok: false }; - -function cancelResponseBodyWithoutWaiting(body: ReadableStream | null): void { - if (!body) return; - try { - void body.cancel().catch(() => undefined); - } catch { - // Some stream implementations throw synchronously from cancel(). - } -} - -async function readResetCreditJson( - response: Response, - signal: AbortSignal, -): Promise { - const declaredLength = Number(response.headers.get("content-length")); - if (Number.isSafeInteger(declaredLength) - && declaredLength >= 0 - && declaredLength > BOUNDED_BODY_MAX_BYTES) { - cancelResponseBodyWithoutWaiting(response.body); - return { ok: false }; - } - try { - const body = await readBoundedResponseBody(response, { - signal, - maxBytes: BOUNDED_BODY_MAX_BYTES, - fatalUtf8: true, - }); - if (!body.displaySafe || body.truncated || !body.text.trim()) return { ok: false }; - return { ok: true, value: JSON.parse(body.text) as unknown }; - } catch { - return { ok: false }; - } -} - -function manualImportDisabledResponse(): Response { - return jsonResponse({ - error: "Manual Codex account import is disabled. Use OAuth login to add a pool account.", - code: "manual_import_disabled", - }, 403); -} - -async function verifyCodexAccountWarmup( - accountId: string, - accessToken: string, - chatgptAccountId: string, -): Promise<{ ok: true; validatedAt: number } | { ok: false; response: Response }> { - try { - await warmCodexAccount({ accessToken, chatgptAccountId }); - return { ok: true, validatedAt: Date.now() }; - } catch (err) { - const reason = codexWarmupFailureReason(err); - return { - ok: false, - response: jsonResponse({ - // Every fallback model was refused for a provisioning reason, so telling the operator to - // reauthenticate sends them back through a login that already succeeded. - error: isCodexWarmupProvisioningFailure(err) - ? "Codex account warmup failed. Verify account model access or provisioning and try again." - : "Codex account warmup failed. Reauthenticate the account and try again.", - code: "codex_warmup_failed", - reason, - accountId, - }, 401), - }; - } -} - -function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void { - const ids = flowId - ? [flowId] - : [...codexAuthLoginState].filter(([, state]) => state.status === "pending").map(([id]) => id); - for (const id of ids) { - let owner = codexAuthLoginState.get(id); - if (!owner) { - pruneCodexLoginState(); - if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) continue; - owner = { status: "error", startedAt: Date.now() }; - codexAuthLoginState.set(id, owner); - } - Object.assign(owner, { status: "error", error, doneAt: Date.now() }); - setTimeout(() => { if (codexAuthLoginState.get(id) === owner) codexAuthLoginState.delete(id); }, 30_000); - } -} - -const MAIN_CACHE_TTL = 5 * 60_000; -const POOL_CACHE_TTL = 5 * 60_000; -const POOL_QUOTA_REFRESH_CONCURRENCY = 4; - -function nonEmptyPlan(value: unknown): string | null { - return codexPlanValue(value) ?? null; -} - -function isRuntimeConfig(config: OcxConfig): boolean { - return !!config && typeof config === "object" && !!config.providers; -} - -function getRuntimeConfig(config: OcxConfig): OcxConfig { - return isRuntimeConfig(config) ? config : loadConfig(); -} - -function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void { - saveConfigPreservingClaudeCode(nextConfig); - if (sourceConfig === nextConfig || !isRuntimeConfig(sourceConfig)) return; - for (const key of Object.keys(sourceConfig) as Array) { - delete sourceConfig[key]; - } - Object.assign(sourceConfig, nextConfig); -} - -interface StagedNewCodexAccountState { - credential: CodexAccountCredentials; - validatedAt?: number; -} - -type PersistNewCodexAccountOutcome = - | { status: "committed"; pickerVisibilityChanged: boolean } - | { status: "publication-failed"; pickerVisibilityChanged: boolean }; - -function codexCredentialPersistenceFailure(accountId: string, catalogRefreshPending: boolean) { - return { - error: CODEX_CREDENTIAL_PERSISTENCE_ERROR, - code: CODEX_CREDENTIAL_PERSISTENCE_CODE, - accountId, - needsReauth: true as const, - ...(catalogRefreshPending ? { catalogRefreshPending: true as const } : {}), - }; -} - -/** Persist config before publishing secret or runtime state under the shared mutation coordinator. */ -function persistNewCodexAccount( - sourceConfig: OcxConfig, - runtimeConfig: OcxConfig, - addedAccount: CodexAccount, - staged: StagedNewCodexAccountState, -): PersistNewCodexAccountOutcome { - return withConfigMutationLockSync(() => { - const previousConfig = { ...runtimeConfig }; - let pickerVisibilityChanged: boolean; - try { - const accounts = [...(runtimeConfig.codexAccounts ?? [])]; - const retainedPickerBindingRestored = codexAccountPickerEnabled(runtimeConfig) - && Object.values(runtimeConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id); - accounts.push(addedAccount); - runtimeConfig.codexAccounts = accounts; - - // Presence of the explicit flag distinguishes a dashboard-managed map from - // a hand-authored legacy map. Preserve manual maps exactly. - const tracksPickerNamespaces = runtimeConfig.codexAccountPickerEnabled !== undefined; - if (tracksPickerNamespaces && runtimeConfig.codexAccountNamespaces) { - runtimeConfig.codexAccountNamespaces = { ...runtimeConfig.codexAccountNamespaces }; - } - const namespaceAdded = tracksPickerNamespaces - && appendDefaultCodexAccountNamespace(runtimeConfig, addedAccount); - pickerVisibilityChanged = namespaceAdded || retainedPickerBindingRestored; - saveRuntimeConfig(sourceConfig, runtimeConfig); - } catch (error) { - for (const key of Object.keys(runtimeConfig) as Array) { - delete runtimeConfig[key]; - } - Object.assign(runtimeConfig, previousConfig); - throw error; - } - - try { - const generation = saveCodexAccountCredential(addedAccount.id, staged.credential, { - validationPending: staged.validatedAt === undefined, - }); - if (staged.validatedAt !== undefined) markCodexAccountValidated(addedAccount.id, staged.validatedAt, generation); - clearAccountNeedsReauth(addedAccount.id); - } catch { - // Config is already durable. Return the failure outcome through the coordinator so its - // generation commit is not rolled back while config.json remains changed. - return { status: "publication-failed" as const, pickerVisibilityChanged }; - } - return { status: "committed" as const, pickerVisibilityChanged }; - }); -} - -/** Bounded catalog-convergence callback supplied by the management dispatcher. */ -export type CodexAuthCatalogConvergence = () => Promise; - -interface AccountNamespaceCatalogRefresh { - catalogRefreshPending: boolean; -} - -/** Collapse post-persistence convergence into the one public recovery bit. */ -async function convergeAccountNamespaceCatalog( - config: OcxConfig, - changed: boolean, - convergeCodexCatalog?: CodexAuthCatalogConvergence, -): Promise { - if (!changed || !codexAccountPickerEnabled(config)) { - return { catalogRefreshPending: false }; - } - if (!convergeCodexCatalog) return { catalogRefreshPending: true }; - - try { - const catalogRefresh = normalizeCatalogDisposition(await convergeCodexCatalog()); - if (!catalogRefresh) return { catalogRefreshPending: true }; - return { catalogRefreshPending: catalogRefreshIsPending(catalogRefresh) }; - } catch { - return { catalogRefreshPending: true }; - } -} - -async function mapWithConcurrency( - items: T[], - concurrency: number, - mapper: (item: T) => Promise, -): Promise { - const results = new Array(items.length); - let next = 0; - const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { - while (next < items.length) { - const index = next++; - results[index] = await mapper(items[index]!); - } - }); - await Promise.all(workers); - return results; -} - -const MAIN_TERMINAL_AUTH_CODES = new Set([ - "invalid_workspace_selected", - "invalid_refresh_token", -]); - -/** - * A WHAM 401 is not itself proof the local credential died. Upstream edges can - * transiently reject a still-valid access token (region/anti-abuse/rotation - * races), and fail-closing on every bare 401 makes a healthy main account flip - * needs-reauth on the next GUI quota poll. Only treat the response as terminal - * when the body carries a known terminal code or the local access token is not - * verifiably live (`accessTokenLive`). Liveness must be strict: a JWT whose - * `exp` cannot be decoded is NOT live — an undecodable token that vouched for - * itself would make a real 401 permanently transient. - */ -async function isTerminalMainAuthResponse(resp: Response, accessTokenLive: boolean): Promise { - if (resp.status === 401) { - if (!accessTokenLive) return true; - const code = await readMainAuthErrorCode(resp); - return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code); - } - if (resp.status !== 403) return false; - const code = await readMainAuthErrorCode(resp); - return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code); -} - -async function readMainAuthErrorCode(resp: Response): Promise { - try { - const body = await readBoundedResponseBody(resp, { totalTimeoutMs: 1_000, inactivityTimeoutMs: 1_000 }); - if (!body.displaySafe) return undefined; - const parsed = JSON.parse(body.text) as { - detail?: { code?: unknown } | string; - error?: { code?: unknown } | string; - code?: unknown; - }; - const code = typeof parsed.detail === "object" && parsed.detail !== null - ? parsed.detail.code - : typeof parsed.error === "object" && parsed.error !== null - ? parsed.error.code - : parsed.code; - return code; - } catch { - return undefined; - } -} - -interface MainResetQuotaProof { - writer: MainQuotaWriter; - credentialGeneration: number; -} - -interface MainAccountInfoFetchResult { - info: MainAccountInfo; - resetRecoveryProof?: MainResetQuotaProof & { dispatchSequence: number }; - /** Ephemeral result of this attempt, omitted when no WHAM request was made. */ - quotaRefresh?: CodexQuotaRefreshOutcome; - /** Internal dispatch fence for diagnostics only; never copied into a public DTO or cache. */ - quotaRefreshGeneration?: number; - /** Whether this attempt safely inspected the physical native-main credential. */ - credentialChecked: boolean; - /** Meaningful only when credentialChecked is true. */ - hasCredential: boolean; - /** Main identity generation captured while the native-main claim was held. */ - identityGeneration?: number; - /** Present only when this call freshly parsed a WHAM usage response. */ - freshQuota?: Omit; - /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ - freshResetCredits?: number; -} - -export interface MainAccountInfoSnapshot { - info: MainAccountInfo; - mainIdentityGeneration: number; - quotaRefresh?: CodexQuotaRefreshOutcome; -} - -export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise { - const result = await fetchMainAccountInfoAttempt(forceRefresh, 1); - return { - info: result.info, - ...(result.quotaRefresh && result.quotaRefreshGeneration !== undefined - && isMainAccountIdentityGenerationLive(result.quotaRefreshGeneration) - ? { quotaRefresh: result.quotaRefresh } : {}), - mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(), - }; -} - -export async function fetchMainAccountInfo(forceRefresh = false): Promise { - return (await fetchMainAccountInfoSnapshot(forceRefresh)).info; -} - -const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null }; - -async function retryMainAccountInfoIfIdentityChanged( - requestAccountId: string | null, - retriesRemaining: number, - nativeMainLease: AdmissionLease, - explicitRefresh: boolean, -): Promise { - const currentAccountId = getMainChatgptAccountId(); - if (currentAccountId === null || currentAccountId === requestAccountId) return null; - reconcileMainCodexAccountRuntimeState(); - return retriesRemaining > 0 - ? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh) - : { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; -} - -async function fetchMainAccountInfoAttempt( - forceRefresh: boolean, - retriesRemaining: number, - existingNativeMainLease?: AdmissionLease, - nativeMainSharedClaimHeld = false, - explicitRefresh: boolean = forceRefresh, -): Promise { - const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim(); - if (!nativeMainLease) { - return { - info: EMPTY_MAIN_ACCOUNT_INFO, - credentialChecked: false, - hasCredential: false, - identityGeneration: captureMainAccountIdentityGeneration(), - }; - } - try { - const operation = async () => ({ - ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh), - identityGeneration: captureMainAccountIdentityGeneration(), - }); - if (nativeMainSharedClaimHeld) return await operation(); - try { - return await withNativeMainCredentialClaim(operation); - } catch (error) { - if (isNativeMainClaimUnavailable(error)) { - return { - info: EMPTY_MAIN_ACCOUNT_INFO, - credentialChecked: false, - hasCredential: false, - identityGeneration: captureMainAccountIdentityGeneration(), - }; - } - throw error; - } - } finally { - if (!existingNativeMainLease) nativeMainLease.release(); - } -} - -async function fetchMainAccountInfoWhileOwned( - forceRefresh: boolean, - retriesRemaining: number, - nativeMainLease: AdmissionLease, - /** - * Whether the *caller* asked for this refresh. `forceRefresh` also means "bypass the - * cache", and `retryMainAccountInfoIfIdentityChanged` re-enters with it set purely to - * re-read after the identity changed. Keeping the two apart stops that retry from - * promoting a background poll into operator intent below. - */ - explicitRefresh: boolean = forceRefresh, -): Promise { - const writerGeneration = captureConfigGeneration(); - reconcileMainCodexAccountRuntimeState(); - const tokenRead = readCodexTokensResult(); - setMainAccountCredentialPresence(tokenRead.status === "ok"); - if (tokenRead.status !== "ok") { - // A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite - // gap, and malformed JSON can be a half-written file. Clearing the cache and marking the - // account for reauth here destroyed healthy email/plan/quota state and pinned a working - // account as unusable. Preserve what we already know and let the caller retry; request - // routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the - // account DTO still reports hasCredential=false while the file is unreadable. - const preserved = getMainAccountInfoCache(); - return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false }; - } - const tokens = tokenRead.tokens; - const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null); - const cached = getMainAccountInfoCache(); - if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) { - return { info: cached, credentialChecked: true, hasCredential: true }; - } - // Bind quota to the owned credential and the account actually selected by WHAM's header. - // A conflicting legacy token/account tuple is not evidence for the new policy. - const mainQuotaWriter = requestAccountId === tokens.account_id - ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) - : undefined; - const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration(); - // Keep diagnostics separate from authentication and freshness policy. Never serialize errors. - const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS); - let quotaPhase: "request" | "body" | "decode" | "publish" = "request"; - let quotaRefreshGeneration = captureMainAccountIdentityGeneration(); - try { - const dispatchSequence = ++quotaDispatchSequence; - const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { - headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, - signal: quotaSignal, - }); - quotaPhase = "publish"; - if (!resp.ok) { - const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); - const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); - if (retried) return retried; - if (dispatchSequence < mainQuotaPublishedSequence) { - return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, - credentialChecked: true, hasCredential: true }; - } - if (terminalAuthFailure) { - // Account for this attempt's own synchronous invalidation, never prior external drift. - const diagnosticStillLive = isMainAccountIdentityGenerationLive(quotaRefreshGeneration); - clearMainAccountInfoCache(); - if (diagnosticStillLive) quotaRefreshGeneration = captureMainAccountIdentityGeneration(); - markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration); - } - return { - info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, - quotaRefresh: { status: "http_error", httpStatus: resp.status }, - quotaRefreshGeneration, - }; - } - quotaPhase = "body"; - const data = (await resp.json()) as WhamUsageResponse; - quotaPhase = "publish"; - const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); - if (retried) return retried; - quotaPhase = "decode"; - if (data === null || typeof data !== "object" || Array.isArray(data)) { - throw new Error("Invalid WHAM usage object"); - } - // Check after body/retry awaits and before any cache, credits, policy or - // Reserve publication. Returning cached state supplies no fresh recovery proof. - if (dispatchSequence < mainQuotaPublishedSequence) { - return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, - credentialChecked: true, hasCredential: true }; - } - quotaPhase = "publish"; - // A delayed response from a replaced bearer cannot revoke a newer Reserve grant, - // even in the same workspace or after an A→B→A credential transition. - if (mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() - && matchesMainQuotaCredential(tokens.access_token, tokens.account_id)) { - observeMainReserveRevocation(data, mainQuotaWriter); - } - quotaPhase = "decode"; - const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()); - const usage = { ...data, ...(plan ? { plan_type: plan } : {}) }; - const quota = parseUsageQuota(usage); - const policyQuota = parseMainPolicyUsageQuota(usage); - quotaPhase = "publish"; - const freshResetCredits = quota?.resetCredits; - // Tag the count with the identity it was read from, so a later response that omits the - // summary can restore the badge without ever crossing an account boundary. - rememberMainResetCredits(requestAccountId, freshResetCredits); - const result = { - email: data.email ?? null, - plan, - quota, - ts: Date.now(), - }; - setMainAccountInfoCache(result); - // Only an explicit refresh may retract a reauth quarantine. A 200 from - // /wham/usage proves the token authenticates to the usage endpoint; it does not - // prove the account can serve Responses traffic, which is a different backend path - // and still answers 403 for a workspace the token may no longer select (#327). - // Letting the background poll clear the flag put such an account straight back into - // rotation: the next request failed the same way and re-marked it, so needsReauth - // never settled and the dashboard kept showing nothing — the symptom #327 reported. - // An explicit refresh is an operator asking to re-evaluate, normally right after - // signing in again, so it stays authoritative. - if (explicitRefresh) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); - // Mirror main quota + plan into the shared stores so the rotation engine can - // score and auto-switch the main account exactly like a pool account (Option A). - setMainAccountPlan(result.plan); - if (result.quota) { - setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota); - } - mainQuotaPublishedSequence = dispatchSequence; - return { - info: result, - quotaRefresh: { status: quota ? "ok" : "not_reported" }, - quotaRefreshGeneration, - credentialChecked: true, - hasCredential: true, - ...(quota ? { freshQuota: quota } : {}), - ...(quota && mainQuotaWriter && isMainQuotaWriterLive(mainQuotaWriter) - && mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() - && matchesMainQuotaCredential(tokens.access_token, tokens.account_id) - ? { resetRecoveryProof: { writer: mainQuotaWriter, credentialGeneration: mainQuotaCredentialGeneration, dispatchSequence } } - : {}), - ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), - }; - } catch (error) { - const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); - if (retried) return retried; - let status: CodexQuotaRefreshOutcome["status"] = "internal_error"; - if ((quotaPhase === "request" || quotaPhase === "body") && quotaSignal.aborted) status = "timeout"; - else if (quotaPhase === "request") status = "network_error"; - else if (quotaPhase === "body") status = error instanceof SyntaxError ? "invalid_response" : "network_error"; - else if (quotaPhase === "decode") status = "invalid_response"; - return { - info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, - quotaRefresh: { status }, - quotaRefreshGeneration, - }; - } -} - -interface PoolQuotaResult { - /** Actual refresh result attached only to the successful usage replay. */ - resetRefreshLineage?: ManualResetRefreshLineage; - quota: StoredAccountQuota | null; - needsReauth: boolean; - /** Credential generation whose cache or network result this DTO state belongs to. */ - credentialGeneration?: number; - /** Present only when this call freshly parsed a WHAM usage response. */ - freshQuota?: Omit; - /** Present only when this call's WHAM response included a non-empty `plan_type`. */ - freshPlan?: string; - /** Credential generation used by this fresh quota request. */ - freshCredentialGeneration?: number; - /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ - freshResetCredits?: number; - quotaProbeSkipped?: true; - /** Positive evidence captured immediately before an upstream WHAM dispatch. */ - quotaProbeAttempted?: { at: number; credentialGeneration: number; dispatchSequence: number }; -} - -// Process-local ordering, never a timestamp or a serialized account identifier. -let quotaDispatchSequence = 0; -// Shared native-main ownership permits concurrent usage readers. Only a later -// successfully published response advances this fence; failed reads do not win. -let mainQuotaPublishedSequence = 0; - -interface PoolQuotaProbeEvidence { - onDispatch?: (sequence: number) => void; - mayPublish?: () => boolean; - attempted?: NonNullable; -} - -function markQuotaProbeAttempted(evidence: PoolQuotaProbeEvidence, credentialGeneration: number): void { - const dispatchSequence = ++quotaDispatchSequence; - evidence.attempted = { at: Date.now(), credentialGeneration, dispatchSequence }; - evidence.onDispatch?.(dispatchSequence); -} - -function withQuotaProbeEvidence( - result: PoolQuotaResult, - evidence: PoolQuotaProbeEvidence, -): PoolQuotaResult { - return evidence.attempted ? { ...result, quotaProbeAttempted: evidence.attempted } : result; -} - -interface PoolQuotaRefreshFlight { - state: { - dispatchSequence?: number; - superseded?: boolean; - startCredentialGeneration?: number; - resolvedCredentialGeneration?: number; - validatePending?: boolean; - }; - promise: Promise; -} - -const poolQuotaRefreshInFlight = new Map>(); -const MAX_POOL_QUOTA_FLIGHTS = 16; - -export class PoolQuotaProbeBusyError extends ResourceAdmissionError { - constructor() { - super("pool_quota_flights", MAX_POOL_QUOTA_FLIGHTS); - this.name = "PoolQuotaProbeBusyError"; - } -} - -function poolQuotaFlightCount(): number { - let count = 0; - for (const flights of poolQuotaRefreshInFlight.values()) count += flights.size; - return count; -} - -/** Focused admission tests only; returns cleanup for the synthetic owners it inserts. */ -export function seedCodexAuthAdmissionForTests(options: { loginFlows?: number; quotaFlights?: number }): () => void { - const prefix = `admission-test-${crypto.randomUUID()}`; - for (let index = 0; index < (options.loginFlows ?? 0); index++) { - codexAuthLoginState.set(`${prefix}-login-${index}`, { status: "starting", startedAt: Date.now() }); - } - for (let index = 0; index < (options.quotaFlights ?? 0); index++) { - poolQuotaRefreshInFlight.set(`${prefix}-quota-${index}`, new Set([{ - state: {}, - promise: new Promise(() => {}), - }])); - } - return () => { - for (const key of [...codexAuthLoginState.keys()]) if (key.startsWith(prefix)) codexAuthLoginState.delete(key); - for (const key of [...poolQuotaRefreshInFlight.keys()]) if (key.startsWith(prefix)) poolQuotaRefreshInFlight.delete(key); - }; -} - -export interface CodexAuthAccountDto { - id: string; - alias?: string; - email: string; - plan?: string | null; - logLabel?: string; - isMain: boolean; - paused: boolean; - /** Selection order; higher is used earlier. Always present, 0 when unset. */ - priority: number; - quota: (StoredAccountQuota | (Omit & { updatedAt: number })) | null; - needsReauth?: boolean; - /** - * Which of the independent causes behind `needsReauth` fired. Present only when the account - * needs the operator; `/api/oauth/accounts` already carries the same field name. - */ - reauthReason?: CodexAccountReauthReason; - /** Automatic selection policy only; explicit routes retain their usual auth checks. */ - selectionExcludedReason?: "plan_excluded"; - selectionExcludedPlan?: string; - hasCredential: boolean; - health: OAuthAccountHealth; - healthLabel: OAuthHealthLabel; - healthSummary: string; - healthAction?: string; - quotaProbeSkipped?: true; - quotaRefresh?: CodexQuotaRefreshOutcome; - mainAccountHardLock?: MainAccountHardLockStatus; -} - -interface FreshPoolPlanUpdate { - accountId: string; - plan: string; - credentialGeneration: number; -} - -/** - * Persist only validated plan leaves against the latest disk snapshot. A quota GET must not save - * the long-lived runtime object wholesale: unrelated manual/provider writes may have landed while - * WHAM requests were in flight. Missing or malformed files fail closed: a read path must not - * recreate a deleted config from the server's older in-memory snapshot. - */ -function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void { - if (updates.length === 0) return; - let outcome: ReturnType>; - try { - outcome = mutatePersistedConfig(persistedConfig => { - const accepted: FreshPoolPlanUpdate[] = []; - let changed = false; - for (const update of updates) { - if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue; - const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId); - const persistedAccount = configuredPoolAccount(persistedConfig, update.accountId); - if (!liveAccount || !persistedAccount) continue; - accepted.push(update); - if (persistedAccount.plan !== update.plan) { - persistedAccount.plan = update.plan; - // WHAM is the authoritative plan source: stamp provenance so a later JWT - // reconcile cannot overwrite this observation within the same credential - // generation (src/codex/plan-from-token.ts jwtMayWritePlan). Stamped only - // alongside a real plan change: a steady-state refresh whose plan is - // unchanged must stay write-free (no-config-write contract), and an - // unchanged value needs no fence — a JWT rewrite to the same text is a - // no-op under the caller's own equality check. - persistedAccount.planSource = "wham"; - persistedAccount.planCredentialGeneration = update.credentialGeneration; - changed = true; - } - } - return { changed, value: accepted }; - }); - } catch (error) { - // Plan persistence is derived metadata on a read route. Contention must fail closed without - // turning account listing into a 500; a later refresh can retry against the latest files. - if (error instanceof ConfigMutationLockError) return; - throw error; - } - if (outcome.status === "unavailable") return; - for (const update of outcome.value) { - // A replacement immediately after the durable commit is allowed to supersede the result, but - // the long-lived object must never be updated from that stale generation. - if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue; - const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId); - if (liveAccount) { - liveAccount.plan = update.plan; - liveAccount.planSource = "wham"; - liveAccount.planCredentialGeneration = update.credentialGeneration; - } - } -} - - - -/** - * One refresh-and-replay for a pool account whose WHAM request came back 401 (#3019). - * - * The account list used to convert any 401 straight into `needsReauth`, and a bare 401 is - * exactly what a stale-but-refreshable bearer produces after a plan change — so a healthy - * credential was thrown away and the operator was told to log in again. - * - * Bounded by the recovery store: one attempt per credential lineage. An unbounded retry - * against an upstream 401 is a self-inflicted credential-stuffing loop, which is why the - * claim is taken BEFORE the refresh and settled by the flight rather than by this caller. - */ -async function recoverPoolQuotaFrom401(ctx: { - accountId: string; - existing: StoredAccountQuota | null; - configuredPlan: string | undefined; - rejectedAccessToken: string; - rejectedGeneration: number; - resp: Response; - quotaProbeEvidence: PoolQuotaProbeEvidence; - onCredentialGeneration?: (generation: number) => void; -}): Promise { - const { accountId, existing, configuredPlan, rejectedAccessToken, rejectedGeneration, resp } = ctx; - - // Structured terminal evidence short-circuits everything: the same allowlist and bounded - // parser the main account uses, because it is the same endpoint answering. - if (await isTerminalPoolAuthResponse(resp)) { - // Durable, not just this response: the account list re-polls, and without a recorded - // mark the next bare 401 finds nothing terminal and reports the account healthy. - // - // Scoped to the generation this evidence is ABOUT. An account-wide mark would outlive - // the credential it condemned, so a late terminal response arriving after the operator - // re-authenticated would quarantine the replacement. - markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration); - return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration }; - } - - const claim = claimQuotaRecovery(accountId, rejectedGeneration); - if (!claim.granted) { - // A lineage fenced by a TERMINAL refresh failure stays terminal. Without this, the - // budget being used would make the next bare 401 report a dead credential as healthy. - if (quotaRecoveryTerminalFor(accountId, rejectedGeneration)) { - return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration }; - } - // Otherwise: this lineage spent its attempt, another caller is mid-refresh, or a - // transient failure is backing off. Report transient and let the next poll try — - // quarantining here would undo the whole point of the budget. - return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration }; - } - - let refreshed: Awaited>; - try { - refreshed = await forceRefreshCodexPoolToken(accountId, { - rejectedGeneration, - rejectedAccessToken, - // Settlement rides the flight, not this await: a cancelled caller would otherwise - // leave the claim to expire while the shared refresh commits, and the already - // refreshed lineage would get a second attempt. - onSettled: outcome => { - if (outcome.kind === "resolved") { - settleQuotaRecovery(accountId, claim.claimId, outcome); - } else if (outcome.error instanceof TokenRefreshError && isTerminalRefreshError(outcome.error)) { - // A revoked or expired grant does not become valid on the next poll. Releasing it - // into backoff would let the following bare 401 find a non-terminal record and - // report a dead credential as healthy. - settleQuotaRecoveryTerminal(accountId, claim.claimId); - } else { - releaseQuotaRecovery(accountId, claim.claimId, QUOTA_RECOVERY_BACKOFF_MS); - } - }, - }); - } catch (e) { - // A refresh that failed terminally is the one case where the credential really is gone. - // Everything else is unknown, and unknown is not proof. - if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) { - markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration); - return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration }; - } - return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration }; - } - - // A byte-identical access token means replaying earns the same 401. Report transient - // rather than burning the replay; the fence already moved to the returned generation. - if (!refreshed.rotated) { - return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation }; - } - - // The flight may have moved the generation while this request was in the air. Tell the - // coalescing layer where the credential actually is, or a late caller joins on a stale - // generation and opens a redundant flight. - ctx.onCredentialGeneration?.(refreshed.generation); - - const writerGeneration = captureConfigGeneration(); - markQuotaProbeAttempted(ctx.quotaProbeEvidence, refreshed.generation); - const poolWriter = capturePoolQuotaWriter(accountId, refreshed); - const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", { - headers: { - Authorization: `Bearer ${refreshed.accessToken}`, - "ChatGPT-Account-Id": refreshed.chatgptAccountId, - }, - signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS), - }); - if (!replay.ok) { - if (replay.status === 401 && await isTerminalPoolAuthResponse(replay)) { - // The refresh already settled this claim non-terminally, so the record alone would - // let the next poll call a dead credential healthy. The evidence is about the - // REFRESHED credential, which is what the replay used. - markAccountNeedsReauth(accountId, writerGeneration, refreshed.generation); - return { quota: existing ?? null, needsReauth: true, credentialGeneration: refreshed.generation }; - } - return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation }; - } - const result = await commitPoolQuotaResponse(replay, { - accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration, poolWriter, - mayPublish: ctx.quotaProbeEvidence.mayPublish, - }); - return result.freshCredentialGeneration === refreshed.generation ? { - ...result, - resetRefreshLineage: { - fromGeneration: rejectedGeneration, - toGeneration: refreshed.generation, - provenance: refreshed.provenance, - }, - } : result; -} - -/** Backoff after a refresh failure that proved nothing about the credential. */ -const QUOTA_RECOVERY_BACKOFF_MS = 60_000; - -/** Same allowlist and bounded parser as the main account: it is the same endpoint. */ -async function isTerminalPoolAuthResponse(resp: Response): Promise { - // Consume the original rather than a clone. `resp.clone()` tees the body, and the - // bounded parser's timeout cancels only its own reader — the unread original branch - // keeps buffering. Nothing needs this response afterwards, so there is nothing to tee. - const code = await readMainAuthErrorCode(resp); - return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code); -} - -/** A revoked or expired grant is terminal; an unknown or transport failure is not. */ -function isTerminalRefreshError(error: TokenRefreshError): boolean { - // Read the discriminator, not the message. TokenRefreshError carries `reason`, and - // matching on human text would let a durable quarantine decision change the next time - // somebody rewords an error string. - return error.reason === "revoked" || error.reason === "expired"; -} - -/** Parse and store a successful WHAM response. Shared by the first attempt and the replay. */ -async function commitPoolQuotaResponse( - resp: Response, - ctx: { - accountId: string; - existing: StoredAccountQuota | null; - configuredPlan: string | undefined; - generation: number; - writerGeneration: number; - poolWriter?: PoolQuotaWriter; - mayPublish?: () => boolean; - }, -): Promise { - const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx; - const data = (await resp.json()) as WhamUsageResponse; - const observedAt = Date.now(); - if (ctx.mayPublish?.() === false) { - return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: generation }; - } - const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined; - const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan }); - const freshResetCredits = quota?.resetCredits; - if (!quota) { - return { - quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId), - needsReauth: false, - credentialGeneration: generation, - ...(freshPlan !== undefined ? { freshPlan, freshCredentialGeneration: generation } : {}), - }; - } - if (!isCodexAccountGenerationLive(accountId, generation)) { - return { quota: null, needsReauth: false, credentialGeneration: generation }; - } - setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, quota, - ctx.poolWriter && isValidWhamHistoryObservation(data) ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined); - return { - quota: getAccountQuota(accountId), - needsReauth: false, - credentialGeneration: generation, - freshQuota: quota, - freshCredentialGeneration: generation, - ...(freshPlan !== undefined ? { freshPlan } : {}), - ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), - }; -} - -async function fetchFreshPoolAccountQuota( - accountId: string, - existing: StoredAccountQuota | null, - configuredPlan?: string, - onCredentialGeneration?: (generation: number) => void, - getValidToken: typeof getValidCodexToken = getValidCodexToken, - quotaProbeEvidence: PoolQuotaProbeEvidence = {}, -): Promise { - const writerGeneration = captureConfigGeneration(); - let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation; - try { - const { accessToken, chatgptAccountId, generation } = await getValidToken(accountId); - const poolWriter = capturePoolQuotaWriter(accountId, { accessToken, chatgptAccountId, generation }); - requestCredentialGeneration = generation; - onCredentialGeneration?.(generation); - markQuotaProbeAttempted(quotaProbeEvidence, generation); - const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { - headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId }, - signal: AbortSignal.timeout(8000), - }); - if (!resp.ok) { - if (resp.status !== 401) { - return withQuotaProbeEvidence( - { quota: existing ?? null, needsReauth: false, credentialGeneration: generation }, - quotaProbeEvidence, - ); - } - // A bare 401 is what a stale-but-refreshable bearer produces after a plan change, so - // quarantining on it tells the operator to re-authenticate an account that was fine - // (#3019). Refresh once, replay once, and only then decide. - const recovered = await recoverPoolQuotaFrom401({ - accountId, - existing, - configuredPlan, - rejectedAccessToken: accessToken, - rejectedGeneration: generation, - resp, - quotaProbeEvidence, - onCredentialGeneration, - }); - return withQuotaProbeEvidence(recovered, quotaProbeEvidence); - } - const committed = await commitPoolQuotaResponse(resp, { - accountId, existing, configuredPlan, generation, writerGeneration, poolWriter, - mayPublish: quotaProbeEvidence.mayPublish, - }); - return withQuotaProbeEvidence(committed, quotaProbeEvidence); - } catch (e) { - if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError - || e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) { - return withQuotaProbeEvidence({ - quota: existing ?? null, - needsReauth: false, - credentialGeneration: requestCredentialGeneration, - quotaProbeSkipped: true, - }, quotaProbeEvidence); - } - if (e instanceof TokenRefreshError) { - return withQuotaProbeEvidence( - { quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration }, - quotaProbeEvidence, - ); - } - return withQuotaProbeEvidence( - { quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration }, - quotaProbeEvidence, - ); - } -} - -export async function fetchPoolAccountQuota( - accountId: string, - forceRefresh = false, - configuredPlan?: string, - getValidToken: typeof getValidCodexToken = getValidCodexToken, - validatePending = false, - afterDispatchSequence?: number, -): Promise { - const existing = getAccountQuota(accountId); - if (afterDispatchSequence === undefined && !forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) { - return { - quota: existing, - needsReauth: false, - credentialGeneration: readCodexAccountRecord(accountId)?.generation, - }; - } - // A token refresh may increment the generation (and rotate the refresh token) before WHAM - // completes. Join a flight whose starting or resolved generation is still current, but let a - // replacement credential with the same pool id start its own request. - const record = readCodexAccountRecord(accountId); - const flights = poolQuotaRefreshInFlight.get(accountId); - const current = flights && [...flights].find(flight => { - const generation = flight.state.resolvedCredentialGeneration - ?? flight.state.startCredentialGeneration; - return !flight.state.superseded - && (afterDispatchSequence === undefined || (flight.state.dispatchSequence ?? 0) > afterDispatchSequence) - && generation !== undefined && isCodexAccountGenerationLive(accountId, generation); - }); - if (current) { - // A manual refresh joining a passive read must not lose its validation intent. - current.state.validatePending ||= validatePending; - return current.promise; - } - if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError(); - - // A post-reset request must not let an older same-account response overwrite its evidence. - // Flags live only as long as the bounded flights; no retained per-account sequence map. - if (afterDispatchSequence !== undefined) { - for (const flight of flights ?? []) flight.state.superseded = true; - } - const state: PoolQuotaRefreshFlight["state"] = { - startCredentialGeneration: record?.generation, - validatePending, - }; - const refresh = fetchFreshPoolAccountQuota( - accountId, - existing, - configuredPlan, - generation => { state.resolvedCredentialGeneration = generation; }, - getValidToken, - { - onDispatch: sequence => { state.dispatchSequence = sequence; }, - mayPublish: () => state.superseded !== true, - }, - ).then(async result => { - // A passive flight has consumed its validation decision. Remove it before - // promise settlement queues other continuations, so a late explicit caller - // starts fresh work instead of setting an intent nobody will read again. - if (!state.validatePending) { - releaseFlight(); - return result; - } - // Only an explicit account-list refresh finishes deferred registration. Passive quota - // polls and startup priming remain read-only with respect to inference spending. - const generation = result.freshCredentialGeneration; - const record = state.validatePending ? readCodexAccountRecord(accountId) : null; - if (record?.codexValidationPending && record.credential && record.deletedAt == null - && generation !== undefined && record.generation === generation - && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? configuredPlan)) { - try { - await warmCodexAccount({ - accessToken: record.credential.accessToken, - chatgptAccountId: record.credential.chatgptAccountId, - }); - markCodexAccountValidated(accountId, Date.now(), generation); - clearAccountNeedsReauth(accountId, generation); - } catch (error) { - // Keep the durable restriction on any failed/partial inference response, even - // when WHAM just reported headroom. No raw upstream text enters diagnostics. - const reason = codexWarmupFailureReason(error); - if (reason === "http_status:401" || reason === "http_status:403") { - markCodexAccountValidationFailed(accountId, reason, { expectedGeneration: generation }); - markAccountNeedsReauth(accountId, captureConfigGeneration(), generation); - } - } - } - return result; - }); - const flight: PoolQuotaRefreshFlight = { state, promise: refresh }; - const activeFlights = flights ?? new Set(); - activeFlights.add(flight); - if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights); - const releaseFlight = () => { - activeFlights.delete(flight); - if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) { - poolQuotaRefreshInFlight.delete(accountId); - } - }; - try { - return await refresh; - } finally { - releaseFlight(); - } -} - -function manualResetAuthStillLive(accountId: string, auth: ResetCreditAuth): boolean { - if (!auth.isMain) { - const record = readCodexAccountRecord(accountId); - return auth.poolGeneration !== undefined - && isCodexAccountGenerationLive(accountId, auth.poolGeneration) - && record?.credential?.chatgptAccountId === auth.chatgptAccountId; - } - const tokens = readCodexTokens(); - return !!auth.mainProof && !!tokens - && tokens.access_token === auth.accessToken && tokens.account_id === auth.chatgptAccountId - && isMainQuotaWriterLive(auth.mainProof.writer) - && auth.mainProof.credentialGeneration === getMainQuotaCredentialGeneration() - && matchesMainQuotaCredential(auth.accessToken, auth.chatgptAccountId); -} - -/** A confirmed spend remains successful even when its optional usage observation fails. */ -async function refreshAfterManualReset( - config: OcxConfig, - accountId: string, - auth: ResetCreditAuth, - claims: ManualResetCooldownClaim[], - didReset: boolean, -): Promise { - const afterDispatchSequence = quotaDispatchSequence; - try { - if (!manualResetAuthStillLive(accountId, auth)) return undefined; - if (auth.isMain) { - const result = await fetchMainAccountInfoAttempt(true, 1, auth.nativeMainLease, - auth.nativeMainSharedClaimHeld === true, false); - const proof = result.resetRecoveryProof; - const recovered = didReset && manualResetAuthStillLive(accountId, auth) - && !!proof && !!auth.mainProof - && proof.dispatchSequence > afterDispatchSequence - && proof.credentialGeneration === auth.mainProof.credentialGeneration - && proof.writer.identityKey === auth.mainProof.writer.identityKey - && proof.writer.identityGeneration === auth.mainProof.writer.identityGeneration - && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.info.plan); - for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, recovered); - return manualResetAuthStillLive(accountId, auth) ? result.freshResetCredits : undefined; - } - const account = configuredPoolAccount(getRuntimeConfig(config), accountId); - if (!account) return undefined; - // Reuse the just-authenticated consume credential for the first usage request. - // getValidCodexToken can silently advance a generation without exposing refresh - // provenance. A 401 here instead uses the existing classified refresh/replay path. - const resetToken: typeof getValidCodexToken = async () => { - if (auth.poolGeneration === undefined || !manualResetAuthStillLive(accountId, auth)) { - throw new CodexCredentialGenerationConflictError(); - } - return { accessToken: auth.accessToken, chatgptAccountId: auth.chatgptAccountId, generation: auth.poolGeneration }; - }; - // `validatePending` is false here: a manual reset settles cooldown, and finishing deferred - // registration stays reserved for an explicit dashboard account-list refresh. - const result = await fetchPoolAccountQuota(accountId, true, account.plan, didReset ? resetToken : getValidCodexToken, - false, didReset ? afterDispatchSequence : undefined); - const record = readCodexAccountRecord(accountId); - const recovered = didReset && record?.credential?.chatgptAccountId === auth.chatgptAccountId - && (result.quotaProbeAttempted?.dispatchSequence ?? 0) > afterDispatchSequence - && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan); - for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, recovered, { - credentialGeneration: result.freshCredentialGeneration, - refreshLineage: result.resetRefreshLineage, - }); - return record?.credential?.chatgptAccountId === auth.chatgptAccountId ? result.freshResetCredits : undefined; - } catch { - // The upstream reset already happened. A failed refresh must not invite another spend. - return undefined; - } -} - -let primeInFlight: Promise | null = null; -/** - * Last prime attempt per pool account. A failed WHAM lookup stores no quota, so - * without this the account stays "unknown" and every later prime trigger re-selects - * it as stale and repeats the same failing request. Successful lookups are already - * throttled by their stored updatedAt; this gives failures the same TTL backoff. - * - * Keyed by credential generation so a re-authentication, refresh, or account removal - * retries immediately instead of waiting out a backoff earned by the old credential. - */ -const poolQuotaPrimeAttemptedAt = new Map(); -let cooldownRecoveryInFlight: Promise | null = null; - -export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Date.now()): Promise { - const openai = config.providers[OPENAI_CODEX_PROVIDER_ID]; - if (!openai - || openai.disabled === true - || !isCanonicalOpenAiForwardProvider(openai) - || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool") return; - if (cooldownRecoveryInFlight) return cooldownRecoveryInFlight; - cooldownRecoveryInFlight = (async () => { - const claims = claimDueCodexQuotaRecoveryProbes(config, POOL_QUOTA_REFRESH_CONCURRENCY, now); - await mapWithConcurrency(claims, POOL_QUOTA_REFRESH_CONCURRENCY, async claim => { - const account = configuredPoolAccount(config, claim.accountId); - if (!account) { - settleCodexQuotaRecoveryProbe(claim, false, {}, now); - return; - } - try { - const result = await fetchPoolAccountQuota(claim.accountId, true, account.plan); - // Defence in depth: independent scopes are already excluded at the claim site. - // Generic WHAM must never clear Reserve even if claim selection changes. - const recovered = (claim.scope === undefined || claim.scope === "shared") - && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan); - settleCodexQuotaRecoveryProbe(claim, recovered, { - credentialGeneration: result.freshCredentialGeneration, - }, now); - } catch { - settleCodexQuotaRecoveryProbe(claim, false, {}, now); - } - }); - })().catch(() => { - // Background recovery is best-effort; routing keeps the cooldown on failure. - }).finally(() => { cooldownRecoveryInFlight = null; }); - return cooldownRecoveryInFlight; -} - -let mainHardLockRecoveryInFlight: Promise | null = null; - -/** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */ -export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise { - if (mainHardLockRecoveryInFlight) return mainHardLockRecoveryInFlight; - if (getMainAccountHardLockStatus(config).state !== "blocked" - || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; - const lease = tryAcquireNativeMainProfileClaim(); - if (!lease) return; - mainHardLockRecoveryInFlight = (async () => { - reconcileMainCodexAccountRuntimeState(); - if (getMainAccountHardLockStatus(config).state !== "blocked" - || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; - const identityGeneration = captureMainAccountIdentityGeneration(); - const writerGeneration = captureConfigGeneration(); - try { - // Refresh can require an exclusive credential claim: never hold WHAM's shared - // claim while obtaining a valid token. The runtime lease spans both operations. - if (!await getValidMainAccountToken({ preserveReauth: true })) return; - } catch (error) { - if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth" - && isMainAccountIdentityGenerationLive(identityGeneration)) { - markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration); - } - return; - } - if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; - await fetchMainAccountInfoAttempt(true, 1, lease, false, false); - })().catch(() => { - // Best-effort background metadata read; no cooldown/pause or policy clearing on failure. - }).finally(() => { - lease.release(); - mainHardLockRecoveryInFlight = null; - }); - return mainHardLockRecoveryInFlight; -} - -export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): void { - registerStateSweepAfterTick({ - name: "codex-cooldown-recovery", - afterTick: () => { - void runCodexCooldownRecoveryProbes(config); - void runMainAccountHardLockRecovery(config); - }, - }); -} - -export interface PrimeCodexPoolQuotasOptions { - /** Test seams for proving fenced/recovery priming performs no native-main work. */ - reconcileMainAccount?: typeof reconcileMainCodexAccountRuntimeState; - readMainTokens?: typeof readCodexTokens; - fetchMainInfo?: typeof fetchMainAccountInfo; -} - -let getValidPoolTokenForPrime = getValidCodexToken; - -/** Test-only: inject a deterministic pre-dispatch credential outcome for quota priming. */ -export function setCodexPoolQuotaTokenResolverForTests( - resolver: typeof getValidCodexToken, -): () => void { - const previous = getValidPoolTokenForPrime; - getValidPoolTokenForPrime = resolver; - return () => { - if (getValidPoolTokenForPrime === resolver) getValidPoolTokenForPrime = previous; - }; -} - -function tryAcquireNativeMainPrimeLease(): AdmissionLease | null { - return tryAcquireNativeMainProfileClaim(); -} - -/** - * Best-effort prime of pool-account (and main) quota so the rotation engine has - * real usage scores instead of leaving every account at the unknown sentinel. - * - * Quota is otherwise populated only from live upstream headers (an idle pool - * account never serves traffic, so it never gets scored) or from the dashboard - * WHAM fetch (a CLI-only user never opens it). Without priming, every account - * stays unknown and auto-switch cannot move (see Phase 10). This runs at startup - * and lazily before routing when the active account is unknown. - * - * Single-flight: concurrent callers share one pass instead of stampeding N WHAM - * fetches. Per-fetch 8s timeouts and the 5-minute POOL_CACHE_TTL already bound - * cost, so the worst case is one WHAM call per account per TTL window. Failures - * are swallowed: a blocked WSL network must never crash startup or a request. - */ -export async function primeCodexPoolQuotas( - config: OcxConfig, - reason: string, - options: PrimeCodexPoolQuotasOptions = {}, -): Promise { - const openai = config.providers[OPENAI_CODEX_PROVIDER_ID]; - // Prune attempt markers for accounts that no longer exist BEFORE the eligibility - // return. A removal that happens while the provider is disabled or out of pool mode - // would otherwise leave a stale failure marker behind; restoring the same account id - // within POOL_CACHE_TTL would then read that old failure as current and skip the - // retry the restored credential is entitled to. - const runtimeConfig = getRuntimeConfig(config); - const configuredPoolIds = new Set((runtimeConfig.codexAccounts ?? []).map(account => account.id)); - for (const accountId of poolQuotaPrimeAttemptedAt.keys()) { - if (!configuredPoolIds.has(accountId)) poolQuotaPrimeAttemptedAt.delete(accountId); - } - if ( - !openai - || openai.disabled === true - || !isCanonicalOpenAiForwardProvider(openai) - || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool" - ) return; - if (primeInFlight) return primeInFlight; - primeInFlight = (async () => { - const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); - const stale = pool.filter(a => { - const q = getAccountQuota(a.id); - if (q) return Date.now() - q.updatedAt >= POOL_CACHE_TTL; - // No stored quota: either never primed, or the last attempt failed. Retry only - // once per TTL window so an unreachable or rejecting account cannot turn every - // prime trigger into another upstream request. - const lastAttempt = poolQuotaPrimeAttemptedAt.get(a.id); - if (!lastAttempt) return true; - // A newer credential invalidates the previous failure: retry without waiting. - if (lastAttempt.generation !== readCodexAccountRecord(a.id)?.generation) return true; - return Date.now() - lastAttempt.at >= POOL_CACHE_TTL; - }); - const primeMain = async () => { - const mainLease = tryAcquireNativeMainPrimeLease(); - if (!mainLease) return; - try { - try { - await withNativeMainCredentialClaim(async () => { - // Keep one local owner and one cross-process reader from physical - // identity reconciliation through WHAM and all quota publication. - (options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)(); - if (getAccountQuota(MAIN_CODEX_ACCOUNT_ID)) return; - if (!(options.readMainTokens ?? readCodexTokens)()) return; - if (options.fetchMainInfo) await options.fetchMainInfo(false); - else await fetchMainAccountInfoAttempt(false, 1, mainLease, true); - }); - } catch (error) { - if (!isNativeMainClaimUnavailable(error)) throw error; - } - } finally { - mainLease.release(); - } - }; - try { - await Promise.allSettled([ - primeMain(), - mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => { - if (!getCodexAccountCredential(a.id)) return; - let result: PoolQuotaResult; - try { - result = await fetchPoolAccountQuota(a.id, false, a.plan, getValidPoolTokenForPrime); - } catch (error) { - // Local quota-flight saturation proves no WHAM request existed for this account. - // Consume it per item so sibling workers remain inside the shared prime lifetime. - if (error instanceof PoolQuotaProbeBusyError) return; - throw error; - } - // Only the data-plane function knows whether upstream dispatch began. Any - // cache hit, credential deferral, or local admission failure remains eligible. - const attempted = result.quotaProbeAttempted; - if (!attempted) return; - if (!configuredPoolAccount(getRuntimeConfig(config), a.id)) { - poolQuotaPrimeAttemptedAt.delete(a.id); - return; - } - poolQuotaPrimeAttemptedAt.set(a.id, { - // getValidCodexToken may rotate the credential before WHAM is sent. - // Bind the backoff to the generation that actually made the request; - // otherwise the next prime sees a false generation change and retries - // the same failed WHAM call immediately. - generation: attempted.credentialGeneration, - at: attempted.at, - }); - }), - ]); - } catch { - // Priming is best-effort; never propagate. - } - if (process.env.OPENCODEX_DEBUG_QUOTA === "1") { - console.warn(`[codex-quota] prime done (reason=${reason}, pool=${pool.length}, refreshed=${stale.length})`); - } - })().finally(() => { primeInFlight = null; }); - return primeInFlight; -} - -/** Test-only: drop any in-flight prime pass so a leaked single-flight promise - * from another suite cannot coalesce into the next prime. */ -export function clearCodexQuotaPrimeState(): void { - primeInFlight = null; - poolQuotaPrimeAttemptedAt.clear(); - getValidPoolTokenForPrime = getValidCodexToken; -} - -/** Test-only: drop the shared single-flight promise while keeping the per-account - * failure backoff, so a test can trigger a second real prime pass and still observe - * the throttle a production caller would see. */ -export function clearCodexQuotaPrimeSingleFlightForTests(): void { - primeInFlight = null; -} - -/** Test-only reset for the worker-level single-flight. */ -export function clearCodexCooldownRecoveryProbeState(): void { - cooldownRecoveryInFlight = null; -} +export { CodexLoginStateBusyError } from "./auth-api/login-state"; +export type { + CodexAccountReauthReason, + CodexAuthAccountDto, + CodexAuthAccountsSnapshot, +} from "./auth-api/account-list"; +export { listCodexAuthAccountsSnapshot, refreshCodexQuotaForActivation, listCodexAuthAccounts } from "./auth-api/account-list"; +export type { MainAccountInfoSnapshot } from "./auth-api/main-account-probe"; +export { fetchMainAccountInfoSnapshot, fetchMainAccountInfo } from "./auth-api/main-account-probe"; +export { PoolQuotaProbeBusyError, seedCodexAuthAdmissionForTests, fetchPoolAccountQuota } from "./auth-api/pool-quota-probe"; +export type { PrimeCodexPoolQuotasOptions } from "./auth-api/pool-mode-gate"; +export { + runCodexCooldownRecoveryProbes, + runMainAccountHardLockRecovery, + registerCodexCooldownRecoveryProbeWorker, + setCodexPoolQuotaTokenResolverForTests, + primeCodexPoolQuotas, + clearCodexQuotaPrimeState, + clearCodexQuotaPrimeSingleFlightForTests, + clearCodexCooldownRecoveryProbeState, +} from "./auth-api/pool-mode-gate"; +export { createResetCreditWhamClient } from "./auth-api/reset-credit-service"; +export type { CodexAuthCatalogConvergence } from "./auth-api/login-flow"; +export { handleCodexAuthAPI } from "./auth-api/routes"; +import { getEffectiveActiveCodexAccountId } from "./routing"; +import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; +import type { OcxConfig } from "../types"; export function effectiveCodexAuthAccountId(config: OcxConfig): string { return getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID; } - -export interface CodexAuthAccountsSnapshot { - accounts: CodexAuthAccountDto[]; - mainIdentityGeneration: number; -} - -export async function listCodexAuthAccountsSnapshot( - config: OcxConfig, - forceRefresh = false, - options: { validatePending?: boolean } = {}, -): Promise { - const runtimeConfig = getRuntimeConfig(config); - const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); - // One redaction decision for the whole snapshot, read once from the operator's config (#3859). - const maskEmails = emailMaskingEnabled(runtimeConfig); - const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1); - const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => { - const cred = getCodexAccountCredential(account.id); - let quotaResult: PoolQuotaResult; - if (!cred) { - quotaResult = { quota: null, needsReauth: true }; - } else { - try { - quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan, getValidCodexToken, options.validatePending === true); - } catch (error) { - if (!(error instanceof PoolQuotaProbeBusyError)) throw error; - quotaResult = { - quota: getAccountQuota(account.id), - needsReauth: false, - credentialGeneration: readCodexAccountRecord(account.id)?.generation, - quotaProbeSkipped: true, - }; - } - } - return { accountId: account.id, quotaResult }; - }); - - // WHAM plan_type is authoritative only for the credential generation that fetched it. Collect - // changes after every parallel read settles, then apply one narrow disk patch for the batch. - const planUpdates = refreshedPool.flatMap(({ accountId, quotaResult }): FreshPoolPlanUpdate[] => { - const plan = quotaResult.freshPlan; - const credentialGeneration = quotaResult.freshCredentialGeneration; - return plan && credentialGeneration !== undefined - ? [{ accountId, plan, credentialGeneration }] - : []; - }); - reconcileFreshPoolAccountPlans(runtimeConfig, planUpdates); - - const withQuota = refreshedPool.flatMap(({ accountId, quotaResult }) => { - const currentAccount = configuredPoolAccount(runtimeConfig, accountId); - if (!currentAccount) return []; - const currentCredential = getCodexAccountCredential(accountId); - if (!currentCredential) { - return [poolAccountDto( - runtimeConfig, - currentAccount, - { quota: null, needsReauth: true }, - false, - isCodexAccountPaused(runtimeConfig, accountId), - getCodexAccountPriority(runtimeConfig, accountId), - maskEmails, - )]; - } - const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration; - const generationLive = resultGeneration === undefined - || isCodexAccountGenerationLive(accountId, resultGeneration); - const effectiveQuotaResult = !generationLive - ? { quota: null, needsReauth: false } - : quotaResult; - // Response DTO can show the WHAM plan even when disk persistence fails closed (lock busy / - // missing config). Persistence still remains generation-gated via reconcileFreshPoolAccountPlans. - const dtoAccount = generationLive && quotaResult.freshPlan - ? { ...currentAccount, plan: quotaResult.freshPlan } - : currentAccount; - return [poolAccountDto( - runtimeConfig, - dtoAccount, - effectiveQuotaResult, - true, - isCodexAccountPaused(runtimeConfig, accountId), - getCodexAccountPriority(runtimeConfig, accountId), - maskEmails, - )]; - }); - const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration(); - const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration); - const mainInfo = mainSnapshotLive ? mainResult.info : EMPTY_MAIN_ACCOUNT_INFO; - const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked - ? mainResult.hasCredential - : getMainAccountCredentialPresence() ?? false; - const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential; - const mainNeedsReauth = mainMissingCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); - const mainHealth = projectCodexAccountHealth({ - accountId: MAIN_CODEX_ACCOUNT_ID, - needsReauth: mainNeedsReauth, - }); - // The main row carries the same attribution as a pool row. Reaching this point without - // `mainMissingCredential` means the runtime reauth flag is what set `mainNeedsReauth`, so the - // cause is a refresh that did not complete. - const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential - ? "missing_credential" - : mainNeedsReauth - ? "refresh_failed" - : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined; - const main: CodexAuthAccountDto = { - id: MAIN_CODEX_ACCOUNT_ID, - email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login", - plan: mainInfo.plan, - ...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined - && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration) - ? { quotaRefresh: mainResult.quotaRefresh } : {}), - logLabel: "main", - isMain: true, - paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), - mainAccountHardLock: getMainAccountHardLockStatus(runtimeConfig), - priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), - hasCredential: hasMainCredential, - needsReauth: mainNeedsReauth, - ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}), - quota: mainInfo.quota - ? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan) - : null, - ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth), - }; - return { - accounts: [main, ...withQuota], - mainIdentityGeneration: mainSnapshotLive - ? fetchedMainGeneration - : captureMainAccountIdentityGeneration(), - }; -} - -/** One opted-in account's metadata; reuse the bounded WHAM 401 recovery and generation fence. */ -export async function refreshCodexQuotaForActivation(config: OcxConfig, accountId: string): Promise { - if (accountId === MAIN_CODEX_ACCOUNT_ID) { - const lease = tryAcquireNativeMainProfileClaim(); - if (!lease) return; - try { - reconcileMainCodexAccountRuntimeState(); - if (isAccountNeedsReauth(accountId)) return; - const identityGeneration = captureMainAccountIdentityGeneration(); - const writerGeneration = captureConfigGeneration(); - try { - // Refresh may need an exclusive claim; prepare before WHAM takes its shared claim. - if (!await getValidMainAccountToken({ preserveReauth: true })) return; - } catch (error) { - if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth" - && isMainAccountIdentityGenerationLive(identityGeneration)) { - markAccountNeedsReauth(accountId, writerGeneration); - } - return; - } - if (isAccountNeedsReauth(accountId)) return; - await fetchMainAccountInfoAttempt(true, 1, lease, false, false); - } finally { - lease.release(); - } - return; - } - const account = configuredPoolAccount(config, accountId); - if (!account) return; - const writerGeneration = captureConfigGeneration(); - const result = await fetchPoolAccountQuota(accountId, true, account.plan); - if (result.needsReauth && result.credentialGeneration !== undefined) { - markAccountNeedsReauth(accountId, writerGeneration, result.credentialGeneration); - } -} - -export async function listCodexAuthAccounts( - config: OcxConfig, - forceRefresh = false, - options: { validatePending?: boolean } = {}, -): Promise { - return (await listCodexAuthAccountsSnapshot(config, forceRefresh, options)).accounts; -} - -interface PauseExhaustedResult { - pausedAccountIds: string[]; - checkedAccountCount: number; - failedAccountCount: number; -} - -function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void { - reconcileCodexActiveAfterExclusion(config, pausedActiveId); -} - -async function pauseExhaustedCodexAccounts( - config: OcxConfig, - persistPausedAccounts: () => void, -): Promise { - const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain); - const nativeMainLease = tryAcquireNativeMainProfileClaim(); - try { - const performPause = async (mainLease?: AdmissionLease): Promise => { - const mainWork = async (): Promise<{ - shouldPause: boolean; - checkedAccountCount: number; - failedAccountCount: number; - }> => { - if (!mainLease) return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 }; - const mainResult = await fetchMainAccountInfoAttempt(true, 1, mainLease, true); - if (!mainResult.credentialChecked || !mainResult.hasCredential) { - return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 0 }; - } - if (!mainResult.freshQuota || !mainResult.info.plan) { - return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 }; - } - return { - shouldPause: !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) - && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan), - checkedAccountCount: 1, - failedAccountCount: 0, - }; - }; - const [mainResult, poolResults] = await Promise.all([ - mainWork(), - mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => { - if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null }; - try { - return { - account, - quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan), - }; - } catch { - // Settle each pool probe independently so a busy/failing account cannot - // abandon an already-confirmed main decision before atomic publication. - return { account, quotaResult: null }; - } - }), - ]); - - let checkedAccountCount = mainResult.checkedAccountCount; - let failedAccountCount = mainResult.failedAccountCount; - const exhaustedIds: string[] = mainResult.shouldPause ? [MAIN_CODEX_ACCOUNT_ID] : []; - for (const { account, quotaResult } of poolResults) { - const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain); - if (!currentAccount) continue; - const generation = quotaResult?.freshCredentialGeneration; - const plan = quotaResult?.freshPlan ?? currentAccount.plan; - if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) { - failedAccountCount += 1; - continue; - } - checkedAccountCount += 1; - if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) { - exhaustedIds.push(account.id); - } - } - - for (const id of exhaustedIds) { - setCodexAccountPaused(config, id, true); - clearThreadAccountMapForAccount(id); - } - for (const id of exhaustedIds) selectFallbackAfterPause(config, id); - const result = { - pausedAccountIds: exhaustedIds, - checkedAccountCount, - failedAccountCount, - }; - // Persist while both the in-process admission and cross-process shared - // claim still own the physical-main identity used for the decision. - if (result.pausedAccountIds.length > 0) persistPausedAccounts(); - return result; - }; - - if (!nativeMainLease) return await performPause(); - try { - return await withNativeMainCredentialClaim(() => performPause(nativeMainLease)); - } catch (error) { - if (isNativeMainClaimUnavailable(error)) return await performPause(); - throw error; - } - } finally { - nativeMainLease?.release(); - } -} - -export async function handleCodexAuthAPI( - req: Request, - url: URL, - config: OcxConfig, - convergeCodexCatalog?: CodexAuthCatalogConvergence, - principal?: import("../server/management-auth").ManagementPrincipal, -): Promise { - - if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") { - const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true"; - return jsonResponse({ accounts: await listCodexAuthAccounts(config, forceRefresh) }); - } - - if (url.pathname === "/api/codex-auth/accounts/refresh" && req.method === "POST") { - // Inference spends quota: only a dashboard session carries the consent - // required by AGENTS_INSTALL.md. Raw-admin/CLI refreshes remain observational. - return jsonResponse({ accounts: await listCodexAuthAccounts(config, true, { - validatePending: principal === "gui-session", - }) }); - } - - if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") { - return manualImportDisabledResponse(); - } - - if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") { - const id = url.searchParams.get("id"); - if (!id) return jsonResponse({ error: "Missing id" }, 400); - const runtimeConfig = getRuntimeConfig(config); - const isLegacyPoolAccount = CODEX_ACCOUNT_ID_RE.test(id) - && (runtimeConfig.codexAccounts ?? []).some(account => !account.isMain && account.id === id); - if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) { - return jsonResponse({ error: "Invalid account id format" }, 400); - } - const pickerVisibilityChanged = deleteCodexAccount(runtimeConfig, id); - saveRuntimeConfig(config, runtimeConfig); - reconcileLiveStateStores(); - const catalogRefresh = await convergeAccountNamespaceCatalog( - runtimeConfig, - pickerVisibilityChanged, - convergeCodexCatalog, - ); - return jsonResponse({ ok: true, ...catalogRefresh }); - } - - if (url.pathname === "/api/codex-auth/accounts/alias" && req.method === "PUT") { - const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown }; - const id = typeof body.id === "string" ? body.id.trim() : ""; - const alias = typeof body.alias === "string" ? body.alias.trim() : ""; - if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400); - if (!isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400); - if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) { - return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400); - } - const runtimeConfig = getRuntimeConfig(config); - const account = (runtimeConfig.codexAccounts ?? []).find(candidate => candidate.id === id && !candidate.isMain); - if (!account) return jsonResponse({ error: "Account not found" }, 404); - if (alias) account.alias = alias; - else delete account.alias; - saveRuntimeConfig(config, runtimeConfig); - return jsonResponse({ ok: true, id, alias: alias || null }); - } - - if (url.pathname === "/api/codex-auth/accounts/pause" && req.method === "PUT") { - const body = await req.json().catch(() => ({})) as { id?: unknown; paused?: unknown }; - const id = typeof body.id === "string" ? body.id.trim() : ""; - if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) { - return jsonResponse({ error: "Invalid account id format" }, 400); - } - if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400); - - const runtimeConfig = getRuntimeConfig(config); - const exists = id === MAIN_CODEX_ACCOUNT_ID - || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id); - if (!exists) return jsonResponse({ error: "Account not found" }, 404); - - setCodexAccountPaused(runtimeConfig, id, body.paused); - if (body.paused) { - clearThreadAccountMapForAccount(id); - selectFallbackAfterPause(runtimeConfig, id); - } - saveRuntimeConfig(config, runtimeConfig); - return jsonResponse({ - ok: true, - id, - paused: body.paused, - activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, - appliesImmediately: true, - }); - } - - // Deliberately a route of its own rather than a field on the alias PATCH: aliases - // are display-only and reject __main__, while selection order is routing metadata - // that the Desktop account must be able to carry. Re-ordering never kicks a live - // thread, so there is no affinity clearing and no appliesImmediately here. - if (url.pathname === "/api/codex-auth/accounts/priority" && req.method === "PUT") { - let parsedBody: unknown; - try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } - if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) { - return jsonResponse({ error: "body must be an object" }, 400); - } - const body = parsedBody as { id?: unknown; priority?: unknown }; - const id = typeof body.id === "string" ? body.id.trim() : ""; - if (!isCodexAccountPriorityKey(id)) { - return jsonResponse({ error: "Invalid account id format" }, 400); - } - - let priority = DEFAULT_ACCOUNT_PRIORITY; - if (body.priority !== null) { - const parsed = parseAccountPriority(body.priority); - if (parsed === null) { - return jsonResponse({ - error: `priority must be null or an integer ${MIN_ACCOUNT_PRIORITY}-${MAX_ACCOUNT_PRIORITY}`, - }, 400); - } - priority = parsed; - } - - const runtimeConfig = getRuntimeConfig(config); - const exists = id === MAIN_CODEX_ACCOUNT_ID - || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id); - if (!exists) return jsonResponse({ error: "Account not found" }, 404); - - setCodexAccountPriority(runtimeConfig, id, priority); - // Both a pin and an order are the operator saying which account to use, so the newer - // statement wins. Without this a pin made before any order existed — an ordinary - // account switch — would outrank the order forever: it blocks preemption and caps - // every eligibility list at its own tier until that account drains or is paused. - clearCodexAccountPin(runtimeConfig); - saveRuntimeConfig(config, runtimeConfig); - return jsonResponse({ - ok: true, - id, - priority, - activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, - }); - } - - if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") { - const runtimeConfig = getRuntimeConfig(config); - const result = await pauseExhaustedCodexAccounts( - runtimeConfig, - () => saveRuntimeConfig(config, runtimeConfig), - ); - const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result; - if (checkedAccountCount === 0 && failedAccountCount > 0) { - return jsonResponse({ - ok: false, - error: "Failed to refresh any Codex account quota", - checkedAccountCount, - failedAccountCount, - }, 502); - } - return jsonResponse({ - ok: true, - pausedAccountIds, - pausedCount: pausedAccountIds.length, - checkedAccountCount, - failedAccountCount, - complete: failedAccountCount === 0, - activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, - appliesImmediately: true, - }); - } - - // Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only - // model path for Codex Desktop, so a cooldown that outlives the real upstream limit - // otherwise leaves editing config.toml as the user's only recovery. - // - // Existence is deliberately NOT disclosed: an unknown id returns 200 with cleared:false - // exactly like an account that simply had no live cooldown, so this route cannot be used - // to enumerate configured accounts. Cooldown state is runtime-only and independent of the - // account list, so 404 would carry no useful meaning anyway. - if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") { - const body = await req.json().catch(() => ({})) as { id?: unknown }; - const id = typeof body.id === "string" ? body.id.trim() : ""; - if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) { - return jsonResponse({ error: "Invalid account id format" }, 400); - } - return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) }); - } - - if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") { - let body: { accountId: string | null }; - try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } - const runtimeConfig = getRuntimeConfig(config); - const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID; - if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { - return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409); - } - if (isCodexAccountPaused(runtimeConfig, targetAccountId)) { - return jsonResponse({ error: "Account is paused" }, 409); - } - if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { - if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400); - const exists = (runtimeConfig.codexAccounts ?? []) - .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId); - if (!exists) return jsonResponse({ error: "Account not found" }, 400); - if (readCodexAccountRecord(body.accountId)?.codexValidationPending) { - return jsonResponse({ error: "Account validation is pending. Refresh quota after recovery to validate it." }, 409); - } - } - runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; - // "Use this account now" outranks selection order until the account is spent: - // persisted here rather than in resetCodexRoutingForManualSelection, which is - // runtime state only. A null id clears the selection instead of making one, so it - // must release the pin rather than record one: pinning the `targetAccountId` - // fallback would leave a pin that no effective active account matches, which - // `isEffectiveCodexAccountPinned` reports as unpinned while the tier filter still - // honours it as a ceiling — invisibly capping the pool at the main account's tier. - if (body.accountId == null) clearCodexAccountPin(runtimeConfig); - else setCodexAccountPin(runtimeConfig, targetAccountId); - resetCodexRoutingForManualSelection(targetAccountId); - saveRuntimeConfig(config, runtimeConfig); - return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true }); - } - - if (url.pathname === "/api/codex-auth/active" && req.method === "GET") { - const runtimeConfig = getRuntimeConfig(config); - return jsonResponse({ - activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, - pinned: isEffectiveCodexAccountPinned(runtimeConfig), - // Which account carries the pin, not just whether the active one does. Under - // round-robin or fill-first the pin caps the tier ceiling at its own tier while the - // strategy cursor moves freely inside that tier, so `pinned` alone goes false on a - // sibling's turn even though the pin is still suppressing every higher tier. The id - // lets a surface mark the account the operator actually chose. - pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null, - autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80, - upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3, - accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy), - accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit), - }); - } - - if (url.pathname === "/api/codex-auth/auto-switch" && req.method === "PUT") { - let body: { threshold: number }; - try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } - if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 100) { - return jsonResponse({ error: "Threshold must be an integer 0-100" }, 400); - } - const runtimeConfig = getRuntimeConfig(config); - runtimeConfig.autoSwitchThreshold = body.threshold; - saveRuntimeConfig(config, runtimeConfig); - return jsonResponse({ ok: true }); - } - - if ( - url.pathname === "/api/codex-auth/pool-strategy" - && (req.method === "PUT" || req.method === "PATCH") - ) { - let parsedBody: unknown; - try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } - if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) { - return jsonResponse({ error: "body must be an object" }, 400); - } - const body = parsedBody as { strategy?: unknown; stickyLimit?: unknown }; - if (body.strategy === undefined && body.stickyLimit === undefined) { - return jsonResponse({ error: "strategy or stickyLimit required" }, 400); - } - const runtimeConfig = getRuntimeConfig(config); - let nextStrategy: NonNullable> | undefined; - let nextSticky: NonNullable> | undefined; - if (body.strategy !== undefined) { - const parsed = parseCodexAccountPoolStrategy(body.strategy); - if (parsed === null) { - return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first, reset-first' }, 400); - } - nextStrategy = parsed; - } - if (body.stickyLimit !== undefined) { - const parsed = parseAccountPoolStickyLimit(body.stickyLimit); - if (parsed === null) { - return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400); - } - nextSticky = parsed; - } - if (nextStrategy !== undefined) runtimeConfig.accountPoolStrategy = nextStrategy; - if (nextSticky !== undefined) runtimeConfig.accountPoolStickyLimit = nextSticky; - saveRuntimeConfig(config, runtimeConfig); - return jsonResponse({ - ok: true, - accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy), - accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit), - }); - } - - if (url.pathname === "/api/codex-auth/failover" && req.method === "PUT") { - let body: { threshold: number }; - try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } - if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 20) { - return jsonResponse({ error: "Threshold must be an integer 0-20" }, 400); - } - const runtimeConfig = getRuntimeConfig(config); - runtimeConfig.upstreamFailoverThreshold = body.threshold; - saveRuntimeConfig(config, runtimeConfig); - return jsonResponse({ ok: true }); - } - - if (url.pathname === "/api/codex-auth/quota/history" && req.method === "GET") { - const accountId = url.searchParams.get("accountId"); - const rawLimit = url.searchParams.get("limit"); - if (url.searchParams.getAll("accountId").length !== 1 || !isValidCodexAccountId(accountId) - || url.searchParams.getAll("limit").length > 1 - || [...url.searchParams.keys()].some(key => key !== "accountId" && key !== "limit") - || (rawLimit !== null && !/^(?:[1-9]|[1-9][0-9]|1[0-9]{2}|200)$/.test(rawLimit))) { - return jsonResponse({ error: "A stored pool accountId and optional limit from 1 to 200 are required" }, 400); - } - const runtimeConfig = getRuntimeConfig(config); - const account = configuredPoolAccount(runtimeConfig, accountId); - if (!account) return jsonResponse({ error: "Unknown pool account" }, 404); - const identity = poolQuotaHistoryIdentity(accountId); - const allHistory = getAccountQuotaHistory(accountId); - const limit = rawLimit === null ? 200 : Number(rawLimit); - const history = { ...allHistory, observations: allHistory.observations.slice(-limit), truncated: allHistory.observations.length > limit }; - const label = account.logLabel; - const labelStillUnique = () => { - const current = getRuntimeConfig(config); - return configuredPoolAccount(current, accountId)?.logLabel === label - && current.codexAccounts?.filter(row => codexAccountLogLabel(row) === label).length === 1; - }; - let capacity: CodexCapacityResult = insufficientCodexCapacity("identity_unavailable"); - if (identity && identity === poolQuotaHistoryIdentity(accountId) && label && CODEX_ACCOUNT_LOG_LABEL_RE.test(label) && labelStillUnique()) { - try { - const usage = await readUsageSnapshotForManagement(); - if (poolQuotaHistoryIdentity(accountId) !== identity || !labelStillUnique()) capacity = insufficientCodexCapacity("identity_changed"); - else if (!usage.revision) capacity = insufficientCodexCapacity("ledger_unavailable"); - else if (usage.truncatedPrefixBytes > 0 || usage.entriesTruncated || usage.entriesDropped > 0) capacity = insufficientCodexCapacity("ledger_truncated"); - else capacity = estimateCodexQuotaCapacity(allHistory.observations, usage.entries, label, - model => codexQuotaScopeForModel(model) === "shared"); - } catch { capacity = insufficientCodexCapacity("ledger_unavailable"); } - } - if (!configuredPoolAccount(getRuntimeConfig(config), accountId)) return jsonResponse({ error: "Unknown pool account" }, 404); - if (identity !== poolQuotaHistoryIdentity(accountId) || (identity && label && !labelStillUnique())) { - return jsonResponse({ accountId, ...getAccountQuotaHistory(accountId, limit), capacity: insufficientCodexCapacity("identity_changed") }); - } - return jsonResponse({ accountId, ...history, capacity }); - } - - if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") { - const quotas: Record = {}; - for (const [id, q] of listAccountQuotas()) quotas[id] = q; - return jsonResponse({ quotas }); - } - - if (url.pathname === "/api/codex-auth/reset-credits" && req.method === "GET") { - const accountId = url.searchParams.get("accountId"); - if (!accountId) return jsonResponse({ error: "accountId required" }, 400); - - try { - const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { - const linkedSignal = signalWithTimeout(8000, req.signal); - let detachBodyAbort = () => {}; - try { - let resp: Response; - try { - resp = await fetch( - "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", - { - headers: { - Authorization: `Bearer ${auth.accessToken}`, - "ChatGPT-Account-Id": auth.chatgptAccountId, - }, - signal: linkedSignal.signal, - }, - ); - } catch (error) { - if (linkedSignal.signal.aborted) { - return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502); - } - throw error; - } - // Own the response body before the bounded reader attaches. If the client - // disconnects in that narrow window, Bun otherwise tears down the native - // body off the awaited path and can report an unhandled rejection. - detachBodyAbort = cancelBodyOnAbort(resp.body, linkedSignal.signal); - if (!resp.ok) { - await resp.body?.cancel().catch(() => {}); - return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); - } - const parsed = await readResetCreditJson(resp, linkedSignal.signal); - if (!parsed.ok) { - return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502); - } - return jsonResponse(safeResetCreditsDto(parsed.value)); - } finally { - detachBodyAbort(); - linkedSignal.cleanup(); - } - }); - return result.ok ? result.value : result.response; - } catch (e) { - return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit lookup failed" }, 500); - } - } - - if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") { - const body = (await req.json().catch(() => ({}))) as { - accountId?: string; - operationId?: unknown; - }; - if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400); - const accountId = body.accountId; - // Optional caller-owned idempotency identity (#3375 axis D). Absent => legacy - // behavior: a fresh random redeem_request_id and no durable ledger row. - // The ledger throws TypeError on a malformed id, so the format check has to - // happen here rather than at the call site, or it surfaces as a 500. - const hasOperationId = body.operationId !== undefined; - if (hasOperationId && !isCodexResetCreditOperationId(body.operationId)) { - return jsonResponse({ error: "Invalid operationId format" }, 400); - } - const requestedOperationId = hasOperationId ? body.operationId as string : undefined; - - try { - const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { - // The ledger keys manual operations by the *physical* ChatGPT account, which is - // only known after the auth wrapper resolves credentials. Open here, not earlier. - let identity = requestedOperationId === undefined - ? undefined - : { - accountId, - chatgptAccountId: auth.chatgptAccountId, - operationId: requestedOperationId, - } as const; - let idempotencyKey: string; - if (identity) { - const opened = openManualResetCreditOperation(identity); - if (opened.kind === "terminal") { - // Durably settled already: replay the recorded outcome instead of - // trusting upstream idempotency for an irreversible spend. No - // `remaining` — that field is only reported from a freshly parsed - // available_count, and a replay has none. - return jsonResponse({ code: opened.code, replayed: true }); - } - if (opened.kind === "identity-mismatch") { - return jsonResponse({ - error: "operation_id_owned_by_another_account", - code: "identity_mismatch", - }, 409); - } - if (opened.kind !== "execute") { - // capacity | unavailable -> fail closed. Falling back to a random id - // would silently reintroduce the double-spend this identity prevents. - const response = jsonResponse({ - error: opened.kind === "capacity" - ? "reset_credit_ledger_capacity" - : "reset_credit_ledger_unavailable", - code: opened.kind, - }, 503); - response.headers.set("Retry-After", "1"); - return response; - } - // Canonical id, which an alias join may map to an earlier caller id. - identity = { ...identity, operationId: opened.operationId }; - idempotencyKey = opened.operationId; - } else { - idempotencyKey = crypto.randomUUID(); - } - const claims = manualResetAuthStillLive(accountId, auth) - ? claimManualResetCooldowns(getRuntimeConfig(config), accountId, Date.now(), auth.poolGeneration) : []; - try { - let resp: Response; - try { - resp = await fetch( - "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", - { - method: "POST", - headers: { - Authorization: `Bearer ${auth.accessToken}`, - "ChatGPT-Account-Id": auth.chatgptAccountId, - "Content-Type": "application/json", - }, - body: JSON.stringify({ redeem_request_id: idempotencyKey }), - signal: AbortSignal.timeout(10_000), - }, - ); - } catch (error) { - // Dispatch outcome unknown: the credit may or may not have been spent. - // Mark ambiguous so a replay of this same id is never treated as new. - if (identity) markManualResetCreditOperationAmbiguous(identity); - throw error; - } - if (!resp.ok) { - await resp.body?.cancel().catch(() => {}); - if (identity) markManualResetCreditOperationAmbiguous(identity); - return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); - } - const result = safeResetCreditConsumeDto(await resp.json()); - if (identity) { - // Narrow explicitly rather than casting: `safeResetCreditConsumeDto` - // normalizes anything unrecognized to "unknown", and settling that - // would come back as a mismatch and leave the row pending anyway. - // Settlement failure never downgrades the user-visible outcome: the - // spend already happened upstream, and reporting failure would invite - // a manual retry -- the exact double-spend this unit removes. - if (result.code === "reset" || result.code === "already_redeemed" - || result.code === "nothing_to_reset" || result.code === "no_credit") { - settleManualResetCreditOperation(identity, result.code); - } else { - markManualResetCreditOperationAmbiguous(identity); - } - } - // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage - // and return remaining only when that refresh freshly parsed available_count. - // Do not fall back to a preserved cached resetCredits (failed/omitted refresh). - if (result.code === "reset" || result.code === "already_redeemed") { - const freshResetCredits = await refreshAfterManualReset( - config, accountId, auth, claims, result.code === "reset", - ); - return jsonResponse({ - code: result.code, - ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits) - ? { remaining: freshResetCredits } - : {}), - }); - } - return jsonResponse(result); - } finally { - // Release only this invocation's leases, including every ambiguous/error outcome. - for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, false); - } - }); - return operation.ok ? operation.value : operation.response; - } catch (e) { - if (e instanceof PoolQuotaProbeBusyError) { - const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); - response.headers.set("Retry-After", "1"); - return response; - } - return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit consume failed" }, 500); - } - } - - if (url.pathname === "/api/codex-auth/login" && req.method === "POST") { - const body = (await req.json().catch(() => ({}))) as { - id?: string; - reauth?: boolean; - openBrowser?: unknown; - device?: unknown; - }; - // Device mode: no local browser, no loopback listener. The only way to add - // an account to a headless hub (#3366). - const useDeviceFlow = body.device === true; - const requestedAccountId = body.id?.trim(); - const reauth = body.reauth === true; - if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) { - return jsonResponse({ error: "Invalid account id format" }, 400); - } - const accountId = requestedAccountId || `chatgpt-${Date.now()}`; - const runtimeConfig = getRuntimeConfig(config); - const preflightConflict = !reauth - ? codexAccountPersistenceConflict(runtimeConfig, accountId, "create") - : undefined; - if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400); - if (reauth) { - if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400); - if (!configuredPoolAccount(runtimeConfig, accountId)) { - return jsonResponse({ error: "Unknown pool account for reauth" }, 404); - } - } - pruneCodexLoginState(); - if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) { - const busy = new CodexLoginStateBusyError(); - const response = jsonResponse({ error: busy.message, code: busy.code }, 503); - response.headers.set("Retry-After", "1"); - return response; - } - const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const loginOwner: CodexLoginStateRow = { status: "starting", startedAt: Date.now() }; - codexAuthLoginState.set(flowId, loginOwner); - try { - const { startLoginFlow, getLoginStatus, publicOAuthAuthenticationErrorMessage } = await import("../oauth"); - const result = await startLoginFlow("chatgpt", { - forceLogin: true, - ...(useDeviceFlow ? { flow: "device" as const } : {}), - }); - - // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts). - // The GUI's window.open is popup-blocked because it runs after an await, not a direct click. - // Both login routes share one resolver so this surface cannot drift from the other. - const { shouldOpenBrowserForLogin } = await import("../oauth/open-browser-choice"); - // A device flow's URL is a verification page the user opens on ANOTHER - // machine. Opening it on the hub host is useless at best, and on a - // headless host it fails. `deviceCode` is the same signal the generic - // OAuth login route uses to make this decision. - if (result.url && !result.deviceCode && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) { - const { openUrl } = await import("../lib/open-url"); - openUrl(result.url); - } - - (async () => { - try { - let completed = false; - // The device grant lives 15 minutes and the whole point is that the - // user walks to another device to enter the code. A 5-minute server - // budget would kill the flow at minute five while the grant is still - // valid. The extra 30 attempts past 450 are settlement margin: a user - // who authorizes in the final seconds still needs the token exchange - // and credential write to land before this loop gives up. - const pollAttempts = useDeviceFlow ? 480 : 150; - for (let i = 0; i < pollAttempts; i++) { - await new Promise(r => setTimeout(r, 2000)); - const st = getLoginStatus("chatgpt"); - if (st.done && st.loggedIn) { - const { getCredential } = await import("../oauth/store"); - const cred = getCredential("chatgpt"); - if (cred) { - const oauthAccountId = cred.accountId; - if (!oauthAccountId) { - setCodexLoginState(flowId, { - status: "error", - error: "Could not determine account identity from OAuth tokens. Please retry OAuth login.", - doneAt: Date.now(), - }); - completed = true; - break; - } - - let email = cred.email || accountId; - let plan: string | undefined; - let quota: Omit | null = null; - try { - const tokens = { access_token: cred.access, account_id: oauthAccountId }; - const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { - headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, - signal: AbortSignal.timeout(8000), - }); - if (resp.ok) { - const data = (await resp.json()) as WhamUsageResponse; - email = data.email ?? email; - plan = nonEmptyPlan(data.plan_type) ?? undefined; - quota = parseUsageQuota(data); - } - } catch { /* wham fetch is non-blocking */ } - // Reauth must refresh the same ChatGPT identity already bound to this pool slot. - // Otherwise a different login would silently overwrite credentials under a trusted id. - if (reauth) { - const existingCred = getCodexAccountCredential(accountId); - const poolAccount = configuredPoolAccount(getRuntimeConfig(config), accountId); - const expectedChatgptId = existingCred?.chatgptAccountId?.trim(); - const expectedEmail = poolAccount?.email?.trim().toLowerCase(); - const gotEmail = email.trim().toLowerCase(); - if (expectedChatgptId) { - if (expectedChatgptId !== oauthAccountId) { - setCodexLoginState(flowId, { - status: "error", - error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.", - doneAt: Date.now(), - }); - completed = true; - break; - } - } else if (expectedEmail) { - if (!gotEmail || gotEmail !== expectedEmail) { - setCodexLoginState(flowId, { - status: "error", - error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.", - doneAt: Date.now(), - }); - completed = true; - break; - } - } else { - // No chatgptAccountId and no pool email — refuse silent identity replacement - // (including empty credential slots that still have a pool row). - setCodexLoginState(flowId, { - status: "error", - error: "Cannot verify account identity for reauth. Remove this account and add it again.", - doneAt: Date.now(), - }); - completed = true; - break; - } - } - - // 1.2: Duplicate check is scoped by personal vs workspace plan bucket. - const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined); - if (collision.collision) { - setCodexLoginState(flowId, { - status: "error", error: collision.reason, doneAt: Date.now(), - }); - completed = true; - break; - } - - // A successful authenticated WHAM read can prove quota is exhausted without - // spending an inference request. Store the account, but defer inference validation - // and keep it unavailable to routing. Unknown/failed usage reads retain the gate. - const warmup = isCodexQuotaExhausted(quota, plan) - ? { ok: true as const, validatedAt: undefined } - : await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId); - if (!warmup.ok) { - const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string }; - setCodexLoginState(flowId, { - status: "error", - error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed", - doneAt: Date.now(), - }); - completed = true; - break; - } - - const latestConfig = getRuntimeConfig(config); - const accounts = latestConfig.codexAccounts ?? []; - const existingIdx = accounts.findIndex(account => account.id === accountId); - let pickerVisibilityChanged = false; - let newAccountPersistence: PersistNewCodexAccountOutcome | null = null; - const commitConflict = codexAccountPersistenceConflict( - latestConfig, - accountId, - reauth ? "reauth" : "create", - ); - if (commitConflict) { - setCodexLoginState(flowId, { - status: "error", - error: commitConflict, - doneAt: Date.now(), - }); - completed = true; - break; - } - - const credential: CodexAccountCredentials = { - accessToken: cred.access, - refreshToken: cred.refresh, - expiresAt: cred.expires, - chatgptAccountId: oauthAccountId, - }; - - if (existingIdx >= 0) { - const generation = saveCodexAccountCredential(accountId, credential, { - validationPending: warmup.validatedAt === undefined, - }); - // A successful reauthentication replaces the credential generation. Do not let a - // failed optional WHAM probe make the replacement inherit quota from the old record. - if (reauth) clearAccountQuota(accountId); - if (warmup.validatedAt !== undefined) markCodexAccountValidated(accountId, warmup.validatedAt, generation); - clearAccountNeedsReauth(accountId); - if (quota) setAccountQuotaFromParsed(accountId, quota); - // Keep the pool id stable; refresh display metadata after a successful login/reauth. - accounts[existingIdx] = withCodexAccountLogLabel({ - ...accounts[existingIdx], - email, - plan: plan ?? accounts[existingIdx].plan, - isMain: false, - }, accounts); - latestConfig.codexAccounts = accounts; - saveRuntimeConfig(config, latestConfig); - } else { - const addedAccount = withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts); - newAccountPersistence = persistNewCodexAccount( - config, - latestConfig, - addedAccount, - { - credential, - validatedAt: warmup.validatedAt, - }, - ); - pickerVisibilityChanged = newAccountPersistence.pickerVisibilityChanged; - } - reconcileLiveStateStores(); - if (newAccountPersistence?.status === "publication-failed") { - markAccountNeedsReauth(accountId); - } - // A new quota row is generation-gated by live account ownership. Reconcile the - // durable config owner first so a partial prior sweep cannot reject this write. - if (newAccountPersistence?.status === "committed" && quota) { - setAccountQuotaFromParsed(accountId, quota); - } - const { catalogRefreshPending } = await convergeAccountNamespaceCatalog( - latestConfig, - pickerVisibilityChanged, - convergeCodexCatalog, - ); - if (newAccountPersistence?.status === "publication-failed") { - setCodexLoginState(flowId, { - status: "error", - ...codexCredentialPersistenceFailure(accountId, catalogRefreshPending), - doneAt: Date.now(), - }); - completed = true; - } else { - setCodexLoginState(flowId, { - status: "done", - accountId, - email, - ...(warmup.validatedAt === undefined ? { validationPending: true } : {}), - ...(catalogRefreshPending ? { catalogRefreshPending: true } : {}), - doneAt: Date.now(), - }); - completed = true; - } - } - break; - } - if (st.done && st.error) { - setCodexLoginState(flowId, { - status: "error", - // startLoginFlow projects background failures before storing login status, so - // fixed actionable OAuth messages retain their type-derived remediation here. - error: st.error, - doneAt: Date.now(), - }); - completed = true; - break; - } - } - if (!completed) { - setCodexLoginState(flowId, { - status: "error", - error: "Login timed out before OAuth completed.", - doneAt: Date.now(), - }); - } - } catch (error) { - const message = error instanceof ConfigMutationLockError - || error instanceof CodexCredentialRefreshLockTimeoutError - ? "Configuration is busy; retry login shortly." - : error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError - ? "Credential refresh is busy; retry login shortly." - : publicOAuthAuthenticationErrorMessage(error); - setCodexLoginState(flowId, { - status: "error", - error: message, - doneAt: Date.now(), - }); - } finally { - // TTL: keep completed flow state available for clients that miss a short polling window. - setTimeout(() => { if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId); }, CODEX_LOGIN_TERMINAL_TTL_MS); - } - })(); - - setCodexLoginState(flowId, { status: "pending" }); - return jsonResponse({ - ok: true, - flowId, - url: result.url, - instructions: result.instructions, - // Dropped before #3366: every device-code surface renders this field, - // so withholding it left the GUI and CLI with no code to show. - ...(result.deviceCode ? { deviceCode: result.deviceCode } : {}), - }); - } catch (e) { - if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId); - const msg = e instanceof Error ? e.message : String(e); - if (msg === "A login for chatgpt is already in progress") { - return jsonResponse({ error: msg, status: "pending" }, 409); - } - if (e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) { - const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); - response.headers.set("Retry-After", "1"); - return response; - } - const { publicOAuthAuthenticationErrorMessage } = await import("../oauth"); - return jsonResponse({ error: publicOAuthAuthenticationErrorMessage(e) }, 500); - } - } - - if (url.pathname === "/api/codex-auth/login/code" && req.method === "POST") { - const body = (await req.json().catch(() => ({}))) as { flowId?: unknown; input?: unknown }; - const flowId = typeof body.flowId === "string" ? body.flowId.trim() : ""; - const input = typeof body.input === "string" ? body.input : ""; - if (!flowId) return jsonResponse({ error: "flowId required" }, 400); - if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400); - - // Import may yield; validate afterwards so cancel/replace cannot race a stale flow through. - const { submitManualLoginCode } = await import("../oauth"); - const flow = codexAuthLoginState.get(flowId); - if (!flow) return jsonResponse({ error: "login flow expired or unknown" }, 400); - if (flow.status !== "pending") return jsonResponse({ error: "login flow is not pending" }, 400); - - const result = submitManualLoginCode("chatgpt", input); - if (!result.ok) return jsonResponse({ error: result.error }, 400); - return jsonResponse({ ok: true }, 202); - } - - if (url.pathname === "/api/codex-auth/login/cancel" && req.method === "POST") { - const body = (await req.json().catch(() => ({}))) as { flowId?: string }; - const { cancelLoginFlow } = await import("../oauth"); - const cancelled = cancelLoginFlow("chatgpt"); - expireCodexAuthFlow(body.flowId ?? null); - return jsonResponse({ ok: true, cancelled }); - } - - if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") { - const flowId = url.searchParams.get("flowId"); - const accountId = url.searchParams.get("accountId")?.trim(); - // Transient flow state carries the address of the account being added, so it follows the - // same operator policy as the stored accounts it is about to become. - const maskFlowEmails = emailMaskingEnabled(config); - // Reauth always has a pre-existing credential; never treat "credential exists" as success - // when the flow map entry is gone (would false-complete on lost/expired flow state). - const reauthStatus = url.searchParams.get("reauth") === "1"; - if (flowId) { - const st = codexAuthLoginState.get(flowId); - if ( - !st - && accountId - && !reauthStatus - && !isAccountNeedsReauth(accountId) - && getCodexAccountCredential(accountId) - ) { - return jsonResponse({ status: "done", accountId, - ...(readCodexAccountRecord(accountId)?.codexValidationPending ? { validationPending: true } : {}), - }); - } - return jsonResponse(st ? { ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined } : { status: "expired" }); - } - // Legacy fallback: return latest pending flow - for (const [, st] of codexAuthLoginState) { - if (st.status === "pending") return jsonResponse({ ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined }); - } - return jsonResponse({ status: "idle" }); - } - - return null; -} diff --git a/src/codex/auth-api/account-list.ts b/src/codex/auth-api/account-list.ts new file mode 100644 index 0000000000..faa8eeefc0 --- /dev/null +++ b/src/codex/auth-api/account-list.ts @@ -0,0 +1,507 @@ +import { codexAccountLogLabel } from "../account-label"; +import { getCodexAccountCredential, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store"; +import { getAccountQuota, isCodexQuotaExhausted, setAccountQuotaFromParsed, withoutRetiredCodexQuota } from "../quota"; +import type { StoredAccountQuota } from "../quota"; +import { ConfigMutationLockError, mutatePersistedConfig } from "../../config"; +import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; +import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause"; +import { getCodexAccountPriority } from "../account-priority"; +import { clearThreadAccountMapForAccount, isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion } from "../routing"; +import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "../plan"; +import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state"; +import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { captureConfigGeneration } from "../../lib/state-store-sweeper"; +import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, isMainAccountIdentityGenerationLive } from "../main-account-cache"; +import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome"; +import { getMainAccountHardLockStatus } from "../main-account-hard-lock"; +import type { MainAccountHardLockStatus } from "../main-account-hard-lock"; +import { emailMaskingEnabled, projectEmail } from "../../lib/privacy"; +import type { CodexAccount, OcxConfig } from "../../types"; +import { oauthAccountHealthFields, projectCodexAccountHealth } from "../../oauth/health"; +import type { OAuthAccountHealth, OAuthHealthLabel } from "../../oauth/health"; +import { isSelectableCodexPoolAccount } from "../account-id"; +import type { AdmissionLease } from "../../lib/admission"; +import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; +import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; +import { fetchMainAccountInfoAttempt, EMPTY_MAIN_ACCOUNT_INFO, mainResetCreditsForCurrentIdentity } from "./main-account-probe"; +import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe"; +import type { PoolQuotaResult } from "./pool-quota-probe"; +import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config"; + +export function quotaForPlan | StoredAccountQuota | null>( + quota: T, + plan: unknown, +): T | null { + const visible = withoutRetiredCodexQuota(quota); + if (!visible || !isThirtyDayOnlyCodexPlan(plan)) return visible; + const quotaWindows = visible; + return { + ...(quotaWindows.monthlyPercent !== undefined ? { monthlyPercent: quotaWindows.monthlyPercent } : {}), + ...(quotaWindows.monthlyResetAt !== undefined ? { monthlyResetAt: quotaWindows.monthlyResetAt } : {}), + // A 30-day plan can still carry a burst window, and it blocks the account on its own. + // Dropping it here would show a healthy card for an account upstream is refusing (#1791). + ...(quotaWindows.shortPercent !== undefined ? { shortPercent: quotaWindows.shortPercent } : {}), + ...(quotaWindows.shortResetAt !== undefined ? { shortResetAt: quotaWindows.shortResetAt } : {}), + ...(quotaWindows.shortWindowSeconds !== undefined ? { shortWindowSeconds: quotaWindows.shortWindowSeconds } : {}), + ...(quotaWindows.customWindows !== undefined ? { customWindows: quotaWindows.customWindows } : {}), + ...(quotaWindows.resetCredits !== undefined ? { resetCredits: quotaWindows.resetCredits } : {}), + ...("updatedAt" in quotaWindows ? { updatedAt: quotaWindows.updatedAt } : {}), + } as T; +} + +/** + * The main account is the only account whose DTO quota comes from the raw WHAM parse + * result instead of the merged store: `poolAccountDto` serializes what + * `commitPoolQuotaResponse` read back out of `getAccountQuota()`, while the main DTO + * spreads `mainInfo.quota` directly. `/wham/usage` carries `rate_limit_reset_credits` + * only intermittently, and the store exists to bridge that gap + * (`setAccountQuotaFromParsed` carries an existing `resetCredits` forward when the new + * snapshot omits it), so the main card lost its ticket badge on every response that + * happened to omit the summary while pool cards kept theirs. + * + * Only `resetCredits` is carried, deliberately, and only from an identity-tagged + * in-process observation rather than the alias-keyed store. The window fields have + * *clearing* semantics — a monthly-only snapshot must drop a stale weekly value (#382) — + * so reinstating the whole stored object would resurrect a window the parse meant to + * clear whenever the store write was refused by generation gating. A freshly parsed value + * always wins, including `0`: zero is defined, so it never takes the fill branch. + */ +export function mainQuotaWithCarriedResetCredits( + parsed: Omit, +): StoredAccountQuota { + const carried = parsed.resetCredits === undefined + ? mainResetCreditsForCurrentIdentity() + : undefined; + return { + ...parsed, + ...(carried !== undefined ? { resetCredits: carried } : {}), + updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(), + }; +} + +/** + * Why an account needs the operator. `missing_credential`, `refresh_failed`, and + * `quota_unauthorized` are the three causes this surface tells apart on its own. `unauthorized` + * and `forbidden` exist because the shared health projection may return them; today + * `projectCodexAccountHealth` only ever produces `refresh_failed`, so accepting the full union + * keeps this field correct if that projection widens rather than silently dropping a reason. + */ +export type CodexAccountReauthReason = + | "missing_credential" + | "refresh_failed" + | "quota_unauthorized" + | "unauthorized" + | "forbidden"; + +export function poolAccountDto( + config: OcxConfig, + account: CodexAccount, + quotaResult: PoolQuotaResult, + hasCredential: boolean, + paused: boolean, + priority: number, + maskEmails: boolean, +): CodexAuthAccountDto { + const plan = codexPlanValue(account.plan); + const quota = quotaForPlan(quotaResult.quota, plan); + const runtimeReauth = isAccountNeedsReauth(account.id); + const needsReauth = !hasCredential || quotaResult.needsReauth || runtimeReauth; + const health = projectCodexAccountHealth({ accountId: account.id, needsReauth }); + // `needsReauth` is an OR of three independent causes plus a persisted verdict resolved inside the + // health projection. Emitting only the boolean is what left #4212's reporter guessing which + // account took their model away and why, so name the cause they actually have to act on. + const reauthReason: CodexAccountReauthReason | undefined = !hasCredential + ? "missing_credential" + : runtimeReauth + ? "refresh_failed" + : quotaResult.needsReauth + ? "quota_unauthorized" + : health.status === "reauth_required" ? health.reason : undefined; + return { + id: account.id, + email: projectEmail(account.email, maskEmails) ?? account.email, + ...(account.alias !== undefined ? { alias: account.alias } : {}), + ...(plan !== undefined ? { plan } : {}), + logLabel: codexAccountLogLabel(account), + isMain: false, + paused, + priority, + quota: quota ? { ...quota } : null, + needsReauth: needsReauth || health.status === "reauth_required", + ...(reauthReason !== undefined ? { reauthReason } : {}), + ...(isCodexAccountPlanExcluded(config, account.id) ? { + selectionExcludedReason: "plan_excluded" as const, + selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan), + } : {}), + hasCredential, + ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}), + ...oauthAccountHealthFields("codex", account.id, health), + }; +} + +export interface CodexAuthAccountDto { + id: string; + alias?: string; + email: string; + plan?: string | null; + logLabel?: string; + isMain: boolean; + paused: boolean; + /** Selection order; higher is used earlier. Always present, 0 when unset. */ + priority: number; + quota: (StoredAccountQuota | (Omit & { updatedAt: number })) | null; + needsReauth?: boolean; + /** + * Which of the independent causes behind `needsReauth` fired. Present only when the account + * needs the operator; `/api/oauth/accounts` already carries the same field name. + */ + reauthReason?: CodexAccountReauthReason; + /** Automatic selection policy only; explicit routes retain their usual auth checks. */ + selectionExcludedReason?: "plan_excluded"; + selectionExcludedPlan?: string; + hasCredential: boolean; + health: OAuthAccountHealth; + healthLabel: OAuthHealthLabel; + healthSummary: string; + healthAction?: string; + quotaProbeSkipped?: true; + quotaRefresh?: CodexQuotaRefreshOutcome; + mainAccountHardLock?: MainAccountHardLockStatus; +} + +export interface FreshPoolPlanUpdate { + accountId: string; + plan: string; + credentialGeneration: number; +} + +/** + * Persist only validated plan leaves against the latest disk snapshot. A quota GET must not save + * the long-lived runtime object wholesale: unrelated manual/provider writes may have landed while + * WHAM requests were in flight. Missing or malformed files fail closed: a read path must not + * recreate a deleted config from the server's older in-memory snapshot. + */ +export function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: FreshPoolPlanUpdate[]): void { + if (updates.length === 0) return; + let outcome: ReturnType>; + try { + outcome = mutatePersistedConfig(persistedConfig => { + const accepted: FreshPoolPlanUpdate[] = []; + let changed = false; + for (const update of updates) { + if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue; + const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId); + const persistedAccount = configuredPoolAccount(persistedConfig, update.accountId); + if (!liveAccount || !persistedAccount) continue; + accepted.push(update); + if (persistedAccount.plan !== update.plan) { + persistedAccount.plan = update.plan; + // WHAM is the authoritative plan source: stamp provenance so a later JWT + // reconcile cannot overwrite this observation within the same credential + // generation (src/codex/plan-from-token.ts jwtMayWritePlan). Stamped only + // alongside a real plan change: a steady-state refresh whose plan is + // unchanged must stay write-free (no-config-write contract), and an + // unchanged value needs no fence — a JWT rewrite to the same text is a + // no-op under the caller's own equality check. + persistedAccount.planSource = "wham"; + persistedAccount.planCredentialGeneration = update.credentialGeneration; + changed = true; + } + } + return { changed, value: accepted }; + }); + } catch (error) { + // Plan persistence is derived metadata on a read route. Contention must fail closed without + // turning account listing into a 500; a later refresh can retry against the latest files. + if (error instanceof ConfigMutationLockError) return; + throw error; + } + if (outcome.status === "unavailable") return; + for (const update of outcome.value) { + // A replacement immediately after the durable commit is allowed to supersede the result, but + // the long-lived object must never be updated from that stale generation. + if (!isCodexAccountGenerationLive(update.accountId, update.credentialGeneration)) continue; + const liveAccount = configuredPoolAccount(runtimeConfig, update.accountId); + if (liveAccount) { + liveAccount.plan = update.plan; + liveAccount.planSource = "wham"; + liveAccount.planCredentialGeneration = update.credentialGeneration; + } + } +} + +export interface CodexAuthAccountsSnapshot { + accounts: CodexAuthAccountDto[]; + mainIdentityGeneration: number; +} + +export async function listCodexAuthAccountsSnapshot( + config: OcxConfig, + forceRefresh = false, + options: { validatePending?: boolean } = {}, +): Promise { + const runtimeConfig = getRuntimeConfig(config); + const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); + // One redaction decision for the whole snapshot, read once from the operator's config (#3859). + const maskEmails = emailMaskingEnabled(runtimeConfig); + const mainResult = await fetchMainAccountInfoAttempt(forceRefresh, 1); + const refreshedPool = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => { + const cred = getCodexAccountCredential(account.id); + let quotaResult: PoolQuotaResult; + if (!cred) { + quotaResult = { quota: null, needsReauth: true }; + } else { + try { + quotaResult = await fetchPoolAccountQuota(account.id, forceRefresh, account.plan, getValidCodexToken, options.validatePending === true); + } catch (error) { + if (!(error instanceof PoolQuotaProbeBusyError)) throw error; + quotaResult = { + quota: getAccountQuota(account.id), + needsReauth: false, + credentialGeneration: readCodexAccountRecord(account.id)?.generation, + quotaProbeSkipped: true, + }; + } + } + return { accountId: account.id, quotaResult }; + }); + + // WHAM plan_type is authoritative only for the credential generation that fetched it. Collect + // changes after every parallel read settles, then apply one narrow disk patch for the batch. + const planUpdates = refreshedPool.flatMap(({ accountId, quotaResult }): FreshPoolPlanUpdate[] => { + const plan = quotaResult.freshPlan; + const credentialGeneration = quotaResult.freshCredentialGeneration; + return plan && credentialGeneration !== undefined + ? [{ accountId, plan, credentialGeneration }] + : []; + }); + reconcileFreshPoolAccountPlans(runtimeConfig, planUpdates); + + const withQuota = refreshedPool.flatMap(({ accountId, quotaResult }) => { + const currentAccount = configuredPoolAccount(runtimeConfig, accountId); + if (!currentAccount) return []; + const currentCredential = getCodexAccountCredential(accountId); + if (!currentCredential) { + return [poolAccountDto( + runtimeConfig, + currentAccount, + { quota: null, needsReauth: true }, + false, + isCodexAccountPaused(runtimeConfig, accountId), + getCodexAccountPriority(runtimeConfig, accountId), + maskEmails, + )]; + } + const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration; + const generationLive = resultGeneration === undefined + || isCodexAccountGenerationLive(accountId, resultGeneration); + const effectiveQuotaResult = !generationLive + ? { quota: null, needsReauth: false } + : quotaResult; + // Response DTO can show the WHAM plan even when disk persistence fails closed (lock busy / + // missing config). Persistence still remains generation-gated via reconcileFreshPoolAccountPlans. + const dtoAccount = generationLive && quotaResult.freshPlan + ? { ...currentAccount, plan: quotaResult.freshPlan } + : currentAccount; + return [poolAccountDto( + runtimeConfig, + dtoAccount, + effectiveQuotaResult, + true, + isCodexAccountPaused(runtimeConfig, accountId), + getCodexAccountPriority(runtimeConfig, accountId), + maskEmails, + )]; + }); + const fetchedMainGeneration = mainResult.identityGeneration ?? captureMainAccountIdentityGeneration(); + const mainSnapshotLive = isMainAccountIdentityGenerationLive(fetchedMainGeneration); + const mainInfo = mainSnapshotLive ? mainResult.info : EMPTY_MAIN_ACCOUNT_INFO; + const hasMainCredential = mainSnapshotLive && mainResult.credentialChecked + ? mainResult.hasCredential + : getMainAccountCredentialPresence() ?? false; + const mainMissingCredential = mainSnapshotLive && mainResult.credentialChecked && !hasMainCredential; + const mainNeedsReauth = mainMissingCredential || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + const mainHealth = projectCodexAccountHealth({ + accountId: MAIN_CODEX_ACCOUNT_ID, + needsReauth: mainNeedsReauth, + }); + // The main row carries the same attribution as a pool row. Reaching this point without + // `mainMissingCredential` means the runtime reauth flag is what set `mainNeedsReauth`, so the + // cause is a refresh that did not complete. + const mainReauthReason: CodexAccountReauthReason | undefined = mainMissingCredential + ? "missing_credential" + : mainNeedsReauth + ? "refresh_failed" + : mainHealth.status === "reauth_required" ? mainHealth.reason : undefined; + const main: CodexAuthAccountDto = { + id: MAIN_CODEX_ACCOUNT_ID, + email: projectEmail(mainInfo.email, maskEmails) ?? "Codex App login", + plan: mainInfo.plan, + ...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined + && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration) + ? { quotaRefresh: mainResult.quotaRefresh } : {}), + logLabel: "main", + isMain: true, + paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), + mainAccountHardLock: getMainAccountHardLockStatus(runtimeConfig), + priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID), + hasCredential: hasMainCredential, + needsReauth: mainNeedsReauth, + ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}), + quota: mainInfo.quota + ? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan) + : null, + ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth), + }; + return { + accounts: [main, ...withQuota], + mainIdentityGeneration: mainSnapshotLive + ? fetchedMainGeneration + : captureMainAccountIdentityGeneration(), + }; +} + +/** One opted-in account's metadata; reuse the bounded WHAM 401 recovery and generation fence. */ +export async function refreshCodexQuotaForActivation(config: OcxConfig, accountId: string): Promise { + if (accountId === MAIN_CODEX_ACCOUNT_ID) { + const lease = tryAcquireNativeMainProfileClaim(); + if (!lease) return; + try { + reconcileMainCodexAccountRuntimeState(); + if (isAccountNeedsReauth(accountId)) return; + const identityGeneration = captureMainAccountIdentityGeneration(); + const writerGeneration = captureConfigGeneration(); + try { + // Refresh may need an exclusive claim; prepare before WHAM takes its shared claim. + if (!await getValidMainAccountToken({ preserveReauth: true })) return; + } catch (error) { + if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth" + && isMainAccountIdentityGenerationLive(identityGeneration)) { + markAccountNeedsReauth(accountId, writerGeneration); + } + return; + } + if (isAccountNeedsReauth(accountId)) return; + await fetchMainAccountInfoAttempt(true, 1, lease, false, false); + } finally { + lease.release(); + } + return; + } + const account = configuredPoolAccount(config, accountId); + if (!account) return; + const writerGeneration = captureConfigGeneration(); + const result = await fetchPoolAccountQuota(accountId, true, account.plan); + if (result.needsReauth && result.credentialGeneration !== undefined) { + markAccountNeedsReauth(accountId, writerGeneration, result.credentialGeneration); + } +} + +export async function listCodexAuthAccounts( + config: OcxConfig, + forceRefresh = false, + options: { validatePending?: boolean } = {}, +): Promise { + return (await listCodexAuthAccountsSnapshot(config, forceRefresh, options)).accounts; +} + +export interface PauseExhaustedResult { + pausedAccountIds: string[]; + checkedAccountCount: number; + failedAccountCount: number; +} + +export function selectFallbackAfterPause(config: OcxConfig, pausedActiveId: string): void { + reconcileCodexActiveAfterExclusion(config, pausedActiveId); +} + +export async function pauseExhaustedCodexAccounts( + config: OcxConfig, + persistPausedAccounts: () => void, +): Promise { + const poolAccounts = (config.codexAccounts ?? []).filter(account => !account.isMain); + const nativeMainLease = tryAcquireNativeMainProfileClaim(); + try { + const performPause = async (mainLease?: AdmissionLease): Promise => { + const mainWork = async (): Promise<{ + shouldPause: boolean; + checkedAccountCount: number; + failedAccountCount: number; + }> => { + if (!mainLease) return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 }; + const mainResult = await fetchMainAccountInfoAttempt(true, 1, mainLease, true); + if (!mainResult.credentialChecked || !mainResult.hasCredential) { + return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 0 }; + } + if (!mainResult.freshQuota || !mainResult.info.plan) { + return { shouldPause: false, checkedAccountCount: 0, failedAccountCount: 1 }; + } + return { + shouldPause: !isCodexAccountPaused(config, MAIN_CODEX_ACCOUNT_ID) + && isCodexQuotaExhausted(mainResult.freshQuota, mainResult.info.plan), + checkedAccountCount: 1, + failedAccountCount: 0, + }; + }; + const [mainResult, poolResults] = await Promise.all([ + mainWork(), + mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async account => { + if (!getCodexAccountCredential(account.id)) return { account, quotaResult: null }; + try { + return { + account, + quotaResult: await fetchPoolAccountQuota(account.id, true, account.plan), + }; + } catch { + // Settle each pool probe independently so a busy/failing account cannot + // abandon an already-confirmed main decision before atomic publication. + return { account, quotaResult: null }; + } + }), + ]); + + let checkedAccountCount = mainResult.checkedAccountCount; + let failedAccountCount = mainResult.failedAccountCount; + const exhaustedIds: string[] = mainResult.shouldPause ? [MAIN_CODEX_ACCOUNT_ID] : []; + for (const { account, quotaResult } of poolResults) { + const currentAccount = (config.codexAccounts ?? []).find(candidate => candidate.id === account.id && !candidate.isMain); + if (!currentAccount) continue; + const generation = quotaResult?.freshCredentialGeneration; + const plan = quotaResult?.freshPlan ?? currentAccount.plan; + if (!quotaResult?.freshQuota || generation === undefined || !isCodexAccountGenerationLive(account.id, generation) || !plan) { + failedAccountCount += 1; + continue; + } + checkedAccountCount += 1; + if (!isCodexAccountPaused(config, account.id) && isCodexQuotaExhausted(quotaResult.freshQuota, plan)) { + exhaustedIds.push(account.id); + } + } + + for (const id of exhaustedIds) { + setCodexAccountPaused(config, id, true); + clearThreadAccountMapForAccount(id); + } + for (const id of exhaustedIds) selectFallbackAfterPause(config, id); + const result = { + pausedAccountIds: exhaustedIds, + checkedAccountCount, + failedAccountCount, + }; + // Persist while both the in-process admission and cross-process shared + // claim still own the physical-main identity used for the decision. + if (result.pausedAccountIds.length > 0) persistPausedAccounts(); + return result; + }; + + if (!nativeMainLease) return await performPause(); + try { + return await withNativeMainCredentialClaim(() => performPause(nativeMainLease)); + } catch (error) { + if (isNativeMainClaimUnavailable(error)) return await performPause(); + throw error; + } + } finally { + nativeMainLease?.release(); + } +} diff --git a/src/codex/auth-api/http.ts b/src/codex/auth-api/http.ts new file mode 100644 index 0000000000..55c6983178 --- /dev/null +++ b/src/codex/auth-api/http.ts @@ -0,0 +1,32 @@ +import { withNativeMainSharedClaim } from "../native-main-claim"; +import { resolveNativeProfileContext } from "../native-profile-store"; +import { NativeProfileError } from "../native-profile-types"; + +export function isNativeMainClaimUnavailable(error: unknown): error is NativeProfileError { + return error instanceof NativeProfileError + && (error.code === "NATIVE_MAIN_CLAIM_BUSY" || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE"); +} + +export function withNativeMainCredentialClaim(operation: () => Promise): Promise { + return withNativeMainSharedClaim(resolveNativeProfileContext(), operation); +} + +export function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +export function nativeMainProfileBusyResponse(): Response { + const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); + response.headers.set("Retry-After", "1"); + return response; +} + +export function manualImportDisabledResponse(): Response { + return jsonResponse({ + error: "Manual Codex account import is disabled. Use OAuth login to add a pool account.", + code: "manual_import_disabled", + }, 403); +} diff --git a/src/codex/auth-api/login-flow.ts b/src/codex/auth-api/login-flow.ts new file mode 100644 index 0000000000..663d68e68f --- /dev/null +++ b/src/codex/auth-api/login-flow.ts @@ -0,0 +1,546 @@ +import { withCodexAccountLogLabel } from "../account-label"; +import { getCodexAccountCredential, markCodexAccountValidated, readCodexAccountRecord, saveCodexAccountCredential, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError } from "../account-store"; +import { clearAccountQuota, isCodexQuotaExhausted, parseUsageQuota, setAccountQuotaFromParsed } from "../quota"; +import type { StoredAccountQuota, WhamUsageResponse } from "../quota"; +import { ConfigMutationLockError, withConfigMutationLockSync } from "../../config"; +import { appendDefaultCodexAccountNamespace, codexAccountPickerEnabled } from "../account-namespaces"; +import { catalogRefreshIsPending, normalizeCatalogDisposition } from "../catalog-refresh-status"; +import { checkAccountIdCollision } from "../auth-collision"; +import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state"; +import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; +import { emailMaskingEnabled, projectEmail } from "../../lib/privacy"; +import { codexWarmupFailureReason, isCodexWarmupProvisioningFailure, warmCodexAccount } from "../warmup"; +import type { CodexAccount, CodexAccountCredentials, OcxConfig } from "../../types"; +import type { CatalogDisposition } from "../convergence-types"; +import { isValidCodexAccountId } from "../account-id"; +import { codexAccountIdNamespaceCollisionError } from "../account-namespace-match"; +import { jsonResponse } from "./http"; +import { codexAuthLoginState, MAX_CODEX_LOGIN_STATE_ROWS, CODEX_LOGIN_TERMINAL_TTL_MS, CodexLoginStateBusyError, setCodexLoginState, pruneCodexLoginState, expireCodexAuthFlow } from "./login-state"; +import type { CodexLoginStateRow } from "./login-state"; +import { getRuntimeConfig, configuredPoolAccount, nonEmptyPlan, saveRuntimeConfig } from "./runtime-config"; + +const CODEX_CREDENTIAL_PERSISTENCE_ERROR = "Account was saved, but credential setup did not complete. Reauthenticate or remove the account."; +const CODEX_CREDENTIAL_PERSISTENCE_CODE = "codex_credential_persistence_failed"; + +export function codexAccountPersistenceConflict( + config: OcxConfig, + accountId: string, + mode: "create" | "reauth", +): string | undefined { + if (mode === "reauth") { + return configuredPoolAccount(config, accountId) + ? undefined + : "Pool account was removed while login was in progress. Add it again as a new account."; + } + const namespaceCollision = codexAccountIdNamespaceCollisionError(config.codexAccountNamespaces, accountId); + if (namespaceCollision) return namespaceCollision; + return (config.codexAccounts ?? []).some(account => account.id === accountId) + || Boolean(getCodexAccountCredential(accountId)) + ? `Account id already exists: ${accountId}` + : undefined; +} + +export async function verifyCodexAccountWarmup( + accountId: string, + accessToken: string, + chatgptAccountId: string, +): Promise<{ ok: true; validatedAt: number } | { ok: false; response: Response }> { + try { + await warmCodexAccount({ accessToken, chatgptAccountId }); + return { ok: true, validatedAt: Date.now() }; + } catch (err) { + const reason = codexWarmupFailureReason(err); + return { + ok: false, + response: jsonResponse({ + // Every fallback model was refused for a provisioning reason, so telling the operator to + // reauthenticate sends them back through a login that already succeeded. + error: isCodexWarmupProvisioningFailure(err) + ? "Codex account warmup failed. Verify account model access or provisioning and try again." + : "Codex account warmup failed. Reauthenticate the account and try again.", + code: "codex_warmup_failed", + reason, + accountId, + }, 401), + }; + } +} + +export interface StagedNewCodexAccountState { + credential: CodexAccountCredentials; + validatedAt?: number; +} + +export type PersistNewCodexAccountOutcome = + | { status: "committed"; pickerVisibilityChanged: boolean } + | { status: "publication-failed"; pickerVisibilityChanged: boolean }; + +export function codexCredentialPersistenceFailure(accountId: string, catalogRefreshPending: boolean) { + return { + error: CODEX_CREDENTIAL_PERSISTENCE_ERROR, + code: CODEX_CREDENTIAL_PERSISTENCE_CODE, + accountId, + needsReauth: true as const, + ...(catalogRefreshPending ? { catalogRefreshPending: true as const } : {}), + }; +} + +/** Persist config before publishing secret or runtime state under the shared mutation coordinator. */ +export function persistNewCodexAccount( + sourceConfig: OcxConfig, + runtimeConfig: OcxConfig, + addedAccount: CodexAccount, + staged: StagedNewCodexAccountState, +): PersistNewCodexAccountOutcome { + return withConfigMutationLockSync(() => { + const previousConfig = { ...runtimeConfig }; + let pickerVisibilityChanged: boolean; + try { + const accounts = [...(runtimeConfig.codexAccounts ?? [])]; + const retainedPickerBindingRestored = codexAccountPickerEnabled(runtimeConfig) + && Object.values(runtimeConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id); + accounts.push(addedAccount); + runtimeConfig.codexAccounts = accounts; + + // Presence of the explicit flag distinguishes a dashboard-managed map from + // a hand-authored legacy map. Preserve manual maps exactly. + const tracksPickerNamespaces = runtimeConfig.codexAccountPickerEnabled !== undefined; + if (tracksPickerNamespaces && runtimeConfig.codexAccountNamespaces) { + runtimeConfig.codexAccountNamespaces = { ...runtimeConfig.codexAccountNamespaces }; + } + const namespaceAdded = tracksPickerNamespaces + && appendDefaultCodexAccountNamespace(runtimeConfig, addedAccount); + pickerVisibilityChanged = namespaceAdded || retainedPickerBindingRestored; + saveRuntimeConfig(sourceConfig, runtimeConfig); + } catch (error) { + for (const key of Object.keys(runtimeConfig) as Array) { + delete runtimeConfig[key]; + } + Object.assign(runtimeConfig, previousConfig); + throw error; + } + + try { + const generation = saveCodexAccountCredential(addedAccount.id, staged.credential, { + validationPending: staged.validatedAt === undefined, + }); + if (staged.validatedAt !== undefined) markCodexAccountValidated(addedAccount.id, staged.validatedAt, generation); + clearAccountNeedsReauth(addedAccount.id); + } catch { + // Config is already durable. Return the failure outcome through the coordinator so its + // generation commit is not rolled back while config.json remains changed. + return { status: "publication-failed" as const, pickerVisibilityChanged }; + } + return { status: "committed" as const, pickerVisibilityChanged }; + }); +} + +/** Bounded catalog-convergence callback supplied by the management dispatcher. */ +export type CodexAuthCatalogConvergence = () => Promise; + +export interface AccountNamespaceCatalogRefresh { + catalogRefreshPending: boolean; +} + +/** Collapse post-persistence convergence into the one public recovery bit. */ +export async function convergeAccountNamespaceCatalog( + config: OcxConfig, + changed: boolean, + convergeCodexCatalog?: CodexAuthCatalogConvergence, +): Promise { + if (!changed || !codexAccountPickerEnabled(config)) { + return { catalogRefreshPending: false }; + } + if (!convergeCodexCatalog) return { catalogRefreshPending: true }; + + try { + const catalogRefresh = normalizeCatalogDisposition(await convergeCodexCatalog()); + if (!catalogRefresh) return { catalogRefreshPending: true }; + return { catalogRefreshPending: catalogRefreshIsPending(catalogRefresh) }; + } catch { + return { catalogRefreshPending: true }; + } +} + +export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig, convergeCodexCatalog?: CodexAuthCatalogConvergence): Promise { + const body = (await req.json().catch(() => ({}))) as { + id?: string; + reauth?: boolean; + openBrowser?: unknown; + device?: unknown; + }; + // Device mode: no local browser, no loopback listener. The only way to add + // an account to a headless hub (#3366). + const useDeviceFlow = body.device === true; + const requestedAccountId = body.id?.trim(); + const reauth = body.reauth === true; + if (requestedAccountId && !isValidCodexAccountId(requestedAccountId)) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } + const accountId = requestedAccountId || `chatgpt-${Date.now()}`; + const runtimeConfig = getRuntimeConfig(config); + const preflightConflict = !reauth + ? codexAccountPersistenceConflict(runtimeConfig, accountId, "create") + : undefined; + if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400); + if (reauth) { + if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400); + if (!configuredPoolAccount(runtimeConfig, accountId)) { + return jsonResponse({ error: "Unknown pool account for reauth" }, 404); + } + } + pruneCodexLoginState(); + if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) { + const busy = new CodexLoginStateBusyError(); + const response = jsonResponse({ error: busy.message, code: busy.code }, 503); + response.headers.set("Retry-After", "1"); + return response; + } + const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const loginOwner: CodexLoginStateRow = { status: "starting", startedAt: Date.now() }; + codexAuthLoginState.set(flowId, loginOwner); + try { + const { startLoginFlow, getLoginStatus, publicOAuthAuthenticationErrorMessage } = await import("../../oauth"); + const result = await startLoginFlow("chatgpt", { + forceLogin: true, + ...(useDeviceFlow ? { flow: "device" as const } : {}), + }); + + // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts). + // The GUI's window.open is popup-blocked because it runs after an await, not a direct click. + // Both login routes share one resolver so this surface cannot drift from the other. + const { shouldOpenBrowserForLogin } = await import("../../oauth/open-browser-choice"); + // A device flow's URL is a verification page the user opens on ANOTHER + // machine. Opening it on the hub host is useless at best, and on a + // headless host it fails. `deviceCode` is the same signal the generic + // OAuth login route uses to make this decision. + if (result.url && !result.deviceCode && shouldOpenBrowserForLogin(body.openBrowser, runtimeConfig)) { + const { openUrl } = await import("../../lib/open-url"); + openUrl(result.url); + } + + (async () => { + try { + let completed = false; + // The device grant lives 15 minutes and the whole point is that the + // user walks to another device to enter the code. A 5-minute server + // budget would kill the flow at minute five while the grant is still + // valid. The extra 30 attempts past 450 are settlement margin: a user + // who authorizes in the final seconds still needs the token exchange + // and credential write to land before this loop gives up. + const pollAttempts = useDeviceFlow ? 480 : 150; + for (let i = 0; i < pollAttempts; i++) { + await new Promise(r => setTimeout(r, 2000)); + const st = getLoginStatus("chatgpt"); + if (st.done && st.loggedIn) { + const { getCredential } = await import("../../oauth/store"); + const cred = getCredential("chatgpt"); + if (cred) { + const oauthAccountId = cred.accountId; + if (!oauthAccountId) { + setCodexLoginState(flowId, { + status: "error", + error: "Could not determine account identity from OAuth tokens. Please retry OAuth login.", + doneAt: Date.now(), + }); + completed = true; + break; + } + + let email = cred.email || accountId; + let plan: string | undefined; + let quota: Omit | null = null; + try { + const tokens = { access_token: cred.access, account_id: oauthAccountId }; + const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { + headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, + signal: AbortSignal.timeout(8000), + }); + if (resp.ok) { + const data = (await resp.json()) as WhamUsageResponse; + email = data.email ?? email; + plan = nonEmptyPlan(data.plan_type) ?? undefined; + quota = parseUsageQuota(data); + } + } catch { /* wham fetch is non-blocking */ } + // Reauth must refresh the same ChatGPT identity already bound to this pool slot. + // Otherwise a different login would silently overwrite credentials under a trusted id. + if (reauth) { + const existingCred = getCodexAccountCredential(accountId); + const poolAccount = configuredPoolAccount(getRuntimeConfig(config), accountId); + const expectedChatgptId = existingCred?.chatgptAccountId?.trim(); + const expectedEmail = poolAccount?.email?.trim().toLowerCase(); + const gotEmail = email.trim().toLowerCase(); + if (expectedChatgptId) { + if (expectedChatgptId !== oauthAccountId) { + setCodexLoginState(flowId, { + status: "error", + error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.", + doneAt: Date.now(), + }); + completed = true; + break; + } + } else if (expectedEmail) { + if (!gotEmail || gotEmail !== expectedEmail) { + setCodexLoginState(flowId, { + status: "error", + error: "Signed-in ChatGPT account does not match this pool account. Sign in with the same account, or remove it and add a new one.", + doneAt: Date.now(), + }); + completed = true; + break; + } + } else { + // No chatgptAccountId and no pool email — refuse silent identity replacement + // (including empty credential slots that still have a pool row). + setCodexLoginState(flowId, { + status: "error", + error: "Cannot verify account identity for reauth. Remove this account and add it again.", + doneAt: Date.now(), + }); + completed = true; + break; + } + } + + // 1.2: Duplicate check is scoped by personal vs workspace plan bucket. + const collision = checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined); + if (collision.collision) { + setCodexLoginState(flowId, { + status: "error", error: collision.reason, doneAt: Date.now(), + }); + completed = true; + break; + } + + // A successful authenticated WHAM read can prove quota is exhausted without + // spending an inference request. Store the account, but defer inference validation + // and keep it unavailable to routing. Unknown/failed usage reads retain the gate. + const warmup = isCodexQuotaExhausted(quota, plan) + ? { ok: true as const, validatedAt: undefined } + : await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId); + if (!warmup.ok) { + const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string }; + setCodexLoginState(flowId, { + status: "error", + error: body.reason ? `${body.error ?? "Codex account warmup failed"} (${body.reason})` : body.error ?? "Codex account warmup failed", + doneAt: Date.now(), + }); + completed = true; + break; + } + + const latestConfig = getRuntimeConfig(config); + const accounts = latestConfig.codexAccounts ?? []; + const existingIdx = accounts.findIndex(account => account.id === accountId); + let pickerVisibilityChanged = false; + let newAccountPersistence: PersistNewCodexAccountOutcome | null = null; + const commitConflict = codexAccountPersistenceConflict( + latestConfig, + accountId, + reauth ? "reauth" : "create", + ); + if (commitConflict) { + setCodexLoginState(flowId, { + status: "error", + error: commitConflict, + doneAt: Date.now(), + }); + completed = true; + break; + } + + const credential: CodexAccountCredentials = { + accessToken: cred.access, + refreshToken: cred.refresh, + expiresAt: cred.expires, + chatgptAccountId: oauthAccountId, + }; + + if (existingIdx >= 0) { + const generation = saveCodexAccountCredential(accountId, credential, { + validationPending: warmup.validatedAt === undefined, + }); + // A successful reauthentication replaces the credential generation. Do not let a + // failed optional WHAM probe make the replacement inherit quota from the old record. + if (reauth) clearAccountQuota(accountId); + if (warmup.validatedAt !== undefined) markCodexAccountValidated(accountId, warmup.validatedAt, generation); + clearAccountNeedsReauth(accountId); + if (quota) setAccountQuotaFromParsed(accountId, quota); + // Keep the pool id stable; refresh display metadata after a successful login/reauth. + accounts[existingIdx] = withCodexAccountLogLabel({ + ...accounts[existingIdx], + email, + plan: plan ?? accounts[existingIdx].plan, + isMain: false, + }, accounts); + latestConfig.codexAccounts = accounts; + saveRuntimeConfig(config, latestConfig); + } else { + const addedAccount = withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts); + newAccountPersistence = persistNewCodexAccount( + config, + latestConfig, + addedAccount, + { + credential, + validatedAt: warmup.validatedAt, + }, + ); + pickerVisibilityChanged = newAccountPersistence.pickerVisibilityChanged; + } + reconcileLiveStateStores(); + if (newAccountPersistence?.status === "publication-failed") { + markAccountNeedsReauth(accountId); + } + // A new quota row is generation-gated by live account ownership. Reconcile the + // durable config owner first so a partial prior sweep cannot reject this write. + if (newAccountPersistence?.status === "committed" && quota) { + setAccountQuotaFromParsed(accountId, quota); + } + const { catalogRefreshPending } = await convergeAccountNamespaceCatalog( + latestConfig, + pickerVisibilityChanged, + convergeCodexCatalog, + ); + if (newAccountPersistence?.status === "publication-failed") { + setCodexLoginState(flowId, { + status: "error", + ...codexCredentialPersistenceFailure(accountId, catalogRefreshPending), + doneAt: Date.now(), + }); + completed = true; + } else { + setCodexLoginState(flowId, { + status: "done", + accountId, + email, + ...(warmup.validatedAt === undefined ? { validationPending: true } : {}), + ...(catalogRefreshPending ? { catalogRefreshPending: true } : {}), + doneAt: Date.now(), + }); + completed = true; + } + } + break; + } + if (st.done && st.error) { + setCodexLoginState(flowId, { + status: "error", + // startLoginFlow projects background failures before storing login status, so + // fixed actionable OAuth messages retain their type-derived remediation here. + error: st.error, + doneAt: Date.now(), + }); + completed = true; + break; + } + } + if (!completed) { + setCodexLoginState(flowId, { + status: "error", + error: "Login timed out before OAuth completed.", + doneAt: Date.now(), + }); + } + } catch (error) { + const message = error instanceof ConfigMutationLockError + || error instanceof CodexCredentialRefreshLockTimeoutError + ? "Configuration is busy; retry login shortly." + : error instanceof CodexCredentialRefreshBusyError || error instanceof CodexCredentialRefreshStaleError + ? "Credential refresh is busy; retry login shortly." + : publicOAuthAuthenticationErrorMessage(error); + setCodexLoginState(flowId, { + status: "error", + error: message, + doneAt: Date.now(), + }); + } finally { + // TTL: keep completed flow state available for clients that miss a short polling window. + setTimeout(() => { if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId); }, CODEX_LOGIN_TERMINAL_TTL_MS); + } + })(); + + setCodexLoginState(flowId, { status: "pending" }); + return jsonResponse({ + ok: true, + flowId, + url: result.url, + instructions: result.instructions, + // Dropped before #3366: every device-code surface renders this field, + // so withholding it left the GUI and CLI with no code to show. + ...(result.deviceCode ? { deviceCode: result.deviceCode } : {}), + }); + } catch (e) { + if (codexAuthLoginState.get(flowId) === loginOwner) codexAuthLoginState.delete(flowId); + const msg = e instanceof Error ? e.message : String(e); + if (msg === "A login for chatgpt is already in progress") { + return jsonResponse({ error: msg, status: "pending" }, 409); + } + if (e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) { + const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); + response.headers.set("Retry-After", "1"); + return response; + } + const { publicOAuthAuthenticationErrorMessage } = await import("../../oauth"); + return jsonResponse({ error: publicOAuthAuthenticationErrorMessage(e) }, 500); + } +} + +export async function handleCodexAuthLoginCode(req: Request): Promise { + const body = (await req.json().catch(() => ({}))) as { flowId?: unknown; input?: unknown }; + const flowId = typeof body.flowId === "string" ? body.flowId.trim() : ""; + const input = typeof body.input === "string" ? body.input : ""; + if (!flowId) return jsonResponse({ error: "flowId required" }, 400); + if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400); + + // Import may yield; validate afterwards so cancel/replace cannot race a stale flow through. + const { submitManualLoginCode } = await import("../../oauth"); + const flow = codexAuthLoginState.get(flowId); + if (!flow) return jsonResponse({ error: "login flow expired or unknown" }, 400); + if (flow.status !== "pending") return jsonResponse({ error: "login flow is not pending" }, 400); + + const result = submitManualLoginCode("chatgpt", input); + if (!result.ok) return jsonResponse({ error: result.error }, 400); + return jsonResponse({ ok: true }, 202); +} + +export async function handleCodexAuthLoginCancel(req: Request): Promise { + const body = (await req.json().catch(() => ({}))) as { flowId?: string }; + const { cancelLoginFlow } = await import("../../oauth"); + const cancelled = cancelLoginFlow("chatgpt"); + expireCodexAuthFlow(body.flowId ?? null); + return jsonResponse({ ok: true, cancelled }); +} + +export async function handleCodexAuthLoginStatus(req: Request, url: URL, config: OcxConfig): Promise { + const flowId = url.searchParams.get("flowId"); + const accountId = url.searchParams.get("accountId")?.trim(); + // Transient flow state carries the address of the account being added, so it follows the + // same operator policy as the stored accounts it is about to become. + const maskFlowEmails = emailMaskingEnabled(config); + // Reauth always has a pre-existing credential; never treat "credential exists" as success + // when the flow map entry is gone (would false-complete on lost/expired flow state). + const reauthStatus = url.searchParams.get("reauth") === "1"; + if (flowId) { + const st = codexAuthLoginState.get(flowId); + if ( + !st + && accountId + && !reauthStatus + && !isAccountNeedsReauth(accountId) + && getCodexAccountCredential(accountId) + ) { + return jsonResponse({ status: "done", accountId, + ...(readCodexAccountRecord(accountId)?.codexValidationPending ? { validationPending: true } : {}), + }); + } + return jsonResponse(st ? { ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined } : { status: "expired" }); + } + // Legacy fallback: return latest pending flow + for (const [, st] of codexAuthLoginState) { + if (st.status === "pending") return jsonResponse({ ...st, email: projectEmail(st.email, maskFlowEmails) ?? undefined }); + } + return jsonResponse({ status: "idle" }); +} diff --git a/src/codex/auth-api/login-state.ts b/src/codex/auth-api/login-state.ts new file mode 100644 index 0000000000..533e1ac59e --- /dev/null +++ b/src/codex/auth-api/login-state.ts @@ -0,0 +1,64 @@ +import { ResourceAdmissionError } from "../../lib/admission"; + +export const MAX_CODEX_LOGIN_STATE_ROWS = 32; +export const CODEX_LOGIN_TERMINAL_TTL_MS = 300_000; +export interface CodexLoginStateRow { + status: string; + startedAt: number; + accountId?: string; + email?: string; + error?: string; + code?: string; + needsReauth?: boolean; + catalogRefreshPending?: boolean; + validationPending?: boolean; + doneAt?: number; +} +export const codexAuthLoginState = new Map(); +export class CodexLoginStateBusyError extends ResourceAdmissionError { + constructor() { super("codex_login_state_rows", MAX_CODEX_LOGIN_STATE_ROWS); this.name = "CodexLoginStateBusyError"; } +} + +export function setCodexLoginState(flowId: string, patch: Partial): void { + const row = codexAuthLoginState.get(flowId); + if (row) Object.assign(row, patch); +} + +export function pruneCodexLoginState(now = Date.now()): void { + for (const [id, row] of codexAuthLoginState) { + if (row.doneAt !== undefined && now - row.doneAt >= CODEX_LOGIN_TERMINAL_TTL_MS) codexAuthLoginState.delete(id); + } + while (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) { + const terminal = [...codexAuthLoginState].filter(([, row]) => row.doneAt !== undefined) + .sort((a, b) => (a[1].doneAt ?? 0) - (b[1].doneAt ?? 0))[0]; + if (!terminal) break; + codexAuthLoginState.delete(terminal[0]); + } +} + +export function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void { + const ids = flowId + ? [flowId] + : [...codexAuthLoginState].filter(([, state]) => state.status === "pending").map(([id]) => id); + for (const id of ids) { + let owner = codexAuthLoginState.get(id); + if (!owner) { + pruneCodexLoginState(); + if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) continue; + owner = { status: "error", startedAt: Date.now() }; + codexAuthLoginState.set(id, owner); + } + Object.assign(owner, { status: "error", error, doneAt: Date.now() }); + setTimeout(() => { if (codexAuthLoginState.get(id) === owner) codexAuthLoginState.delete(id); }, 30_000); + } +} +/** Package-internal admission-test seam: seed synthetic login-flow rows and return a prefix-scoped cleanup. */ +export function seedLoginRowsForTests(prefix: string, count: number): () => void { + for (let index = 0; index < count; index++) { + codexAuthLoginState.set(`${prefix}-login-${index}`, { status: "starting", startedAt: Date.now() }); + } + return () => { + for (const key of [...codexAuthLoginState.keys()]) if (key.startsWith(prefix)) codexAuthLoginState.delete(key); + }; +} + diff --git a/src/codex/auth-api/main-account-probe.ts b/src/codex/auth-api/main-account-probe.ts new file mode 100644 index 0000000000..792d30d250 --- /dev/null +++ b/src/codex/auth-api/main-account-probe.ts @@ -0,0 +1,331 @@ +import { parseMainPolicyUsageQuota, parseUsageQuota, setAccountQuotaFromParsed } from "../quota"; +import type { StoredAccountQuota, WhamUsageResponse } from "../quota"; +import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; +import { getMainChatgptAccountId, readCodexTokensResult } from "../auth-collision"; +import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state"; +import { extractAccountId } from "../../oauth/chatgpt"; +import { getMainAccountPlan, isMainAccountTokenVerifiablyLive, MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../main-account"; +import { captureConfigGeneration } from "../../lib/state-store-sweeper"; +import { captureMainAccountIdentityGeneration, clearMainAccountInfoCache, getMainAccountInfoCache, getMainQuotaCredentialGeneration, isMainAccountIdentityGenerationLive, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential, setMainAccountCredentialPresence, setMainAccountInfoCache } from "../main-account-cache"; +import type { MainQuotaWriter, MainAccountInfo } from "../main-account-cache"; +import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome"; +import { observeMainReserveRevocation } from "../reserve-availability"; +import type { AdmissionLease } from "../../lib/admission"; +import { nonEmptyPlan } from "./runtime-config"; +import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; +import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing"; +import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; +import { MAIN_TERMINAL_AUTH_CODES, readMainAuthErrorCode, nextQuotaDispatchSequence, isQuotaDispatchCurrent, publishQuotaDispatch } from "./pool-quota-probe"; + +/** + * Last reset-credit count this process parsed for the main account, tagged with the + * physical ChatGPT account it was read from. + * + * It is deliberately memory-only. The quota store is keyed by the stable `__main__` + * ALIAS, and `~/.codex/auth.json` can be swapped for another account while the proxy is + * not running — `reconcileMainCodexAccountRuntimeState` only purges alias-keyed state + * when it observes the id CHANGE, and its first observation after a restart has nothing + * to compare against. A disk-hydrated `__main__` entry can therefore belong to the + * previous login, so filling the DTO from it would show one account's tickets on + * another's card. Pool accounts have no such hole because their store key IS the account + * id. Binding the value to `requestAccountId` keeps the fill honest: after a restart the + * badge simply waits for the first usage response that carries the summary. + */ +let mainResetCreditsProvenance: { accountId: string; credits: number } | null = null; + +export function rememberMainResetCredits(accountId: string | null, credits: number | undefined): void { + if (accountId === null || credits === undefined) return; + mainResetCreditsProvenance = { accountId, credits }; +} + +/** Forget the remembered count when the physical main identity is no longer the same. */ +export function mainResetCreditsForCurrentIdentity(): number | undefined { + if (!mainResetCreditsProvenance) return undefined; + const currentAccountId = getMainChatgptAccountId(); + if (currentAccountId === null) return undefined; + if (currentAccountId !== mainResetCreditsProvenance.accountId) { + mainResetCreditsProvenance = null; + return undefined; + } + return mainResetCreditsProvenance.credits; +} + +export const MAIN_CACHE_TTL = 5 * 60_000; + +/** + * A WHAM 401 is not itself proof the local credential died. Upstream edges can + * transiently reject a still-valid access token (region/anti-abuse/rotation + * races), and fail-closing on every bare 401 makes a healthy main account flip + * needs-reauth on the next GUI quota poll. Only treat the response as terminal + * when the body carries a known terminal code or the local access token is not + * verifiably live (`accessTokenLive`). Liveness must be strict: a JWT whose + * `exp` cannot be decoded is NOT live — an undecodable token that vouched for + * itself would make a real 401 permanently transient. + */ +export async function isTerminalMainAuthResponse(resp: Response, accessTokenLive: boolean): Promise { + if (resp.status === 401) { + if (!accessTokenLive) return true; + const code = await readMainAuthErrorCode(resp); + return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code); + } + if (resp.status !== 403) return false; + const code = await readMainAuthErrorCode(resp); + return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code); +} + +export interface MainResetQuotaProof { + writer: MainQuotaWriter; + credentialGeneration: number; +} + +export interface MainAccountInfoFetchResult { + info: MainAccountInfo; + resetRecoveryProof?: MainResetQuotaProof & { dispatchSequence: number }; + /** Ephemeral result of this attempt, omitted when no WHAM request was made. */ + quotaRefresh?: CodexQuotaRefreshOutcome; + /** Internal dispatch fence for diagnostics only; never copied into a public DTO or cache. */ + quotaRefreshGeneration?: number; + /** Whether this attempt safely inspected the physical native-main credential. */ + credentialChecked: boolean; + /** Meaningful only when credentialChecked is true. */ + hasCredential: boolean; + /** Main identity generation captured while the native-main claim was held. */ + identityGeneration?: number; + /** Present only when this call freshly parsed a WHAM usage response. */ + freshQuota?: Omit; + /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ + freshResetCredits?: number; +} + +export interface MainAccountInfoSnapshot { + info: MainAccountInfo; + mainIdentityGeneration: number; + quotaRefresh?: CodexQuotaRefreshOutcome; +} + +export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise { + const result = await fetchMainAccountInfoAttempt(forceRefresh, 1); + return { + info: result.info, + ...(result.quotaRefresh && result.quotaRefreshGeneration !== undefined + && isMainAccountIdentityGenerationLive(result.quotaRefreshGeneration) + ? { quotaRefresh: result.quotaRefresh } : {}), + mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(), + }; +} + +export async function fetchMainAccountInfo(forceRefresh = false): Promise { + return (await fetchMainAccountInfoSnapshot(forceRefresh)).info; +} + +export const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null }; + +export async function retryMainAccountInfoIfIdentityChanged( + requestAccountId: string | null, + retriesRemaining: number, + nativeMainLease: AdmissionLease, + explicitRefresh: boolean, +): Promise { + const currentAccountId = getMainChatgptAccountId(); + if (currentAccountId === null || currentAccountId === requestAccountId) return null; + reconcileMainCodexAccountRuntimeState(); + return retriesRemaining > 0 + ? fetchMainAccountInfoWhileOwned(true, retriesRemaining - 1, nativeMainLease, explicitRefresh) + : { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true }; +} + +export async function fetchMainAccountInfoAttempt( + forceRefresh: boolean, + retriesRemaining: number, + existingNativeMainLease?: AdmissionLease, + nativeMainSharedClaimHeld = false, + explicitRefresh: boolean = forceRefresh, +): Promise { + const nativeMainLease = existingNativeMainLease ?? tryAcquireNativeMainProfileClaim(); + if (!nativeMainLease) { + return { + info: EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: false, + hasCredential: false, + identityGeneration: captureMainAccountIdentityGeneration(), + }; + } + try { + const operation = async () => ({ + ...await fetchMainAccountInfoWhileOwned(forceRefresh, retriesRemaining, nativeMainLease, explicitRefresh), + identityGeneration: captureMainAccountIdentityGeneration(), + }); + if (nativeMainSharedClaimHeld) return await operation(); + try { + return await withNativeMainCredentialClaim(operation); + } catch (error) { + if (isNativeMainClaimUnavailable(error)) { + return { + info: EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: false, + hasCredential: false, + identityGeneration: captureMainAccountIdentityGeneration(), + }; + } + throw error; + } + } finally { + if (!existingNativeMainLease) nativeMainLease.release(); + } +} + +export async function fetchMainAccountInfoWhileOwned( + forceRefresh: boolean, + retriesRemaining: number, + nativeMainLease: AdmissionLease, + /** + * Whether the *caller* asked for this refresh. `forceRefresh` also means "bypass the + * cache", and `retryMainAccountInfoIfIdentityChanged` re-enters with it set purely to + * re-read after the identity changed. Keeping the two apart stops that retry from + * promoting a background poll into operator intent below. + */ + explicitRefresh: boolean = forceRefresh, +): Promise { + const writerGeneration = captureConfigGeneration(); + reconcileMainCodexAccountRuntimeState(); + const tokenRead = readCodexTokensResult(); + setMainAccountCredentialPresence(tokenRead.status === "ok"); + if (tokenRead.status !== "ok") { + // A local read failure is NOT proof of sign-out: a missing file can be a non-atomic rewrite + // gap, and malformed JSON can be a half-written file. Clearing the cache and marking the + // account for reauth here destroyed healthy email/plan/quota state and pinned a working + // account as unusable. Preserve what we already know and let the caller retry; request + // routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the + // account DTO still reports hasCredential=false while the file is unreadable. + const preserved = getMainAccountInfoCache(); + return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: false }; + } + const tokens = tokenRead.tokens; + const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null); + const cached = getMainAccountInfoCache(); + if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) { + return { info: cached, credentialChecked: true, hasCredential: true }; + } + // Bind quota to the owned credential and the account actually selected by WHAM's header. + // A conflicting legacy token/account tuple is not evidence for the new policy. + const mainQuotaWriter = requestAccountId === tokens.account_id + ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) + : undefined; + const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration(); + // Keep diagnostics separate from authentication and freshness policy. Never serialize errors. + const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS); + let quotaPhase: "request" | "body" | "decode" | "publish" = "request"; + let quotaRefreshGeneration = captureMainAccountIdentityGeneration(); + try { + const dispatchSequence = nextQuotaDispatchSequence(); + const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { + headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id }, + signal: quotaSignal, + }); + quotaPhase = "publish"; + if (!resp.ok) { + const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive()); + const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); + if (retried) return retried; + if (!isQuotaDispatchCurrent(dispatchSequence)) { + return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: true, hasCredential: true }; + } + if (terminalAuthFailure) { + // Account for this attempt's own synchronous invalidation, never prior external drift. + const diagnosticStillLive = isMainAccountIdentityGenerationLive(quotaRefreshGeneration); + clearMainAccountInfoCache(); + if (diagnosticStillLive) quotaRefreshGeneration = captureMainAccountIdentityGeneration(); + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration); + } + return { + info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, + quotaRefresh: { status: "http_error", httpStatus: resp.status }, + quotaRefreshGeneration, + }; + } + quotaPhase = "body"; + const data = (await resp.json()) as WhamUsageResponse; + quotaPhase = "publish"; + const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); + if (retried) return retried; + quotaPhase = "decode"; + if (data === null || typeof data !== "object" || Array.isArray(data)) { + throw new Error("Invalid WHAM usage object"); + } + // Check after body/retry awaits and before any cache, credits, policy or + // Reserve publication. Returning cached state supplies no fresh recovery proof. + if (!isQuotaDispatchCurrent(dispatchSequence)) { + return { info: getMainAccountInfoCache() ?? EMPTY_MAIN_ACCOUNT_INFO, + credentialChecked: true, hasCredential: true }; + } + quotaPhase = "publish"; + // A delayed response from a replaced bearer cannot revoke a newer Reserve grant, + // even in the same workspace or after an A→B→A credential transition. + if (mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() + && matchesMainQuotaCredential(tokens.access_token, tokens.account_id)) { + observeMainReserveRevocation(data, mainQuotaWriter); + } + quotaPhase = "decode"; + const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan()); + const usage = { ...data, ...(plan ? { plan_type: plan } : {}) }; + const quota = parseUsageQuota(usage); + const policyQuota = parseMainPolicyUsageQuota(usage); + quotaPhase = "publish"; + const freshResetCredits = quota?.resetCredits; + // Tag the count with the identity it was read from, so a later response that omits the + // summary can restore the badge without ever crossing an account boundary. + rememberMainResetCredits(requestAccountId, freshResetCredits); + const result = { + email: data.email ?? null, + plan, + quota, + ts: Date.now(), + }; + setMainAccountInfoCache(result); + // Only an explicit refresh may retract a reauth quarantine. A 200 from + // /wham/usage proves the token authenticates to the usage endpoint; it does not + // prove the account can serve Responses traffic, which is a different backend path + // and still answers 403 for a workspace the token may no longer select (#327). + // Letting the background poll clear the flag put such an account straight back into + // rotation: the next request failed the same way and re-marked it, so needsReauth + // never settled and the dashboard kept showing nothing — the symptom #327 reported. + // An explicit refresh is an operator asking to re-evaluate, normally right after + // signing in again, so it stays authoritative. + if (explicitRefresh) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); + // Mirror main quota + plan into the shared stores so the rotation engine can + // score and auto-switch the main account exactly like a pool account (Option A). + setMainAccountPlan(result.plan); + if (result.quota) { + setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota, writerGeneration, mainQuotaWriter, policyQuota); + } + publishQuotaDispatch(dispatchSequence); + return { + info: result, + quotaRefresh: { status: quota ? "ok" : "not_reported" }, + quotaRefreshGeneration, + credentialChecked: true, + hasCredential: true, + ...(quota ? { freshQuota: quota } : {}), + ...(quota && mainQuotaWriter && isMainQuotaWriterLive(mainQuotaWriter) + && mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration() + && matchesMainQuotaCredential(tokens.access_token, tokens.account_id) + ? { resetRecoveryProof: { writer: mainQuotaWriter, credentialGeneration: mainQuotaCredentialGeneration, dispatchSequence } } + : {}), + ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), + }; + } catch (error) { + const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh); + if (retried) return retried; + let status: CodexQuotaRefreshOutcome["status"] = "internal_error"; + if ((quotaPhase === "request" || quotaPhase === "body") && quotaSignal.aborted) status = "timeout"; + else if (quotaPhase === "request") status = "network_error"; + else if (quotaPhase === "body") status = error instanceof SyntaxError ? "invalid_response" : "network_error"; + else if (quotaPhase === "decode") status = "invalid_response"; + return { + info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true, + quotaRefresh: { status }, + quotaRefreshGeneration, + }; + } +} diff --git a/src/codex/auth-api/pool-mode-gate.ts b/src/codex/auth-api/pool-mode-gate.ts new file mode 100644 index 0000000000..804750be0d --- /dev/null +++ b/src/codex/auth-api/pool-mode-gate.ts @@ -0,0 +1,274 @@ +import { getCodexAccountCredential, getValidCodexToken, readCodexAccountRecord } from "../account-store"; +import { getAccountQuota, isCompleteCodexQuotaRecoverySnapshot } from "../quota"; +import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; +import { claimDueCodexQuotaRecoveryProbes, settleCodexQuotaRecoveryProbe } from "../routing"; +import { readCodexTokens } from "../auth-collision"; +import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state"; +import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper"; +import { captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache"; +import { getMainAccountHardLockStatus } from "../main-account-hard-lock"; +import type { OcxConfig } from "../../types"; +import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { providerCodexAccountMode } from "../../providers/registry"; +import { isSelectableCodexPoolAccount } from "../account-id"; +import type { AdmissionLease } from "../../lib/admission"; +import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; +import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; +import type { PoolQuotaResult } from "./pool-quota-probe"; +import { fetchMainAccountInfoAttempt, fetchMainAccountInfo } from "./main-account-probe"; +import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_CACHE_TTL, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe"; +import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config"; + +let primeInFlight: Promise | null = null; +/** + * Last prime attempt per pool account. A failed WHAM lookup stores no quota, so + * without this the account stays "unknown" and every later prime trigger re-selects + * it as stale and repeats the same failing request. Successful lookups are already + * throttled by their stored updatedAt; this gives failures the same TTL backoff. + * + * Keyed by credential generation so a re-authentication, refresh, or account removal + * retries immediately instead of waiting out a backoff earned by the old credential. + */ +const poolQuotaPrimeAttemptedAt = new Map(); +let cooldownRecoveryInFlight: Promise | null = null; + +export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Date.now()): Promise { + const openai = config.providers[OPENAI_CODEX_PROVIDER_ID]; + if (!openai + || openai.disabled === true + || !isCanonicalOpenAiForwardProvider(openai) + || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool") return; + if (cooldownRecoveryInFlight) return cooldownRecoveryInFlight; + cooldownRecoveryInFlight = (async () => { + const claims = claimDueCodexQuotaRecoveryProbes(config, POOL_QUOTA_REFRESH_CONCURRENCY, now); + await mapWithConcurrency(claims, POOL_QUOTA_REFRESH_CONCURRENCY, async claim => { + const account = configuredPoolAccount(config, claim.accountId); + if (!account) { + settleCodexQuotaRecoveryProbe(claim, false, {}, now); + return; + } + try { + const result = await fetchPoolAccountQuota(claim.accountId, true, account.plan); + // Defence in depth: independent scopes are already excluded at the claim site. + // Generic WHAM must never clear Reserve even if claim selection changes. + const recovered = (claim.scope === undefined || claim.scope === "shared") + && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan); + settleCodexQuotaRecoveryProbe(claim, recovered, { + credentialGeneration: result.freshCredentialGeneration, + }, now); + } catch { + settleCodexQuotaRecoveryProbe(claim, false, {}, now); + } + }); + })().catch(() => { + // Background recovery is best-effort; routing keeps the cooldown on failure. + }).finally(() => { cooldownRecoveryInFlight = null; }); + return cooldownRecoveryInFlight; +} + +let mainHardLockRecoveryInFlight: Promise | null = null; + +/** Metadata-only recovery on the existing sweep; failures retain the observed policy block. */ +export async function runMainAccountHardLockRecovery(config: OcxConfig): Promise { + if (mainHardLockRecoveryInFlight) return mainHardLockRecoveryInFlight; + if (getMainAccountHardLockStatus(config).state !== "blocked" + || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; + const lease = tryAcquireNativeMainProfileClaim(); + if (!lease) return; + mainHardLockRecoveryInFlight = (async () => { + reconcileMainCodexAccountRuntimeState(); + if (getMainAccountHardLockStatus(config).state !== "blocked" + || isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; + const identityGeneration = captureMainAccountIdentityGeneration(); + const writerGeneration = captureConfigGeneration(); + try { + // Refresh can require an exclusive credential claim: never hold WHAM's shared + // claim while obtaining a valid token. The runtime lease spans both operations. + if (!await getValidMainAccountToken({ preserveReauth: true })) return; + } catch (error) { + if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth" + && isMainAccountIdentityGenerationLive(identityGeneration)) { + markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration); + } + return; + } + if (isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID)) return; + await fetchMainAccountInfoAttempt(true, 1, lease, false, false); + })().catch(() => { + // Best-effort background metadata read; no cooldown/pause or policy clearing on failure. + }).finally(() => { + lease.release(); + mainHardLockRecoveryInFlight = null; + }); + return mainHardLockRecoveryInFlight; +} + +export function registerCodexCooldownRecoveryProbeWorker(config: OcxConfig): void { + registerStateSweepAfterTick({ + name: "codex-cooldown-recovery", + afterTick: () => { + void runCodexCooldownRecoveryProbes(config); + void runMainAccountHardLockRecovery(config); + }, + }); +} + +export interface PrimeCodexPoolQuotasOptions { + /** Test seams for proving fenced/recovery priming performs no native-main work. */ + reconcileMainAccount?: typeof reconcileMainCodexAccountRuntimeState; + readMainTokens?: typeof readCodexTokens; + fetchMainInfo?: typeof fetchMainAccountInfo; +} + +let getValidPoolTokenForPrime = getValidCodexToken; + +/** Test-only: inject a deterministic pre-dispatch credential outcome for quota priming. */ +export function setCodexPoolQuotaTokenResolverForTests( + resolver: typeof getValidCodexToken, +): () => void { + const previous = getValidPoolTokenForPrime; + getValidPoolTokenForPrime = resolver; + return () => { + if (getValidPoolTokenForPrime === resolver) getValidPoolTokenForPrime = previous; + }; +} + +export function tryAcquireNativeMainPrimeLease(): AdmissionLease | null { + return tryAcquireNativeMainProfileClaim(); +} + +/** + * Best-effort prime of pool-account (and main) quota so the rotation engine has + * real usage scores instead of leaving every account at the unknown sentinel. + * + * Quota is otherwise populated only from live upstream headers (an idle pool + * account never serves traffic, so it never gets scored) or from the dashboard + * WHAM fetch (a CLI-only user never opens it). Without priming, every account + * stays unknown and auto-switch cannot move (see Phase 10). This runs at startup + * and lazily before routing when the active account is unknown. + * + * Single-flight: concurrent callers share one pass instead of stampeding N WHAM + * fetches. Per-fetch 8s timeouts and the 5-minute POOL_CACHE_TTL already bound + * cost, so the worst case is one WHAM call per account per TTL window. Failures + * are swallowed: a blocked WSL network must never crash startup or a request. + */ +export async function primeCodexPoolQuotas( + config: OcxConfig, + reason: string, + options: PrimeCodexPoolQuotasOptions = {}, +): Promise { + const openai = config.providers[OPENAI_CODEX_PROVIDER_ID]; + // Prune attempt markers for accounts that no longer exist BEFORE the eligibility + // return. A removal that happens while the provider is disabled or out of pool mode + // would otherwise leave a stale failure marker behind; restoring the same account id + // within POOL_CACHE_TTL would then read that old failure as current and skip the + // retry the restored credential is entitled to. + const runtimeConfig = getRuntimeConfig(config); + const configuredPoolIds = new Set((runtimeConfig.codexAccounts ?? []).map(account => account.id)); + for (const accountId of poolQuotaPrimeAttemptedAt.keys()) { + if (!configuredPoolIds.has(accountId)) poolQuotaPrimeAttemptedAt.delete(accountId); + } + if ( + !openai + || openai.disabled === true + || !isCanonicalOpenAiForwardProvider(openai) + || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool" + ) return; + if (primeInFlight) return primeInFlight; + primeInFlight = (async () => { + const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount); + const stale = pool.filter(a => { + const q = getAccountQuota(a.id); + if (q) return Date.now() - q.updatedAt >= POOL_CACHE_TTL; + // No stored quota: either never primed, or the last attempt failed. Retry only + // once per TTL window so an unreachable or rejecting account cannot turn every + // prime trigger into another upstream request. + const lastAttempt = poolQuotaPrimeAttemptedAt.get(a.id); + if (!lastAttempt) return true; + // A newer credential invalidates the previous failure: retry without waiting. + if (lastAttempt.generation !== readCodexAccountRecord(a.id)?.generation) return true; + return Date.now() - lastAttempt.at >= POOL_CACHE_TTL; + }); + const primeMain = async () => { + const mainLease = tryAcquireNativeMainPrimeLease(); + if (!mainLease) return; + try { + try { + await withNativeMainCredentialClaim(async () => { + // Keep one local owner and one cross-process reader from physical + // identity reconciliation through WHAM and all quota publication. + (options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)(); + if (getAccountQuota(MAIN_CODEX_ACCOUNT_ID)) return; + if (!(options.readMainTokens ?? readCodexTokens)()) return; + if (options.fetchMainInfo) await options.fetchMainInfo(false); + else await fetchMainAccountInfoAttempt(false, 1, mainLease, true); + }); + } catch (error) { + if (!isNativeMainClaimUnavailable(error)) throw error; + } + } finally { + mainLease.release(); + } + }; + try { + await Promise.allSettled([ + primeMain(), + mapWithConcurrency(stale, POOL_QUOTA_REFRESH_CONCURRENCY, async a => { + if (!getCodexAccountCredential(a.id)) return; + let result: PoolQuotaResult; + try { + result = await fetchPoolAccountQuota(a.id, false, a.plan, getValidPoolTokenForPrime); + } catch (error) { + // Local quota-flight saturation proves no WHAM request existed for this account. + // Consume it per item so sibling workers remain inside the shared prime lifetime. + if (error instanceof PoolQuotaProbeBusyError) return; + throw error; + } + // Only the data-plane function knows whether upstream dispatch began. Any + // cache hit, credential deferral, or local admission failure remains eligible. + const attempted = result.quotaProbeAttempted; + if (!attempted) return; + if (!configuredPoolAccount(getRuntimeConfig(config), a.id)) { + poolQuotaPrimeAttemptedAt.delete(a.id); + return; + } + poolQuotaPrimeAttemptedAt.set(a.id, { + // getValidCodexToken may rotate the credential before WHAM is sent. + // Bind the backoff to the generation that actually made the request; + // otherwise the next prime sees a false generation change and retries + // the same failed WHAM call immediately. + generation: attempted.credentialGeneration, + at: attempted.at, + }); + }), + ]); + } catch { + // Priming is best-effort; never propagate. + } + if (process.env.OPENCODEX_DEBUG_QUOTA === "1") { + console.warn(`[codex-quota] prime done (reason=${reason}, pool=${pool.length}, refreshed=${stale.length})`); + } + })().finally(() => { primeInFlight = null; }); + return primeInFlight; +} + +/** Test-only: drop any in-flight prime pass so a leaked single-flight promise + * from another suite cannot coalesce into the next prime. */ +export function clearCodexQuotaPrimeState(): void { + primeInFlight = null; + poolQuotaPrimeAttemptedAt.clear(); + getValidPoolTokenForPrime = getValidCodexToken; +} + +/** Test-only: drop the shared single-flight promise while keeping the per-account + * failure backoff, so a test can trigger a second real prime pass and still observe + * the throttle a production caller would see. */ +export function clearCodexQuotaPrimeSingleFlightForTests(): void { + primeInFlight = null; +} + +/** Test-only reset for the worker-level single-flight. */ +export function clearCodexCooldownRecoveryProbeState(): void { + cooldownRecoveryInFlight = null; +} diff --git a/src/codex/auth-api/pool-quota-probe.ts b/src/codex/auth-api/pool-quota-probe.ts new file mode 100644 index 0000000000..afa30916d4 --- /dev/null +++ b/src/codex/auth-api/pool-quota-probe.ts @@ -0,0 +1,512 @@ +import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, forceRefreshCodexPoolToken, markCodexAccountValidated, markCodexAccountValidationFailed, readCodexAccountRecord, CodexCredentialGenerationConflictError, CodexCredentialRefreshLockTimeoutError, CodexCredentialRefreshBusyError, CodexCredentialRefreshStaleError, TokenRefreshError } from "../account-store"; +import type { PoolQuotaWriter } from "../quota-types"; +import { isValidWhamHistoryObservation, getAccountQuota, isCompleteCodexQuotaRecoverySnapshot, parseUsageQuota, setAccountQuotaFromParsed } from "../quota"; +import type { StoredAccountQuota, WhamUsageResponse } from "../quota"; +import type { ManualResetRefreshLineage } from "../routing"; +import { clearAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state"; +import { captureConfigGeneration } from "../../lib/state-store-sweeper"; +import { codexWarmupFailureReason, warmCodexAccount } from "../warmup"; +import { readBoundedResponseBody } from "../../lib/bounded-body"; +import { ResourceAdmissionError } from "../../lib/admission"; +import { WHAM_REQUEST_TIMEOUT_MS } from "../quota-recovery-timing"; +import { claimQuotaRecovery, quotaRecoveryTerminalFor, releaseQuotaRecovery, settleQuotaRecovery, settleQuotaRecoveryTerminal } from "../quota-401-recovery"; +import { seedLoginRowsForTests } from "./login-state"; +import { nonEmptyPlan } from "./runtime-config"; + +export const POOL_CACHE_TTL = 5 * 60_000; +export const POOL_QUOTA_REFRESH_CONCURRENCY = 4; + +export const MAIN_TERMINAL_AUTH_CODES = new Set([ + "invalid_workspace_selected", + "invalid_refresh_token", +]); + +export async function readMainAuthErrorCode(resp: Response): Promise { + try { + const body = await readBoundedResponseBody(resp, { totalTimeoutMs: 1_000, inactivityTimeoutMs: 1_000 }); + if (!body.displaySafe) return undefined; + const parsed = JSON.parse(body.text) as { + detail?: { code?: unknown } | string; + error?: { code?: unknown } | string; + code?: unknown; + }; + const code = typeof parsed.detail === "object" && parsed.detail !== null + ? parsed.detail.code + : typeof parsed.error === "object" && parsed.error !== null + ? parsed.error.code + : parsed.code; + return code; + } catch { + return undefined; + } +} + +export interface PoolQuotaResult { + /** Actual refresh result attached only to the successful usage replay. */ + resetRefreshLineage?: ManualResetRefreshLineage; + quota: StoredAccountQuota | null; + needsReauth: boolean; + /** Credential generation whose cache or network result this DTO state belongs to. */ + credentialGeneration?: number; + /** Present only when this call freshly parsed a WHAM usage response. */ + freshQuota?: Omit; + /** Present only when this call's WHAM response included a non-empty `plan_type`. */ + freshPlan?: string; + /** Credential generation used by this fresh quota request. */ + freshCredentialGeneration?: number; + /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ + freshResetCredits?: number; + quotaProbeSkipped?: true; + /** Positive evidence captured immediately before an upstream WHAM dispatch. */ + quotaProbeAttempted?: { at: number; credentialGeneration: number; dispatchSequence: number }; +} + +// Process-local ordering, never a timestamp or a serialized account identifier. +let quotaDispatchSequence = 0; +// Shared native-main ownership permits concurrent usage readers. Only a later +// successfully published response advances this fence; failed reads do not win. +let mainQuotaPublishedSequence = 0; + +export function nextQuotaDispatchSequence(): number { + return ++quotaDispatchSequence; +} + +export function currentQuotaDispatchSequence(): number { + return quotaDispatchSequence; +} + +export function isQuotaDispatchCurrent(sequence: number): boolean { + return sequence >= mainQuotaPublishedSequence; +} + +export function publishQuotaDispatch(sequence: number): void { + mainQuotaPublishedSequence = sequence; +} + +export interface PoolQuotaProbeEvidence { + onDispatch?: (sequence: number) => void; + mayPublish?: () => boolean; + attempted?: NonNullable; +} + +export function markQuotaProbeAttempted(evidence: PoolQuotaProbeEvidence, credentialGeneration: number): void { + const dispatchSequence = nextQuotaDispatchSequence(); + evidence.attempted = { at: Date.now(), credentialGeneration, dispatchSequence }; + evidence.onDispatch?.(dispatchSequence); +} + +export function withQuotaProbeEvidence( + result: PoolQuotaResult, + evidence: PoolQuotaProbeEvidence, +): PoolQuotaResult { + return evidence.attempted ? { ...result, quotaProbeAttempted: evidence.attempted } : result; +} + +export interface PoolQuotaRefreshFlight { + state: { + dispatchSequence?: number; + superseded?: boolean; + startCredentialGeneration?: number; + resolvedCredentialGeneration?: number; + validatePending?: boolean; + }; + promise: Promise; +} + +export const poolQuotaRefreshInFlight = new Map>(); +export const MAX_POOL_QUOTA_FLIGHTS = 16; + +export class PoolQuotaProbeBusyError extends ResourceAdmissionError { + constructor() { + super("pool_quota_flights", MAX_POOL_QUOTA_FLIGHTS); + this.name = "PoolQuotaProbeBusyError"; + } +} + +export function poolQuotaFlightCount(): number { + let count = 0; + for (const flights of poolQuotaRefreshInFlight.values()) count += flights.size; + return count; +} + +/** Focused admission tests only; returns cleanup for the synthetic owners it inserts. */ +export function seedCodexAuthAdmissionForTests(options: { loginFlows?: number; quotaFlights?: number }): () => void { + const prefix = `admission-test-${crypto.randomUUID()}`; + const cleanupLoginRows = seedLoginRowsForTests(prefix, options.loginFlows ?? 0); + for (let index = 0; index < (options.quotaFlights ?? 0); index++) { + poolQuotaRefreshInFlight.set(`${prefix}-quota-${index}`, new Set([{ + state: {}, + promise: new Promise(() => {}), + }])); + } + return () => { + cleanupLoginRows(); + for (const key of [...poolQuotaRefreshInFlight.keys()]) if (key.startsWith(prefix)) poolQuotaRefreshInFlight.delete(key); + }; +} + +/** + * One refresh-and-replay for a pool account whose WHAM request came back 401 (#3019). + * + * The account list used to convert any 401 straight into `needsReauth`, and a bare 401 is + * exactly what a stale-but-refreshable bearer produces after a plan change — so a healthy + * credential was thrown away and the operator was told to log in again. + * + * Bounded by the recovery store: one attempt per credential lineage. An unbounded retry + * against an upstream 401 is a self-inflicted credential-stuffing loop, which is why the + * claim is taken BEFORE the refresh and settled by the flight rather than by this caller. + */ +export async function recoverPoolQuotaFrom401(ctx: { + accountId: string; + existing: StoredAccountQuota | null; + configuredPlan: string | undefined; + rejectedAccessToken: string; + rejectedGeneration: number; + resp: Response; + quotaProbeEvidence: PoolQuotaProbeEvidence; + onCredentialGeneration?: (generation: number) => void; +}): Promise { + const { accountId, existing, configuredPlan, rejectedAccessToken, rejectedGeneration, resp } = ctx; + + // Structured terminal evidence short-circuits everything: the same allowlist and bounded + // parser the main account uses, because it is the same endpoint answering. + if (await isTerminalPoolAuthResponse(resp)) { + // Durable, not just this response: the account list re-polls, and without a recorded + // mark the next bare 401 finds nothing terminal and reports the account healthy. + // + // Scoped to the generation this evidence is ABOUT. An account-wide mark would outlive + // the credential it condemned, so a late terminal response arriving after the operator + // re-authenticated would quarantine the replacement. + markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration); + return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration }; + } + + const claim = claimQuotaRecovery(accountId, rejectedGeneration); + if (!claim.granted) { + // A lineage fenced by a TERMINAL refresh failure stays terminal. Without this, the + // budget being used would make the next bare 401 report a dead credential as healthy. + if (quotaRecoveryTerminalFor(accountId, rejectedGeneration)) { + return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration }; + } + // Otherwise: this lineage spent its attempt, another caller is mid-refresh, or a + // transient failure is backing off. Report transient and let the next poll try — + // quarantining here would undo the whole point of the budget. + return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration }; + } + + let refreshed: Awaited>; + try { + refreshed = await forceRefreshCodexPoolToken(accountId, { + rejectedGeneration, + rejectedAccessToken, + // Settlement rides the flight, not this await: a cancelled caller would otherwise + // leave the claim to expire while the shared refresh commits, and the already + // refreshed lineage would get a second attempt. + onSettled: outcome => { + if (outcome.kind === "resolved") { + settleQuotaRecovery(accountId, claim.claimId, outcome); + } else if (outcome.error instanceof TokenRefreshError && isTerminalRefreshError(outcome.error)) { + // A revoked or expired grant does not become valid on the next poll. Releasing it + // into backoff would let the following bare 401 find a non-terminal record and + // report a dead credential as healthy. + settleQuotaRecoveryTerminal(accountId, claim.claimId); + } else { + releaseQuotaRecovery(accountId, claim.claimId, QUOTA_RECOVERY_BACKOFF_MS); + } + }, + }); + } catch (e) { + // A refresh that failed terminally is the one case where the credential really is gone. + // Everything else is unknown, and unknown is not proof. + if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) { + markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration); + return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration }; + } + return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration }; + } + + // A byte-identical access token means replaying earns the same 401. Report transient + // rather than burning the replay; the fence already moved to the returned generation. + if (!refreshed.rotated) { + return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation }; + } + + // The flight may have moved the generation while this request was in the air. Tell the + // coalescing layer where the credential actually is, or a late caller joins on a stale + // generation and opens a redundant flight. + ctx.onCredentialGeneration?.(refreshed.generation); + + const writerGeneration = captureConfigGeneration(); + markQuotaProbeAttempted(ctx.quotaProbeEvidence, refreshed.generation); + const poolWriter = capturePoolQuotaWriter(accountId, refreshed); + const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", { + headers: { + Authorization: `Bearer ${refreshed.accessToken}`, + "ChatGPT-Account-Id": refreshed.chatgptAccountId, + }, + signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS), + }); + if (!replay.ok) { + if (replay.status === 401 && await isTerminalPoolAuthResponse(replay)) { + // The refresh already settled this claim non-terminally, so the record alone would + // let the next poll call a dead credential healthy. The evidence is about the + // REFRESHED credential, which is what the replay used. + markAccountNeedsReauth(accountId, writerGeneration, refreshed.generation); + return { quota: existing ?? null, needsReauth: true, credentialGeneration: refreshed.generation }; + } + return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation }; + } + const result = await commitPoolQuotaResponse(replay, { + accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration, poolWriter, + mayPublish: ctx.quotaProbeEvidence.mayPublish, + }); + return result.freshCredentialGeneration === refreshed.generation ? { + ...result, + resetRefreshLineage: { + fromGeneration: rejectedGeneration, + toGeneration: refreshed.generation, + provenance: refreshed.provenance, + }, + } : result; +} + +/** Backoff after a refresh failure that proved nothing about the credential. */ +export const QUOTA_RECOVERY_BACKOFF_MS = 60_000; + +/** Same allowlist and bounded parser as the main account: it is the same endpoint. */ +export async function isTerminalPoolAuthResponse(resp: Response): Promise { + // Consume the original rather than a clone. `resp.clone()` tees the body, and the + // bounded parser's timeout cancels only its own reader — the unread original branch + // keeps buffering. Nothing needs this response afterwards, so there is nothing to tee. + const code = await readMainAuthErrorCode(resp); + return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code); +} + +/** A revoked or expired grant is terminal; an unknown or transport failure is not. */ +export function isTerminalRefreshError(error: TokenRefreshError): boolean { + // Read the discriminator, not the message. TokenRefreshError carries `reason`, and + // matching on human text would let a durable quarantine decision change the next time + // somebody rewords an error string. + return error.reason === "revoked" || error.reason === "expired"; +} + +/** Parse and store a successful WHAM response. Shared by the first attempt and the replay. */ +export async function commitPoolQuotaResponse( + resp: Response, + ctx: { + accountId: string; + existing: StoredAccountQuota | null; + configuredPlan: string | undefined; + generation: number; + writerGeneration: number; + poolWriter?: PoolQuotaWriter; + mayPublish?: () => boolean; + }, +): Promise { + const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx; + const data = (await resp.json()) as WhamUsageResponse; + const observedAt = Date.now(); + if (ctx.mayPublish?.() === false) { + return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: generation }; + } + const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined; + const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan }); + const freshResetCredits = quota?.resetCredits; + if (!quota) { + return { + quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId), + needsReauth: false, + credentialGeneration: generation, + ...(freshPlan !== undefined ? { freshPlan, freshCredentialGeneration: generation } : {}), + }; + } + if (!isCodexAccountGenerationLive(accountId, generation)) { + return { quota: null, needsReauth: false, credentialGeneration: generation }; + } + setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, quota, + ctx.poolWriter && isValidWhamHistoryObservation(data) ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined); + return { + quota: getAccountQuota(accountId), + needsReauth: false, + credentialGeneration: generation, + freshQuota: quota, + freshCredentialGeneration: generation, + ...(freshPlan !== undefined ? { freshPlan } : {}), + ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), + }; +} + +export async function fetchFreshPoolAccountQuota( + accountId: string, + existing: StoredAccountQuota | null, + configuredPlan?: string, + onCredentialGeneration?: (generation: number) => void, + getValidToken: typeof getValidCodexToken = getValidCodexToken, + quotaProbeEvidence: PoolQuotaProbeEvidence = {}, +): Promise { + const writerGeneration = captureConfigGeneration(); + let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation; + try { + const { accessToken, chatgptAccountId, generation } = await getValidToken(accountId); + const poolWriter = capturePoolQuotaWriter(accountId, { accessToken, chatgptAccountId, generation }); + requestCredentialGeneration = generation; + onCredentialGeneration?.(generation); + markQuotaProbeAttempted(quotaProbeEvidence, generation); + const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { + headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId }, + signal: AbortSignal.timeout(8000), + }); + if (!resp.ok) { + if (resp.status !== 401) { + return withQuotaProbeEvidence( + { quota: existing ?? null, needsReauth: false, credentialGeneration: generation }, + quotaProbeEvidence, + ); + } + // A bare 401 is what a stale-but-refreshable bearer produces after a plan change, so + // quarantining on it tells the operator to re-authenticate an account that was fine + // (#3019). Refresh once, replay once, and only then decide. + const recovered = await recoverPoolQuotaFrom401({ + accountId, + existing, + configuredPlan, + rejectedAccessToken: accessToken, + rejectedGeneration: generation, + resp, + quotaProbeEvidence, + onCredentialGeneration, + }); + return withQuotaProbeEvidence(recovered, quotaProbeEvidence); + } + const committed = await commitPoolQuotaResponse(resp, { + accountId, existing, configuredPlan, generation, writerGeneration, poolWriter, + mayPublish: quotaProbeEvidence.mayPublish, + }); + return withQuotaProbeEvidence(committed, quotaProbeEvidence); + } catch (e) { + if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError + || e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) { + return withQuotaProbeEvidence({ + quota: existing ?? null, + needsReauth: false, + credentialGeneration: requestCredentialGeneration, + quotaProbeSkipped: true, + }, quotaProbeEvidence); + } + if (e instanceof TokenRefreshError) { + return withQuotaProbeEvidence( + { quota: existing ?? null, needsReauth: true, credentialGeneration: requestCredentialGeneration }, + quotaProbeEvidence, + ); + } + return withQuotaProbeEvidence( + { quota: existing ?? null, needsReauth: false, credentialGeneration: requestCredentialGeneration }, + quotaProbeEvidence, + ); + } +} + +export async function fetchPoolAccountQuota( + accountId: string, + forceRefresh = false, + configuredPlan?: string, + getValidToken: typeof getValidCodexToken = getValidCodexToken, + validatePending = false, + afterDispatchSequence?: number, +): Promise { + const existing = getAccountQuota(accountId); + if (afterDispatchSequence === undefined && !forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) { + return { + quota: existing, + needsReauth: false, + credentialGeneration: readCodexAccountRecord(accountId)?.generation, + }; + } + // A token refresh may increment the generation (and rotate the refresh token) before WHAM + // completes. Join a flight whose starting or resolved generation is still current, but let a + // replacement credential with the same pool id start its own request. + const record = readCodexAccountRecord(accountId); + const flights = poolQuotaRefreshInFlight.get(accountId); + const current = flights && [...flights].find(flight => { + const generation = flight.state.resolvedCredentialGeneration + ?? flight.state.startCredentialGeneration; + return !flight.state.superseded + && (afterDispatchSequence === undefined || (flight.state.dispatchSequence ?? 0) > afterDispatchSequence) + && generation !== undefined && isCodexAccountGenerationLive(accountId, generation); + }); + if (current) { + // A manual refresh joining a passive read must not lose its validation intent. + current.state.validatePending ||= validatePending; + return current.promise; + } + if (poolQuotaFlightCount() >= MAX_POOL_QUOTA_FLIGHTS) throw new PoolQuotaProbeBusyError(); + + // A post-reset request must not let an older same-account response overwrite its evidence. + // Flags live only as long as the bounded flights; no retained per-account sequence map. + if (afterDispatchSequence !== undefined) { + for (const flight of flights ?? []) flight.state.superseded = true; + } + const state: PoolQuotaRefreshFlight["state"] = { + startCredentialGeneration: record?.generation, + validatePending, + }; + const refresh = fetchFreshPoolAccountQuota( + accountId, + existing, + configuredPlan, + generation => { state.resolvedCredentialGeneration = generation; }, + getValidToken, + { + onDispatch: sequence => { state.dispatchSequence = sequence; }, + mayPublish: () => state.superseded !== true, + }, + ).then(async result => { + // A passive flight has consumed its validation decision. Remove it before + // promise settlement queues other continuations, so a late explicit caller + // starts fresh work instead of setting an intent nobody will read again. + if (!state.validatePending) { + releaseFlight(); + return result; + } + // Only an explicit account-list refresh finishes deferred registration. Passive quota + // polls and startup priming remain read-only with respect to inference spending. + const generation = result.freshCredentialGeneration; + const record = state.validatePending ? readCodexAccountRecord(accountId) : null; + if (record?.codexValidationPending && record.credential && record.deletedAt == null + && generation !== undefined && record.generation === generation + && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? configuredPlan)) { + try { + await warmCodexAccount({ + accessToken: record.credential.accessToken, + chatgptAccountId: record.credential.chatgptAccountId, + }); + markCodexAccountValidated(accountId, Date.now(), generation); + clearAccountNeedsReauth(accountId, generation); + } catch (error) { + // Keep the durable restriction on any failed/partial inference response, even + // when WHAM just reported headroom. No raw upstream text enters diagnostics. + const reason = codexWarmupFailureReason(error); + if (reason === "http_status:401" || reason === "http_status:403") { + markCodexAccountValidationFailed(accountId, reason, { expectedGeneration: generation }); + markAccountNeedsReauth(accountId, captureConfigGeneration(), generation); + } + } + } + return result; + }); + const flight: PoolQuotaRefreshFlight = { state, promise: refresh }; + const activeFlights = flights ?? new Set(); + activeFlights.add(flight); + if (!flights) poolQuotaRefreshInFlight.set(accountId, activeFlights); + const releaseFlight = () => { + activeFlights.delete(flight); + if (activeFlights.size === 0 && poolQuotaRefreshInFlight.get(accountId) === activeFlights) { + poolQuotaRefreshInFlight.delete(accountId); + } + }; + try { + return await refresh; + } finally { + releaseFlight(); + } +} diff --git a/src/codex/auth-api/reset-credit-service.ts b/src/codex/auth-api/reset-credit-service.ts new file mode 100644 index 0000000000..232904c520 --- /dev/null +++ b/src/codex/auth-api/reset-credit-service.ts @@ -0,0 +1,422 @@ +import { getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord, CodexCredentialGenerationConflictError } from "../account-store"; +import { isCompleteCodexQuotaRecoverySnapshot } from "../quota"; +import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle"; +import { claimManualResetCooldowns, settleManualResetCooldown } from "../routing"; +import type { ManualResetCooldownClaim } from "../routing"; +import { readCodexTokens } from "../auth-collision"; +import { extractAccountId } from "../../oauth/chatgpt"; +import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { getMainQuotaCredentialGeneration, isMainQuotaWriterLive, matchesMainQuotaCredential, observeMainQuotaCredential } from "../main-account-cache"; +import type { OcxConfig } from "../../types"; +import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBody } from "../../lib/bounded-body"; +import { cancelBodyOnAbort, signalWithTimeout } from "../../lib/abort"; +import { hasLegacyMainCodexPoolAccount, isValidCodexAccountId } from "../account-id"; +import { markManualResetCreditOperationAmbiguous, openManualResetCreditOperation, settleManualResetCreditOperation } from "../reset-credit-operation-ledger"; +import type { AdmissionLease } from "../../lib/admission"; +import { tryAcquireNativeMainProfileClaim } from "../native-main-admission"; +import { jsonResponse, nativeMainProfileBusyResponse, withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http"; +import { fetchMainAccountInfoAttempt } from "./main-account-probe"; +import type { MainResetQuotaProof } from "./main-account-probe"; +import { currentQuotaDispatchSequence, fetchPoolAccountQuota } from "./pool-quota-probe"; +import { getRuntimeConfig, configuredPoolAccount } from "./runtime-config"; + +interface ResetCreditAuth { + isMain: boolean; + accessToken: string; + chatgptAccountId: string; + nativeMainLease?: AdmissionLease; + nativeMainSharedClaimHeld?: true; + poolGeneration?: number; + mainProof?: MainResetQuotaProof; +} + +async function withResetCreditAuth( + runtimeConfig: OcxConfig, + accountId: string, + operation: (auth: ResetCreditAuth) => Promise, +): Promise<{ ok: true; value: T } | { ok: false; response: Response }> { + if (accountId === MAIN_CODEX_ACCOUNT_ID) { + if (hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { + return { ok: false, response: jsonResponse({ error: "Remove the legacy __main__ pool row before using the Desktop account" }, 409) }; + } + const nativeMainLease = tryAcquireNativeMainProfileClaim(); + if (!nativeMainLease) return { ok: false, response: nativeMainProfileBusyResponse() }; + try { + try { + return await withNativeMainCredentialClaim(async () => { + const tokens = readCodexTokens(); + if (!tokens) { + return { ok: false, response: jsonResponse({ error: "Main Codex account not logged in" }, 401) }; + } + reconcileMainCodexAccountRuntimeState(); + const physicalId = extractAccountId(tokens.id_token, tokens.access_token) ?? tokens.account_id; + const writer = physicalId === tokens.account_id + ? observeMainQuotaCredential(tokens.access_token, tokens.account_id) : undefined; + return { + ok: true, + value: await operation({ + isMain: true, + ...(writer ? { mainProof: { writer, credentialGeneration: getMainQuotaCredentialGeneration() } } : {}), + accessToken: tokens.access_token, + chatgptAccountId: tokens.account_id, + nativeMainLease, + nativeMainSharedClaimHeld: true, + }), + }; + }); + } catch (error) { + if (isNativeMainClaimUnavailable(error)) { + return { ok: false, response: nativeMainProfileBusyResponse() }; + } + throw error; + } + } finally { + nativeMainLease.release(); + } + } + if (!isValidCodexAccountId(accountId)) { + return { ok: false, response: jsonResponse({ error: "Invalid account id format" }, 400) }; + } + if (!configuredPoolAccount(runtimeConfig, accountId)) { + return { ok: false, response: jsonResponse({ error: "Unknown Codex account" }, 404) }; + } + const cred = await getValidCodexToken(accountId); + return { + ok: true, + value: await operation({ + isMain: false, + poolGeneration: cred.generation, + accessToken: cred.accessToken, + chatgptAccountId: cred.chatgptAccountId, + }), + }; +} + +function safeResetCreditsDto(input: unknown): { credits: { granted_at: string; expires_at: string }[]; available_count?: number } { + const obj = typeof input === "object" && input !== null ? input as Record : {}; + const rawCredits = Array.isArray(obj.credits) ? obj.credits : []; + const credits = rawCredits.flatMap((raw): { granted_at: string; expires_at: string }[] => { + if (typeof raw !== "object" || raw === null) return []; + const credit = raw as Record; + return typeof credit.granted_at === "string" && typeof credit.expires_at === "string" + ? [{ granted_at: credit.granted_at, expires_at: credit.expires_at }] + : []; + }); + const rawAvailable = (obj.rate_limit_reset_credits as { available_count?: unknown } | null | undefined)?.available_count + ?? obj.available_count; + return { + credits, + ...(typeof rawAvailable === "number" && Number.isFinite(rawAvailable) ? { available_count: rawAvailable } : {}), + }; +} + +function safeResetCreditConsumeDto(input: unknown): { code: string } { + const obj = typeof input === "object" && input !== null ? input as Record : {}; + return { code: typeof obj.code === "string" ? obj.code : "unknown" }; +} + +/** + * Background reset-credit access for the auto-redeemer (#822). Goes through the same + * account/lease wrapper as the management routes, but takes a caller-owned + * `redeem_request_id` so a journaled id can be replayed idempotently after a crash. + * Throws on any auth or upstream failure; the caller treats a throw on consume as ambiguous. + */ +export function createResetCreditWhamClient(config: OcxConfig, accountId: string): { + inspect: () => Promise<{ credits: { granted_at: string; expires_at: string }[] }>; + consume: (redeemRequestId: string) => Promise<{ code: string }>; +} { + const run = async (operation: (auth: ResetCreditAuth) => Promise): Promise => { + const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, operation); + if (result.ok) return result.value; + throw new Error(`reset-credit auth unavailable (${result.response.status})`); + }; + return { + inspect: () => run(async auth => { + const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", { + headers: { Authorization: `Bearer ${auth.accessToken}`, "ChatGPT-Account-Id": auth.chatgptAccountId }, + signal: AbortSignal.timeout(8000), + }); + if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); } + const parsed = await readResetCreditJson(resp, AbortSignal.timeout(8000)); + if (!parsed.ok) throw new Error("invalid upstream reset-credit response"); + return { credits: safeResetCreditsDto(parsed.value).credits }; + }), + consume: redeemRequestId => run(async auth => { + const resp = await fetch("https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", { + method: "POST", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "ChatGPT-Account-Id": auth.chatgptAccountId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ redeem_request_id: redeemRequestId }), + signal: AbortSignal.timeout(10_000), + }); + if (!resp.ok) { await resp.body?.cancel().catch(() => {}); throw new Error(`upstream ${resp.status}`); } + return safeResetCreditConsumeDto(await resp.json()); + }), + }; +} + +type ResetCreditJsonRead = + | { ok: true; value: unknown } + | { ok: false }; + +function cancelResponseBodyWithoutWaiting(body: ReadableStream | null): void { + if (!body) return; + try { + void body.cancel().catch(() => undefined); + } catch { + // Some stream implementations throw synchronously from cancel(). + } +} + +async function readResetCreditJson( + response: Response, + signal: AbortSignal, +): Promise { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isSafeInteger(declaredLength) + && declaredLength >= 0 + && declaredLength > BOUNDED_BODY_MAX_BYTES) { + cancelResponseBodyWithoutWaiting(response.body); + return { ok: false }; + } + try { + const body = await readBoundedResponseBody(response, { + signal, + maxBytes: BOUNDED_BODY_MAX_BYTES, + fatalUtf8: true, + }); + if (!body.displaySafe || body.truncated || !body.text.trim()) return { ok: false }; + return { ok: true, value: JSON.parse(body.text) as unknown }; + } catch { + return { ok: false }; + } +} + +function manualResetAuthStillLive(accountId: string, auth: ResetCreditAuth): boolean { + if (!auth.isMain) { + const record = readCodexAccountRecord(accountId); + return auth.poolGeneration !== undefined + && isCodexAccountGenerationLive(accountId, auth.poolGeneration) + && record?.credential?.chatgptAccountId === auth.chatgptAccountId; + } + const tokens = readCodexTokens(); + return !!auth.mainProof && !!tokens + && tokens.access_token === auth.accessToken && tokens.account_id === auth.chatgptAccountId + && isMainQuotaWriterLive(auth.mainProof.writer) + && auth.mainProof.credentialGeneration === getMainQuotaCredentialGeneration() + && matchesMainQuotaCredential(auth.accessToken, auth.chatgptAccountId); +} + +/** A confirmed spend remains successful even when its optional usage observation fails. */ +async function refreshAfterManualReset( + config: OcxConfig, + accountId: string, + auth: ResetCreditAuth, + claims: ManualResetCooldownClaim[], + didReset: boolean, +): Promise { + const afterDispatchSequence = currentQuotaDispatchSequence(); + try { + if (!manualResetAuthStillLive(accountId, auth)) return undefined; + if (auth.isMain) { + const result = await fetchMainAccountInfoAttempt(true, 1, auth.nativeMainLease, + auth.nativeMainSharedClaimHeld === true, false); + const proof = result.resetRecoveryProof; + const recovered = didReset && manualResetAuthStillLive(accountId, auth) + && !!proof && !!auth.mainProof + && proof.dispatchSequence > afterDispatchSequence + && proof.credentialGeneration === auth.mainProof.credentialGeneration + && proof.writer.identityKey === auth.mainProof.writer.identityKey + && proof.writer.identityGeneration === auth.mainProof.writer.identityGeneration + && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.info.plan); + for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, recovered); + return manualResetAuthStillLive(accountId, auth) ? result.freshResetCredits : undefined; + } + const account = configuredPoolAccount(getRuntimeConfig(config), accountId); + if (!account) return undefined; + // Reuse the just-authenticated consume credential for the first usage request. + // getValidCodexToken can silently advance a generation without exposing refresh + // provenance. A 401 here instead uses the existing classified refresh/replay path. + const resetToken: typeof getValidCodexToken = async () => { + if (auth.poolGeneration === undefined || !manualResetAuthStillLive(accountId, auth)) { + throw new CodexCredentialGenerationConflictError(); + } + return { accessToken: auth.accessToken, chatgptAccountId: auth.chatgptAccountId, generation: auth.poolGeneration }; + }; + // `validatePending` is false here: a manual reset settles cooldown, and finishing deferred + // registration stays reserved for an explicit dashboard account-list refresh. + const result = await fetchPoolAccountQuota(accountId, true, account.plan, didReset ? resetToken : getValidCodexToken, + false, didReset ? afterDispatchSequence : undefined); + const record = readCodexAccountRecord(accountId); + const recovered = didReset && record?.credential?.chatgptAccountId === auth.chatgptAccountId + && (result.quotaProbeAttempted?.dispatchSequence ?? 0) > afterDispatchSequence + && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan); + for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, recovered, { + credentialGeneration: result.freshCredentialGeneration, + refreshLineage: result.resetRefreshLineage, + }); + return record?.credential?.chatgptAccountId === auth.chatgptAccountId ? result.freshResetCredits : undefined; + } catch { + // The upstream reset already happened. A failed refresh must not invite another spend. + return undefined; + } +} + +export async function inspectResetCredits(config: OcxConfig, accountId: string, signal: AbortSignal): Promise { + const result = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { + const linkedSignal = signalWithTimeout(8000, signal); + let detachBodyAbort = () => {}; + try { + let resp: Response; + try { + resp = await fetch( + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits", + { + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "ChatGPT-Account-Id": auth.chatgptAccountId, + }, + signal: linkedSignal.signal, + }, + ); + } catch (error) { + if (linkedSignal.signal.aborted) { + return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502); + } + throw error; + } + // Own the response body before the bounded reader attaches. If the client + // disconnects in that narrow window, Bun otherwise tears down the native + // body off the awaited path and can report an unhandled rejection. + detachBodyAbort = cancelBodyOnAbort(resp.body, linkedSignal.signal); + if (!resp.ok) { + await resp.body?.cancel().catch(() => {}); + return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); + } + const parsed = await readResetCreditJson(resp, linkedSignal.signal); + if (!parsed.ok) { + return jsonResponse({ error: "Invalid upstream reset-credit response" }, 502); + } + return jsonResponse(safeResetCreditsDto(parsed.value)); + } finally { + detachBodyAbort(); + linkedSignal.cleanup(); + } + }); + return result.ok ? result.value : result.response; +} + +export async function consumeResetCredits(config: OcxConfig, accountId: string, requestedOperationId: string | undefined): Promise { + const operation = await withResetCreditAuth(getRuntimeConfig(config), accountId, async auth => { + // The ledger keys manual operations by the *physical* ChatGPT account, which is + // only known after the auth wrapper resolves credentials. Open here, not earlier. + let identity = requestedOperationId === undefined + ? undefined + : { + accountId, + chatgptAccountId: auth.chatgptAccountId, + operationId: requestedOperationId, + } as const; + let idempotencyKey: string; + if (identity) { + const opened = openManualResetCreditOperation(identity); + if (opened.kind === "terminal") { + // Durably settled already: replay the recorded outcome instead of + // trusting upstream idempotency for an irreversible spend. No + // `remaining` — that field is only reported from a freshly parsed + // available_count, and a replay has none. + return jsonResponse({ code: opened.code, replayed: true }); + } + if (opened.kind === "identity-mismatch") { + return jsonResponse({ + error: "operation_id_owned_by_another_account", + code: "identity_mismatch", + }, 409); + } + if (opened.kind !== "execute") { + // capacity | unavailable -> fail closed. Falling back to a random id + // would silently reintroduce the double-spend this identity prevents. + const response = jsonResponse({ + error: opened.kind === "capacity" + ? "reset_credit_ledger_capacity" + : "reset_credit_ledger_unavailable", + code: opened.kind, + }, 503); + response.headers.set("Retry-After", "1"); + return response; + } + // Canonical id, which an alias join may map to an earlier caller id. + identity = { ...identity, operationId: opened.operationId }; + idempotencyKey = opened.operationId; + } else { + idempotencyKey = crypto.randomUUID(); + } + const claims = manualResetAuthStillLive(accountId, auth) + ? claimManualResetCooldowns(getRuntimeConfig(config), accountId, Date.now(), auth.poolGeneration) : []; + try { + let resp: Response; + try { + resp = await fetch( + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume", + { + method: "POST", + headers: { + Authorization: `Bearer ${auth.accessToken}`, + "ChatGPT-Account-Id": auth.chatgptAccountId, + "Content-Type": "application/json", + }, + body: JSON.stringify({ redeem_request_id: idempotencyKey }), + signal: AbortSignal.timeout(10_000), + }, + ); + } catch (error) { + // Dispatch outcome unknown: the credit may or may not have been spent. + // Mark ambiguous so a replay of this same id is never treated as new. + if (identity) markManualResetCreditOperationAmbiguous(identity); + throw error; + } + if (!resp.ok) { + await resp.body?.cancel().catch(() => {}); + if (identity) markManualResetCreditOperationAmbiguous(identity); + return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); + } + const result = safeResetCreditConsumeDto(await resp.json()); + if (identity) { + // Narrow explicitly rather than casting: `safeResetCreditConsumeDto` + // normalizes anything unrecognized to "unknown", and settling that + // would come back as a mismatch and leave the row pending anyway. + // Settlement failure never downgrades the user-visible outcome: the + // spend already happened upstream, and reporting failure would invite + // a manual retry -- the exact double-spend this unit removes. + if (result.code === "reset" || result.code === "already_redeemed" + || result.code === "nothing_to_reset" || result.code === "no_credit") { + settleManualResetCreditOperation(identity, result.code); + } else { + markManualResetCreditOperationAmbiguous(identity); + } + } + // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage + // and return remaining only when that refresh freshly parsed available_count. + // Do not fall back to a preserved cached resetCredits (failed/omitted refresh). + if (result.code === "reset" || result.code === "already_redeemed") { + const freshResetCredits = await refreshAfterManualReset( + config, accountId, auth, claims, result.code === "reset", + ); + return jsonResponse({ + code: result.code, + ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits) + ? { remaining: freshResetCredits } + : {}), + }); + } + return jsonResponse(result); + } finally { + // Release only this invocation's leases, including every ambiguous/error outcome. + for (const claim of claims) settleManualResetCooldown(getRuntimeConfig(config), claim, false); + } + }); + return operation.ok ? operation.value : operation.response; +} diff --git a/src/codex/auth-api/routes.ts b/src/codex/auth-api/routes.ts new file mode 100644 index 0000000000..b710052847 --- /dev/null +++ b/src/codex/auth-api/routes.ts @@ -0,0 +1,425 @@ +import { CODEX_ACCOUNT_LOG_LABEL_RE, codexAccountLogLabel } from "../account-label"; +import { poolQuotaHistoryIdentity, readCodexAccountRecord } from "../account-store"; +import { estimateCodexQuotaCapacity, insufficientCodexCapacity } from "../quota-capacity"; +import type { CodexCapacityResult } from "../quota-capacity"; +import { readUsageSnapshotForManagement } from "../../usage/log"; +import { getAccountQuotaHistory, listAccountQuotas } from "../quota"; +import { deleteCodexAccount } from "../account-lifecycle"; +import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause"; +import { clearCodexAccountPin, isCodexAccountPriorityKey, pinnedCodexAccountId, setCodexAccountPin, setCodexAccountPriority } from "../account-priority"; +import { codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing"; +import { DEFAULT_ACCOUNT_PRIORITY, MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, parseCodexAccountPoolStrategy, parseAccountPriority } from "../pool-rotation"; +import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; +import type { OcxConfig } from "../../types"; +import { CODEX_ACCOUNT_ID_RE, hasLegacyMainCodexPoolAccount, isSelectableCodexPoolAccount, isValidCodexAccountId } from "../account-id"; +import { isCodexResetCreditOperationId } from "../reset-credit-recovery"; +import { listCodexAuthAccounts, selectFallbackAfterPause, pauseExhaustedCodexAccounts } from "./account-list"; +import { jsonResponse, manualImportDisabledResponse } from "./http"; +import { convergeAccountNamespaceCatalog, handleCodexAuthLoginStart, handleCodexAuthLoginCode, handleCodexAuthLoginCancel, handleCodexAuthLoginStatus } from "./login-flow"; +import type { CodexAuthCatalogConvergence } from "./login-flow"; +import { PoolQuotaProbeBusyError } from "./pool-quota-probe"; +import { inspectResetCredits, consumeResetCredits } from "./reset-credit-service"; +import { getRuntimeConfig, saveRuntimeConfig, configuredPoolAccount } from "./runtime-config"; + +export async function handleCodexAuthAPI( + req: Request, + url: URL, + config: OcxConfig, + convergeCodexCatalog?: CodexAuthCatalogConvergence, + principal?: import("../../server/management-auth").ManagementPrincipal, +): Promise { + if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") { + const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true"; + return jsonResponse({ accounts: await listCodexAuthAccounts(config, forceRefresh) }); + } + + if (url.pathname === "/api/codex-auth/accounts/refresh" && req.method === "POST") { + // Inference spends quota: only a dashboard session carries the consent + // required by AGENTS_INSTALL.md. Raw-admin/CLI refreshes remain observational. + return jsonResponse({ accounts: await listCodexAuthAccounts(config, true, { + validatePending: principal === "gui-session", + }) }); + } + + if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") { + return manualImportDisabledResponse(); + } + + if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") { + const id = url.searchParams.get("id"); + if (!id) return jsonResponse({ error: "Missing id" }, 400); + const runtimeConfig = getRuntimeConfig(config); + const isLegacyPoolAccount = CODEX_ACCOUNT_ID_RE.test(id) + && (runtimeConfig.codexAccounts ?? []).some(account => !account.isMain && account.id === id); + if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } + const pickerVisibilityChanged = deleteCodexAccount(runtimeConfig, id); + saveRuntimeConfig(config, runtimeConfig); + reconcileLiveStateStores(); + const catalogRefresh = await convergeAccountNamespaceCatalog( + runtimeConfig, + pickerVisibilityChanged, + convergeCodexCatalog, + ); + return jsonResponse({ ok: true, ...catalogRefresh }); + } + + if (url.pathname === "/api/codex-auth/accounts/alias" && req.method === "PUT") { + const body = await req.json().catch(() => ({})) as { id?: unknown; alias?: unknown }; + const id = typeof body.id === "string" ? body.id.trim() : ""; + const alias = typeof body.alias === "string" ? body.alias.trim() : ""; + if (id === MAIN_CODEX_ACCOUNT_ID) return jsonResponse({ error: "Main Codex account alias is not configurable" }, 400); + if (!isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400); + if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) { + return jsonResponse({ error: "Alias must be a string of at most 80 printable characters" }, 400); + } + const runtimeConfig = getRuntimeConfig(config); + const account = (runtimeConfig.codexAccounts ?? []).find(candidate => candidate.id === id && !candidate.isMain); + if (!account) return jsonResponse({ error: "Account not found" }, 404); + if (alias) account.alias = alias; + else delete account.alias; + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ ok: true, id, alias: alias || null }); + } + + if (url.pathname === "/api/codex-auth/accounts/pause" && req.method === "PUT") { + const body = await req.json().catch(() => ({})) as { id?: unknown; paused?: unknown }; + const id = typeof body.id === "string" ? body.id.trim() : ""; + if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } + if (typeof body.paused !== "boolean") return jsonResponse({ error: "paused must be a boolean" }, 400); + + const runtimeConfig = getRuntimeConfig(config); + const exists = id === MAIN_CODEX_ACCOUNT_ID + || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id); + if (!exists) return jsonResponse({ error: "Account not found" }, 404); + + setCodexAccountPaused(runtimeConfig, id, body.paused); + if (body.paused) { + clearThreadAccountMapForAccount(id); + selectFallbackAfterPause(runtimeConfig, id); + } + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + id, + paused: body.paused, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + appliesImmediately: true, + }); + } + + // Deliberately a route of its own rather than a field on the alias PATCH: aliases + // are display-only and reject __main__, while selection order is routing metadata + // that the Desktop account must be able to carry. Re-ordering never kicks a live + // thread, so there is no affinity clearing and no appliesImmediately here. + if (url.pathname === "/api/codex-auth/accounts/priority" && req.method === "PUT") { + let parsedBody: unknown; + try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } + if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) { + return jsonResponse({ error: "body must be an object" }, 400); + } + const body = parsedBody as { id?: unknown; priority?: unknown }; + const id = typeof body.id === "string" ? body.id.trim() : ""; + if (!isCodexAccountPriorityKey(id)) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } + + let priority = DEFAULT_ACCOUNT_PRIORITY; + if (body.priority !== null) { + const parsed = parseAccountPriority(body.priority); + if (parsed === null) { + return jsonResponse({ + error: `priority must be null or an integer ${MIN_ACCOUNT_PRIORITY}-${MAX_ACCOUNT_PRIORITY}`, + }, 400); + } + priority = parsed; + } + + const runtimeConfig = getRuntimeConfig(config); + const exists = id === MAIN_CODEX_ACCOUNT_ID + || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id); + if (!exists) return jsonResponse({ error: "Account not found" }, 404); + + setCodexAccountPriority(runtimeConfig, id, priority); + // Both a pin and an order are the operator saying which account to use, so the newer + // statement wins. Without this a pin made before any order existed — an ordinary + // account switch — would outrank the order forever: it blocks preemption and caps + // every eligibility list at its own tier until that account drains or is paused. + clearCodexAccountPin(runtimeConfig); + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + id, + priority, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + }); + } + + if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") { + const runtimeConfig = getRuntimeConfig(config); + const result = await pauseExhaustedCodexAccounts( + runtimeConfig, + () => saveRuntimeConfig(config, runtimeConfig), + ); + const { pausedAccountIds, checkedAccountCount, failedAccountCount } = result; + if (checkedAccountCount === 0 && failedAccountCount > 0) { + return jsonResponse({ + ok: false, + error: "Failed to refresh any Codex account quota", + checkedAccountCount, + failedAccountCount, + }, 502); + } + return jsonResponse({ + ok: true, + pausedAccountIds, + pausedCount: pausedAccountIds.length, + checkedAccountCount, + failedAccountCount, + complete: failedAccountCount === 0, + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + appliesImmediately: true, + }); + } + + // Manual escape from a quota cooldown. Injected Codex routing makes this proxy the only + // model path for Codex Desktop, so a cooldown that outlives the real upstream limit + // otherwise leaves editing config.toml as the user's only recovery. + // + // Existence is deliberately NOT disclosed: an unknown id returns 200 with cleared:false + // exactly like an account that simply had no live cooldown, so this route cannot be used + // to enumerate configured accounts. Cooldown state is runtime-only and independent of the + // account list, so 404 would carry no useful meaning anyway. + if (url.pathname === "/api/codex-auth/accounts/clear-cooldown" && req.method === "POST") { + const body = await req.json().catch(() => ({})) as { id?: unknown }; + const id = typeof body.id === "string" ? body.id.trim() : ""; + if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) { + return jsonResponse({ error: "Invalid account id format" }, 400); + } + return jsonResponse({ ok: true, id, cleared: clearCodexAccountCooldown(id) }); + } + + if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") { + let body: { accountId: string | null }; + try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } + const runtimeConfig = getRuntimeConfig(config); + const targetAccountId = body.accountId ?? MAIN_CODEX_ACCOUNT_ID; + if (body.accountId === MAIN_CODEX_ACCOUNT_ID && hasLegacyMainCodexPoolAccount(runtimeConfig.codexAccounts)) { + return jsonResponse({ error: "Remove the legacy __main__ pool row before selecting the Desktop account" }, 409); + } + if (isCodexAccountPaused(runtimeConfig, targetAccountId)) { + return jsonResponse({ error: "Account is paused" }, 409); + } + if (body.accountId != null && body.accountId !== MAIN_CODEX_ACCOUNT_ID) { + if (!isValidCodexAccountId(body.accountId)) return jsonResponse({ error: "Invalid account id format" }, 400); + const exists = (runtimeConfig.codexAccounts ?? []) + .some(account => isSelectableCodexPoolAccount(account) && account.id === body.accountId); + if (!exists) return jsonResponse({ error: "Account not found" }, 400); + if (readCodexAccountRecord(body.accountId)?.codexValidationPending) { + return jsonResponse({ error: "Account validation is pending. Refresh quota after recovery to validate it." }, 409); + } + } + runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; + // "Use this account now" outranks selection order until the account is spent: + // persisted here rather than in resetCodexRoutingForManualSelection, which is + // runtime state only. A null id clears the selection instead of making one, so it + // must release the pin rather than record one: pinning the `targetAccountId` + // fallback would leave a pin that no effective active account matches, which + // `isEffectiveCodexAccountPinned` reports as unpinned while the tier filter still + // honours it as a ceiling — invisibly capping the pool at the main account's tier. + if (body.accountId == null) clearCodexAccountPin(runtimeConfig); + else setCodexAccountPin(runtimeConfig, targetAccountId); + resetCodexRoutingForManualSelection(targetAccountId); + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true }); + } + + if (url.pathname === "/api/codex-auth/active" && req.method === "GET") { + const runtimeConfig = getRuntimeConfig(config); + return jsonResponse({ + activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null, + pinned: isEffectiveCodexAccountPinned(runtimeConfig), + // Which account carries the pin, not just whether the active one does. Under + // round-robin or fill-first the pin caps the tier ceiling at its own tier while the + // strategy cursor moves freely inside that tier, so `pinned` alone goes false on a + // sibling's turn even though the pin is still suppressing every higher tier. The id + // lets a surface mark the account the operator actually chose. + pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null, + autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80, + upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3, + accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy), + accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit), + }); + } + + if (url.pathname === "/api/codex-auth/auto-switch" && req.method === "PUT") { + let body: { threshold: number }; + try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } + if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 100) { + return jsonResponse({ error: "Threshold must be an integer 0-100" }, 400); + } + const runtimeConfig = getRuntimeConfig(config); + runtimeConfig.autoSwitchThreshold = body.threshold; + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ ok: true }); + } + + if ( + url.pathname === "/api/codex-auth/pool-strategy" + && (req.method === "PUT" || req.method === "PATCH") + ) { + let parsedBody: unknown; + try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } + if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) { + return jsonResponse({ error: "body must be an object" }, 400); + } + const body = parsedBody as { strategy?: unknown; stickyLimit?: unknown }; + if (body.strategy === undefined && body.stickyLimit === undefined) { + return jsonResponse({ error: "strategy or stickyLimit required" }, 400); + } + const runtimeConfig = getRuntimeConfig(config); + let nextStrategy: NonNullable> | undefined; + let nextSticky: NonNullable> | undefined; + if (body.strategy !== undefined) { + const parsed = parseCodexAccountPoolStrategy(body.strategy); + if (parsed === null) { + return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first, reset-first' }, 400); + } + nextStrategy = parsed; + } + if (body.stickyLimit !== undefined) { + const parsed = parseAccountPoolStickyLimit(body.stickyLimit); + if (parsed === null) { + return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400); + } + nextSticky = parsed; + } + if (nextStrategy !== undefined) runtimeConfig.accountPoolStrategy = nextStrategy; + if (nextSticky !== undefined) runtimeConfig.accountPoolStickyLimit = nextSticky; + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ + ok: true, + accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy), + accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit), + }); + } + + if (url.pathname === "/api/codex-auth/failover" && req.method === "PUT") { + let body: { threshold: number }; + try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } + if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 20) { + return jsonResponse({ error: "Threshold must be an integer 0-20" }, 400); + } + const runtimeConfig = getRuntimeConfig(config); + runtimeConfig.upstreamFailoverThreshold = body.threshold; + saveRuntimeConfig(config, runtimeConfig); + return jsonResponse({ ok: true }); + } + + if (url.pathname === "/api/codex-auth/quota/history" && req.method === "GET") { + const accountId = url.searchParams.get("accountId"); + const rawLimit = url.searchParams.get("limit"); + if (url.searchParams.getAll("accountId").length !== 1 || !isValidCodexAccountId(accountId) + || url.searchParams.getAll("limit").length > 1 + || [...url.searchParams.keys()].some(key => key !== "accountId" && key !== "limit") + || (rawLimit !== null && !/^(?:[1-9]|[1-9][0-9]|1[0-9]{2}|200)$/.test(rawLimit))) { + return jsonResponse({ error: "A stored pool accountId and optional limit from 1 to 200 are required" }, 400); + } + const runtimeConfig = getRuntimeConfig(config); + const account = configuredPoolAccount(runtimeConfig, accountId); + if (!account) return jsonResponse({ error: "Unknown pool account" }, 404); + const identity = poolQuotaHistoryIdentity(accountId); + const allHistory = getAccountQuotaHistory(accountId); + const limit = rawLimit === null ? 200 : Number(rawLimit); + const history = { ...allHistory, observations: allHistory.observations.slice(-limit), truncated: allHistory.observations.length > limit }; + const label = account.logLabel; + const labelStillUnique = () => { + const current = getRuntimeConfig(config); + return configuredPoolAccount(current, accountId)?.logLabel === label + && current.codexAccounts?.filter(row => codexAccountLogLabel(row) === label).length === 1; + }; + let capacity: CodexCapacityResult = insufficientCodexCapacity("identity_unavailable"); + if (identity && identity === poolQuotaHistoryIdentity(accountId) && label && CODEX_ACCOUNT_LOG_LABEL_RE.test(label) && labelStillUnique()) { + try { + const usage = await readUsageSnapshotForManagement(); + if (poolQuotaHistoryIdentity(accountId) !== identity || !labelStillUnique()) capacity = insufficientCodexCapacity("identity_changed"); + else if (!usage.revision) capacity = insufficientCodexCapacity("ledger_unavailable"); + else if (usage.truncatedPrefixBytes > 0 || usage.entriesTruncated || usage.entriesDropped > 0) capacity = insufficientCodexCapacity("ledger_truncated"); + else capacity = estimateCodexQuotaCapacity(allHistory.observations, usage.entries, label, + model => codexQuotaScopeForModel(model) === "shared"); + } catch { capacity = insufficientCodexCapacity("ledger_unavailable"); } + } + if (!configuredPoolAccount(getRuntimeConfig(config), accountId)) return jsonResponse({ error: "Unknown pool account" }, 404); + if (identity !== poolQuotaHistoryIdentity(accountId) || (identity && label && !labelStillUnique())) { + return jsonResponse({ accountId, ...getAccountQuotaHistory(accountId, limit), capacity: insufficientCodexCapacity("identity_changed") }); + } + return jsonResponse({ accountId, ...history, capacity }); + } + + if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") { + const quotas: Record = {}; + for (const [id, q] of listAccountQuotas()) quotas[id] = q; + return jsonResponse({ quotas }); + } + + if (url.pathname === "/api/codex-auth/reset-credits" && req.method === "GET") { + const accountId = url.searchParams.get("accountId"); + if (!accountId) return jsonResponse({ error: "accountId required" }, 400); + + try { + return await inspectResetCredits(config, accountId, req.signal); + } catch (e) { + return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit lookup failed" }, 500); + } + } + + if (url.pathname === "/api/codex-auth/reset-credits/consume" && req.method === "POST") { + const body = (await req.json().catch(() => ({}))) as { + accountId?: string; + operationId?: unknown; + }; + if (!body.accountId) return jsonResponse({ error: "accountId required" }, 400); + const accountId = body.accountId; + // Optional caller-owned idempotency identity (#3375 axis D). Absent => legacy + // behavior: a fresh random redeem_request_id and no durable ledger row. + // The ledger throws TypeError on a malformed id, so the format check has to + // happen here rather than at the call site, or it surfaces as a 500. + const hasOperationId = body.operationId !== undefined; + if (hasOperationId && !isCodexResetCreditOperationId(body.operationId)) { + return jsonResponse({ error: "Invalid operationId format" }, 400); + } + const requestedOperationId = hasOperationId ? body.operationId as string : undefined; + try { + return await consumeResetCredits(config, accountId, requestedOperationId); + } catch (e) { + if (e instanceof PoolQuotaProbeBusyError) { + const response = jsonResponse({ error: "server_busy", code: "server_busy" }, 503); + response.headers.set("Retry-After", "1"); + return response; + } + return jsonResponse({ error: e instanceof Error ? e.message : "Reset credit consume failed" }, 500); + } + } + + if (url.pathname === "/api/codex-auth/login" && req.method === "POST") { + return handleCodexAuthLoginStart(req, config, convergeCodexCatalog); + } + + if (url.pathname === "/api/codex-auth/login/code" && req.method === "POST") { + return handleCodexAuthLoginCode(req); + } + + if (url.pathname === "/api/codex-auth/login/cancel" && req.method === "POST") { + return handleCodexAuthLoginCancel(req); + } + + if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") { + return handleCodexAuthLoginStatus(req, url, config); + } + + return null; +} diff --git a/src/codex/auth-api/runtime-config.ts b/src/codex/auth-api/runtime-config.ts new file mode 100644 index 0000000000..8bfb35df7a --- /dev/null +++ b/src/codex/auth-api/runtime-config.ts @@ -0,0 +1,48 @@ +import { loadConfig, saveConfigPreservingClaudeCode } from "../../config"; +import { codexPlanValue } from "../plan"; +import type { CodexAccount, OcxConfig } from "../../types"; +import { isSelectableCodexPoolAccount, isValidCodexAccountId } from "../account-id"; + +export function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null { + if (!isValidCodexAccountId(accountId)) return null; + return (config.codexAccounts ?? []) + .find(account => account.id === accountId && isSelectableCodexPoolAccount(account)) ?? null; +} + +export function nonEmptyPlan(value: unknown): string | null { + return codexPlanValue(value) ?? null; +} + +export function isRuntimeConfig(config: OcxConfig): boolean { + return !!config && typeof config === "object" && !!config.providers; +} + +export function getRuntimeConfig(config: OcxConfig): OcxConfig { + return isRuntimeConfig(config) ? config : loadConfig(); +} + +export function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void { + saveConfigPreservingClaudeCode(nextConfig); + if (sourceConfig === nextConfig || !isRuntimeConfig(sourceConfig)) return; + for (const key of Object.keys(sourceConfig) as Array) { + delete sourceConfig[key]; + } + Object.assign(sourceConfig, nextConfig); +} + +export async function mapWithConcurrency( + items: T[], + concurrency: number, + mapper: (item: T) => Promise, +): Promise { + const results = new Array(items.length); + let next = 0; + const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (next < items.length) { + const index = next++; + results[index] = await mapper(items[index]!); + } + }); + await Promise.all(workers); + return results; +} diff --git a/src/codex/catalog/combo-member.ts b/src/codex/catalog/combo-member.ts new file mode 100644 index 0000000000..68978dd456 --- /dev/null +++ b/src/codex/catalog/combo-member.ts @@ -0,0 +1,375 @@ +import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; +import { initialModelSelectionPending } from "../../providers/initial-model-selection"; +import { execFileSync } from "node:child_process"; +import { createHash, createHmac, randomBytes } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config"; +import { resolveProviderApiKey } from "../../providers/key-store"; +import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; +import { + clearModelCache, + clearProviderDiscoveryStatus, + captureModelCacheGeneration, + DEFAULT_MODEL_CACHE_TTL_MS, + getFreshCached, + getStaleCached, + isModelsFetchCoolingDown, + isModelCacheGenerationCurrent, + markModelsFetchFailure, + markProviderDiscoveryFailed, + markProviderDiscoveryOk, + shouldLogDiscoveryFailure, + setCached, + type ProviderModelDiscoveryFailure, +} from "../model-cache"; +import { + buildModelsRequest, + getValidAccessTokenSnapshot, + observeActiveOAuthAccessToken, + resolveModelsAuthToken, + type OAuthActiveTokenObservation, +} from "../../oauth"; +import type { OcxConfig, OcxProviderConfig } from "../../types"; +import { modelInList } from "../../types"; +import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; +import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; +import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; +import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; +import { + captureFastPolicyAuthority, + fastPolicyForModel, + serviceTierSupportFromPolicy, +} from "../../providers/service-tier"; +import type { FastPolicyAuthority } from "../../providers/fastwire"; +import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry"; +import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; +import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; +import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; +import { effectiveModelAliases } from "../../providers/default-aliases"; +import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; +import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; +import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; +import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; +import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; +import { fetchQoderModels } from "../../adapters/qoder/live-models"; +import { resolveQoderProfile } from "../../adapters/qoder/profiles"; +import { fetchDevinUsableModels } from "../../adapters/devin/live-models"; +import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { + COMBO_NAMESPACE, + comboModelId, + getCombo, + listComboIds, + quotaInactiveReason, + targetKey, +} from "../../combos"; +import type { NormalizedComboConfig } from "../../combos/types"; +import { + ProviderOutboundPolicyError, + providerOutboundGet, + providerOutboundPost, + providerRedirectError, +} from "../../lib/provider-outbound"; +import { redactSecretString } from "../../lib/redact"; +import { + extractProviderModelItems, + isRegistryModelDiscoveryUrl, + readBoundedDiscoveryJson, + resolveProviderModelDiscovery, + type ModelDiscoveryResponseFailure, + type ProviderModelsApiItem, + type ResolvedProviderModelDiscovery, +} from "../../providers/model-discovery"; +import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; +import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show"; +import upstreamModelsSnapshot from "../data/upstream-models.json"; +import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing"; +import type { CatalogModel } from "./parsing"; +import { disabledNativeSlugs, hasComboTargets, hasNativeOpenAiCapabilityMetadata, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeOpenAiCapabilityDisplayName, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; +import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; +import type { ComboCatalogOmission } from "./aggregation"; +import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; +import type { + CatalogAdmissionSnapshot, + CatalogDiscoveryPolicyField, + CatalogGatherAuthorityIdentity, + CatalogProviderDiscoveryPolicySnapshot, + CatalogProcessLocalEvidence, + CatalogSourceEvidence, + CatalogTrustedOpenAiApiPolicySnapshot, +} from "../convergence-types"; +import { applyProviderConfigHints, configuredAutoCompactTokenLimit, positiveSafeInteger } from "./model-hints"; + +/** Model ids each provider must retain for combo catalog derivation (OCX-111). */ +export function configuredComboTargetModelsByProvider( + config: Pick, +): Map> { + const byProvider = new Map>(); + for (const id of listComboIds(config)) { + const combo = getCombo(config, id); + if (!combo) continue; + for (const target of combo.targets) { + let models = byProvider.get(target.provider); + if (!models) { + models = new Set(); + byProvider.set(target.provider, models); + } + models.add(target.model); + } + } + return byProvider; +} +/** + * Last-resort context window for combo member synthesis when discovery, + * provider config, and an enabled Context cap all omit one. Matches the + * catalog entry default in `normalizeRoutedCatalogEntry` so incomplete live + * rows still catalog. An enabled Context cap is the operator-facing window, + * not a clamp on this placeholder. + */ +const COMBO_MEMBER_CONTEXT_FALLBACK = 128_000; + +interface ComboCatalogMemberFallback { + readonly contextWindow?: number; + /** Input ceiling when it is lower than the window (native GPT-5.6: 922k under 1.05M). */ + readonly maxInputTokens?: number; + readonly maxOutputTokens?: number; + readonly autoCompactTokenLimit?: number; + readonly inputModalities?: readonly string[]; + readonly reasoningEfforts?: readonly string[]; +} + +/** + * Ladder advertised for a combo member whose vendor metadata says it reasons but + * carries no explicit ladder (Claude, Grok). Codex needs a non-empty ladder to show + * the effort control; the routed adapters clamp to the real upstream top rung. + */ +const ROUTED_COMBO_MEMBER_REASONING_EFFORTS: readonly string[] = ["low", "medium", "high", "xhigh", "max"]; + +/** + * Vendor-table lookup tolerant of point releases and date pins. Configured combo + * targets often name a variant the table does not carry (`claude-fable-5-1`, + * `claude-opus-4-5-20251101`); the base family row still describes its modality + * and reasoning capability, so fall back to it before giving up. + */ +function comboMemberVendorMetadata(provider: string, modelId: string): ModelMetadata | undefined { + const exact = getModelMetadataCaseInsensitive(provider, modelId); + if (exact) return exact; + let candidate = modelId.replace(/\[[^\]]*\]$/, ""); + while (true) { + const trimmed = candidate.replace(/-\d+$/, ""); + if (trimmed === candidate || !trimmed.includes("-")) return undefined; + const hit = getModelMetadataCaseInsensitive(provider, trimmed); + if (hit) return hit; + candidate = trimmed; + } +} + +/** + * Combo members are usually thin discovery rows (id + context window). Without a + * capability source the combo intersection collapses to text-only / no effort ladder, + * and the Codex app then refuses image attachments and hides the effort picker for + * every Claude combo. The generated vendor table knows both, so use it as the + * last-resort fallback when the caller supplied none. + * + * `ModelMetadata.maxTokens` is the OUTPUT ceiling, so it fills `maxOutputTokens`. + * Mapping it onto `maxInputTokens` would be read by the combo intersection + * (`aggregation.ts` `Math.min` over member input ceilings) as a 128k input limit and + * shrink a 1M Claude combo window to 128k, taking autoCompactTokenLimit down with it. + */ +function vendorMetadataComboFallback(target: { provider: string; model: string }): ComboCatalogMemberFallback | undefined { + const metadataProvider = resolveMetadataProvider(target.provider); + // Custom OpenAI-compatible routes commonly retain the canonical OpenAI model id + // while using a provider name that has no metadata alias. Reuse only its effort + // ladder below; context/modality rows remain provider-owned. + const metadata = metadataProvider + ? comboMemberVendorMetadata(metadataProvider, target.model) + : comboMemberVendorMetadata("openai", target.model); + if (!metadata) return undefined; + return { + ...(metadataProvider && typeof metadata.contextWindow === "number" && metadata.contextWindow > 0 + ? { contextWindow: metadata.contextWindow } + : {}), + ...(metadataProvider && typeof metadata.maxTokens === "number" && metadata.maxTokens > 0 + ? { maxOutputTokens: metadata.maxTokens } + : {}), + ...(metadataProvider && Array.isArray(metadata.input) && metadata.input.length > 0 + ? { inputModalities: [...metadata.input] } + : {}), + ...(metadata.reasoning === true ? { reasoningEfforts: [...ROUTED_COMBO_MEMBER_REASONING_EFFORTS] } : {}), + }; +} + +/** + * Resolve a combo target to a catalog member for derivation. + * Prefer discovery metadata; when the target is missing from the gather map or + * lacks a positive contextWindow, synthesize from the (registry-enriched) + * provider config so combos remain catalogued when targets are configured but + * discovery metadata is incomplete. Disabled providers stay unresolved. + * When hints still omit contextWindow, prefer known maxInputTokens, else the + * enabled Context cap, else COMBO_MEMBER_CONTEXT_FALLBACK so a live row + * without ctx does not drop the whole combo from the public catalog. + */ +export function resolveComboCatalogMember( + target: { provider: string; model: string }, + memberByKey: ReadonlyMap, + providers: ReadonlyMap, + contextCap?: number, + callerFallback?: ComboCatalogMemberFallback, + metadataModelIdCaseFold?: boolean, +): CatalogModel | undefined { + const existing = memberByKey.get(targetKey(target)); + const prov = providers.get(target.provider); + const fallback = callerFallback ?? vendorMetadataComboFallback(target); + // Disabled providers never contribute members — even a complete discovery row + // is unusable for catalog derivation while the provider is off. + if (prov?.disabled === true) return undefined; + + const withFallbackMetadata = (member: CatalogModel): CatalogModel => { + const contextWindow = typeof member.contextWindow === "number" && member.contextWindow > 0 + ? member.contextWindow + : undefined; + const addMaxInput = fallback !== undefined && contextWindow !== undefined + && !(typeof member.maxInputTokens === "number" && member.maxInputTokens > 0); + const addMaxOutput = fallback !== undefined + && typeof fallback.maxOutputTokens === "number" + && fallback.maxOutputTokens > 0 + && !(typeof member.maxOutputTokens === "number" && member.maxOutputTokens > 0); + const effectiveMaxInput = addMaxInput + ? Math.min(fallback?.maxInputTokens ?? contextWindow!, contextWindow!) + : member.maxInputTokens; + const softCandidates = [member.autoCompactTokenLimit, fallback?.autoCompactTokenLimit] + .filter((value): value is number => typeof value === "number" && value > 0); + const autoCompactTokenLimit = contextWindow !== undefined && softCandidates.length > 0 + ? clampAutoCompactTokenLimit(contextWindow, effectiveMaxInput, Math.min(...softCandidates)) + : member.autoCompactTokenLimit; + const adjustAutoCompact = autoCompactTokenLimit !== member.autoCompactTokenLimit; + const addModalities = (!Array.isArray(member.inputModalities) || member.inputModalities.length === 0) + && fallback?.inputModalities !== undefined; + const addReasoning = member.reasoningEfforts === undefined + && fallback?.reasoningEfforts !== undefined; + if (!addMaxInput && !addMaxOutput && !adjustAutoCompact && !addModalities && !addReasoning) return member; + return { + ...member, + // Never claim a larger input budget than the window, and prefer the model's own + // measured ceiling when the fallback carries one. + ...(addMaxInput ? { maxInputTokens: effectiveMaxInput } : {}), + ...(addMaxOutput ? { maxOutputTokens: fallback!.maxOutputTokens } : {}), + ...(adjustAutoCompact && autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), + ...(addModalities ? { inputModalities: [...fallback!.inputModalities!] } : {}), + ...(addReasoning ? { reasoningEfforts: [...fallback!.reasoningEfforts!] } : {}), + }; + }; + + // Complete live/configured rows still honour providerContextCaps so a high + // discovery window cannot outrun an operator-configured cap. Native-alias + // fallback metadata may fill only capability gaps; it never raises an explicit + // discovered/configured context window. + if ( + existing + && typeof existing.contextWindow === "number" + && existing.contextWindow > 0 + ) { + // Live discovery can explicitly say text-only even when configured routing + // supplies a vision sidecar. Apply the same provider hints used for thin + // rows before deriving a combo from this complete row. + const hinted = prov && isModelVisionSidecarConsumer(prov, existing.id) + ? applyProviderConfigHints(target.provider, prov, existing, contextCap, metadataModelIdCaseFold) + : existing; + const capped = applyProviderContextCap(hinted.contextWindow, contextCap); + if (capped === undefined || capped === existing.contextWindow) { + return withFallbackMetadata(hinted); + } + const maxInput = typeof hinted.maxInputTokens === "number" && hinted.maxInputTokens > 0 + ? Math.min(hinted.maxInputTokens, capped) + : Math.min(fallback?.maxInputTokens ?? capped, capped); + return withFallbackMetadata({ + ...hinted, + contextWindow: capped, + maxInputTokens: maxInput, + contextCap, + contextCapped: true as const, + }); + } + + const base: CatalogModel = existing ?? { + id: target.model, + provider: target.provider, + }; + const hinted = prov + ? applyProviderConfigHints(target.provider, prov, base, contextCap, metadataModelIdCaseFold) + : base; + const hintedContext = typeof hinted.contextWindow === "number" && hinted.contextWindow > 0 + ? hinted.contextWindow + : undefined; + const knownMaxInput = typeof hinted.maxInputTokens === "number" && hinted.maxInputTokens > 0 + ? hinted.maxInputTokens + : (typeof base.maxInputTokens === "number" && base.maxInputTokens > 0 + ? base.maxInputTokens + : undefined); + // Kept OUT of knownMaxInput on purpose: that value doubles as a context-window fallback + // below, and a native alias whose input ceiling (922k) is lower than its window (1.05M) + // would otherwise shrink the advertised window to the input limit. + const fallbackMaxInput = existing || prov ? fallback?.maxInputTokens : undefined; + // Real discovery/config values win. A native alias is the next fallback tier. + // The generic 128k/text synthesis from #1305 remains the final fallback. + const fallbackContext = existing || prov ? fallback?.contextWindow : undefined; + const uncappedContext = hintedContext + ?? knownMaxInput + ?? fallbackContext + ?? (existing || prov ? resolveUnknownRoutedContextWindow(contextCap) : undefined); + if (uncappedContext === undefined) return undefined; + // 真发现值才压低。resolveUnknownRoutedContextWindow 已经把 cap 当成窗口填进去了,不能再 min 一次。 + const usedDiscoveredWindow = hintedContext !== undefined || knownMaxInput !== undefined || fallbackContext !== undefined; + const cappedContext = usedDiscoveredWindow + ? applyProviderContextCap(uncappedContext, contextCap) + : uncappedContext; + const contextWindow = cappedContext ?? uncappedContext; + const fallbackCapped = usedDiscoveredWindow + && contextCap !== undefined + && cappedContext !== undefined + && cappedContext !== uncappedContext; + + const inputModalities = hinted.inputModalities + ?? base.inputModalities + ?? (fallback?.inputModalities ? [...fallback.inputModalities] : undefined) + ?? ["text"]; + const reasoningEfforts = hinted.reasoningEfforts + ?? (prov ? configuredReasoningEfforts(prov, target.model) : undefined) + ?? base.reasoningEfforts + ?? (fallback?.reasoningEfforts ? [...fallback.reasoningEfforts] : undefined); + const maxOutputTokens = positiveSafeInteger(hinted.maxOutputTokens, base.maxOutputTokens) + ?? (existing || prov ? positiveSafeInteger(fallback?.maxOutputTokens) : undefined); + // The model's own measured input ceiling still applies when discovery gave us nothing: + // GPT-5.6 advertises a 1.05M window but refuses input past 922k. + const effectiveMaxInput = knownMaxInput ?? fallbackMaxInput; + const maxInputTokens = effectiveMaxInput !== undefined + ? Math.min(effectiveMaxInput, contextWindow) + : contextWindow; + const softCandidates = [ + hinted.autoCompactTokenLimit, + base.autoCompactTokenLimit, + fallback?.autoCompactTokenLimit, + configuredAutoCompactTokenLimit(prov, target.model), + ].filter((value): value is number => typeof value === "number" && value > 0); + // A generic 128k synthesis is a catalog compatibility fallback, not evidence + // that a configured soft policy has an authoritative window to clamp against. + const hasAuthoritativeAutoCompactBasis = hintedContext !== undefined + || fallbackContext !== undefined + || contextCap !== undefined; + const autoCompactTokenLimit = hasAuthoritativeAutoCompactBasis && softCandidates.length > 0 + ? clampAutoCompactTokenLimit(contextWindow, maxInputTokens, Math.min(...softCandidates)) + : undefined; + + return { + ...hinted, + inputModalities, + ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), + contextWindow, + maxInputTokens, + ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), + ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), + ...(fallbackCapped ? { contextCap, contextCapped: true as const } : {}), + }; +} diff --git a/src/codex/catalog/gather-capture.ts b/src/codex/catalog/gather-capture.ts new file mode 100644 index 0000000000..8bf566b4fe --- /dev/null +++ b/src/codex/catalog/gather-capture.ts @@ -0,0 +1,533 @@ +import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; +import { initialModelSelectionPending } from "../../providers/initial-model-selection"; +import { execFileSync } from "node:child_process"; +import { createHash, createHmac, randomBytes } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config"; +import { resolveProviderApiKey } from "../../providers/key-store"; +import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; +import { + clearModelCache, + clearProviderDiscoveryStatus, + captureModelCacheGeneration, + DEFAULT_MODEL_CACHE_TTL_MS, + getFreshCached, + getStaleCached, + isModelsFetchCoolingDown, + isModelCacheGenerationCurrent, + markModelsFetchFailure, + markProviderDiscoveryFailed, + markProviderDiscoveryOk, + shouldLogDiscoveryFailure, + setCached, + type ProviderModelDiscoveryFailure, +} from "../model-cache"; +import { + buildModelsRequest, + getValidAccessTokenSnapshot, + observeActiveOAuthAccessToken, + resolveModelsAuthToken, + type OAuthActiveTokenObservation, +} from "../../oauth"; +import type { OcxConfig, OcxProviderConfig } from "../../types"; +import { modelInList } from "../../types"; +import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; +import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; +import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; +import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; +import { + captureFastPolicyAuthority, + fastPolicyForModel, + serviceTierSupportFromPolicy, +} from "../../providers/service-tier"; +import type { FastPolicyAuthority } from "../../providers/fastwire"; +import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry"; +import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; +import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; +import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; +import { effectiveModelAliases } from "../../providers/default-aliases"; +import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; +import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; +import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; +import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; +import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; +import { fetchQoderModels } from "../../adapters/qoder/live-models"; +import { resolveQoderProfile } from "../../adapters/qoder/profiles"; +import { fetchDevinUsableModels } from "../../adapters/devin/live-models"; +import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { + COMBO_NAMESPACE, + comboModelId, + getCombo, + listComboIds, + quotaInactiveReason, + targetKey, +} from "../../combos"; +import type { NormalizedComboConfig } from "../../combos/types"; +import { + ProviderOutboundPolicyError, + providerOutboundGet, + providerOutboundPost, + providerRedirectError, +} from "../../lib/provider-outbound"; +import { redactSecretString } from "../../lib/redact"; +import { + extractProviderModelItems, + isRegistryModelDiscoveryUrl, + readBoundedDiscoveryJson, + resolveProviderModelDiscovery, + type ModelDiscoveryResponseFailure, + type ProviderModelsApiItem, + type ResolvedProviderModelDiscovery, +} from "../../providers/model-discovery"; +import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; +import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show"; +import upstreamModelsSnapshot from "../data/upstream-models.json"; +import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing"; +import type { CatalogModel } from "./parsing"; +import { disabledNativeSlugs, hasComboTargets, hasNativeOpenAiCapabilityMetadata, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeOpenAiCapabilityDisplayName, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; +import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; +import type { ComboCatalogOmission } from "./aggregation"; +import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; +import type { + CatalogAdmissionSnapshot, + CatalogDiscoveryPolicyField, + CatalogGatherAuthorityIdentity, + CatalogProviderDiscoveryPolicySnapshot, + CatalogProcessLocalEvidence, + CatalogSourceEvidence, + CatalogTrustedOpenAiApiPolicySnapshot, +} from "../convergence-types"; +import { applyRegistryCapabilitySeedFill, modelCapabilities, modelInputModalities } from "./model-hints"; +import { configuredComboTargetModelsByProvider } from "./combo-member"; + +/** Concurrent gatherRoutedModels callers with the same catalog identity share one live discovery. + * Keyed by gatherFlightKey so a different config cannot join or evict the wrong flight. */ +export interface CatalogGatherProviderAuthOutcome { + readonly provider: string; + readonly state: OAuthActiveTokenObservation["kind"]; +} + +export interface CatalogGatherProviderModelOutcome { + readonly provider: string; + readonly state: "authoritative" | "degraded"; +} +export interface ModelsAuthResolution { + readonly apiKey: string | undefined; + readonly observed: boolean; + readonly oauthApiBaseUrl?: string; + readonly oauthProjectId?: string; +} + +export type ModelsAuthResolver = + | { readonly kind: "refreshing" } + | { + readonly kind: "observed"; + readonly resolve: (name: string, provider: OcxProviderConfig) => ModelsAuthResolution; + }; + +export type ModelsAuthResolverFactory = ( + outcomes: CatalogGatherProviderAuthOutcome[], +) => ModelsAuthResolver; + +export interface CapturedModelsRequest { + readonly method: "GET" | "POST"; + readonly url: string; + readonly headersWithoutCredential: Readonly>; + readonly headersWithCredential: Readonly>; +} + +export interface CapturedProviderGather { + readonly name: string; + readonly provider: OcxProviderConfig; + readonly discovery: ResolvedProviderModelDiscovery; + readonly policy: CatalogProviderDiscoveryPolicySnapshot; + readonly request: CapturedModelsRequest; + readonly fastPolicyAuthority: FastPolicyAuthority; + readonly metadataModelIdCaseFold: boolean; + readonly effectiveAlias?: string | null; + readonly observedAuth?: ModelsAuthResolution; + /** + * Configured model ids this provider must keep even when live discovery omits + * them — combo targets that are also listed in providers.*.models (OCX-111). + * Combo-only ids (not in models[]) stay out of the public catalog and are + * synthesized for combo derivation instead (#1305). + */ + readonly retainConfiguredModelIds?: ReadonlySet; +} + +export interface GatherFlightCapture { + readonly discoveryPolicyIdentity: string; + readonly authIdentity: string; + readonly providerGraphIdentity: string; + readonly discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[]; + readonly providers: readonly CapturedProviderGather[]; + readonly authResolver: ModelsAuthResolver; + readonly providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[]; + readonly openAiApiPolicy: CatalogTrustedOpenAiApiPolicySnapshot; +} +export function withCanonicalOpenAiForwardAuthDefault( + name: string, + provider: OcxProviderConfig, +): OcxProviderConfig { + if (name !== OPENAI_CODEX_PROVIDER_ID || provider.authMode !== undefined) return provider; + const candidate = { ...provider, authMode: "forward" as const }; + return isCanonicalOpenAiForwardProvider(candidate) ? candidate : provider; +} +const CATALOG_GATHER_AUTHORITY_KEY = randomBytes(32); +const REQUEST_CREDENTIAL_SENTINEL = `ocx-catalog-credential-${randomBytes(16).toString("hex")}`; +function stableJson(value: unknown): string { + return JSON.stringify(value, (_key, nested) => { + if (nested && typeof nested === "object" && !Array.isArray(nested)) { + return Object.fromEntries(Object.entries(nested as Record).sort(([a], [b]) => a.localeCompare(b))); + } + return nested; + }); +} + +function framed(value: string): string { + return `${Buffer.byteLength(value, "utf8")}:${value}`; +} + +function canonicalAuthorityEncoding(value: unknown): string { + if (value === null) return "null"; + if (value === undefined) return "undefined"; + if (typeof value === "string") return `string${framed(value)}`; + if (typeof value === "boolean") return value ? "boolean1" : "boolean0"; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("Catalog authority cannot encode a non-finite number."); + const encoded = Object.is(value, -0) ? "-0" : String(value); + return `number${framed(encoded)}`; + } + if (Array.isArray(value)) { + return `array${value.length}:${value.map(item => framed(canonicalAuthorityEncoding(item))).join("")}`; + } + if (typeof value === "object") { + const record = value as Record; + const keys = Object.keys(record).sort((left, right) => left.localeCompare(right)); + return `object${keys.length}:${keys.map(key => ( + `${framed(key)}${framed(canonicalAuthorityEncoding(record[key]))}` + )).join("")}`; + } + throw new TypeError(`Catalog authority cannot encode ${typeof value}.`); +} + +function keyedGatherIdentity(domain: string, value: unknown): string { + return createHmac("sha256", CATALOG_GATHER_AUTHORITY_KEY) + .update(framed(domain)) + .update(framed(canonicalAuthorityEncoding(value))) + .digest("hex"); +} + +export function keyedGatherBytesIdentity(domain: string, value: Uint8Array): string { + return createHmac("sha256", CATALOG_GATHER_AUTHORITY_KEY) + .update(framed(domain)) + .update(`${value.byteLength}:`) + .update(value) + .digest("hex"); +} + +export function createCatalogGatherAuthorityIdentity( + snapshot: CatalogAdmissionSnapshot, + sourceEvidence: CatalogSourceEvidence, + processLocal: CatalogProcessLocalEvidence, + discoveryPolicies: readonly CatalogProviderDiscoveryPolicySnapshot[], +): CatalogGatherAuthorityIdentity { + const sourceEvidenceIdentity = keyedGatherIdentity("catalog-source-evidence-v1", sourceEvidence); + const processLocalEvidenceIdentity = keyedGatherIdentity("catalog-process-local-v1", processLocal); + const discoveryPolicyIdentity = keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicies); + return Object.freeze({ + version: 1 as const, + authorityId: keyedGatherIdentity("catalog-authority-v1", { + admittedConfig: snapshot.configIdentity, + discoveryPolicyIdentity, + sourceEvidenceIdentity, + processLocalEvidenceIdentity, + }), + admittedConfig: Object.freeze({ + ...snapshot.configIdentity, + generation: Object.freeze({ ...snapshot.configIdentity.generation }), + }), + authSnapshotIdentity: keyedGatherIdentity( + "catalog-auth-v1", + sourceEvidence.conditional["provider-auth-selection"], + ), + discoveryPolicyIdentity, + nativeCatalogSourceIdentity: keyedGatherIdentity( + "catalog-native-v1", + sourceEvidence.conditional["native-catalog-selection"], + ), + sourceEvidenceIdentity, + processLocalEvidenceIdentity, + }); +} + +function detachedClone(value: T): T { + if (Array.isArray(value)) return value.map(item => detachedClone(item)) as T; + if (value && typeof value === "object") { + const clone: Record = {}; + for (const key of Object.keys(value)) { + clone[key] = detachedClone((value as Record)[key]); + } + return clone as T; + } + return value; +} + +function recursivelyFreeze(value: T): T { + if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; + for (const nested of Object.values(value as Record)) recursivelyFreeze(nested); + return Object.freeze(value); +} + +function detachedFrozen(value: T): T { + return recursivelyFreeze(detachedClone(value)); +} + +function capturedField( + value: T | undefined, + key: K, +): CatalogDiscoveryPolicyField { + if (!value || !Object.hasOwn(value, key)) return Object.freeze({ state: "absent" }); + return detachedFrozen({ state: "present" as const, value: value[key] }); +} + +export function captureTrustedOpenAiApiPolicy( + name: string, + registryTransportMatch: boolean, +): CatalogTrustedOpenAiApiPolicySnapshot { + if (name !== OPENAI_API_PROVIDER_ID) return Object.freeze({ state: "unused" }); + if (!registryTransportMatch) return Object.freeze({ state: "transport-mismatch" }); + const entry = getProviderRegistryEntry(name); + if (!entry?.models) return Object.freeze({ state: "registry-models-absent" }); + return detachedFrozen({ + state: "captured" as const, + models: entry.models, + ...(entry.modelContextWindows ? { modelContextWindows: entry.modelContextWindows } : {}), + ...(entry.modelMaxInputTokens ? { modelMaxInputTokens: entry.modelMaxInputTokens } : {}), + ...(entry.modelMaxOutputTokens ? { modelMaxOutputTokens: entry.modelMaxOutputTokens } : {}), + ...(entry.virtualModels ? { virtualModels: entry.virtualModels } : {}), + ...(entry.modelInputModalities ? { modelInputModalities: entry.modelInputModalities } : {}), + ...(entry.modelReasoningEfforts ? { modelReasoningEfforts: entry.modelReasoningEfforts } : {}), + }); +} + +function captureModelsRequest( + name: string, + provider: OcxProviderConfig, + observedAuth: ModelsAuthResolution | undefined, +): CapturedModelsRequest { + const observed = observedAuth + ? { oauthApiBaseUrl: observedAuth.oauthApiBaseUrl } + : undefined; + const withoutCredential = buildModelsRequest(provider, undefined, name, observed); + const withCredential = buildModelsRequest(provider, REQUEST_CREDENTIAL_SENTINEL, name, observed); + const method = withoutCredential.method ?? "GET"; + if (withoutCredential.url !== withCredential.url || method !== (withCredential.method ?? "GET")) { + throw new TypeError(`Provider model discovery URL for ${name} depends on credential bytes.`); + } + return detachedFrozen({ + method, + url: withoutCredential.url, + headersWithoutCredential: withoutCredential.headers, + headersWithCredential: withCredential.headers, + }); +} +export function captureProviderGather( + name: string, + configured: OcxProviderConfig, + authResolver: ModelsAuthResolver, + retainConfiguredModelIds?: ReadonlySet, + config?: Pick, +): CapturedProviderGather { + const enriched = detachedClone(withCanonicalOpenAiForwardAuthDefault(name, configured)); + enrichProviderFromRegistry(name, enriched); + applyRegistryCapabilitySeedFill(name, enriched); + const registryTransportMatch = providerMatchesRegistryTransport(name, enriched); + const provider = recursivelyFreeze(enriched); + const fastPolicyAuthority = captureFastPolicyAuthority( + name, + provider, + registryTransportMatch, + configured, + ); + const metadataModelIdCaseFold = shouldCaseFoldMetadataModelId(name); + const observedAuth = authResolver.kind === "observed" + && provider.authMode !== "forward" + && provider.liveModels !== false + ? authResolver.resolve(name, provider) + : undefined; + const request = captureModelsRequest(name, provider, observedAuth); + const resolved = resolveProviderModelDiscovery(name, provider); + const discovery = detachedFrozen({ + ...(resolved.spec ? { spec: resolved.spec } : {}), + maxResponseBytes: resolved.maxResponseBytes, + maxModels: resolved.maxModels, + }); + const trustedOpenAiApi = captureTrustedOpenAiApiPolicy(name, registryTransportMatch); + const policy = detachedFrozen({ + provider: name, + registryTransportMatch, + location: { + spec: discovery.spec ? "present" as const : "absent" as const, + url: capturedField(discovery.spec, "url"), + path: capturedField(discovery.spec, "path"), + query: capturedField(discovery.spec, "query"), + }, + finalMethod: request.method, + finalUrl: request.url, + filter: capturedField(discovery.spec, "filter"), + maxResponseBytes: discovery.maxResponseBytes, + maxModels: discovery.maxModels, + trustedOpenAiApi, + }); + const effectiveAlias = effectiveProviderAliasDecision(name, configured, config); + return Object.freeze({ + name, + provider, + discovery, + policy, + request, + fastPolicyAuthority, + metadataModelIdCaseFold, + effectiveAlias, + ...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}), + ...(retainConfiguredModelIds && retainConfiguredModelIds.size > 0 + ? { retainConfiguredModelIds } + : {}), + }); +} +export function captureGatherFlight( + config: OcxConfig, + createAuthResolver: ModelsAuthResolverFactory, +): GatherFlightCapture { + const providerAuthOutcomes: CatalogGatherProviderAuthOutcome[] = []; + const authResolver = createAuthResolver(providerAuthOutcomes); + const comboTargetsByProvider = configuredComboTargetModelsByProvider(config); + const providers = Object.entries(config.providers) + .filter(([, provider]) => provider.disabled !== true) + .map(([name, provider]) => captureProviderGather( + name, + provider, + authResolver, + comboTargetsByProvider.get(name), + config, + )); + const discoveryPolicySnapshots = Object.freeze(providers.map(provider => provider.policy)); + return Object.freeze({ + discoveryPolicyIdentity: keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicySnapshots), + // Credentials are hashed under the same unexported per-process key, never + // stored or compared in the clear: this value can reach a map key and must + // not disclose a token. The final headers are included because a static + // header can carry authority just as an `apiKey` can. + authIdentity: keyedGatherIdentity("catalog-gather-auth-v1", providers.map(provider => ({ + name: provider.name, + authMode: provider.provider.authMode ?? null, + liveModels: provider.provider.liveModels ?? null, + credential: provider.provider.apiKey ?? null, + observedAuth: provider.observedAuth ?? null, + headers: provider.request.headersWithCredential, + url: provider.request.url, + }))), + // Every enriched provider row the flight will gather from, in admission order. + // Anything that can change a catalog row lives in here by construction. + providerGraphIdentity: keyedGatherIdentity("catalog-gather-provider-graph-v1", + providers.map(provider => ({ + name: provider.name, + // `fetch` is a caller-owned transport executor, not admitted state: the + // outbound transport honors it so a caller can supply its own HTTP path. + // It is the one member of a provider row that is legitimately a function, + // so it is dropped here rather than allowed to break every encode. + provider: omitProviderTransportExecutor(provider.provider), + fastPolicyAuthority: provider.fastPolicyAuthority, + // Combo retention is capture-time state, not a provider-row field. Two + // gathers that share providers but differ in combo targets must not join. + retainConfiguredModelIds: [...(provider.retainConfiguredModelIds ?? [])].sort(), + }))), + discoveryPolicySnapshots, + providers: Object.freeze(providers), + authResolver, + providerAuthOutcomes: Object.freeze([...providerAuthOutcomes]), + openAiApiPolicy: providers.find(provider => provider.name === OPENAI_API_PROVIDER_ID)?.policy.trustedOpenAiApi + ?? Object.freeze({ state: "unused" as const }), + }); +} + +/** + * Drop the caller-owned transport executor before hashing a provider row. + * + * Fails closed on anything ELSE that cannot be encoded: the point of hashing the + * whole row is that no field escapes the comparison, so a second function member + * must surface as an encode error rather than being quietly skipped here. + */ +function omitProviderTransportExecutor(provider: OcxProviderConfig): Record { + const entries = Object.entries(provider).filter(([key]) => key !== "fetch"); + return Object.fromEntries(entries); +} + +export function materializeCapturedHeaders( + request: CapturedModelsRequest, + apiKey: string | undefined, +): Record { + const source = apiKey ? request.headersWithCredential : request.headersWithoutCredential; + return Object.fromEntries(Object.entries(source).map(([name, value]) => [ + name, + apiKey ? value.split(REQUEST_CREDENTIAL_SENTINEL).join(apiKey) : value, + ])); +} + +function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Record { + return { + n: name, + // Preserve the persisted tri-state. Registry enrichment may turn an omitted value into + // `false` while an explicit `true` stays live, so those callers must not share a flight. + live: prov.liveModels ?? null, + base: prov.baseUrl ?? "", + adapter: prov.adapter ?? "", + models: [...(prov.models ?? [])].sort(), + retain: [...(prov.retainModels ?? [])].sort(), + selected: [...(prov.selectedModels ?? [])].sort(), + displayNames: prov.modelDisplayNames ?? null, + defaultModel: prov.defaultModel ?? null, + ctx: prov.contextWindow ?? null, + ctxW: prov.modelContextWindows ?? null, + maxIn: prov.modelMaxInputTokens ?? null, + maxOut: prov.modelMaxOutputTokens ?? null, + autoCompact: prov.modelAutoCompactTokenLimits ?? null, + inMod: prov.modelInputModalities ?? null, + capabilities: prov.modelCapabilities ?? null, + re: prov.modelReasoningEfforts ?? null, + defRe: prov.modelDefaultReasoningEfforts ?? null, + rsSum: prov.modelSupportsReasoningSummaries ?? null, + verbosity: prov.modelSupportsVerbosity ?? null, + rsDel: prov.modelReasoningSummaryDelivery ?? null, + serviceTier: prov.modelSupportsServiceTier ?? null, + noVis: [...(prov.noVisionModels ?? [])].sort(), + ptc: prov.parallelToolCalls ?? null, + gMode: prov.googleMode ?? null, + }; +} + +export function gatherFlightKey(config: OcxConfig): string { + const providers = Object.entries(config.providers) + .filter(([, prov]) => prov.disabled !== true) + .map(([name, prov]) => providerCatalogFingerprint(name, prov)) + .sort((a, b) => String(a.n).localeCompare(String(b.n))); + const assembly = stableJson({ + providers, + disabledModels: [...(config.disabledModels ?? [])].sort(), + combos: config.combos ?? {}, + customModels: (config.customModels ?? []).map((cm) => ({ + p: cm.provider, + m: cm.modelId, + d: cm.displayName ?? null, + cw: cm.contextWindow ?? null, + im: cm.inputModalities ?? null, + })), + caps: config.providerContextCaps ?? null, + }); + const digest = createHash("sha256").update(assembly).digest("hex").slice(0, 16); + return `${digest}#${config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS}`; +} diff --git a/src/codex/catalog/model-hints.ts b/src/codex/catalog/model-hints.ts new file mode 100644 index 0000000000..25b91552cf --- /dev/null +++ b/src/codex/catalog/model-hints.ts @@ -0,0 +1,691 @@ +import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; +import { initialModelSelectionPending } from "../../providers/initial-model-selection"; +import { execFileSync } from "node:child_process"; +import { createHash, createHmac, randomBytes } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config"; +import { resolveProviderApiKey } from "../../providers/key-store"; +import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; +import { + clearModelCache, + clearProviderDiscoveryStatus, + captureModelCacheGeneration, + DEFAULT_MODEL_CACHE_TTL_MS, + getFreshCached, + getStaleCached, + isModelsFetchCoolingDown, + isModelCacheGenerationCurrent, + markModelsFetchFailure, + markProviderDiscoveryFailed, + markProviderDiscoveryOk, + shouldLogDiscoveryFailure, + setCached, + type ProviderModelDiscoveryFailure, +} from "../model-cache"; +import { + buildModelsRequest, + getValidAccessTokenSnapshot, + observeActiveOAuthAccessToken, + resolveModelsAuthToken, + type OAuthActiveTokenObservation, +} from "../../oauth"; +import type { OcxConfig, OcxProviderConfig } from "../../types"; +import { modelInList } from "../../types"; +import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; +import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; +import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; +import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; +import { + captureFastPolicyAuthority, + fastPolicyForModel, + serviceTierSupportFromPolicy, +} from "../../providers/service-tier"; +import type { FastPolicyAuthority } from "../../providers/fastwire"; +import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry"; +import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; +import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; +import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; +import { effectiveModelAliases } from "../../providers/default-aliases"; +import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; +import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; +import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; +import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; +import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; +import { fetchQoderModels } from "../../adapters/qoder/live-models"; +import { resolveQoderProfile } from "../../adapters/qoder/profiles"; +import { fetchDevinUsableModels } from "../../adapters/devin/live-models"; +import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { + COMBO_NAMESPACE, + comboModelId, + getCombo, + listComboIds, + quotaInactiveReason, + targetKey, +} from "../../combos"; +import type { NormalizedComboConfig } from "../../combos/types"; +import { + ProviderOutboundPolicyError, + providerOutboundGet, + providerOutboundPost, + providerRedirectError, +} from "../../lib/provider-outbound"; +import { redactSecretString } from "../../lib/redact"; +import { + extractProviderModelItems, + isRegistryModelDiscoveryUrl, + readBoundedDiscoveryJson, + resolveProviderModelDiscovery, + type ModelDiscoveryResponseFailure, + type ProviderModelsApiItem, + type ResolvedProviderModelDiscovery, +} from "../../providers/model-discovery"; +import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; +import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show"; +import upstreamModelsSnapshot from "../data/upstream-models.json"; +import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing"; +import type { CatalogModel } from "./parsing"; +import { disabledNativeSlugs, hasComboTargets, hasNativeOpenAiCapabilityMetadata, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeOpenAiCapabilityDisplayName, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; +import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; +import type { ComboCatalogOmission } from "./aggregation"; +import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; +import type { + CatalogAdmissionSnapshot, + CatalogDiscoveryPolicyField, + CatalogGatherAuthorityIdentity, + CatalogProviderDiscoveryPolicySnapshot, + CatalogProcessLocalEvidence, + CatalogSourceEvidence, + CatalogTrustedOpenAiApiPolicySnapshot, +} from "../convergence-types"; + + +/** + * Fill the registry seed's per-model numeric capability maps beneath the provider's own + * values, mutating `prov` in place. The merge is per key — an operator's entry always + * wins; a model the persisted map never mentions picks up its seed value — matching + * `mergeRecordFill` in src/router.ts exactly. + * + * Routing already performs this fill at resolve time (routedProviderConfig in + * src/router.ts) and the catalog did not, and that divergence is #4570: + * zhipu-bigmodel-coding/glm-5.3-flash reached the live catalog with correct modalities + * but no context window, because an install persisted before Flash joined the seed map + * held a truthy partial `modelContextWindows` that shadowed the whole seed. + * + * This lives here and not in enrichProviderFromRegistry because enrichment output is + * persisted on a management POST, and #1409 (pinned by + * tests/server/management-provider-validation.test.ts) requires that a save never write + * registry seed keys into the operator's config. The gather clone is detached and + * frozen, never saved, so the catalog can see the seed without the config gaining it. + */ +export function applyRegistryCapabilitySeedFill(name: string, prov: OcxProviderConfig): void { + // router.ts resolves the canonical OpenAI API provider's token maps with + // mergePositiveNumberCaps (user values cap the seed rather than replace it), so a + // plain fill here would give that one provider catalog semantics routing never has. + if (name === OPENAI_API_PROVIDER_ID) return; + if (!providerMatchesRegistryTransport(name, prov)) return; + const entry = getProviderRegistryEntry(name); + if (!entry) return; + if (entry.modelContextWindows || prov.modelContextWindows) { + prov.modelContextWindows = { ...(entry.modelContextWindows ?? {}), ...(prov.modelContextWindows ?? {}) }; + } + if (entry.modelMaxOutputTokens || prov.modelMaxOutputTokens) { + prov.modelMaxOutputTokens = { ...(entry.modelMaxOutputTokens ?? {}), ...(prov.modelMaxOutputTokens ?? {}) }; + } +} +const NUMERIC_MODEL_ID_SEGMENT = /^\d+$/; + +/** + * Resolve an unknown Claude point release or date pin from the nearest configured + * family row. Only numeric tail segments are removed so unrelated model families + * cannot inherit one another's limits. + */ +function anthropicFamilyContextWindow( + record: Record | undefined, + id: string, +): number | undefined { + if (!record || !id.toLowerCase().startsWith("claude-")) return undefined; + let candidate = id; + while (true) { + const cut = candidate.lastIndexOf("-"); + if (cut <= 0 || !NUMERIC_MODEL_ID_SEGMENT.test(candidate.slice(cut + 1))) return undefined; + candidate = candidate.slice(0, cut); + const value = modelRecordValue(record, candidate); + if (typeof value === "number" && value > 0) return value; + } +} + +/** + * Resolve the configured context window in exact-model, Anthropic numeric-family, + * then provider-wide order. Return undefined when the selected value is not positive. + */ +export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined { + const configured = modelRecordValue(prov.modelContextWindows, id) + ?? (prov.adapter === "anthropic" ? anthropicFamilyContextWindow(prov.modelContextWindows, id) : undefined) + ?? prov.contextWindow; + return typeof configured === "number" && configured > 0 ? configured : undefined; +} + +export function configuredInputModalities(prov: OcxProviderConfig, id: string): string[] | undefined { + const declared = Object.hasOwn(prov.modelCapabilities ?? {}, id) + ? prov.modelCapabilities?.[id]?.inputModalities : undefined; + const modalities = declared ?? modelRecordValue(prov.modelInputModalities, id); + return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined; +} + +/** Exact display-only override for one provider-native model id. */ +export function configuredModelDisplayName( + prov: OcxProviderConfig, + id: string, +): string | undefined { + if (!prov.modelDisplayNames || !Object.hasOwn(prov.modelDisplayNames, id)) return undefined; + const value = prov.modelDisplayNames[id]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function configuredMaxInputTokens(prov: OcxProviderConfig, id: string): number | undefined { + const configured = modelRecordValue(prov.modelMaxInputTokens, id); + return typeof configured === "number" && configured > 0 ? configured : undefined; +} + +function generatedMaxOutputTokens( + providerName: string, + id: string, + metadataId = id, + metadataModelIdCaseFold?: boolean, +): number | undefined { + const metadataProvider = providerName === OPENAI_API_PROVIDER_ID || providerName === OPENAI_CODEX_PROVIDER_ID + ? "openai" + : resolveMetadataProvider(providerName); + if (!metadataProvider) return undefined; + const metadata = getModelMetadata(metadataProvider, metadataId) + ?? ((metadataModelIdCaseFold ?? (providerName === OPENAI_API_PROVIDER_ID || providerName === OPENAI_CODEX_PROVIDER_ID + ? false + : shouldCaseFoldMetadataModelId(providerName))) + ? getModelMetadataCaseInsensitive(metadataProvider, metadataId) + : undefined); + return positiveSafeInteger(metadata?.maxTokens); +} + +export function routedMaxOutputTokens( + providerName: string, + provider: OcxProviderConfig, + model: CatalogModel, + metadataId = model.id, + metadataModelIdCaseFold?: boolean, +): number | undefined { + const discovered = positiveSafeInteger(model.maxOutputTokens); + const generated = generatedMaxOutputTokens(providerName, model.id, metadataId, metadataModelIdCaseFold); + const configured = positiveSafeInteger( + modelRecordValue(provider.modelMaxOutputTokens, model.id), + ); + const authoritative = discovered ?? generated; + if (configured === undefined) return authoritative; + return authoritative === undefined + ? configured + : Math.min(authoritative, configured); +} + +export function configuredAutoCompactTokenLimit( + prov: OcxProviderConfig | undefined, + id: string, +): number | undefined { + if (!prov) return undefined; + const configured = modelRecordValue(prov.modelAutoCompactTokenLimits, id); + return typeof configured === "number" && Number.isSafeInteger(configured) && configured > 0 + ? configured + : undefined; +} + +export function configuredReasoningSummarySupport(prov: OcxProviderConfig | undefined, id: string): boolean | undefined { + if (!prov) return undefined; + const explicit = modelRecordValue(prov.modelSupportsReasoningSummaries, id); + if (explicit !== undefined) return explicit; + return modelRecordValue(prov.modelReasoningSummaryDelivery, id) !== undefined ? true : undefined; +} + +function configuredVerbositySupport(name: string, prov: OcxProviderConfig | undefined, id: string): boolean | undefined { + const explicit = prov ? modelRecordValue(prov.modelSupportsVerbosity, id) : undefined; + if (explicit !== undefined) return explicit; + if (!prov) return undefined; + void name; + // Provider-wide fallback for ids the per-model map does not enumerate — a live-discovered + // model would otherwise re-advertise a control the upstream accepts and ignores. + // + // Read from the PROVIDER CONFIG, never from PROVIDER_REGISTRY. A gather flight captures its + // registry authority up front and forbids any later registry read, so consulting the registry + // here made a custom-destination flight fall back to "configured" instead of serving its own + // discovery result (tests/codex-integration/codex-gather-authority.test.ts). `applyVerbosityDefaults` in + // providers/derive.ts materializes the registry default into the config at seed/enrich time. + return prov.supportsVerbosity; +} + +export function applyProviderConfigHints( + name: string, + prov: OcxProviderConfig, + model: CatalogModel, + providerCap?: number, + metadataModelIdCaseFold?: boolean, + effectiveAlias?: string | null, +): CatalogModel { + const displayName = configuredModelDisplayName(prov, model.id); + // The alias decision is resolved once at flight admission (captureProviderGather) and threaded + // through as `effectiveAlias`. Re-deriving it here would read PROVIDER_REGISTRY after admission, + // which is exactly the authority leak tests/codex-integration/codex-gather-authority.test.ts + // forbids: a flight must not consult the live registry once its transport has been captured. + // When no decision was threaded in, carry whatever the row already resolved to instead. + const providerAlias = typeof effectiveAlias === "string" || effectiveAlias === null + ? effectiveAlias + : model.providerAlias; + const configuredCap = configuredContextWindow(prov, model.id); + const configuredMaxInput = configuredMaxInputTokens(prov, model.id); + const maxOutputTokens = routedMaxOutputTokens(name, prov, model, model.id, metadataModelIdCaseFold); + const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id); + let inputModalities = configuredInputModalities(prov, model.id); + // The shared vision-sidecar consumer predicate keeps catalog advertisement and request-time + // planning aligned. The catalog must still advertise image input — the Codex app + // gates attachments client-side on input_modalities, and a text-only entry would block images + // before the sidecar ever runs ("This model does not support image inputs"). Discovery-derived + // text-only rows stay untouched: the runtime predicate only reads these two config sources, so + // it would not convert those. + const sidecarCovered = isModelVisionSidecarConsumer(prov, model.id); + if (sidecarCovered) { + const base = inputModalities ?? model.inputModalities ?? ["text"]; + inputModalities = base.includes("image") ? [...base] : [...base, "image"]; + } + const reasoningEfforts = configuredReasoningEfforts(prov, model.id); + const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort; + const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id); + const supportsVerbosity = configuredVerbositySupport(name, prov, model.id); + const fastPolicy = fastPolicyForModel(prov, model.id, name); + const supportsServiceTier = serviceTierSupportFromPolicy(fastPolicy); + const { + supportsServiceTier: _staleServiceTier, + fastTierDescription: _staleFastTierDescription, + providerAlias: _staleProviderAlias, + ...modelWithoutServiceTier + } = model; + // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。 + const discoveredWindow = typeof model.contextWindow === "number" && model.contextWindow > 0 + ? model.contextWindow + : undefined; + const hintedWindow = discoveredWindow !== undefined + ? (configuredCap !== undefined ? Math.min(discoveredWindow, configuredCap) : discoveredWindow) + : (configuredCap ?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined)); + const hinted = { + ...modelWithoutServiceTier, + ...(displayName !== undefined ? { displayName } : {}), + ...(providerAlias !== undefined ? { providerAlias } : {}), + ...(hintedWindow !== undefined ? { contextWindow: hintedWindow } : {}), + ...(inputModalities ? { inputModalities } : {}), + ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), + ...(configuredMaxInput !== undefined + ? { + maxInputTokens: typeof model.maxInputTokens === "number" && model.maxInputTokens > 0 + ? Math.min(model.maxInputTokens, configuredMaxInput) + : configuredMaxInput, + } + : {}), + ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), + ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), + ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), + ...(typeof supportsVerbosity === "boolean" ? { supportsVerbosity } : {}), + ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), + ...(supportsServiceTier === true && fastPolicy.fastTierDescription !== undefined + ? { fastTierDescription: fastPolicy.fastTierDescription } + : {}), + // Default-on for openai-chat providers (explicit false opts out); other adapters + // advertise only on explicit opt-in. + ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false) + ? { parallelToolCalls: true } + : {}), + ...(prov.codexToolMode !== undefined ? { codexToolMode: prov.codexToolMode } : {}), + }; + const capped = applyProviderContextCap(hinted.contextWindow, providerCap); + const withCap = providerCap !== undefined + ? capped !== hinted.contextWindow + ? { ...hinted, contextWindow: capped, contextCap: providerCap, contextCapped: true } + : { ...hinted, contextCap: providerCap, contextCapped: false } + : hinted; + const contextWindow = typeof withCap.contextWindow === "number" && withCap.contextWindow > 0 + ? withCap.contextWindow + : undefined; + const boundedMaxInput = typeof withCap.maxInputTokens === "number" && withCap.maxInputTokens > 0 + ? (contextWindow !== undefined ? Math.min(withCap.maxInputTokens, contextWindow) : withCap.maxInputTokens) + : undefined; + const withHardBounds = boundedMaxInput !== undefined && boundedMaxInput !== withCap.maxInputTokens + ? { ...withCap, maxInputTokens: boundedMaxInput } + : withCap; + const softCandidates = [model.autoCompactTokenLimit, configuredAutoCompact] + .filter((value): value is number => typeof value === "number" && value > 0); + if (contextWindow === undefined || softCandidates.length === 0) return withHardBounds; + return { + ...withHardBounds, + autoCompactTokenLimit: clampAutoCompactTokenLimit( + contextWindow, + boundedMaxInput, + Math.min(...softCandidates), + ), + }; +} + +export function catalogHintsFromProviderConfig( + name: string, + prov: OcxProviderConfig, + id: string, + contextCap?: number, + metadataModelIdCaseFold?: boolean, + effectiveAlias?: string | null, +): Partial { + const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap, metadataModelIdCaseFold, effectiveAlias); + const { provider: _provider, id: _id, ...hints } = hinted; + return hints; +} + +export function applyConfigHintsToCachedModels( + name: string, + prov: OcxProviderConfig, + models: CatalogModel[], + contextCap?: number, + metadataModelIdCaseFold?: boolean, + effectiveAlias?: string | null, +): CatalogModel[] { + return models.map(model => applyProviderConfigHints(name, prov, model, contextCap, metadataModelIdCaseFold, effectiveAlias)); +} +export const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]); + +export const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly>> = { + kimi: new Set([ + "k3[1m]", + "kimi-k2.7-code", + "kimi-k2.7-code-highspeed", + "kimi-k2.6", + "kimi-k2.5", + ]), + xai: new Set([ + "grok-4.3", + "grok-4.20-multi-agent-0309", + "grok-4.20-0309-reasoning", + "grok-4.20-0309-non-reasoning", + "grok-build-0.1", + "grok-composer-2.5-fast", + ]), +}; +/** + * Z.AI and Neuralwatt advertise GLM reasoning as a bare boolean, which would otherwise + * collapse to the four-tier default ladder that omits `max`. These two helpers name the + * ladder each GLM generation actually honours on the wire. + */ +/** GLM-5.2 and its 1M alias: the full five-tier ladder including `max`. */ +export function isGlm52ModelId(id: string): boolean { + const normalized = id.trim().toLowerCase(); + return normalized === "glm-5.2" || normalized === "glm-5.2[1m]"; +} +/** + * GLM-5.3 and its 1M alias. 260814: docs.z.ai/devpack/latest-model folds every incoming + * effort into three effective tiers (low/minimal/light -> low, medium/high -> high, + * xhigh/max/ultra -> max), so a boolean capability must not be expanded to five rows. + */ +export function isGlm53ModelId(id: string): boolean { + const normalized = id.trim().toLowerCase(); + return normalized === "glm-5.3" || normalized === "glm-5.3[1m]"; +} + +function plainRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +const MODEL_DISCOVERY_METADATA_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; + +export function positiveSafeInteger(...values: unknown[]): number | undefined { + return values.find(value => typeof value === "number" && Number.isSafeInteger(value) && value > 0) as number | undefined; +} + +function normalizedMetadataString(raw: string, maxLength: number): string | undefined { + if (raw.length > maxLength * 4 || MODEL_DISCOVERY_METADATA_CONTROL_CHARS.test(raw)) return undefined; + const normalized = raw.trim().toLowerCase().replace(/\s+/g, "-").slice(0, maxLength); + return normalized || undefined; +} + +function normalizedStringList(value: unknown, maxItems = 32, maxLength = 64): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const out: string[] = []; + const maxInspectedItems = Math.max(maxItems * 8, maxItems); + for (let i = 0; i < value.length && i < maxInspectedItems; i += 1) { + const raw = value[i]; + if (typeof raw !== "string") continue; + const normalized = normalizedMetadataString(raw, maxLength); + if (normalized && !out.includes(normalized)) out.push(normalized); + if (out.length >= maxItems) break; + } + return out.length > 0 ? out : undefined; +} + +export function modelCapabilities(item: ProviderModelsApiItem): string[] | undefined { + const metadata = plainRecord(item.metadata); + const metadataCapabilities = metadata?.capabilities; + const capabilityRecord = plainRecord(metadataCapabilities) + ?? plainRecord(item.capabilities) + ?? plainRecord(item.features); + const out = new Set(); + for (const list of [item.capabilities, item.features, item.supported_features, metadataCapabilities]) { + for (const capability of normalizedStringList(list) ?? []) out.add(capability); + } + const capabilityFields = capabilityRecord ?? {}; + let inspectedCapabilityFields = 0; + for (const key in capabilityFields) { + if (!Object.hasOwn(capabilityFields, key)) continue; + inspectedCapabilityFields += 1; + if (inspectedCapabilityFields > 256 || out.size >= 32) break; + if (capabilityFields[key] === true) { + const normalized = normalizedMetadataString(key, 64); + if (normalized) out.add(normalized); + } + } + for (const field of ["supports_tools", "supports_tool_calling", "supports_function_calling"] as const) { + if (item[field] === true) out.add("tools"); + } + for (const field of ["supports_reasoning", "reasoning"] as const) { + if (item[field] === true) out.add("reasoning"); + } + return out.size > 0 ? [...out].filter(Boolean).slice(0, 32) : undefined; +} + +export function modelInputModalities( + item: ProviderModelsApiItem, + capabilities: readonly string[] | undefined, +): string[] | undefined { + const metadata = plainRecord(item.metadata); + const capabilityRecord = plainRecord(metadata?.capabilities) + ?? plainRecord(item.capabilities) + ?? plainRecord(item.features); + const explicit = normalizedStringList( + item.input_modalities + ?? item.modalities + ?? metadata?.input_modalities + ?? capabilityRecord?.input_modalities + ?? plainRecord(item.architecture)?.input_modalities, + 8, + 24, + )?.filter(value => ( + // Codex parses `input_modalities` as a closed enum of text | image | audio. A provider that + // advertises anything else (zenmux reports "video") must not reach the catalog: Codex rejects + // the whole file, so plugins, apps and MCP servers all stop loading over one model's metadata. + value === "text" || value === "image" || value === "audio" + )); + if (explicit && explicit.length > 0) return explicit; + const architecture = plainRecord(item.architecture); + const architectureModality = typeof architecture?.modality === "string" + ? normalizedMetadataString(architecture.modality, 64) + : undefined; + if (architectureModality?.includes("->")) { + const [rawInput = ""] = architectureModality.split("->"); + const inferred = rawInput + .split("+") + .filter(value => value === "text" || value === "image" || value === "audio"); + if (inferred.length > 0) return [...new Set(inferred)]; + } + // GitHub Copilot nests vision support one level down as `capabilities.supports.vision`, so the + // flat read alone finds nothing and every Copilot model falls through to `["text"]` — Codex then + // refuses image attachments on models that accept them (#2941). Precedence is by specificity: + // a flat boolean is authoritative when present, the nested boolean is consulted only otherwise, + // and a non-boolean at either level decides NOTHING so the signals below still apply. Two things + // this ordering deliberately avoids: a deny-wins rule across both levels would flip a provider + // reporting flat `true` with nested `false` from image-capable to text-only, changing behaviour + // that predates Copilot support; and a truthy test would let the string `"no"` advertise image + // input. The payload also carries a SECOND `vision` key under `limits` holding an image count, + // which is why this reads one exact path instead of searching `capabilities` for a vision-ish key. + const nestedSupports = plainRecord(capabilityRecord?.supports); + const explicitVisionSupport = typeof capabilityRecord?.vision === "boolean" + ? capabilityRecord.vision + : typeof nestedSupports?.vision === "boolean" + ? nestedSupports.vision + : undefined; + if (explicitVisionSupport === false) return ["text"]; + if (explicitVisionSupport === true || capabilities?.some(value => ( + value === "vision" || value === "image-input" || value === "image_input" + // llama.cpp and Ollama-compatible servers report vision as "multimodal" — + // it is the only image signal those servers emit (#1797). Mapped to the + // closed `text|image` enum rather than passed through: an out-of-enum + // modality makes Codex reject the entire catalog file. + || value === "multimodal" + ))) { + return ["text", "image"]; + } + return undefined; +} + +/** + * A per-token rate exactly as a /models row publishes it, or undefined when the value is not a + * usable non-negative number. Providers ship these both as JSON numbers and as decimal strings — + * OpenRouter encodes free as the string `"0.00000000"` — so both shapes are accepted and nothing + * else is. The explicit numeric-shape test has to run BEFORE any coercion: `Number("")` and + * `Number(" ")` are both 0 and `Number(true)` is 1, so a bare `Number(value)` would classify a + * row with an empty price string as free. + */ +const DISCOVERED_PRICING_RATE_PATTERN = /^-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?$/; + +function discoveredPricingRate(value: unknown): number | undefined { + const numeric = typeof value === "number" + ? value + : typeof value === "string" && DISCOVERED_PRICING_RATE_PATTERN.test(value.trim()) + ? Number(value.trim()) + : undefined; + if (numeric === undefined || !Number.isFinite(numeric) || numeric < 0) return undefined; + return numeric; +} + +/** + * Cost class for one discovered row, read from the provider's own `pricing` object (#3666). + * + * Fail closed. Only a complete pair of non-negative numeric rates classifies at all; a missing, + * one-sided, non-numeric, or negative rate is "unknown" and therefore excluded from a free-only + * filter. Showing a paid model under a Free filter spends the user's money, while hiding a free + * one costs a click. + * + * Two things that look like evidence and are not. A `:free` id suffix is an OpenRouter naming + * convention, not a price — Nous ships `:free` slugs on a provider whose `freeTier` is false on + * purpose. And the operator's own `modelCosts` overlay is an estimate they typed, not something + * the provider published, so a zeroed overlay never reaches this field either. + * + * Classification is on numeric zero and never on a unit conversion: OpenRouter quotes USD per + * token while the cost overlays and the jawcode bundle quote per 1M, and zero is zero in both. + */ +export function discoveredPricingStatus(item: ProviderModelsApiItem): "free" | "paid" | "unknown" { + const pricing = plainRecord(item.pricing) ?? plainRecord(plainRecord(item.metadata)?.pricing); + if (!pricing) return "unknown"; + const prompt = discoveredPricingRate(pricing.prompt ?? pricing.input); + const completion = discoveredPricingRate(pricing.completion ?? pricing.output); + if (prompt === undefined || completion === undefined) return "unknown"; + return prompt === 0 && completion === 0 ? "free" : "paid"; +} + +export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial { + const metadata = plainRecord(item.metadata); + const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities); + const limits = plainRecord(metadata?.limits); + const capabilityLimits = plainRecord(plainRecord(item.capabilities)?.limits); + const contextWindow = + positiveSafeInteger( + limits?.max_context_length, + // GitHub Copilot reports the live context window here instead of in the metadata or + // top-level fields used by other OpenAI-compatible catalogs (#3156). Keep the existing + // metadata field authoritative when both are present: adding this provider-specific + // fallback must not change previously recognized providers. + capabilityLimits?.max_context_window_tokens, + metadata?.context_length, + item.context_length, + item.context_size, + item.max_model_len, + item.max_context_length, + // llama.cpp reports the served context under `meta`: `n_ctx` is what the + // server was actually started with, `n_ctx_train` the model's trained + // maximum. Prefer the served value — routing must not promise a window the + // running server will refuse. Both come LAST so no provider already + // supplying a recognized field changes behavior (#1797). + plainRecord(item.meta)?.n_ctx, + plainRecord(item.meta)?.n_ctx_train, + // A chained OpenCodex hub (and other re-serving gateways) reports the per-model + // window on the same capability record this function already reads for + // `max_output_tokens` below (#4032). Without it every routed row fell through to + // the 128k compatibility floor in parsing.ts while local forward rows kept their + // real values. Appended after the recognized fields for the same reason as the + // llama.cpp entries above: no provider that already resolves changes behavior. + capabilityRecord?.context_length, + ); + const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens); + const maxOutputTokens = positiveSafeInteger( + capabilityRecord?.max_output_tokens, + limits?.max_output_tokens, + metadata?.max_output_tokens, + item.max_output_tokens, + ); + // Some OpenAI-compatible catalogs expose the selectable ladder under + // `reasoning_parameters.efforts` instead of the older `reasoning_efforts` key. + // Treat both as model metadata: otherwise a valid upstream capability disappears + // before client exporters (including omp) can advertise it. + const reasoningParameters = plainRecord(item.reasoning_parameters) + ?? plainRecord(metadata?.reasoning_parameters) + ?? plainRecord(capabilityRecord?.reasoning_parameters); + const rawReasoningEfforts = capabilityRecord?.reasoning_effort + ?? item.reasoning_efforts + ?? reasoningParameters?.efforts; + const listedReasoningEfforts = normalizedStringList(rawReasoningEfforts, 8, 24); + const reasoningEfforts = listedReasoningEfforts + ? sanitizeCodexReasoningEfforts(listedReasoningEfforts) + : typeof rawReasoningEfforts === "boolean" + ? (rawReasoningEfforts + ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm53ModelId(item.id) + ? ["low", "high", "max"] + : (providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id) + ? ["low", "medium", "high", "xhigh", "max"] + : ["low", "medium", "high", "xhigh"]) + : []) + : undefined; + const capabilities = modelCapabilities(item); + const inputModalities = modelInputModalities(item, capabilities); + const pricingStatus = discoveredPricingStatus(item); + return { + ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}), + ...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}), + ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), + ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), + ...(inputModalities ? { inputModalities } : {}), + ...(capabilities ? { capabilities } : {}), + // Omitted when the classification is "unknown", following this function's existing + // contract that an unknown property is absent rather than present-and-empty. Callers + // that need to tell "provider published no prices" from "this build does not classify" + // call discoveredPricingStatus directly. + ...(pricingStatus !== "unknown" ? { pricingStatus } : {}), + }; +} + +export function boundedOwnedBy(value: unknown): string | undefined { + if (typeof value !== "string" || value.length === 0 || value.length > 256) return undefined; + if (MODEL_DISCOVERY_METADATA_CONTROL_CHARS.test(value)) return undefined; + return value; +} diff --git a/src/codex/catalog/model-visibility.ts b/src/codex/catalog/model-visibility.ts new file mode 100644 index 0000000000..0273a19052 --- /dev/null +++ b/src/codex/catalog/model-visibility.ts @@ -0,0 +1,304 @@ +import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; +import { initialModelSelectionPending } from "../../providers/initial-model-selection"; +import { execFileSync } from "node:child_process"; +import { createHash, createHmac, randomBytes } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config"; +import { resolveProviderApiKey } from "../../providers/key-store"; +import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; +import { + clearModelCache, + clearProviderDiscoveryStatus, + captureModelCacheGeneration, + DEFAULT_MODEL_CACHE_TTL_MS, + getFreshCached, + getStaleCached, + isModelsFetchCoolingDown, + isModelCacheGenerationCurrent, + markModelsFetchFailure, + markProviderDiscoveryFailed, + markProviderDiscoveryOk, + shouldLogDiscoveryFailure, + setCached, + type ProviderModelDiscoveryFailure, +} from "../model-cache"; +import { + buildModelsRequest, + getValidAccessTokenSnapshot, + observeActiveOAuthAccessToken, + resolveModelsAuthToken, + type OAuthActiveTokenObservation, +} from "../../oauth"; +import type { OcxConfig, OcxProviderConfig } from "../../types"; +import { modelInList } from "../../types"; +import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; +import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; +import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; +import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; +import { + captureFastPolicyAuthority, + fastPolicyForModel, + serviceTierSupportFromPolicy, +} from "../../providers/service-tier"; +import type { FastPolicyAuthority } from "../../providers/fastwire"; +import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry"; +import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; +import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; +import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; +import { effectiveModelAliases } from "../../providers/default-aliases"; +import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; +import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; +import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; +import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; +import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; +import { fetchQoderModels } from "../../adapters/qoder/live-models"; +import { resolveQoderProfile } from "../../adapters/qoder/profiles"; +import { fetchDevinUsableModels } from "../../adapters/devin/live-models"; +import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { + COMBO_NAMESPACE, + comboModelId, + getCombo, + listComboIds, + quotaInactiveReason, + targetKey, +} from "../../combos"; +import type { NormalizedComboConfig } from "../../combos/types"; +import { + ProviderOutboundPolicyError, + providerOutboundGet, + providerOutboundPost, + providerRedirectError, +} from "../../lib/provider-outbound"; +import { redactSecretString } from "../../lib/redact"; +import { + extractProviderModelItems, + isRegistryModelDiscoveryUrl, + readBoundedDiscoveryJson, + resolveProviderModelDiscovery, + type ModelDiscoveryResponseFailure, + type ProviderModelsApiItem, + type ResolvedProviderModelDiscovery, +} from "../../providers/model-discovery"; +import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; +import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show"; +import upstreamModelsSnapshot from "../data/upstream-models.json"; +import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing"; +import type { CatalogModel } from "./parsing"; +import { disabledNativeSlugs, hasComboTargets, hasNativeOpenAiCapabilityMetadata, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeOpenAiCapabilityDisplayName, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; +import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; +import type { ComboCatalogOmission } from "./aggregation"; +import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; +import type { + CatalogAdmissionSnapshot, + CatalogDiscoveryPolicyField, + CatalogGatherAuthorityIdentity, + CatalogProviderDiscoveryPolicySnapshot, + CatalogProcessLocalEvidence, + CatalogSourceEvidence, + CatalogTrustedOpenAiApiPolicySnapshot, +} from "../convergence-types"; +import { CALLABLE_CONFIGURED_COMPATIBILITY_MODELS, applyProviderConfigHints } from "./model-hints"; + +const DATED_VARIANT_YYYYMMDD = /^(\d{4})(\d{2})(\d{2})$/; +const DATED_VARIANT_YYMMDD = /^(2\d)(\d{2})(\d{2})$/; +const DATED_VARIANT_MMDD_OR_YYMM = /^(\d{2})(\d{2})$/; + +/** Whether a Gregorian year contains February 29th. */ +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +/** + * Whether a month/day pair exists in the given year. Without a year, February 29th is + * accepted because it occurs in at least one calendar year. + */ +function isValidCalendarDate(year: number | undefined, month: number, day: number): boolean { + if (year !== undefined && (year < 1 || year > 9999)) return false; + if (month < 1 || month > 12 || day < 1) return false; + const daysInMonth = [ + 31, year === undefined || isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, + 31, 31, 30, 31, 30, 31, + ]; + return day <= daysInMonth[month - 1]!; +} + +/** + * Release-date suffixes providers actually publish: `YYYYMMDD` (`-20251001`), `YYMMDD` + * (`-260806`), `MMDD` (`-0813`) and `YYMM` (`-2512`). A `\d{8}`-only rule matched none of + * the dated ids on a real multi-provider install, so DeepSeek, Kimi, Mistral, Qwen and + * Solar aliases all fell through to `droppedConfiguredIds` (#3024). + * + * Calendar validation rejects impossible month-end and leap-day values as well as ordinary + * numeric suffixes such as `-2048`, `-4096` and `-8192`. `-1024` is the one irreducible + * collision — it is a valid `MMDD` (October 24th) — so it reads as dated. That is a known, + * accepted cost; the test table pins it so it cannot become a surprise later. + * + * Hyphenated ISO suffixes (`-2024-08-06`, `-05-06`) are deliberately out of scope: a + * hyphenated suffix is ambiguous against ordinary name segments and needs its own call. + */ +function isDatedVariantSuffix(suffix: string): boolean { + const yyyyMmDd = DATED_VARIANT_YYYYMMDD.exec(suffix); + if (yyyyMmDd) { + return isValidCalendarDate( + Number(yyyyMmDd[1]), Number(yyyyMmDd[2]), Number(yyyyMmDd[3]), + ); + } + + const yyMmDd = DATED_VARIANT_YYMMDD.exec(suffix); + if (yyMmDd) { + return isValidCalendarDate( + 2000 + Number(yyMmDd[1]), Number(yyMmDd[2]), Number(yyMmDd[3]), + ); + } + + const mmDdOrYyMm = DATED_VARIANT_MMDD_OR_YYMM.exec(suffix); + if (!mmDdOrYyMm) return false; + const first = Number(mmDdOrYyMm[1]); + const second = Number(mmDdOrYyMm[2]); + return isValidCalendarDate(undefined, first, second) + || (first >= 20 && first <= 29 && second >= 1 && second <= 12); +} + +/** Whether `liveId` is a supported dated release of the configured base id. */ +export function isDatedVariantId(liveId: string, configuredId: string): boolean { + if (!liveId.startsWith(`${configuredId}-`)) return false; + return isDatedVariantSuffix(liveId.slice(configuredId.length + 1)); +} + +export const lastDropWarnSignature = new Map(); +let lastWarningReconciledGeneration = 0; + +export function reconcileProviderFetchWarnings(generation: number): number { + if (generation <= lastWarningReconciledGeneration) return 0; + const removed = lastDropWarnSignature.size; + lastDropWarnSignature.clear(); + lastWarningReconciledGeneration = generation; + return removed; +} +export function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds: string[]): void { + const signature = [...droppedConfiguredIds].sort().join(","); + if (lastDropWarnSignature.get(name) === signature) return; + lastDropWarnSignature.set(name, signature); + console.warn( + `[opencodex] Provider model discovery for "${name}" omitted configured model ids; dropping them from the authoritative live catalog: ${droppedConfiguredIds.join(", ")}.`, + ); +} +export function shouldExposeProviderModel(providerName: string, modelId: string): boolean { + if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free"); + // xAI /models advertises both the dated deployment and this floating alias. + // Keep only grok-4.20-multi-agent-0309; the alias is the same server-side id. + if (providerName === "xai" && modelId === "grok-4.20-multi-agent-beta-latest") return false; + return true; +} + +export function shouldRetainConfiguredProviderModel( + providerName: string, + modelId: string, + prov?: OcxProviderConfig, +): boolean { + if (CALLABLE_CONFIGURED_COMPATIBILITY_MODELS[providerName]?.has(modelId)) return true; + if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free"); + if (modelInList(prov?.retainModels, modelId)) return true; + return false; +} + +/** + * Fold dated-release aliases and retain configured rows that must survive an + * authoritative live roster (compatibility allow-list, combo targets, Vertex + * default). Used on every discovery return — live, fresh cache, stale, and + * failure fallback — so a warm cache captured before a combo existed still + * surfaces the configured target (OCX-111 / #1308). + * + * Cache writes should pass `retainComboTargets: false` so combo retention is + * re-applied on read against the current capture, not frozen into the TTL entry. + */ +export function mergeConfiguredModelsIntoLiveCatalog(opts: { + name: string; + provider: OcxProviderConfig; + models: readonly CatalogModel[]; + configured: readonly CatalogModel[]; + retainConfiguredModelIds?: ReadonlySet; + contextCap?: number; + seedVertexDefault?: boolean; + retainComboTargets?: boolean; + metadataModelIdCaseFold?: boolean; +}): { models: CatalogModel[]; droppedConfiguredIds: string[] } { + const { + name, + provider: prov, + configured, + retainConfiguredModelIds, + contextCap, + seedVertexDefault, + retainComboTargets = true, + metadataModelIdCaseFold, + } = opts; + const out = [...opts.models]; + const present = new Set(out.map(model => model.id)); + const droppedConfiguredIds: string[] = []; + for (const candidate of configured) { + if (present.has(candidate.id)) continue; + const dated = out.find(live => isDatedVariantId(live.id, candidate.id)); + if (dated) { + out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap, metadataModelIdCaseFold)); + present.add(candidate.id); + continue; + } + if ( + seedVertexDefault === true + || shouldRetainConfiguredProviderModel(name, candidate.id, prov) + || (retainComboTargets && retainConfiguredModelIds?.has(candidate.id) === true) + ) { + out.push(candidate); + present.add(candidate.id); + continue; + } + droppedConfiguredIds.push(candidate.id); + } + return { models: out, droppedConfiguredIds }; +} + +export function filterCatalogVisibleModels( + models: CatalogModel[], + config: Pick, +): CatalogModel[] { + const disabled = new Set(config.disabledModels ?? []); + const allowByProvider = new Map>(); + for (const [name, prov] of Object.entries(config.providers)) { + const sel = prov.selectedModels; + // Keyed the way `sync.ts` keys the same list, so a slash-bearing native id and + // the encoded slug the Codex picker displays are one entry rather than two. A + // bare `Set(sel)` matched only the native form, so an allowlist written from the + // displayed slug — which `ocx models remove` also accepts — hid every model it + // was meant to keep. + // + // The key is deliberately lossy: `p/a/b` and `p/a-b` collapse to one entry, so a + // provider publishing both spellings has them selected together. That is a real + // limitation, pinned by the tests below and tracked as a follow-up; it is NOT + // fixed here. Resolving selections against the current roster instead was tried + // and rejected — the roster is an incomplete dictionary (live discovery can omit + // a published id), so it produces the same over-grant while additionally + // disagreeing with the `slugEquivalenceKey` contract `sync.ts` uses at merge time. + // Two catalog stages with different equivalence relations is the exact bug class + // this change exists to remove. + if (Array.isArray(sel) && sel.length > 0) { + allowByProvider.set(name, new Set(sel.map(model => slugEquivalenceKey(routedSlug(name, model))))); + } + } + return models.filter(m => { + if (initialModelSelectionPending(config.providers[m.provider])) return false; + const nativeAlias = m.provider === COMBO_NAMESPACE && m.nativeAlias === true; + // disabledModels may be stored raw (canonical) or encoded (legacy UI writes). + for (const stored of disabled) { + // Combo management stores the public alias, while canonical `combo/` references + // remain valid for backward compatibility through slugEquals below. + if (m.alias !== undefined && stored === catalogModelSlug(m) && !nativeAlias) return false; + if (slugEquals(stored, m.provider, m.id)) return false; + } + const allow = allowByProvider.get(m.provider); + return !allow || allow.has(slugEquivalenceKey(routedSlug(m.provider, m.id))); + }); +} diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index eaa76f3fdd..b3916a2f76 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1,2944 +1,54 @@ -import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; -import { initialModelSelectionPending } from "../../providers/initial-model-selection"; -import { execFileSync } from "node:child_process"; -import { createHash, createHmac, randomBytes } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; -import { delimiter, dirname, join, resolve } from "node:path"; -import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config"; -import { resolveProviderApiKey } from "../../providers/key-store"; -import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; -import { - clearModelCache, - clearProviderDiscoveryStatus, - captureModelCacheGeneration, - DEFAULT_MODEL_CACHE_TTL_MS, - getFreshCached, - getStaleCached, - isModelsFetchCoolingDown, - isModelCacheGenerationCurrent, - markModelsFetchFailure, - markProviderDiscoveryFailed, - markProviderDiscoveryOk, - shouldLogDiscoveryFailure, - setCached, - type ProviderModelDiscoveryFailure, -} from "../model-cache"; -import { - buildModelsRequest, - getValidAccessTokenSnapshot, - observeActiveOAuthAccessToken, - resolveModelsAuthToken, - type OAuthActiveTokenObservation, -} from "../../oauth"; -import type { OcxConfig, OcxProviderConfig } from "../../types"; -import { modelInList } from "../../types"; -import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; -import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; -import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; -import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; -import { - captureFastPolicyAuthority, - fastPolicyForModel, - serviceTierSupportFromPolicy, -} from "../../providers/service-tier"; -import type { FastPolicyAuthority } from "../../providers/fastwire"; -import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry"; -import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; -import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; -import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; -import { effectiveModelAliases } from "../../providers/default-aliases"; -import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; -import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; -import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; -import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; -import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; -import { fetchQoderModels } from "../../adapters/qoder/live-models"; -import { resolveQoderProfile } from "../../adapters/qoder/profiles"; -import { fetchDevinUsableModels } from "../../adapters/devin/live-models"; -import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; -import { - COMBO_NAMESPACE, - comboModelId, - getCombo, - listComboIds, - quotaInactiveReason, - targetKey, -} from "../../combos"; -import type { NormalizedComboConfig } from "../../combos/types"; -import { - ProviderOutboundPolicyError, - providerOutboundGet, - providerOutboundPost, - providerRedirectError, -} from "../../lib/provider-outbound"; -import { redactSecretString } from "../../lib/redact"; -import { - extractProviderModelItems, - isRegistryModelDiscoveryUrl, - readBoundedDiscoveryJson, - resolveProviderModelDiscovery, - type ModelDiscoveryResponseFailure, - type ProviderModelsApiItem, - type ResolvedProviderModelDiscovery, -} from "../../providers/model-discovery"; -import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; -import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show"; -import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; - - -import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing"; -import type { CatalogModel } from "./parsing"; -import { disabledNativeSlugs, hasComboTargets, hasNativeOpenAiCapabilityMetadata, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeOpenAiCapabilityDisplayName, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; -import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; -import type { ComboCatalogOmission } from "./aggregation"; -import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; -import type { - CatalogAdmissionSnapshot, - CatalogDiscoveryPolicyField, - CatalogGatherAuthorityIdentity, - CatalogProviderDiscoveryPolicySnapshot, - CatalogProcessLocalEvidence, - CatalogSourceEvidence, - CatalogTrustedOpenAiApiPolicySnapshot, -} from "../convergence-types"; - export type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; -/** Concurrent gatherRoutedModels callers with the same catalog identity share one live discovery. - * Keyed by gatherFlightKey so a different config cannot join or evict the wrong flight. */ -export interface CatalogGatherProviderAuthOutcome { - readonly provider: string; - readonly state: OAuthActiveTokenObservation["kind"]; -} - -export interface CatalogGatherProviderModelOutcome { - readonly provider: string; - readonly state: "authoritative" | "degraded"; -} - -export interface GatherRoutedModelsOptions { - comboOmissions?: ComboCatalogOmission[]; - providerAuthOutcomes?: CatalogGatherProviderAuthOutcome[]; - /** Flight-local authority of each provider's returned model rows. */ - providerModelOutcomes?: CatalogGatherProviderModelOutcome[]; - /** Internal convergence sink for the immutable policy that produced the returned rows. */ - discoveryPolicySnapshots?: CatalogProviderDiscoveryPolicySnapshot[]; -} - -interface GatherFlightResult { - models: CatalogModel[]; - comboOmissions: ComboCatalogOmission[]; - providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[]; - providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[]; - discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[]; -} - -interface ProviderModelsResult { - readonly models: CatalogModel[]; - readonly outcome: CatalogGatherProviderModelOutcome; -} - -interface ModelsAuthResolution { - readonly apiKey: string | undefined; - readonly observed: boolean; - readonly oauthApiBaseUrl?: string; - readonly oauthProjectId?: string; -} - -type ModelsAuthResolver = - | { readonly kind: "refreshing" } - | { - readonly kind: "observed"; - readonly resolve: (name: string, provider: OcxProviderConfig) => ModelsAuthResolution; - }; - -type ModelsAuthResolverFactory = ( - outcomes: CatalogGatherProviderAuthOutcome[], -) => ModelsAuthResolver; - -interface CapturedModelsRequest { - readonly method: "GET" | "POST"; - readonly url: string; - readonly headersWithoutCredential: Readonly>; - readonly headersWithCredential: Readonly>; -} - -interface CapturedProviderGather { - readonly name: string; - readonly provider: OcxProviderConfig; - readonly discovery: ResolvedProviderModelDiscovery; - readonly policy: CatalogProviderDiscoveryPolicySnapshot; - readonly request: CapturedModelsRequest; - readonly fastPolicyAuthority: FastPolicyAuthority; - readonly metadataModelIdCaseFold: boolean; - readonly effectiveAlias?: string | null; - readonly observedAuth?: ModelsAuthResolution; - /** - * Configured model ids this provider must keep even when live discovery omits - * them — combo targets that are also listed in providers.*.models (OCX-111). - * Combo-only ids (not in models[]) stay out of the public catalog and are - * synthesized for combo derivation instead (#1305). - */ - readonly retainConfiguredModelIds?: ReadonlySet; -} - -interface GatherFlightCapture { - readonly discoveryPolicyIdentity: string; - readonly authIdentity: string; - readonly providerGraphIdentity: string; - readonly discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[]; - readonly providers: readonly CapturedProviderGather[]; - readonly authResolver: ModelsAuthResolver; - readonly providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[]; - readonly openAiApiPolicy: CatalogTrustedOpenAiApiPolicySnapshot; -} - -interface GatherInflightEntry { - readonly discoveryPolicyIdentity: string; - /** - * The credential half of the join decision. - * - * `gatherFlightKey`'s fingerprint carries endpoints and model lists but no - * `authMode`, key or headers, and discovery policy does not carry them either. - * Two admissions differing ONLY in credential therefore produced the same key - * and the same policy, so the second joined the first and published rows the - * old key had fetched — reproduced against the real routes by rotating a key - * through `/api/providers/keys` mid-flight. - * - * Now REDUNDANT with `providerGraphIdentity`, which hashes the whole provider - * row and therefore covers `apiKey` too: removing this term alone leaves the - * credential regression green. It is kept deliberately, for two reasons. It - * covers what the graph cannot — the RESOLVED auth (`observedAuth`) and the - * final materialized headers, which are derived rather than stored, so an - * OAuth token that changes while the row is byte-identical still separates - * admissions. And it states the credential rule where a reader looks for it, - * instead of leaving it as an emergent property of hashing everything. - */ - readonly authIdentity: string; - /** - * The whole admitted provider graph, not a chosen subset. - * - * `providerCatalogFingerprint` is an ALLOW-LIST, so every field it forgot was - * silently treated as equivalence: credentials leaked a flight until - * `authIdentity` landed, and `reasoningEfforts` leaked one after that — both - * reproduced against real routes. Enumerating fields cannot converge, because - * the next field added to a provider row inherits the same defect. This - * identity therefore covers the enriched, frozen provider objects the flight - * actually gathered from, so a join is refused unless the admissions agree on - * everything rather than on everything somebody remembered to list. - */ - readonly providerGraphIdentity: string; - readonly promise: Promise; -} - -function withCanonicalOpenAiForwardAuthDefault( - name: string, - provider: OcxProviderConfig, -): OcxProviderConfig { - if (name !== OPENAI_CODEX_PROVIDER_ID || provider.authMode !== undefined) return provider; - const candidate = { ...provider, authMode: "forward" as const }; - return isCanonicalOpenAiForwardProvider(candidate) ? candidate : provider; -} - -const gatherInflight = new Map(); -const CATALOG_GATHER_AUTHORITY_KEY = randomBytes(32); -const REQUEST_CREDENTIAL_SENTINEL = `ocx-catalog-credential-${randomBytes(16).toString("hex")}`; -const MAX_CONCURRENT_CATALOG_GATHERS = 8; -const gatherGate = createAdmissionGate("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS); - -export class CatalogGatherBusyError extends ResourceAdmissionError { - override readonly code = "catalog_busy"; - readonly retryAfterSeconds = 1; - constructor() { - super("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS); - this.name = "CatalogGatherBusyError"; - } -} - -export function catalogGatherAdmissionMetrics(): AdmissionMetrics { - return gatherGate.metrics(); -} - -function stableJson(value: unknown): string { - return JSON.stringify(value, (_key, nested) => { - if (nested && typeof nested === "object" && !Array.isArray(nested)) { - return Object.fromEntries(Object.entries(nested as Record).sort(([a], [b]) => a.localeCompare(b))); - } - return nested; - }); -} - -function framed(value: string): string { - return `${Buffer.byteLength(value, "utf8")}:${value}`; -} - -function canonicalAuthorityEncoding(value: unknown): string { - if (value === null) return "null"; - if (value === undefined) return "undefined"; - if (typeof value === "string") return `string${framed(value)}`; - if (typeof value === "boolean") return value ? "boolean1" : "boolean0"; - if (typeof value === "number") { - if (!Number.isFinite(value)) throw new TypeError("Catalog authority cannot encode a non-finite number."); - const encoded = Object.is(value, -0) ? "-0" : String(value); - return `number${framed(encoded)}`; - } - if (Array.isArray(value)) { - return `array${value.length}:${value.map(item => framed(canonicalAuthorityEncoding(item))).join("")}`; - } - if (typeof value === "object") { - const record = value as Record; - const keys = Object.keys(record).sort((left, right) => left.localeCompare(right)); - return `object${keys.length}:${keys.map(key => ( - `${framed(key)}${framed(canonicalAuthorityEncoding(record[key]))}` - )).join("")}`; - } - throw new TypeError(`Catalog authority cannot encode ${typeof value}.`); -} - -function keyedGatherIdentity(domain: string, value: unknown): string { - return createHmac("sha256", CATALOG_GATHER_AUTHORITY_KEY) - .update(framed(domain)) - .update(framed(canonicalAuthorityEncoding(value))) - .digest("hex"); -} - -function keyedGatherBytesIdentity(domain: string, value: Uint8Array): string { - return createHmac("sha256", CATALOG_GATHER_AUTHORITY_KEY) - .update(framed(domain)) - .update(`${value.byteLength}:`) - .update(value) - .digest("hex"); -} - -export function createCatalogGatherAuthorityIdentity( - snapshot: CatalogAdmissionSnapshot, - sourceEvidence: CatalogSourceEvidence, - processLocal: CatalogProcessLocalEvidence, - discoveryPolicies: readonly CatalogProviderDiscoveryPolicySnapshot[], -): CatalogGatherAuthorityIdentity { - const sourceEvidenceIdentity = keyedGatherIdentity("catalog-source-evidence-v1", sourceEvidence); - const processLocalEvidenceIdentity = keyedGatherIdentity("catalog-process-local-v1", processLocal); - const discoveryPolicyIdentity = keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicies); - return Object.freeze({ - version: 1 as const, - authorityId: keyedGatherIdentity("catalog-authority-v1", { - admittedConfig: snapshot.configIdentity, - discoveryPolicyIdentity, - sourceEvidenceIdentity, - processLocalEvidenceIdentity, - }), - admittedConfig: Object.freeze({ - ...snapshot.configIdentity, - generation: Object.freeze({ ...snapshot.configIdentity.generation }), - }), - authSnapshotIdentity: keyedGatherIdentity( - "catalog-auth-v1", - sourceEvidence.conditional["provider-auth-selection"], - ), - discoveryPolicyIdentity, - nativeCatalogSourceIdentity: keyedGatherIdentity( - "catalog-native-v1", - sourceEvidence.conditional["native-catalog-selection"], - ), - sourceEvidenceIdentity, - processLocalEvidenceIdentity, - }); -} - -function detachedClone(value: T): T { - if (Array.isArray(value)) return value.map(item => detachedClone(item)) as T; - if (value && typeof value === "object") { - const clone: Record = {}; - for (const key of Object.keys(value)) { - clone[key] = detachedClone((value as Record)[key]); - } - return clone as T; - } - return value; -} - -function recursivelyFreeze(value: T): T { - if (!value || typeof value !== "object" || Object.isFrozen(value)) return value; - for (const nested of Object.values(value as Record)) recursivelyFreeze(nested); - return Object.freeze(value); -} - -function detachedFrozen(value: T): T { - return recursivelyFreeze(detachedClone(value)); -} - -function capturedField( - value: T | undefined, - key: K, -): CatalogDiscoveryPolicyField { - if (!value || !Object.hasOwn(value, key)) return Object.freeze({ state: "absent" }); - return detachedFrozen({ state: "present" as const, value: value[key] }); -} - -function captureTrustedOpenAiApiPolicy( - name: string, - registryTransportMatch: boolean, -): CatalogTrustedOpenAiApiPolicySnapshot { - if (name !== OPENAI_API_PROVIDER_ID) return Object.freeze({ state: "unused" }); - if (!registryTransportMatch) return Object.freeze({ state: "transport-mismatch" }); - const entry = getProviderRegistryEntry(name); - if (!entry?.models) return Object.freeze({ state: "registry-models-absent" }); - return detachedFrozen({ - state: "captured" as const, - models: entry.models, - ...(entry.modelContextWindows ? { modelContextWindows: entry.modelContextWindows } : {}), - ...(entry.modelMaxInputTokens ? { modelMaxInputTokens: entry.modelMaxInputTokens } : {}), - ...(entry.modelMaxOutputTokens ? { modelMaxOutputTokens: entry.modelMaxOutputTokens } : {}), - ...(entry.virtualModels ? { virtualModels: entry.virtualModels } : {}), - ...(entry.modelInputModalities ? { modelInputModalities: entry.modelInputModalities } : {}), - ...(entry.modelReasoningEfforts ? { modelReasoningEfforts: entry.modelReasoningEfforts } : {}), - }); -} - -function captureModelsRequest( - name: string, - provider: OcxProviderConfig, - observedAuth: ModelsAuthResolution | undefined, -): CapturedModelsRequest { - const observed = observedAuth - ? { oauthApiBaseUrl: observedAuth.oauthApiBaseUrl } - : undefined; - const withoutCredential = buildModelsRequest(provider, undefined, name, observed); - const withCredential = buildModelsRequest(provider, REQUEST_CREDENTIAL_SENTINEL, name, observed); - const method = withoutCredential.method ?? "GET"; - if (withoutCredential.url !== withCredential.url || method !== (withCredential.method ?? "GET")) { - throw new TypeError(`Provider model discovery URL for ${name} depends on credential bytes.`); - } - return detachedFrozen({ - method, - url: withoutCredential.url, - headersWithoutCredential: withoutCredential.headers, - headersWithCredential: withCredential.headers, - }); -} - -/** - * Fill the registry seed's per-model numeric capability maps beneath the provider's own - * values, mutating `prov` in place. The merge is per key — an operator's entry always - * wins; a model the persisted map never mentions picks up its seed value — matching - * `mergeRecordFill` in src/router.ts exactly. - * - * Routing already performs this fill at resolve time (routedProviderConfig in - * src/router.ts) and the catalog did not, and that divergence is #4570: - * zhipu-bigmodel-coding/glm-5.3-flash reached the live catalog with correct modalities - * but no context window, because an install persisted before Flash joined the seed map - * held a truthy partial `modelContextWindows` that shadowed the whole seed. - * - * This lives here and not in enrichProviderFromRegistry because enrichment output is - * persisted on a management POST, and #1409 (pinned by - * tests/server/management-provider-validation.test.ts) requires that a save never write - * registry seed keys into the operator's config. The gather clone is detached and - * frozen, never saved, so the catalog can see the seed without the config gaining it. - */ -export function applyRegistryCapabilitySeedFill(name: string, prov: OcxProviderConfig): void { - // router.ts resolves the canonical OpenAI API provider's token maps with - // mergePositiveNumberCaps (user values cap the seed rather than replace it), so a - // plain fill here would give that one provider catalog semantics routing never has. - if (name === OPENAI_API_PROVIDER_ID) return; - if (!providerMatchesRegistryTransport(name, prov)) return; - const entry = getProviderRegistryEntry(name); - if (!entry) return; - if (entry.modelContextWindows || prov.modelContextWindows) { - prov.modelContextWindows = { ...(entry.modelContextWindows ?? {}), ...(prov.modelContextWindows ?? {}) }; - } - if (entry.modelMaxOutputTokens || prov.modelMaxOutputTokens) { - prov.modelMaxOutputTokens = { ...(entry.modelMaxOutputTokens ?? {}), ...(prov.modelMaxOutputTokens ?? {}) }; - } -} - -function captureProviderGather( - name: string, - configured: OcxProviderConfig, - authResolver: ModelsAuthResolver, - retainConfiguredModelIds?: ReadonlySet, - config?: Pick, -): CapturedProviderGather { - const enriched = detachedClone(withCanonicalOpenAiForwardAuthDefault(name, configured)); - enrichProviderFromRegistry(name, enriched); - applyRegistryCapabilitySeedFill(name, enriched); - const registryTransportMatch = providerMatchesRegistryTransport(name, enriched); - const provider = recursivelyFreeze(enriched); - const fastPolicyAuthority = captureFastPolicyAuthority( - name, - provider, - registryTransportMatch, - configured, - ); - const metadataModelIdCaseFold = shouldCaseFoldMetadataModelId(name); - const observedAuth = authResolver.kind === "observed" - && provider.authMode !== "forward" - && provider.liveModels !== false - ? authResolver.resolve(name, provider) - : undefined; - const request = captureModelsRequest(name, provider, observedAuth); - const resolved = resolveProviderModelDiscovery(name, provider); - const discovery = detachedFrozen({ - ...(resolved.spec ? { spec: resolved.spec } : {}), - maxResponseBytes: resolved.maxResponseBytes, - maxModels: resolved.maxModels, - }); - const trustedOpenAiApi = captureTrustedOpenAiApiPolicy(name, registryTransportMatch); - const policy = detachedFrozen({ - provider: name, - registryTransportMatch, - location: { - spec: discovery.spec ? "present" as const : "absent" as const, - url: capturedField(discovery.spec, "url"), - path: capturedField(discovery.spec, "path"), - query: capturedField(discovery.spec, "query"), - }, - finalMethod: request.method, - finalUrl: request.url, - filter: capturedField(discovery.spec, "filter"), - maxResponseBytes: discovery.maxResponseBytes, - maxModels: discovery.maxModels, - trustedOpenAiApi, - }); - const effectiveAlias = effectiveProviderAliasDecision(name, configured, config); - return Object.freeze({ - name, - provider, - discovery, - policy, - request, - fastPolicyAuthority, - metadataModelIdCaseFold, - effectiveAlias, - ...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}), - ...(retainConfiguredModelIds && retainConfiguredModelIds.size > 0 - ? { retainConfiguredModelIds } - : {}), - }); -} - -/** Model ids each provider must retain for combo catalog derivation (OCX-111). */ -export function configuredComboTargetModelsByProvider( - config: Pick, -): Map> { - const byProvider = new Map>(); - for (const id of listComboIds(config)) { - const combo = getCombo(config, id); - if (!combo) continue; - for (const target of combo.targets) { - let models = byProvider.get(target.provider); - if (!models) { - models = new Set(); - byProvider.set(target.provider, models); - } - models.add(target.model); - } - } - return byProvider; -} - -function captureGatherFlight( - config: OcxConfig, - createAuthResolver: ModelsAuthResolverFactory, -): GatherFlightCapture { - const providerAuthOutcomes: CatalogGatherProviderAuthOutcome[] = []; - const authResolver = createAuthResolver(providerAuthOutcomes); - const comboTargetsByProvider = configuredComboTargetModelsByProvider(config); - const providers = Object.entries(config.providers) - .filter(([, provider]) => provider.disabled !== true) - .map(([name, provider]) => captureProviderGather( - name, - provider, - authResolver, - comboTargetsByProvider.get(name), - config, - )); - const discoveryPolicySnapshots = Object.freeze(providers.map(provider => provider.policy)); - return Object.freeze({ - discoveryPolicyIdentity: keyedGatherIdentity("catalog-discovery-policy-v1", discoveryPolicySnapshots), - // Credentials are hashed under the same unexported per-process key, never - // stored or compared in the clear: this value can reach a map key and must - // not disclose a token. The final headers are included because a static - // header can carry authority just as an `apiKey` can. - authIdentity: keyedGatherIdentity("catalog-gather-auth-v1", providers.map(provider => ({ - name: provider.name, - authMode: provider.provider.authMode ?? null, - liveModels: provider.provider.liveModels ?? null, - credential: provider.provider.apiKey ?? null, - observedAuth: provider.observedAuth ?? null, - headers: provider.request.headersWithCredential, - url: provider.request.url, - }))), - // Every enriched provider row the flight will gather from, in admission order. - // Anything that can change a catalog row lives in here by construction. - providerGraphIdentity: keyedGatherIdentity("catalog-gather-provider-graph-v1", - providers.map(provider => ({ - name: provider.name, - // `fetch` is a caller-owned transport executor, not admitted state: the - // outbound transport honors it so a caller can supply its own HTTP path. - // It is the one member of a provider row that is legitimately a function, - // so it is dropped here rather than allowed to break every encode. - provider: omitProviderTransportExecutor(provider.provider), - fastPolicyAuthority: provider.fastPolicyAuthority, - // Combo retention is capture-time state, not a provider-row field. Two - // gathers that share providers but differ in combo targets must not join. - retainConfiguredModelIds: [...(provider.retainConfiguredModelIds ?? [])].sort(), - }))), - discoveryPolicySnapshots, - providers: Object.freeze(providers), - authResolver, - providerAuthOutcomes: Object.freeze([...providerAuthOutcomes]), - openAiApiPolicy: providers.find(provider => provider.name === OPENAI_API_PROVIDER_ID)?.policy.trustedOpenAiApi - ?? Object.freeze({ state: "unused" as const }), - }); -} - -/** - * Drop the caller-owned transport executor before hashing a provider row. - * - * Fails closed on anything ELSE that cannot be encoded: the point of hashing the - * whole row is that no field escapes the comparison, so a second function member - * must surface as an encode error rather than being quietly skipped here. - */ -function omitProviderTransportExecutor(provider: OcxProviderConfig): Record { - const entries = Object.entries(provider).filter(([key]) => key !== "fetch"); - return Object.fromEntries(entries); -} - -function materializeCapturedHeaders( - request: CapturedModelsRequest, - apiKey: string | undefined, -): Record { - const source = apiKey ? request.headersWithCredential : request.headersWithoutCredential; - return Object.fromEntries(Object.entries(source).map(([name, value]) => [ - name, - apiKey ? value.split(REQUEST_CREDENTIAL_SENTINEL).join(apiKey) : value, - ])); -} - -function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Record { - return { - n: name, - // Preserve the persisted tri-state. Registry enrichment may turn an omitted value into - // `false` while an explicit `true` stays live, so those callers must not share a flight. - live: prov.liveModels ?? null, - base: prov.baseUrl ?? "", - adapter: prov.adapter ?? "", - models: [...(prov.models ?? [])].sort(), - retain: [...(prov.retainModels ?? [])].sort(), - selected: [...(prov.selectedModels ?? [])].sort(), - displayNames: prov.modelDisplayNames ?? null, - defaultModel: prov.defaultModel ?? null, - ctx: prov.contextWindow ?? null, - ctxW: prov.modelContextWindows ?? null, - maxIn: prov.modelMaxInputTokens ?? null, - maxOut: prov.modelMaxOutputTokens ?? null, - autoCompact: prov.modelAutoCompactTokenLimits ?? null, - inMod: prov.modelInputModalities ?? null, - capabilities: prov.modelCapabilities ?? null, - re: prov.modelReasoningEfforts ?? null, - defRe: prov.modelDefaultReasoningEfforts ?? null, - rsSum: prov.modelSupportsReasoningSummaries ?? null, - verbosity: prov.modelSupportsVerbosity ?? null, - rsDel: prov.modelReasoningSummaryDelivery ?? null, - serviceTier: prov.modelSupportsServiceTier ?? null, - noVis: [...(prov.noVisionModels ?? [])].sort(), - ptc: prov.parallelToolCalls ?? null, - gMode: prov.googleMode ?? null, - }; -} - -function gatherFlightKey(config: OcxConfig): string { - const providers = Object.entries(config.providers) - .filter(([, prov]) => prov.disabled !== true) - .map(([name, prov]) => providerCatalogFingerprint(name, prov)) - .sort((a, b) => String(a.n).localeCompare(String(b.n))); - const assembly = stableJson({ - providers, - disabledModels: [...(config.disabledModels ?? [])].sort(), - combos: config.combos ?? {}, - customModels: (config.customModels ?? []).map((cm) => ({ - p: cm.provider, - m: cm.modelId, - d: cm.displayName ?? null, - cw: cm.contextWindow ?? null, - im: cm.inputModalities ?? null, - })), - caps: config.providerContextCaps ?? null, - }); - const digest = createHash("sha256").update(assembly).digest("hex").slice(0, 16); - return `${digest}#${config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS}`; -} - -/** Drop in-flight gather so tests / full cache clears do not reuse a stale promise. */ -export function clearGatherRoutedModelsInflight(): void { - gatherInflight.clear(); -} - -const NUMERIC_MODEL_ID_SEGMENT = /^\d+$/; - -/** - * Resolve an unknown Claude point release or date pin from the nearest configured - * family row. Only numeric tail segments are removed so unrelated model families - * cannot inherit one another's limits. - */ -function anthropicFamilyContextWindow( - record: Record | undefined, - id: string, -): number | undefined { - if (!record || !id.toLowerCase().startsWith("claude-")) return undefined; - let candidate = id; - while (true) { - const cut = candidate.lastIndexOf("-"); - if (cut <= 0 || !NUMERIC_MODEL_ID_SEGMENT.test(candidate.slice(cut + 1))) return undefined; - candidate = candidate.slice(0, cut); - const value = modelRecordValue(record, candidate); - if (typeof value === "number" && value > 0) return value; - } -} - -/** - * Resolve the configured context window in exact-model, Anthropic numeric-family, - * then provider-wide order. Return undefined when the selected value is not positive. - */ -export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined { - const configured = modelRecordValue(prov.modelContextWindows, id) - ?? (prov.adapter === "anthropic" ? anthropicFamilyContextWindow(prov.modelContextWindows, id) : undefined) - ?? prov.contextWindow; - return typeof configured === "number" && configured > 0 ? configured : undefined; -} - -export function configuredInputModalities(prov: OcxProviderConfig, id: string): string[] | undefined { - const declared = Object.hasOwn(prov.modelCapabilities ?? {}, id) - ? prov.modelCapabilities?.[id]?.inputModalities : undefined; - const modalities = declared ?? modelRecordValue(prov.modelInputModalities, id); - return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined; -} - -/** Exact display-only override for one provider-native model id. */ -export function configuredModelDisplayName( - prov: OcxProviderConfig, - id: string, -): string | undefined { - if (!prov.modelDisplayNames || !Object.hasOwn(prov.modelDisplayNames, id)) return undefined; - const value = prov.modelDisplayNames[id]; - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -export function configuredMaxInputTokens(prov: OcxProviderConfig, id: string): number | undefined { - const configured = modelRecordValue(prov.modelMaxInputTokens, id); - return typeof configured === "number" && configured > 0 ? configured : undefined; -} - -function generatedMaxOutputTokens( - providerName: string, - id: string, - metadataId = id, - metadataModelIdCaseFold?: boolean, -): number | undefined { - const metadataProvider = providerName === OPENAI_API_PROVIDER_ID || providerName === OPENAI_CODEX_PROVIDER_ID - ? "openai" - : resolveMetadataProvider(providerName); - if (!metadataProvider) return undefined; - const metadata = getModelMetadata(metadataProvider, metadataId) - ?? ((metadataModelIdCaseFold ?? (providerName === OPENAI_API_PROVIDER_ID || providerName === OPENAI_CODEX_PROVIDER_ID - ? false - : shouldCaseFoldMetadataModelId(providerName))) - ? getModelMetadataCaseInsensitive(metadataProvider, metadataId) - : undefined); - return positiveSafeInteger(metadata?.maxTokens); -} - -function routedMaxOutputTokens( - providerName: string, - provider: OcxProviderConfig, - model: CatalogModel, - metadataId = model.id, - metadataModelIdCaseFold?: boolean, -): number | undefined { - const discovered = positiveSafeInteger(model.maxOutputTokens); - const generated = generatedMaxOutputTokens(providerName, model.id, metadataId, metadataModelIdCaseFold); - const configured = positiveSafeInteger( - modelRecordValue(provider.modelMaxOutputTokens, model.id), - ); - const authoritative = discovered ?? generated; - if (configured === undefined) return authoritative; - return authoritative === undefined - ? configured - : Math.min(authoritative, configured); -} - -export function configuredAutoCompactTokenLimit( - prov: OcxProviderConfig | undefined, - id: string, -): number | undefined { - if (!prov) return undefined; - const configured = modelRecordValue(prov.modelAutoCompactTokenLimits, id); - return typeof configured === "number" && Number.isSafeInteger(configured) && configured > 0 - ? configured - : undefined; -} - -function configuredReasoningSummarySupport(prov: OcxProviderConfig | undefined, id: string): boolean | undefined { - if (!prov) return undefined; - const explicit = modelRecordValue(prov.modelSupportsReasoningSummaries, id); - if (explicit !== undefined) return explicit; - return modelRecordValue(prov.modelReasoningSummaryDelivery, id) !== undefined ? true : undefined; -} - -function configuredVerbositySupport(name: string, prov: OcxProviderConfig | undefined, id: string): boolean | undefined { - const explicit = prov ? modelRecordValue(prov.modelSupportsVerbosity, id) : undefined; - if (explicit !== undefined) return explicit; - if (!prov) return undefined; - void name; - // Provider-wide fallback for ids the per-model map does not enumerate — a live-discovered - // model would otherwise re-advertise a control the upstream accepts and ignores. - // - // Read from the PROVIDER CONFIG, never from PROVIDER_REGISTRY. A gather flight captures its - // registry authority up front and forbids any later registry read, so consulting the registry - // here made a custom-destination flight fall back to "configured" instead of serving its own - // discovery result (tests/codex-integration/codex-gather-authority.test.ts). `applyVerbosityDefaults` in - // providers/derive.ts materializes the registry default into the config at seed/enrich time. - return prov.supportsVerbosity; -} - -export function applyProviderConfigHints( - name: string, - prov: OcxProviderConfig, - model: CatalogModel, - providerCap?: number, - metadataModelIdCaseFold?: boolean, - effectiveAlias?: string | null, -): CatalogModel { - const displayName = configuredModelDisplayName(prov, model.id); - // The alias decision is resolved once at flight admission (captureProviderGather) and threaded - // through as `effectiveAlias`. Re-deriving it here would read PROVIDER_REGISTRY after admission, - // which is exactly the authority leak tests/codex-integration/codex-gather-authority.test.ts - // forbids: a flight must not consult the live registry once its transport has been captured. - // When no decision was threaded in, carry whatever the row already resolved to instead. - const providerAlias = typeof effectiveAlias === "string" || effectiveAlias === null - ? effectiveAlias - : model.providerAlias; - const configuredCap = configuredContextWindow(prov, model.id); - const configuredMaxInput = configuredMaxInputTokens(prov, model.id); - const maxOutputTokens = routedMaxOutputTokens(name, prov, model, model.id, metadataModelIdCaseFold); - const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id); - let inputModalities = configuredInputModalities(prov, model.id); - // The shared vision-sidecar consumer predicate keeps catalog advertisement and request-time - // planning aligned. The catalog must still advertise image input — the Codex app - // gates attachments client-side on input_modalities, and a text-only entry would block images - // before the sidecar ever runs ("This model does not support image inputs"). Discovery-derived - // text-only rows stay untouched: the runtime predicate only reads these two config sources, so - // it would not convert those. - const sidecarCovered = isModelVisionSidecarConsumer(prov, model.id); - if (sidecarCovered) { - const base = inputModalities ?? model.inputModalities ?? ["text"]; - inputModalities = base.includes("image") ? [...base] : [...base, "image"]; - } - const reasoningEfforts = configuredReasoningEfforts(prov, model.id); - const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort; - const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id); - const supportsVerbosity = configuredVerbositySupport(name, prov, model.id); - const fastPolicy = fastPolicyForModel(prov, model.id, name); - const supportsServiceTier = serviceTierSupportFromPolicy(fastPolicy); - const { - supportsServiceTier: _staleServiceTier, - fastTierDescription: _staleFastTierDescription, - providerAlias: _staleProviderAlias, - ...modelWithoutServiceTier - } = model; - // 已发现窗口只允许被配置值压低;缺窗口时,已开的 Context cap 就是实际窗口。 - const discoveredWindow = typeof model.contextWindow === "number" && model.contextWindow > 0 - ? model.contextWindow - : undefined; - const hintedWindow = discoveredWindow !== undefined - ? (configuredCap !== undefined ? Math.min(discoveredWindow, configuredCap) : discoveredWindow) - : (configuredCap ?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined)); - const hinted = { - ...modelWithoutServiceTier, - ...(displayName !== undefined ? { displayName } : {}), - ...(providerAlias !== undefined ? { providerAlias } : {}), - ...(hintedWindow !== undefined ? { contextWindow: hintedWindow } : {}), - ...(inputModalities ? { inputModalities } : {}), - ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), - ...(configuredMaxInput !== undefined - ? { - maxInputTokens: typeof model.maxInputTokens === "number" && model.maxInputTokens > 0 - ? Math.min(model.maxInputTokens, configuredMaxInput) - : configuredMaxInput, - } - : {}), - ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), - ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), - ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), - ...(typeof supportsVerbosity === "boolean" ? { supportsVerbosity } : {}), - ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), - ...(supportsServiceTier === true && fastPolicy.fastTierDescription !== undefined - ? { fastTierDescription: fastPolicy.fastTierDescription } - : {}), - // Default-on for openai-chat providers (explicit false opts out); other adapters - // advertise only on explicit opt-in. - ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false) - ? { parallelToolCalls: true } - : {}), - ...(prov.codexToolMode !== undefined ? { codexToolMode: prov.codexToolMode } : {}), - }; - const capped = applyProviderContextCap(hinted.contextWindow, providerCap); - const withCap = providerCap !== undefined - ? capped !== hinted.contextWindow - ? { ...hinted, contextWindow: capped, contextCap: providerCap, contextCapped: true } - : { ...hinted, contextCap: providerCap, contextCapped: false } - : hinted; - const contextWindow = typeof withCap.contextWindow === "number" && withCap.contextWindow > 0 - ? withCap.contextWindow - : undefined; - const boundedMaxInput = typeof withCap.maxInputTokens === "number" && withCap.maxInputTokens > 0 - ? (contextWindow !== undefined ? Math.min(withCap.maxInputTokens, contextWindow) : withCap.maxInputTokens) - : undefined; - const withHardBounds = boundedMaxInput !== undefined && boundedMaxInput !== withCap.maxInputTokens - ? { ...withCap, maxInputTokens: boundedMaxInput } - : withCap; - const softCandidates = [model.autoCompactTokenLimit, configuredAutoCompact] - .filter((value): value is number => typeof value === "number" && value > 0); - if (contextWindow === undefined || softCandidates.length === 0) return withHardBounds; - return { - ...withHardBounds, - autoCompactTokenLimit: clampAutoCompactTokenLimit( - contextWindow, - boundedMaxInput, - Math.min(...softCandidates), - ), - }; -} - -export function catalogHintsFromProviderConfig( - name: string, - prov: OcxProviderConfig, - id: string, - contextCap?: number, - metadataModelIdCaseFold?: boolean, - effectiveAlias?: string | null, -): Partial { - const hinted = applyProviderConfigHints(name, prov, { id, provider: name }, contextCap, metadataModelIdCaseFold, effectiveAlias); - const { provider: _provider, id: _id, ...hints } = hinted; - return hints; -} - -export function applyConfigHintsToCachedModels( - name: string, - prov: OcxProviderConfig, - models: CatalogModel[], - contextCap?: number, - metadataModelIdCaseFold?: boolean, - effectiveAlias?: string | null, -): CatalogModel[] { - return models.map(model => applyProviderConfigHints(name, prov, model, contextCap, metadataModelIdCaseFold, effectiveAlias)); -} - - -/** - * Last-resort context window for combo member synthesis when discovery, - * provider config, and an enabled Context cap all omit one. Matches the - * catalog entry default in `normalizeRoutedCatalogEntry` so incomplete live - * rows still catalog. An enabled Context cap is the operator-facing window, - * not a clamp on this placeholder. - */ -const COMBO_MEMBER_CONTEXT_FALLBACK = 128_000; - -interface ComboCatalogMemberFallback { - readonly contextWindow?: number; - /** Input ceiling when it is lower than the window (native GPT-5.6: 922k under 1.05M). */ - readonly maxInputTokens?: number; - readonly maxOutputTokens?: number; - readonly autoCompactTokenLimit?: number; - readonly inputModalities?: readonly string[]; - readonly reasoningEfforts?: readonly string[]; -} - -/** - * Ladder advertised for a combo member whose vendor metadata says it reasons but - * carries no explicit ladder (Claude, Grok). Codex needs a non-empty ladder to show - * the effort control; the routed adapters clamp to the real upstream top rung. - */ -const ROUTED_COMBO_MEMBER_REASONING_EFFORTS: readonly string[] = ["low", "medium", "high", "xhigh", "max"]; - -/** - * Vendor-table lookup tolerant of point releases and date pins. Configured combo - * targets often name a variant the table does not carry (`claude-fable-5-1`, - * `claude-opus-4-5-20251101`); the base family row still describes its modality - * and reasoning capability, so fall back to it before giving up. - */ -function comboMemberVendorMetadata(provider: string, modelId: string): ModelMetadata | undefined { - const exact = getModelMetadataCaseInsensitive(provider, modelId); - if (exact) return exact; - let candidate = modelId.replace(/\[[^\]]*\]$/, ""); - while (true) { - const trimmed = candidate.replace(/-\d+$/, ""); - if (trimmed === candidate || !trimmed.includes("-")) return undefined; - const hit = getModelMetadataCaseInsensitive(provider, trimmed); - if (hit) return hit; - candidate = trimmed; - } -} - -/** - * Combo members are usually thin discovery rows (id + context window). Without a - * capability source the combo intersection collapses to text-only / no effort ladder, - * and the Codex app then refuses image attachments and hides the effort picker for - * every Claude combo. The generated vendor table knows both, so use it as the - * last-resort fallback when the caller supplied none. - * - * `ModelMetadata.maxTokens` is the OUTPUT ceiling, so it fills `maxOutputTokens`. - * Mapping it onto `maxInputTokens` would be read by the combo intersection - * (`aggregation.ts` `Math.min` over member input ceilings) as a 128k input limit and - * shrink a 1M Claude combo window to 128k, taking autoCompactTokenLimit down with it. - */ -function vendorMetadataComboFallback(target: { provider: string; model: string }): ComboCatalogMemberFallback | undefined { - const metadataProvider = resolveMetadataProvider(target.provider); - // Custom OpenAI-compatible routes commonly retain the canonical OpenAI model id - // while using a provider name that has no metadata alias. Reuse only its effort - // ladder below; context/modality rows remain provider-owned. - const metadata = metadataProvider - ? comboMemberVendorMetadata(metadataProvider, target.model) - : comboMemberVendorMetadata("openai", target.model); - if (!metadata) return undefined; - return { - ...(metadataProvider && typeof metadata.contextWindow === "number" && metadata.contextWindow > 0 - ? { contextWindow: metadata.contextWindow } - : {}), - ...(metadataProvider && typeof metadata.maxTokens === "number" && metadata.maxTokens > 0 - ? { maxOutputTokens: metadata.maxTokens } - : {}), - ...(metadataProvider && Array.isArray(metadata.input) && metadata.input.length > 0 - ? { inputModalities: [...metadata.input] } - : {}), - ...(metadata.reasoning === true ? { reasoningEfforts: [...ROUTED_COMBO_MEMBER_REASONING_EFFORTS] } : {}), - }; -} - -/** - * Resolve a combo target to a catalog member for derivation. - * Prefer discovery metadata; when the target is missing from the gather map or - * lacks a positive contextWindow, synthesize from the (registry-enriched) - * provider config so combos remain catalogued when targets are configured but - * discovery metadata is incomplete. Disabled providers stay unresolved. - * When hints still omit contextWindow, prefer known maxInputTokens, else the - * enabled Context cap, else COMBO_MEMBER_CONTEXT_FALLBACK so a live row - * without ctx does not drop the whole combo from the public catalog. - */ -export function resolveComboCatalogMember( - target: { provider: string; model: string }, - memberByKey: ReadonlyMap, - providers: ReadonlyMap, - contextCap?: number, - callerFallback?: ComboCatalogMemberFallback, - metadataModelIdCaseFold?: boolean, -): CatalogModel | undefined { - const existing = memberByKey.get(targetKey(target)); - const prov = providers.get(target.provider); - const fallback = callerFallback ?? vendorMetadataComboFallback(target); - // Disabled providers never contribute members — even a complete discovery row - // is unusable for catalog derivation while the provider is off. - if (prov?.disabled === true) return undefined; - - const withFallbackMetadata = (member: CatalogModel): CatalogModel => { - const contextWindow = typeof member.contextWindow === "number" && member.contextWindow > 0 - ? member.contextWindow - : undefined; - const addMaxInput = fallback !== undefined && contextWindow !== undefined - && !(typeof member.maxInputTokens === "number" && member.maxInputTokens > 0); - const addMaxOutput = fallback !== undefined - && typeof fallback.maxOutputTokens === "number" - && fallback.maxOutputTokens > 0 - && !(typeof member.maxOutputTokens === "number" && member.maxOutputTokens > 0); - const effectiveMaxInput = addMaxInput - ? Math.min(fallback?.maxInputTokens ?? contextWindow!, contextWindow!) - : member.maxInputTokens; - const softCandidates = [member.autoCompactTokenLimit, fallback?.autoCompactTokenLimit] - .filter((value): value is number => typeof value === "number" && value > 0); - const autoCompactTokenLimit = contextWindow !== undefined && softCandidates.length > 0 - ? clampAutoCompactTokenLimit(contextWindow, effectiveMaxInput, Math.min(...softCandidates)) - : member.autoCompactTokenLimit; - const adjustAutoCompact = autoCompactTokenLimit !== member.autoCompactTokenLimit; - const addModalities = (!Array.isArray(member.inputModalities) || member.inputModalities.length === 0) - && fallback?.inputModalities !== undefined; - const addReasoning = member.reasoningEfforts === undefined - && fallback?.reasoningEfforts !== undefined; - if (!addMaxInput && !addMaxOutput && !adjustAutoCompact && !addModalities && !addReasoning) return member; - return { - ...member, - // Never claim a larger input budget than the window, and prefer the model's own - // measured ceiling when the fallback carries one. - ...(addMaxInput ? { maxInputTokens: effectiveMaxInput } : {}), - ...(addMaxOutput ? { maxOutputTokens: fallback!.maxOutputTokens } : {}), - ...(adjustAutoCompact && autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), - ...(addModalities ? { inputModalities: [...fallback!.inputModalities!] } : {}), - ...(addReasoning ? { reasoningEfforts: [...fallback!.reasoningEfforts!] } : {}), - }; - }; - - // Complete live/configured rows still honour providerContextCaps so a high - // discovery window cannot outrun an operator-configured cap. Native-alias - // fallback metadata may fill only capability gaps; it never raises an explicit - // discovered/configured context window. - if ( - existing - && typeof existing.contextWindow === "number" - && existing.contextWindow > 0 - ) { - // Live discovery can explicitly say text-only even when configured routing - // supplies a vision sidecar. Apply the same provider hints used for thin - // rows before deriving a combo from this complete row. - const hinted = prov && isModelVisionSidecarConsumer(prov, existing.id) - ? applyProviderConfigHints(target.provider, prov, existing, contextCap, metadataModelIdCaseFold) - : existing; - const capped = applyProviderContextCap(hinted.contextWindow, contextCap); - if (capped === undefined || capped === existing.contextWindow) { - return withFallbackMetadata(hinted); - } - const maxInput = typeof hinted.maxInputTokens === "number" && hinted.maxInputTokens > 0 - ? Math.min(hinted.maxInputTokens, capped) - : Math.min(fallback?.maxInputTokens ?? capped, capped); - return withFallbackMetadata({ - ...hinted, - contextWindow: capped, - maxInputTokens: maxInput, - contextCap, - contextCapped: true as const, - }); - } - - const base: CatalogModel = existing ?? { - id: target.model, - provider: target.provider, - }; - const hinted = prov - ? applyProviderConfigHints(target.provider, prov, base, contextCap, metadataModelIdCaseFold) - : base; - const hintedContext = typeof hinted.contextWindow === "number" && hinted.contextWindow > 0 - ? hinted.contextWindow - : undefined; - const knownMaxInput = typeof hinted.maxInputTokens === "number" && hinted.maxInputTokens > 0 - ? hinted.maxInputTokens - : (typeof base.maxInputTokens === "number" && base.maxInputTokens > 0 - ? base.maxInputTokens - : undefined); - // Kept OUT of knownMaxInput on purpose: that value doubles as a context-window fallback - // below, and a native alias whose input ceiling (922k) is lower than its window (1.05M) - // would otherwise shrink the advertised window to the input limit. - const fallbackMaxInput = existing || prov ? fallback?.maxInputTokens : undefined; - // Real discovery/config values win. A native alias is the next fallback tier. - // The generic 128k/text synthesis from #1305 remains the final fallback. - const fallbackContext = existing || prov ? fallback?.contextWindow : undefined; - const uncappedContext = hintedContext - ?? knownMaxInput - ?? fallbackContext - ?? (existing || prov ? resolveUnknownRoutedContextWindow(contextCap) : undefined); - if (uncappedContext === undefined) return undefined; - // 真发现值才压低。resolveUnknownRoutedContextWindow 已经把 cap 当成窗口填进去了,不能再 min 一次。 - const usedDiscoveredWindow = hintedContext !== undefined || knownMaxInput !== undefined || fallbackContext !== undefined; - const cappedContext = usedDiscoveredWindow - ? applyProviderContextCap(uncappedContext, contextCap) - : uncappedContext; - const contextWindow = cappedContext ?? uncappedContext; - const fallbackCapped = usedDiscoveredWindow - && contextCap !== undefined - && cappedContext !== undefined - && cappedContext !== uncappedContext; - - const inputModalities = hinted.inputModalities - ?? base.inputModalities - ?? (fallback?.inputModalities ? [...fallback.inputModalities] : undefined) - ?? ["text"]; - const reasoningEfforts = hinted.reasoningEfforts - ?? (prov ? configuredReasoningEfforts(prov, target.model) : undefined) - ?? base.reasoningEfforts - ?? (fallback?.reasoningEfforts ? [...fallback.reasoningEfforts] : undefined); - const maxOutputTokens = positiveSafeInteger(hinted.maxOutputTokens, base.maxOutputTokens) - ?? (existing || prov ? positiveSafeInteger(fallback?.maxOutputTokens) : undefined); - // The model's own measured input ceiling still applies when discovery gave us nothing: - // GPT-5.6 advertises a 1.05M window but refuses input past 922k. - const effectiveMaxInput = knownMaxInput ?? fallbackMaxInput; - const maxInputTokens = effectiveMaxInput !== undefined - ? Math.min(effectiveMaxInput, contextWindow) - : contextWindow; - const softCandidates = [ - hinted.autoCompactTokenLimit, - base.autoCompactTokenLimit, - fallback?.autoCompactTokenLimit, - configuredAutoCompactTokenLimit(prov, target.model), - ].filter((value): value is number => typeof value === "number" && value > 0); - // A generic 128k synthesis is a catalog compatibility fallback, not evidence - // that a configured soft policy has an authoritative window to clamp against. - const hasAuthoritativeAutoCompactBasis = hintedContext !== undefined - || fallbackContext !== undefined - || contextCap !== undefined; - const autoCompactTokenLimit = hasAuthoritativeAutoCompactBasis && softCandidates.length > 0 - ? clampAutoCompactTokenLimit(contextWindow, maxInputTokens, Math.min(...softCandidates)) - : undefined; - - return { - ...hinted, - inputModalities, - ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), - contextWindow, - maxInputTokens, - ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), - ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), - ...(fallbackCapped ? { contextCap, contextCapped: true as const } : {}), - }; -} - -const DATED_VARIANT_YYYYMMDD = /^(\d{4})(\d{2})(\d{2})$/; -const DATED_VARIANT_YYMMDD = /^(2\d)(\d{2})(\d{2})$/; -const DATED_VARIANT_MMDD_OR_YYMM = /^(\d{2})(\d{2})$/; - -/** Whether a Gregorian year contains February 29th. */ -function isLeapYear(year: number): boolean { - return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); -} - -/** - * Whether a month/day pair exists in the given year. Without a year, February 29th is - * accepted because it occurs in at least one calendar year. - */ -function isValidCalendarDate(year: number | undefined, month: number, day: number): boolean { - if (year !== undefined && (year < 1 || year > 9999)) return false; - if (month < 1 || month > 12 || day < 1) return false; - const daysInMonth = [ - 31, year === undefined || isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, - 31, 31, 30, 31, 30, 31, - ]; - return day <= daysInMonth[month - 1]!; -} - -/** - * Release-date suffixes providers actually publish: `YYYYMMDD` (`-20251001`), `YYMMDD` - * (`-260806`), `MMDD` (`-0813`) and `YYMM` (`-2512`). A `\d{8}`-only rule matched none of - * the dated ids on a real multi-provider install, so DeepSeek, Kimi, Mistral, Qwen and - * Solar aliases all fell through to `droppedConfiguredIds` (#3024). - * - * Calendar validation rejects impossible month-end and leap-day values as well as ordinary - * numeric suffixes such as `-2048`, `-4096` and `-8192`. `-1024` is the one irreducible - * collision — it is a valid `MMDD` (October 24th) — so it reads as dated. That is a known, - * accepted cost; the test table pins it so it cannot become a surprise later. - * - * Hyphenated ISO suffixes (`-2024-08-06`, `-05-06`) are deliberately out of scope: a - * hyphenated suffix is ambiguous against ordinary name segments and needs its own call. - */ -function isDatedVariantSuffix(suffix: string): boolean { - const yyyyMmDd = DATED_VARIANT_YYYYMMDD.exec(suffix); - if (yyyyMmDd) { - return isValidCalendarDate( - Number(yyyyMmDd[1]), Number(yyyyMmDd[2]), Number(yyyyMmDd[3]), - ); - } - - const yyMmDd = DATED_VARIANT_YYMMDD.exec(suffix); - if (yyMmDd) { - return isValidCalendarDate( - 2000 + Number(yyMmDd[1]), Number(yyMmDd[2]), Number(yyMmDd[3]), - ); - } - - const mmDdOrYyMm = DATED_VARIANT_MMDD_OR_YYMM.exec(suffix); - if (!mmDdOrYyMm) return false; - const first = Number(mmDdOrYyMm[1]); - const second = Number(mmDdOrYyMm[2]); - return isValidCalendarDate(undefined, first, second) - || (first >= 20 && first <= 29 && second >= 1 && second <= 12); -} - -/** Whether `liveId` is a supported dated release of the configured base id. */ -export function isDatedVariantId(liveId: string, configuredId: string): boolean { - if (!liveId.startsWith(`${configuredId}-`)) return false; - return isDatedVariantSuffix(liveId.slice(configuredId.length + 1)); -} - -export const lastDropWarnSignature = new Map(); -let lastWarningReconciledGeneration = 0; - -export function reconcileProviderFetchWarnings(generation: number): number { - if (generation <= lastWarningReconciledGeneration) return 0; - const removed = lastDropWarnSignature.size; - lastDropWarnSignature.clear(); - lastWarningReconciledGeneration = generation; - return removed; -} - -export const QUIET_AUTHORITATIVE_CATALOG_PROVIDERS = new Set(["kimi", "xai"]); - -export const CALLABLE_CONFIGURED_COMPATIBILITY_MODELS: Readonly>> = { - kimi: new Set([ - "k3[1m]", - "kimi-k2.7-code", - "kimi-k2.7-code-highspeed", - "kimi-k2.6", - "kimi-k2.5", - ]), - xai: new Set([ - "grok-4.3", - "grok-4.20-multi-agent-0309", - "grok-4.20-0309-reasoning", - "grok-4.20-0309-non-reasoning", - "grok-build-0.1", - "grok-composer-2.5-fast", - ]), -}; - -export function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds: string[]): void { - const signature = [...droppedConfiguredIds].sort().join(","); - if (lastDropWarnSignature.get(name) === signature) return; - lastDropWarnSignature.set(name, signature); - console.warn( - `[opencodex] Provider model discovery for "${name}" omitted configured model ids; dropping them from the authoritative live catalog: ${droppedConfiguredIds.join(", ")}.`, - ); -} - -/** - * Z.AI and Neuralwatt advertise GLM reasoning as a bare boolean, which would otherwise - * collapse to the four-tier default ladder that omits `max`. These two helpers name the - * ladder each GLM generation actually honours on the wire. - */ -/** GLM-5.2 and its 1M alias: the full five-tier ladder including `max`. */ -export function isGlm52ModelId(id: string): boolean { - const normalized = id.trim().toLowerCase(); - return normalized === "glm-5.2" || normalized === "glm-5.2[1m]"; -} -/** - * GLM-5.3 and its 1M alias. 260814: docs.z.ai/devpack/latest-model folds every incoming - * effort into three effective tiers (low/minimal/light -> low, medium/high -> high, - * xhigh/max/ultra -> max), so a boolean capability must not be expanded to five rows. - */ -export function isGlm53ModelId(id: string): boolean { - const normalized = id.trim().toLowerCase(); - return normalized === "glm-5.3" || normalized === "glm-5.3[1m]"; -} - -function plainRecord(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? value as Record - : undefined; -} - -const MODEL_DISCOVERY_METADATA_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; - -function positiveSafeInteger(...values: unknown[]): number | undefined { - return values.find(value => typeof value === "number" && Number.isSafeInteger(value) && value > 0) as number | undefined; -} - -function normalizedMetadataString(raw: string, maxLength: number): string | undefined { - if (raw.length > maxLength * 4 || MODEL_DISCOVERY_METADATA_CONTROL_CHARS.test(raw)) return undefined; - const normalized = raw.trim().toLowerCase().replace(/\s+/g, "-").slice(0, maxLength); - return normalized || undefined; -} - -function normalizedStringList(value: unknown, maxItems = 32, maxLength = 64): string[] | undefined { - if (!Array.isArray(value)) return undefined; - const out: string[] = []; - const maxInspectedItems = Math.max(maxItems * 8, maxItems); - for (let i = 0; i < value.length && i < maxInspectedItems; i += 1) { - const raw = value[i]; - if (typeof raw !== "string") continue; - const normalized = normalizedMetadataString(raw, maxLength); - if (normalized && !out.includes(normalized)) out.push(normalized); - if (out.length >= maxItems) break; - } - return out.length > 0 ? out : undefined; -} - -function modelCapabilities(item: ProviderModelsApiItem): string[] | undefined { - const metadata = plainRecord(item.metadata); - const metadataCapabilities = metadata?.capabilities; - const capabilityRecord = plainRecord(metadataCapabilities) - ?? plainRecord(item.capabilities) - ?? plainRecord(item.features); - const out = new Set(); - for (const list of [item.capabilities, item.features, item.supported_features, metadataCapabilities]) { - for (const capability of normalizedStringList(list) ?? []) out.add(capability); - } - const capabilityFields = capabilityRecord ?? {}; - let inspectedCapabilityFields = 0; - for (const key in capabilityFields) { - if (!Object.hasOwn(capabilityFields, key)) continue; - inspectedCapabilityFields += 1; - if (inspectedCapabilityFields > 256 || out.size >= 32) break; - if (capabilityFields[key] === true) { - const normalized = normalizedMetadataString(key, 64); - if (normalized) out.add(normalized); - } - } - for (const field of ["supports_tools", "supports_tool_calling", "supports_function_calling"] as const) { - if (item[field] === true) out.add("tools"); - } - for (const field of ["supports_reasoning", "reasoning"] as const) { - if (item[field] === true) out.add("reasoning"); - } - return out.size > 0 ? [...out].filter(Boolean).slice(0, 32) : undefined; -} - -function modelInputModalities( - item: ProviderModelsApiItem, - capabilities: readonly string[] | undefined, -): string[] | undefined { - const metadata = plainRecord(item.metadata); - const capabilityRecord = plainRecord(metadata?.capabilities) - ?? plainRecord(item.capabilities) - ?? plainRecord(item.features); - const explicit = normalizedStringList( - item.input_modalities - ?? item.modalities - ?? metadata?.input_modalities - ?? capabilityRecord?.input_modalities - ?? plainRecord(item.architecture)?.input_modalities, - 8, - 24, - )?.filter(value => ( - // Codex parses `input_modalities` as a closed enum of text | image | audio. A provider that - // advertises anything else (zenmux reports "video") must not reach the catalog: Codex rejects - // the whole file, so plugins, apps and MCP servers all stop loading over one model's metadata. - value === "text" || value === "image" || value === "audio" - )); - if (explicit && explicit.length > 0) return explicit; - const architecture = plainRecord(item.architecture); - const architectureModality = typeof architecture?.modality === "string" - ? normalizedMetadataString(architecture.modality, 64) - : undefined; - if (architectureModality?.includes("->")) { - const [rawInput = ""] = architectureModality.split("->"); - const inferred = rawInput - .split("+") - .filter(value => value === "text" || value === "image" || value === "audio"); - if (inferred.length > 0) return [...new Set(inferred)]; - } - // GitHub Copilot nests vision support one level down as `capabilities.supports.vision`, so the - // flat read alone finds nothing and every Copilot model falls through to `["text"]` — Codex then - // refuses image attachments on models that accept them (#2941). Precedence is by specificity: - // a flat boolean is authoritative when present, the nested boolean is consulted only otherwise, - // and a non-boolean at either level decides NOTHING so the signals below still apply. Two things - // this ordering deliberately avoids: a deny-wins rule across both levels would flip a provider - // reporting flat `true` with nested `false` from image-capable to text-only, changing behaviour - // that predates Copilot support; and a truthy test would let the string `"no"` advertise image - // input. The payload also carries a SECOND `vision` key under `limits` holding an image count, - // which is why this reads one exact path instead of searching `capabilities` for a vision-ish key. - const nestedSupports = plainRecord(capabilityRecord?.supports); - const explicitVisionSupport = typeof capabilityRecord?.vision === "boolean" - ? capabilityRecord.vision - : typeof nestedSupports?.vision === "boolean" - ? nestedSupports.vision - : undefined; - if (explicitVisionSupport === false) return ["text"]; - if (explicitVisionSupport === true || capabilities?.some(value => ( - value === "vision" || value === "image-input" || value === "image_input" - // llama.cpp and Ollama-compatible servers report vision as "multimodal" — - // it is the only image signal those servers emit (#1797). Mapped to the - // closed `text|image` enum rather than passed through: an out-of-enum - // modality makes Codex reject the entire catalog file. - || value === "multimodal" - ))) { - return ["text", "image"]; - } - return undefined; -} - -/** - * A per-token rate exactly as a /models row publishes it, or undefined when the value is not a - * usable non-negative number. Providers ship these both as JSON numbers and as decimal strings — - * OpenRouter encodes free as the string `"0.00000000"` — so both shapes are accepted and nothing - * else is. The explicit numeric-shape test has to run BEFORE any coercion: `Number("")` and - * `Number(" ")` are both 0 and `Number(true)` is 1, so a bare `Number(value)` would classify a - * row with an empty price string as free. - */ -const DISCOVERED_PRICING_RATE_PATTERN = /^-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?$/; - -function discoveredPricingRate(value: unknown): number | undefined { - const numeric = typeof value === "number" - ? value - : typeof value === "string" && DISCOVERED_PRICING_RATE_PATTERN.test(value.trim()) - ? Number(value.trim()) - : undefined; - if (numeric === undefined || !Number.isFinite(numeric) || numeric < 0) return undefined; - return numeric; -} - -/** - * Cost class for one discovered row, read from the provider's own `pricing` object (#3666). - * - * Fail closed. Only a complete pair of non-negative numeric rates classifies at all; a missing, - * one-sided, non-numeric, or negative rate is "unknown" and therefore excluded from a free-only - * filter. Showing a paid model under a Free filter spends the user's money, while hiding a free - * one costs a click. - * - * Two things that look like evidence and are not. A `:free` id suffix is an OpenRouter naming - * convention, not a price — Nous ships `:free` slugs on a provider whose `freeTier` is false on - * purpose. And the operator's own `modelCosts` overlay is an estimate they typed, not something - * the provider published, so a zeroed overlay never reaches this field either. - * - * Classification is on numeric zero and never on a unit conversion: OpenRouter quotes USD per - * token while the cost overlays and the jawcode bundle quote per 1M, and zero is zero in both. - */ -export function discoveredPricingStatus(item: ProviderModelsApiItem): "free" | "paid" | "unknown" { - const pricing = plainRecord(item.pricing) ?? plainRecord(plainRecord(item.metadata)?.pricing); - if (!pricing) return "unknown"; - const prompt = discoveredPricingRate(pricing.prompt ?? pricing.input); - const completion = discoveredPricingRate(pricing.completion ?? pricing.output); - if (prompt === undefined || completion === undefined) return "unknown"; - return prompt === 0 && completion === 0 ? "free" : "paid"; -} - -export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial { - const metadata = plainRecord(item.metadata); - const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities); - const limits = plainRecord(metadata?.limits); - const capabilityLimits = plainRecord(plainRecord(item.capabilities)?.limits); - const contextWindow = - positiveSafeInteger( - limits?.max_context_length, - // GitHub Copilot reports the live context window here instead of in the metadata or - // top-level fields used by other OpenAI-compatible catalogs (#3156). Keep the existing - // metadata field authoritative when both are present: adding this provider-specific - // fallback must not change previously recognized providers. - capabilityLimits?.max_context_window_tokens, - metadata?.context_length, - item.context_length, - item.context_size, - item.max_model_len, - item.max_context_length, - // llama.cpp reports the served context under `meta`: `n_ctx` is what the - // server was actually started with, `n_ctx_train` the model's trained - // maximum. Prefer the served value — routing must not promise a window the - // running server will refuse. Both come LAST so no provider already - // supplying a recognized field changes behavior (#1797). - plainRecord(item.meta)?.n_ctx, - plainRecord(item.meta)?.n_ctx_train, - // A chained OpenCodex hub (and other re-serving gateways) reports the per-model - // window on the same capability record this function already reads for - // `max_output_tokens` below (#4032). Without it every routed row fell through to - // the 128k compatibility floor in parsing.ts while local forward rows kept their - // real values. Appended after the recognized fields for the same reason as the - // llama.cpp entries above: no provider that already resolves changes behavior. - capabilityRecord?.context_length, - ); - const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens); - const maxOutputTokens = positiveSafeInteger( - capabilityRecord?.max_output_tokens, - limits?.max_output_tokens, - metadata?.max_output_tokens, - item.max_output_tokens, - ); - // Some OpenAI-compatible catalogs expose the selectable ladder under - // `reasoning_parameters.efforts` instead of the older `reasoning_efforts` key. - // Treat both as model metadata: otherwise a valid upstream capability disappears - // before client exporters (including omp) can advertise it. - const reasoningParameters = plainRecord(item.reasoning_parameters) - ?? plainRecord(metadata?.reasoning_parameters) - ?? plainRecord(capabilityRecord?.reasoning_parameters); - const rawReasoningEfforts = capabilityRecord?.reasoning_effort - ?? item.reasoning_efforts - ?? reasoningParameters?.efforts; - const listedReasoningEfforts = normalizedStringList(rawReasoningEfforts, 8, 24); - const reasoningEfforts = listedReasoningEfforts - ? sanitizeCodexReasoningEfforts(listedReasoningEfforts) - : typeof rawReasoningEfforts === "boolean" - ? (rawReasoningEfforts - ? ((providerName === "neuralwatt" || providerName === "zai") && isGlm53ModelId(item.id) - ? ["low", "high", "max"] - : (providerName === "neuralwatt" || providerName === "zai") && isGlm52ModelId(item.id) - ? ["low", "medium", "high", "xhigh", "max"] - : ["low", "medium", "high", "xhigh"]) - : []) - : undefined; - const capabilities = modelCapabilities(item); - const inputModalities = modelInputModalities(item, capabilities); - const pricingStatus = discoveredPricingStatus(item); - return { - ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}), - ...(maxInputTokens && maxInputTokens > 0 ? { maxInputTokens } : {}), - ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), - ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), - ...(inputModalities ? { inputModalities } : {}), - ...(capabilities ? { capabilities } : {}), - // Omitted when the classification is "unknown", following this function's existing - // contract that an unknown property is absent rather than present-and-empty. Callers - // that need to tell "provider published no prices" from "this build does not classify" - // call discoveredPricingStatus directly. - ...(pricingStatus !== "unknown" ? { pricingStatus } : {}), - }; -} - -function boundedOwnedBy(value: unknown): string | undefined { - if (typeof value !== "string" || value.length === 0 || value.length > 256) return undefined; - if (MODEL_DISCOVERY_METADATA_CONTROL_CHARS.test(value)) return undefined; - return value; -} - -const refreshingModelsAuthResolver: ModelsAuthResolver = { kind: "refreshing" }; - -function observedModelsAuthResolver( - authStoreBuffer: Uint8Array | null, - outcomes: CatalogGatherProviderAuthOutcome[], -): ModelsAuthResolver { - return { - kind: "observed", - resolve(name, provider) { - if (provider.authMode === "forward") return { apiKey: undefined, observed: true }; - if (provider.authMode !== "oauth") { - return { apiKey: resolveProviderApiKey(provider.apiKey), observed: true }; - } - - const observation = observeActiveOAuthAccessToken(name, authStoreBuffer); - outcomes.push({ provider: name, state: observation.kind }); - if (observation.kind !== "available") return { apiKey: undefined, observed: true }; - return { - apiKey: observation.snapshot.accessToken, - observed: true, - ...(observation.snapshot.apiBaseUrl ? { oauthApiBaseUrl: observation.snapshot.apiBaseUrl } : {}), - ...(observation.snapshot.projectId ? { oauthProjectId: observation.snapshot.projectId } : {}), - }; - }, - }; -} - -async function fetchProviderModelsWithAuth( - captured: CapturedProviderGather, - ttlMs: number, - contextCap: number | undefined, - resolveAuth: ModelsAuthResolver, -): Promise { - const { name, provider: prov, discovery, request, metadataModelIdCaseFold } = captured; - const observed = ( - models: CatalogModel[], - state: CatalogGatherProviderModelOutcome["state"], - ): ProviderModelsResult => ({ models, outcome: { provider: name, state } }); - // Capture before any credential refresh or outbound await. OAuth account changes clear this - // generation, so a request started with the former account cannot later publish its result. - const cacheGeneration = captureModelCacheGeneration(name); - const isCurrentCacheGeneration = () => isModelCacheGenerationCurrent(name, cacheGeneration); - if (prov.authMode === "forward") return observed([], "authoritative"); // ChatGPT backend has no /models - const seedVertexDefault = prov.adapter === "google" - && prov.googleMode === "vertex" - && (prov.models?.length ?? 0) === 0 - && Boolean(prov.defaultModel); - const seedStaticDefault = prov.liveModels === false - && (prov.models?.length ?? 0) === 0 - && Boolean(prov.defaultModel); - // Ordered dedupe union: implicit default seed, then `models`, then `retainModels`. `configured` is the - // single seed for the static path, the degraded fallback, drop diagnostics, and provider hints, - // so a retain-only id must enter here or it never exists to be retained (#1690). - const configuredIds = [...new Set([ - ...((seedVertexDefault || seedStaticDefault) && prov.defaultModel ? [prov.defaultModel] : []), - ...(prov.models ?? []), - ...(prov.retainModels ?? []), - ])]; - const configured: CatalogModel[] = configuredIds.map(id => ({ - id, - provider: name, - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), - })); - const withConfiguredRetention = ( - models: CatalogModel[], - options?: { retainComboTargets?: boolean; warnDrops?: boolean }, - ): CatalogModel[] => { - const { models: merged, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ - name, - provider: prov, - models, - configured, - retainConfiguredModelIds: captured.retainConfiguredModelIds, - contextCap, - seedVertexDefault, - retainComboTargets: options?.retainComboTargets, - metadataModelIdCaseFold, - }); - if ( - options?.warnDrops === true - && droppedConfiguredIds.length > 0 - && name !== OPENAI_API_PROVIDER_ID - && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name) - ) { - warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds); - } - return merged; - }; - // Static catalogs never need an OAuth refresh or an upstream model request. Clear any - // discovery failure left by an older live configuration even when the account is logged out. - if (prov.liveModels === false) { - clearProviderDiscoveryStatus(name); - return observed(configured, "authoritative"); - } - const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing" - ? prov.authMode === "oauth" && effectiveGoogleMode(name, prov) === "cloud-code-assist" - ? await getValidAccessTokenSnapshot(name) - .then(snapshot => ({ - apiKey: snapshot.accessToken, - observed: false, - ...(snapshot.projectId ? { oauthProjectId: snapshot.projectId } : {}), - })) - .catch(() => ({ apiKey: undefined, observed: false })) - : { apiKey: await resolveModelsAuthToken(name, prov), observed: false } - : resolveAuth.resolve(name, prov)); - const apiKey = auth.apiKey; - // A configured default is a real callable selector and must remain discoverable when a - // compatible provider's live /models request fails (issue #308). Static providers already seed - // their default selector above when no explicit model list exists. - const failedDiscoveryConfigured = configured.length > 0 || !prov.defaultModel || prov.adapter !== "anthropic" - ? configured - : [{ - id: prov.defaultModel, - provider: name, - ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), - }]; - const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined; - const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => ( - vertexDefaultSeed && !models.some(model => model.id === vertexDefaultSeed.id) - ? [...models, vertexDefaultSeed] - : models - ); - if (prov.adapter === "qoder") { - if (!apiKey) return observed(configured, "degraded"); - const profile = resolveQoderProfile(prov.baseUrl); - if (!profile) return observed(configured, "degraded"); - // Qoder's model list is entitlement-specific. Bind cache reads/writes to an irreversible PAT - // fingerprint so an account switch cannot observe another account's roster, even if a caller - // bypasses the normal config mutation path that clears provider caches. - const authorityIdentity = createHash("sha256").update(apiKey).digest("hex"); - const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); - if (fresh) { - return observed(withConfiguredRetention( - applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), - ), "authoritative"); - } - const scopedStale = getStaleCached(name, authorityIdentity); - if (isModelsFetchCoolingDown(name) && scopedStale) { - return observed(withConfiguredRetention( - applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), - ), "degraded"); - } - const live = await fetchQoderModels(profile, apiKey); - if (live.ok) { - const discovered = live.models.map(id => ({ - id, - provider: name, - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), - })); - const forCache = withConfiguredRetention(discovered, { retainComboTargets: false }); - if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) { - return observed(withConfiguredRetention(configured), "degraded"); - } - markProviderDiscoveryOk(name, live.models.length); - return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); - } - if (isCurrentCacheGeneration()) { - markModelsFetchFailure(name); - markProviderDiscoveryFailed(name, { reason: "provider" }); - console.warn(`[opencodex] Qoder model discovery for "${name}" failed [${live.error}]${live.detail ? `: ${live.detail}` : ""}; using stale/static catalog degradation.`); - } - const stale = getStaleCached(name, authorityIdentity); - return observed(withConfiguredRetention( - stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured, - ), "degraded"); - } - if (prov.adapter === "devin") { - if (!apiKey) return observed(configured, "degraded"); - const cachedDevin = getFreshCached(name, ttlMs); - if (cachedDevin) { - return observed( - withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedDevin)), - "authoritative", - ); - } - if (isModelsFetchCoolingDown(name)) { - const cooling = getStaleCached(name); - return observed( - withConfiguredRetention( - cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured, - ), - "degraded", - ); - } - const liveResult = await fetchDevinUsableModels({ apiKey, baseUrl: prov.baseUrl }); - if (liveResult.ok) { - // Live catalog is the source of truth — use the discovered base models - // directly, not a filtered subset of the static seed. - // - // That extends to the context window. Cognition publishes no window - // anywhere, so the per-account catalog is the only first-party number, - // and the shipped static table is a degraded-mode guess that was wrong - // for nine of its eleven rows. The live value is applied first and the - // config hints run after it, so an explicit per-model override and an - // enabled Context cap still win — this only replaces the number nobody - // chose. - const result = liveResult.models.map((id) => { - const liveWindow = liveResult.contextWindows[id]; - return { - id, - provider: name, - ...(liveWindow ? { contextWindow: liveWindow } : {}), - // The account catalog names the effort variants each base model has, so - // its ladder is measured rather than assumed. Without this the entry - // inherits the generic routed ladder and offers rungs the model rounds - // away, and every client that keys an effort control off this field — - // the Pi-shaped exports — renders no control at all. - ...(liveResult.efforts[id]?.length ? { reasoningEfforts: liveResult.efforts[id] } : {}), - // The account catalog's per-base supportsImages vote collapses to one - // modalities value. It spreads before the hints so exact - // modelCapabilities declarations, the legacy modelInputModalities - // record and the vision-sidecar rewrite keep winning — the live - // value survives only when none of them applies. - ...(liveResult.inputModalities[id]?.length ? { inputModalities: liveResult.inputModalities[id] } : {}), - ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), - } as CatalogModel; - }); - const forCache = withConfiguredRetention(result, { retainComboTargets: false }); - if (!setCached(name, forCache, Date.now(), cacheGeneration)) { - return observed(withConfiguredRetention(configured), "degraded"); - } - markProviderDiscoveryOk(name, liveResult.models.length); - return observed(withConfiguredRetention(forCache), "authoritative"); - } - if (isCurrentCacheGeneration()) { - markModelsFetchFailure(name); - markProviderDiscoveryFailed(name, { reason: liveResult.error === "auth" ? "provider" : "invalid_response" }); - } - const stale = getStaleCached(name); - return observed( - withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured), - "degraded", - ); - } - if (prov.adapter === "cursor") { - if (!apiKey) return observed(configured, "degraded"); - // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed - // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort - // suffix) but filter the static seed to the bases the account actually has — so models not on the - // plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed. - const cachedCursor = getFreshCached(name, ttlMs); - if (cachedCursor) { - return observed( - withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias)), - "authoritative", - ); - } - if (isModelsFetchCoolingDown(name)) { - const cooling = getStaleCached(name); - return observed( - withConfiguredRetention( - cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, metadataModelIdCaseFold, captured.effectiveAlias) : configured, - ), - "degraded", - ); - } - const cursorFetch = (prov as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch; - const liveResult = await fetchCursorUsableModels({ - apiKey, - baseUrl: prov.baseUrl, - upstreamHttpVersion: prov.upstreamHttpVersion, - ...(cursorFetch ? { fetch: cursorFetch } : {}), - }); - if (liveResult.ok) { - const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models); - const result = available.length > 0 ? available : configured; - // Cache the discovery-filtered roster without combo retention so a later - // gather can re-apply the current capture's retain set on read. - const forCache = withConfiguredRetention(result, { retainComboTargets: false }); - if (!setCached(name, forCache, Date.now(), cacheGeneration)) { - return observed(withConfiguredRetention(configured), "degraded"); - } - // Publish roster-derived state only for a discovery the cache accepted: a stale - // in-flight capture (generation revoked by a credential/config change) must not - // overwrite the spelling or Max-Mode evidence of the newer one. - recordLiveCursorClaudeModels(liveResult.models); - // Live Max-Mode evidence feeds the umbrella resolver's ultra gate - // (devlog 260828_cursor_umbrella_catalog; union with static evidence). - recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []); - markProviderDiscoveryOk(name, liveResult.models.length); - return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); - } - if (isCurrentCacheGeneration()) { - markModelsFetchFailure(name); - markProviderDiscoveryFailed(name, { reason: "provider" }); - console.warn( - `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`, - ); - } - const staleCursor = getStaleCached(name); - return observed( - withConfiguredRetention( - staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias) : configured, - ), - "degraded", - ); - } - if (prov.authMode === "oauth" && !apiKey) { - // No usable token (logged out, or account marked needsReauth). Still surface the - // configured static catalog so the GUI Models tab / rail counts are not empty — - // matching Cursor's !apiKey → configured degradation and fetch-failure fallback. - return observed(configured, "degraded"); - } - const cloudCodeAssist = effectiveGoogleMode(name, prov) === "cloud-code-assist"; - const project = prov.project ?? auth.oauthProjectId; - if (cloudCodeAssist && !project) return observed(configured, "degraded"); - const fresh = getFreshCached(name, ttlMs); - if (fresh) { - return observed( - withConfiguredRetention( - withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)), - ), - "authoritative", - ); // dedups Codex's frequent /v1/models polling within the TTL - } - if (isModelsFetchCoolingDown(name)) { - // A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the - // fetch timeout on every catalog poll — the dashboard polls this path per page load. - const stale = getStaleCached(name); - return observed( - withConfiguredRetention( - stale - ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)) - : failedDiscoveryConfigured, - ), - "degraded", - ); - } - const url = request.url; - let headers = materializeCapturedHeaders(request, apiKey); - // One Ollama authority contract: for canonical ollama-cloud/ollama-native rows, discovery - // (/v1/models), enrichment (/api/show) and inference (/api/chat) must all materialize the - // SAME effective credential/header authority. buildModelsRequest's generic tail writes the - // generated Bearer AFTER configured headers, but the native inference adapter applies - // provider.headers LAST (configured wins, case-insensitive collapse). Reapply the configured - // provider headers here so the whole Ollama request family shares that one authority. - if (ollamaShowEnrichable(name, prov)) { - headers = applyConfiguredHeadersLast(headers, prov.headers); - } - const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com") - ? "vertex-aiplatform" - : "provider-models"; - const failedDiscoveryFallback = ( - failure: ProviderModelDiscoveryFailure, - ): { models: CatalogModel[]; fallback: "stale" | "configured"; shouldLog: boolean } => { - if (!isCurrentCacheGeneration()) { - return { - models: withConfiguredRetention(failedDiscoveryConfigured), - fallback: "configured", - shouldLog: false, - }; - } - // Decide logging BEFORE recording the new status, so we can compare against the prior one and - // suppress an identical repeated failure (#395 log flood). The failure stays observable via the - // discovery-status API regardless. - const shouldLog = shouldLogDiscoveryFailure(name, failure); - markModelsFetchFailure(name); - markProviderDiscoveryFailed(name, failure); - const stale = getStaleCached(name); - return { - models: withConfiguredRetention( - stale - ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)) - : failedDiscoveryConfigured, - ), - fallback: stale ? "stale" : "configured", - shouldLog, - }; - }; - try { - // Canonical-URL TUN transparency for Clash/Surge/Mihomo fake-IP DNS: - // `isRegistryModelDiscoveryUrl` proves the FINAL request URL is the - // registry's own fixed discovery URL, so a purely-benchmark DNS answer may - // be pin-connected through the intercepting TUN without proxy env. The - // proof is on the URL — not the provider name — because an OAuth/forward - // name matches any baseUrl by design. Retargeted or renamed custom rows - // fetch a different URL and keep the rejection. - const outboundDependencies = { isCanonicalUrl: isRegistryModelDiscoveryUrl }; - const res = request.method === "POST" - ? await providerOutboundPost(name, prov, url, { - headers, - body: JSON.stringify({ project }), - signal: AbortSignal.timeout(8000), - }, outboundDependencies) - : await providerOutboundGet(name, prov, url, { - headers, - signal: AbortSignal.timeout(8000), - }, outboundDependencies); - const redirectError = await providerRedirectError(res, url); - if (redirectError) { - const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status }); - if (shouldLog) { - console.warn( - `[opencodex] Provider model discovery for "${name}" ${redirectError} [urlClass=${urlClass}, fallback=${fallback}].`, - ); - } - return observed(models, "degraded"); - } - if (!res.ok) { - const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status }); - if (shouldLog) { - console.warn( - `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`, - ); - } - return observed(models, "degraded"); - } - - const contentType = ( - res.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() || "missing" - ).slice(0, 80); - const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes); - if (!bounded.ok) { - const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); - const diagnostic = bounded.reason === "response_too_large" - ? `exceeded the ${discovery.maxResponseBytes}-byte response limit` - : contentType === "application/json" || contentType.endsWith("+json") - ? "returned invalid JSON in a 2xx response" - : "returned a non-JSON 2xx response"; - if (shouldLog) { - console.warn( - `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, - ); - } - return observed(models, "degraded"); - } - const antigravity = cloudCodeAssist - ? parseAntigravityAvailableModels(bounded.value, discovery.maxModels) - : undefined; - if (cloudCodeAssist && !antigravity) { - const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); - if (shouldLog) { - console.warn( - `[opencodex] Provider model discovery for "${name}" returned malformed CCA model data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, - ); - } - return observed(models, "degraded"); - } - if (antigravity) { - const live = antigravity.map(model => applyProviderConfigHints(name, prov, { - id: model.id, - provider: name, - // CCA only exposes a numeric thinking budget. Until the adapter owns an exact Codex - // effort-to-wire mapping for a newly discovered model, do not advertise a false ladder. - reasoningEfforts: [], - ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), - ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}), - }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)); - const forCache = withConfiguredRetention(live, { retainComboTargets: false }); - if (!setCached(name, forCache, Date.now(), cacheGeneration)) { - return observed(withConfiguredRetention(configured), "degraded"); - } - registerAntigravityDiscoveredWireModels(prov.baseUrl, antigravity, { - provider: name, - cacheGeneration, - }); - markProviderDiscoveryOk(name, live.length); - return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); - } - const googleAiStudio = effectiveGoogleMode(name, prov) === "ai-studio" - ? extractGoogleAiStudioModelItems(bounded.value, discovery.maxModels) - : undefined; - // Native /v1beta/models wins; a google row served by an OpenAI-compatible - // gateway keeps the generic data[] / top-level-array contract. - const extracted = googleAiStudio?.ok - ? googleAiStudio - : extractProviderModelItems(bounded.value, discovery); - if (!extracted.ok) { - const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); - const diagnostic: Record = { - response_too_large: "returned an oversized 2xx response", - invalid_json: "returned invalid JSON in a 2xx response", - invalid_shape: "returned malformed 2xx data", - too_many_models: `exceeded the ${discovery.maxModels}-row model limit`, - }; - if (shouldLog) { - console.warn( - `[opencodex] Provider model discovery for "${name}" ${diagnostic[extracted.reason]} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, - ); - } - return observed(models, "degraded"); - } - const items = extracted.items; - // Ollama Cloud enrichment: /v1/models carries no per-model context or capability metadata, - // so a newly announced id would otherwise publish generic defaults. /api/show fills that - // per model, fail-soft, bounded, and cached with this gather's result. Explicit configured - // metadata keeps its normal precedence (applyProviderConfigHints applies the discovered - // window only where exact config is absent, and the provider context cap still caps it). - const showEnrichment = ollamaShowEnrichable(name, prov) - ? await fetchOllamaShowEnrichment({ - headers, - discoveryUrl: request.url, - modelIds: items.map(m => m.id), - provider: prov, - }).catch(() => undefined) - : undefined; - const live = items.map(m => { - const ownedBy = boundedOwnedBy(m.owned_by); - // Precedence: the authoritative /v1/models row wins; /api/show fills only metadata the - // models-API row does not carry. applyProviderConfigHints then applies explicit - // configured metadata over both, and the provider context cap still caps the result. - const modelsApiHints = catalogHintsFromModelsApiItem(name, m); - const show = showEnrichment?.metadata.get(m.id); - const discoveredHints = { - ...modelsApiHints, - ...(modelsApiHints.contextWindow === undefined && show?.contextWindow !== undefined - ? { contextWindow: show.contextWindow } - : {}), - ...(modelsApiHints.inputModalities === undefined && show?.nativeVision === true - ? { inputModalities: ["text", "image"] as string[] } - : {}), - }; - return applyProviderConfigHints(name, prov, { - id: m.id, - provider: name, - ...(ownedBy ? { owned_by: ownedBy } : {}), - ...discoveredHints, - }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias); - }) - .filter(m => shouldExposeProviderModel(name, m.id)); - // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into - // `live`; otherwise configured entries would be reported as discovered ones. - const liveModelCount = live.length; - // Dated-release aliases + configured retention (compat allow-list, combo targets, - // Vertex default). Cache without combo retention so a later gather re-applies the - // current capture's retain set on read (warm-cache OCX-111 / #1308). - const forCache = withConfiguredRetention(live, { retainComboTargets: false }); - const returned = withConfiguredRetention(forCache, { warnDrops: true }); - const droppedConfiguredIds = configured - .map(model => model.id) - .filter(id => !returned.some(model => model.id === id)); - if (returned.length === 0 && name !== OPENAI_API_PROVIDER_ID) { - console.warn( - `[opencodex] Provider model discovery for "${name}" returned an authoritative empty catalog; ${droppedConfiguredIds.length > 0 ? `dropping configured model ids: ${droppedConfiguredIds.join(", ")}` : "no models will be exposed"}.`, - ); - } - if (!setCached(name, forCache, Date.now(), cacheGeneration)) { - return observed(withConfiguredRetention(configured), "degraded"); - } - markProviderDiscoveryOk(name, liveModelCount); - return observed(returned, "authoritative"); - } catch (error) { - if (error instanceof ProviderOutboundPolicyError) { - const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" }); - if (shouldLog) { - console.warn( - `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${error.message} [urlClass=${urlClass}, fallback=${fallback}].`, - ); - } - return observed(models, "degraded"); - } - const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "network" }); - if (shouldLog) { - console.warn( - `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`, - ); - } - return observed(models, "degraded"); - } -} - -export async function fetchProviderModels( - name: string, - prov: OcxProviderConfig, - ttlMs: number, - contextCap?: number, -): Promise { - const captured = captureProviderGather(name, prov, refreshingModelsAuthResolver); - return (await fetchProviderModelsWithAuth( - captured, - ttlMs, - contextCap, - refreshingModelsAuthResolver, - )).models; -} - -export function shouldExposeProviderModel(providerName: string, modelId: string): boolean { - if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free"); - // xAI /models advertises both the dated deployment and this floating alias. - // Keep only grok-4.20-multi-agent-0309; the alias is the same server-side id. - if (providerName === "xai" && modelId === "grok-4.20-multi-agent-beta-latest") return false; - return true; -} - -export function shouldRetainConfiguredProviderModel( - providerName: string, - modelId: string, - prov?: OcxProviderConfig, -): boolean { - if (CALLABLE_CONFIGURED_COMPATIBILITY_MODELS[providerName]?.has(modelId)) return true; - if (providerName === "opencode-free") return modelId === "big-pickle" || modelId.endsWith("-free"); - if (modelInList(prov?.retainModels, modelId)) return true; - return false; -} - -/** - * Fold dated-release aliases and retain configured rows that must survive an - * authoritative live roster (compatibility allow-list, combo targets, Vertex - * default). Used on every discovery return — live, fresh cache, stale, and - * failure fallback — so a warm cache captured before a combo existed still - * surfaces the configured target (OCX-111 / #1308). - * - * Cache writes should pass `retainComboTargets: false` so combo retention is - * re-applied on read against the current capture, not frozen into the TTL entry. - */ -export function mergeConfiguredModelsIntoLiveCatalog(opts: { - name: string; - provider: OcxProviderConfig; - models: readonly CatalogModel[]; - configured: readonly CatalogModel[]; - retainConfiguredModelIds?: ReadonlySet; - contextCap?: number; - seedVertexDefault?: boolean; - retainComboTargets?: boolean; - metadataModelIdCaseFold?: boolean; -}): { models: CatalogModel[]; droppedConfiguredIds: string[] } { - const { - name, - provider: prov, - configured, - retainConfiguredModelIds, - contextCap, - seedVertexDefault, - retainComboTargets = true, - metadataModelIdCaseFold, - } = opts; - const out = [...opts.models]; - const present = new Set(out.map(model => model.id)); - const droppedConfiguredIds: string[] = []; - for (const candidate of configured) { - if (present.has(candidate.id)) continue; - const dated = out.find(live => isDatedVariantId(live.id, candidate.id)); - if (dated) { - out.push(applyProviderConfigHints(name, prov, { ...dated, id: candidate.id }, contextCap, metadataModelIdCaseFold)); - present.add(candidate.id); - continue; - } - if ( - seedVertexDefault === true - || shouldRetainConfiguredProviderModel(name, candidate.id, prov) - || (retainComboTargets && retainConfiguredModelIds?.has(candidate.id) === true) - ) { - out.push(candidate); - present.add(candidate.id); - continue; - } - droppedConfiguredIds.push(candidate.id); - } - return { models: out, droppedConfiguredIds }; -} - -export function filterCatalogVisibleModels( - models: CatalogModel[], - config: Pick, -): CatalogModel[] { - const disabled = new Set(config.disabledModels ?? []); - const allowByProvider = new Map>(); - for (const [name, prov] of Object.entries(config.providers)) { - const sel = prov.selectedModels; - // Keyed the way `sync.ts` keys the same list, so a slash-bearing native id and - // the encoded slug the Codex picker displays are one entry rather than two. A - // bare `Set(sel)` matched only the native form, so an allowlist written from the - // displayed slug — which `ocx models remove` also accepts — hid every model it - // was meant to keep. - // - // The key is deliberately lossy: `p/a/b` and `p/a-b` collapse to one entry, so a - // provider publishing both spellings has them selected together. That is a real - // limitation, pinned by the tests below and tracked as a follow-up; it is NOT - // fixed here. Resolving selections against the current roster instead was tried - // and rejected — the roster is an incomplete dictionary (live discovery can omit - // a published id), so it produces the same over-grant while additionally - // disagreeing with the `slugEquivalenceKey` contract `sync.ts` uses at merge time. - // Two catalog stages with different equivalence relations is the exact bug class - // this change exists to remove. - if (Array.isArray(sel) && sel.length > 0) { - allowByProvider.set(name, new Set(sel.map(model => slugEquivalenceKey(routedSlug(name, model))))); - } - } - return models.filter(m => { - if (initialModelSelectionPending(config.providers[m.provider])) return false; - const nativeAlias = m.provider === COMBO_NAMESPACE && m.nativeAlias === true; - // disabledModels may be stored raw (canonical) or encoded (legacy UI writes). - for (const stored of disabled) { - // Combo management stores the public alias, while canonical `combo/` references - // remain valid for backward compatibility through slugEquals below. - if (m.alias !== undefined && stored === catalogModelSlug(m) && !nativeAlias) return false; - if (slugEquals(stored, m.provider, m.id)) return false; - } - const allow = allowByProvider.get(m.provider); - return !allow || allow.has(slugEquivalenceKey(routedSlug(m.provider, m.id))); - }); -} - -export async function gatherRoutedModels( - config: OcxConfig, - options?: GatherRoutedModelsOptions, -): Promise { - return gatherRoutedModelsWithAuth( - config, - `refreshing:${gatherFlightKey(config)}`, - () => refreshingModelsAuthResolver, - options, - ); -} - -/** - * Catalog-gather model discovery using only auth-store bytes already captured by the - * filesystem-evidence owner. This entry point never reaches the refreshing resolver. - */ -export async function gatherRoutedModelsForCatalogGather( - config: OcxConfig, - evidence: CatalogGatherProviderAuthEvidence, - options?: GatherRoutedModelsOptions, -): Promise { - const authStoreBuffer = evidence.authStoreBuffer === null - ? null - : Uint8Array.from(evidence.authStoreBuffer); - const authIdentity = authStoreBuffer === null - ? "absent" - : keyedGatherBytesIdentity("catalog-observed-auth-v1", authStoreBuffer); - return gatherRoutedModelsWithAuth( - config, - `observed:${authIdentity}:${gatherFlightKey(config)}`, - outcomes => observedModelsAuthResolver(authStoreBuffer, outcomes), - options, - ); -} - -async function gatherRoutedModelsWithAuth( - config: OcxConfig, - key: string, - createAuthResolver: ModelsAuthResolverFactory, - options?: GatherRoutedModelsOptions, -): Promise { - const capture = captureGatherFlight(config, createAuthResolver); - const bucket = gatherInflight.get(key) ?? []; - let entry = bucket.find(candidate => ( - candidate.discoveryPolicyIdentity === capture.discoveryPolicyIdentity - && candidate.authIdentity === capture.authIdentity - && candidate.providerGraphIdentity === capture.providerGraphIdentity - )); - if (!entry) { - const lease = gatherGate.tryAcquire(); - if (!lease) throw new CatalogGatherBusyError(); - // Claim the slot synchronously before any await so same-key callers join this flight. - // Distinct authorities retain separate entries even when their legacy bucket matches. - let ownedEntry!: GatherInflightEntry; - const flight = gatherRoutedModelsUncached(config, capture).finally(() => { - const current = gatherInflight.get(key); - const index = current?.indexOf(ownedEntry) ?? -1; - if (current && index >= 0) current.splice(index, 1); - if (current?.length === 0) gatherInflight.delete(key); - lease.release(); - }); - ownedEntry = Object.freeze({ - discoveryPolicyIdentity: capture.discoveryPolicyIdentity, - authIdentity: capture.authIdentity, - providerGraphIdentity: capture.providerGraphIdentity, - promise: flight, - }); - bucket.push(ownedEntry); - gatherInflight.set(key, bucket); - entry = ownedEntry; - } - const { - models, - comboOmissions, - providerAuthOutcomes, - providerModelOutcomes, - discoveryPolicySnapshots, - } = await entry.promise; - if (options?.comboOmissions) { - options.comboOmissions.length = 0; - options.comboOmissions.push(...comboOmissions); - } - if (options?.providerAuthOutcomes) { - options.providerAuthOutcomes.length = 0; - options.providerAuthOutcomes.push(...providerAuthOutcomes); - } - if (options?.providerModelOutcomes) { - options.providerModelOutcomes.length = 0; - options.providerModelOutcomes.push(...providerModelOutcomes); - } - if (options?.discoveryPolicySnapshots) { - options.discoveryPolicySnapshots.length = 0; - options.discoveryPolicySnapshots.push(...discoveryPolicySnapshots); - } - return models; -} - -/** Bound a custom row whose model id has pinned native Codex metadata, without changing stored configuration. */ -function boundCustomNativeReasoning( - model: CatalogModel, - allowed: readonly string[], - nativeDefault: string | undefined, -): CatalogModel { - if (allowed.length === 0 || model.reasoningEfforts === undefined) return model; - const bounded = { ...model }; - if (model.reasoningEfforts.length === 0) { - bounded.reasoningEfforts = []; - delete bounded.defaultReasoningEffort; - return bounded; - } - const declared = new Set(model.reasoningEfforts); - const surviving = [...new Set(allowed)].filter(effort => declared.has(effort)); - const fallback = nativeDefault && allowed.includes(nativeDefault) ? nativeDefault : allowed[0]!; - // A nonempty but incompatible declaration is not an explicit no-reasoning setting. - bounded.reasoningEfforts = surviving.length > 0 ? surviving : [fallback]; - bounded.defaultReasoningEffort = model.defaultReasoningEffort - && bounded.reasoningEfforts.includes(model.defaultReasoningEffort) - ? model.defaultReasoningEffort - : bounded.reasoningEfforts.includes(fallback) ? fallback : bounded.reasoningEfforts[0]!; - return bounded; -} - -async function gatherRoutedModelsUncached( - config: OcxConfig, - capture: GatherFlightCapture, -): Promise { - // Flight-local list: joiners copy from the resolved promise, not a process-global last write. - const localOmissions: ComboCatalogOmission[] = []; - const localProviderAuthOutcomes = capture.providerAuthOutcomes; - const resolveAuth = capture.authResolver; - const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS; - // Persisted provider entries can predate newer registry fields (noVisionModels, - // modelInputModalities, ...). The ROUTER merges registry seeds at request time - // (routedProviderConfig), so the proxy behaves correctly — the catalog listing must see the - // same merged view or its advertisements drift from actual proxy behavior (e.g. a - // vision-sidecar model advertised text-only, blocking image attachments app-side). - // Enrich a CLONE: hydrated defaults must never leak into the persisted config. - const activeProviders = capture.providers; - const providerResults = await Promise.all( - activeProviders.map(provider => fetchProviderModelsWithAuth( - provider, - ttlMs, - providerContextCap(config, provider.name), - resolveAuth, - )), - ); - const lists = providerResults.map(result => result.models); - const apiAugmented = augmentRoutedModelsWithCapturedOpenAiApiRows( - lists.flat(), - config, - capture.openAiApiPolicy, - ); - const apiProvider = activeProviders.find(provider => provider.name === OPENAI_API_PROVIDER_ID); - // Trusted reconstruction replaces whole rows, including the earlier Fast hints. - // Restore only that capability from the same captured authority used by discovery. - if (apiProvider) { - for (const model of apiAugmented) { - if (model.provider !== OPENAI_API_PROVIDER_ID) continue; - const policy = fastPolicyForModel(apiProvider.provider, model.id, apiProvider.name); - const supported = serviceTierSupportFromPolicy(policy); - if (supported !== undefined) model.supportsServiceTier = supported; - if (supported === true && policy.fastTierDescription !== undefined) model.fastTierDescription = policy.fastTierDescription; - } - } - const metadataModelIdCaseFoldByProvider = new Map( - activeProviders.map(provider => [provider.name, provider.metadataModelIdCaseFold]), - ); - const all = augmentRoutedModelsWithMetadata( - apiAugmented, - activeProviders.map(provider => provider.name), - config.providers, - config, - metadataModelIdCaseFoldByProvider, - ) - // Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog - // intentionally mirrors Cursor's public model table, including Gemini image preview, so the - // exposure decision goes through shouldExposeRoutedModel (single choke point). - .filter(shouldExposeRoutedModel); - const memberByKey = new Map(all.map(model => [`${model.provider}/${model.id}`, model])); - // [Decision Log] - // - 목적과 의도: 콤보 타겟에 native OpenAI(Codex login) 모델이 포함될 때 카탈로그에서 - // 누락되는 버그(issue #268)를 수정. "openai" provider는 forward-auth(Codex login - // passthrough)이므로 fetchProviderModels가 항상 []를 반환하고, native slugs는 - // 별도 정적 경로(nativeOpenAiSlugs)로만 노출됨. 따라서 memberByKey에 - // openai/ 키가 존재하지 않아 콤보가 조용히 drop됨. - // - 기존 구현 및 제약 조건: memberByKey는 routed provider /models fetch 결과로만 구성. - // - 검토한 주요 대안: (A) native slugs를 all 배열에 직접 push — /v1/models와 온디스크 - // 카탈로그에서 native 모델이 중복 노출되는 부작용 발생. (B) memberByKey에만 synthetic - // CatalogModel을 주입 — 콤보 멤버 해석에만 사용하고 all에는 추가하지 않으므로 기존 - // 노출 경로에 영향 없음. - // - 선택한 방식: (B) — synthetic entries를 memberByKey에만 주입. - // - 다른 대안 대신 이 방식을 선택한 이유: 기존 native 모델 노출 경로(/v1/models, 온디스크 - // 카탈로그 sync, management API)를 전혀 변경하지 않고 콤보 resolution만 수선하기 때문. - // - 장점, 단점 및 영향: 장점 — 최소 수정, 기존 경로 무변경. 단점 — synthetic entries의 - // capability 데이터가 static/upstream snapshot 기반이므로, 사용자가 커스텀 config - // 힌트(modelContextWindows 등)로 native 모델의 context window를 오버라이드한 경우 - // 반영되지 않음. 하지만 nativeOpenAiContextWindow가 이미 config 오버라이드를 - // 우선시하므로 실제 충돌 가능성은 낮음. - if (!hasComboTargets(config)) { - // Skip the native slug injection entirely when no combos are configured — avoids - // calling nativeOpenAiSlugs() (which reads the live Codex catalog from disk) for - // configs that will never need it. - } else { - const disabled = disabledNativeSlugs(config); - const openaiContextCap = nativeContextLimits(config); - const requiredNativeComboTargets = new Set(listComboIds(config).flatMap(id => { - const combo = getCombo(config, id); - return combo?.targets.flatMap(target => ( - target.provider === "openai" ? [target.model] : [] - )) ?? []; - })); - for (const slug of nativeOpenAiSlugs()) { - // A bare native disable key hides the native row, not a combo that targets it. - // Keep synthetic native metadata available to those combos. - if (disabled.has(slug) && !requiredNativeComboTargets.has(slug)) continue; - const contextWindow = nativeOpenAiContextWindow(slug, openaiContextCap); - if (contextWindow === undefined) continue; - const synthetic: CatalogModel = { - provider: "openai", - id: slug, - owned_by: "openai", - contextWindow, - // Input limit, not the total window. These coincide for native GPT-5.6 today (the - // advertised 922,000 window is already capped at its measured ceiling), but the two - // stay separate fields because routed/API rows of the same family run a wider window. - // Falls back to the window for slugs with no separate ceiling. - maxInputTokens: Math.min(nativeOpenAiMaxInputTokens(slug, openaiContextCap) ?? contextWindow, contextWindow), - ...(nativeOpenAiMaxOutputTokens(slug) !== undefined - ? { maxOutputTokens: nativeOpenAiMaxOutputTokens(slug) } - : {}), - autoCompactTokenLimit: nativeOpenAiAutoCompactTokenLimit(slug, openaiContextCap), - inputModalities: nativeInputModalities(slug), - reasoningEfforts: nativeReasoningEfforts(slug), - ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}), - }; - const key = `openai/${slug}`; - // Only inject when not already present from a routed provider (an API-key - // "openai" provider could shadow the native one). - if (!memberByKey.has(key)) memberByKey.set(key, synthetic); - } - } - // Enriched (registry-hydrated) provider clones — shared by combo member synthesis and - // custom-model vision-sidecar inheritance so both see the same merged registry view. - const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider])); - for (const id of listComboIds(config)) { - const combo = getCombo(config, id); - if (!combo) continue; - const comboNativeLimits = nativeContextLimits(config); - const nativeContextWindow = combo.nativeAlias && combo.alias - ? nativeOpenAiContextWindow(combo.alias, comboNativeLimits) - : undefined; - const nativeAliasMaxInput = combo.nativeAlias && combo.alias - ? (combo.alias.startsWith("gpt-5.6-") || combo.alias.includes("daybreak") - ? NATIVE_GPT56_MAX_INPUT_TOKENS - : nativeOpenAiMaxInputTokens(combo.alias, comboNativeLimits) ?? nativeOpenAiContextWindow(combo.alias, comboNativeLimits)) - : undefined; - const nativeAliasAutoCompact = combo.nativeAlias && combo.alias - ? nativeOpenAiAutoCompactTokenLimit(combo.alias, comboNativeLimits) - : undefined; - const nativeAliasFallback = combo.nativeAlias && combo.alias && nativeContextWindow !== undefined - ? { - contextWindow: nativeContextWindow, - ...(nativeAliasMaxInput !== undefined ? { maxInputTokens: nativeAliasMaxInput } : {}), - ...(nativeOpenAiMaxOutputTokens(combo.alias) !== undefined - ? { maxOutputTokens: nativeOpenAiMaxOutputTokens(combo.alias) } - : {}), - ...(nativeAliasAutoCompact !== undefined ? { autoCompactTokenLimit: nativeAliasAutoCompact } : {}), - inputModalities: nativeInputModalities(combo.alias), - reasoningEfforts: nativeReasoningEfforts(combo.alias), - } - : undefined; - const members = combo.targets - .map(target => resolveComboCatalogMember( - target, - memberByKey, - enrichedByName, - providerContextCap(config, target.provider), - nativeAliasFallback, - metadataModelIdCaseFoldByProvider.get(target.provider), - )) - .filter((member): member is CatalogModel => member !== undefined); - const derived = deriveComboCatalogModel(id, combo, members); - if (derived) { - const nativeDefault = combo.nativeAlias && combo.alias - ? nativeDefaultReasoningEffort(combo.alias) - : undefined; - if (combo.defaultEffort === null - && nativeDefault - && derived.reasoningEfforts?.includes(nativeDefault)) { - derived.defaultReasoningEffort = nativeDefault; - } - all.push(derived); - } - else warnUncataloguedComboOnce(id, combo, members, localOmissions); - } - replaceLastComboCatalogOmissions(localOmissions); - all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider))); - // Provider-derived rows keyed by their Codex-facing slug: a custom override replaces the row - // with the same slug below, so that row's provider capability metadata is the inheritance source. - const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model])); - const customModels = (config.customModels ?? []).map(cm => { - const rawProvider = config.providers[cm.provider]; - const effectiveProvider = enrichedByName.get(cm.provider) ?? rawProvider; - // Registry routing backfills an omitted authMode on the built-in OpenAI provider to - // forward. Keep the catalog projection on the same contract while still failing closed - // for every explicit non-forward mode and every non-canonical endpoint. - const providerForCanonicalCheck = rawProvider - ? withCanonicalOpenAiForwardAuthDefault(cm.provider, rawProvider) - : undefined; - const codexForwardNativeCapabilityAlias = cm.provider === OPENAI_CODEX_PROVIDER_ID - && providerForCanonicalCheck !== undefined - && isCanonicalOpenAiForwardProvider(providerForCanonicalCheck) - && hasNativeOpenAiCapabilityMetadata(cm.modelId); - const customNativeLimits = { - ...nativeContextLimits(config), - ...(typeof cm.contextWindow === "number" && cm.contextWindow > 0 - ? { modelWindows: { ...(nativeContextLimits(config).modelWindows ?? {}), [cm.modelId]: cm.contextWindow } } - : {}), - }; - const nativeAliasContextWindow = codexForwardNativeCapabilityAlias - ? nativeOpenAiContextWindow(cm.modelId, customNativeLimits) - : undefined; - const customContextWindow = cm.contextWindow - ? nativeAliasContextWindow !== undefined - ? nativeAliasContextWindow - : cm.contextWindow - : nativeAliasContextWindow; - const nativeAliasMaxInputTokens = codexForwardNativeCapabilityAlias - ? nativeOpenAiMaxInputTokens(cm.modelId, customNativeLimits) - : undefined; - const nativeAliasMaxOutputTokens = codexForwardNativeCapabilityAlias - ? nativeOpenAiMaxOutputTokens(cm.modelId) - : undefined; - const configuredMaxInput = rawProvider - ? configuredMaxInputTokens(rawProvider, cm.modelId) - : undefined; - const hardMaxCandidates = [nativeAliasMaxInputTokens, configuredMaxInput] - .filter((value): value is number => typeof value === "number" && value > 0); - const customMaxInputTokens = hardMaxCandidates.length > 0 - ? Math.min( - ...hardMaxCandidates, - ...(customContextWindow !== undefined ? [customContextWindow] : []), - ) - : undefined; - const customMaxOutputTokens = rawProvider - ? routedMaxOutputTokens(cm.provider, rawProvider, { - id: cm.modelId, - provider: cm.provider, - ...(nativeAliasMaxOutputTokens !== undefined ? { maxOutputTokens: nativeAliasMaxOutputTokens } : {}), - }, cm.modelId, metadataModelIdCaseFoldByProvider.get(cm.provider)) - : nativeAliasMaxOutputTokens; - const configuredAutoCompact = configuredAutoCompactTokenLimit(rawProvider, cm.modelId); - const customAutoCompactTokenLimit = codexForwardNativeCapabilityAlias - ? nativeOpenAiAutoCompactTokenLimit(cm.modelId, customNativeLimits) - : customContextWindow !== undefined && configuredAutoCompact !== undefined - ? clampAutoCompactTokenLimit(customContextWindow, customMaxInputTokens, configuredAutoCompact) - : undefined; - const nativeAliasDefaultEffort = codexForwardNativeCapabilityAlias - ? nativeDefaultReasoningEffort(cm.modelId) - : undefined; - const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId); - const fastPolicy = effectiveProvider - ? fastPolicyForModel(effectiveProvider, cm.modelId, cm.provider) - : undefined; - const supportsServiceTier = fastPolicy - ? serviceTierSupportFromPolicy(fastPolicy) - : undefined; - const base: CatalogModel = { - id: cm.modelId, - provider: cm.provider, - catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND, - // Display-only label: never feeds routing (customModels are keyed by routedSlug below). - ...(cm.displayName - ? { displayName: cm.displayName } - : codexForwardNativeCapabilityAlias - ? { displayName: nativeOpenAiCapabilityDisplayName(cm.modelId) ?? cm.modelId } : {}), - ...(customContextWindow !== undefined ? { contextWindow: customContextWindow } : {}), - ...(customMaxInputTokens !== undefined ? { maxInputTokens: customMaxInputTokens } : {}), - ...(customMaxOutputTokens !== undefined ? { maxOutputTokens: customMaxOutputTokens } : {}), - ...(customAutoCompactTokenLimit !== undefined ? { autoCompactTokenLimit: customAutoCompactTokenLimit } : {}), - ...(cm.inputModalities - ? { inputModalities: cm.inputModalities } - : codexForwardNativeCapabilityAlias ? { inputModalities: nativeInputModalities(cm.modelId) } : {}), - ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), - // Native-alias defaults apply only where the custom row declares nothing: the explicit - // spreads below must win (later in object order), so a stored `[]` stays empty and a - // declared ladder is narrowed to proven native capabilities after the merge below. - ...(codexForwardNativeCapabilityAlias - ? { - codexForwardNativeCapabilityAlias: true, - parallelToolCalls: nativeParallelToolCalls(cm.modelId), - ...(Array.isArray(cm.reasoningEfforts) - ? {} - : { - reasoningEfforts: nativeReasoningEfforts(cm.modelId), - ...(nativeAliasDefaultEffort ? { defaultReasoningEffort: nativeAliasDefaultEffort } : {}), - }), - } - : {}), - // Explicit custom-row ladder wins over the inherited provider row below: the merge only - // gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept - // instead of being replaced by that row's metadata. Capability-backed native model ids - // are bounded against their own pinned ladder after the merge, including gateways. - ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), - ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), - ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), - ...(supportsServiceTier === true && fastPolicy?.fastTierDescription !== undefined - ? { fastTierDescription: fastPolicy.fastTierDescription } - : {}), - ...(cm.codexToolMode !== undefined - ? { codexToolMode: cm.codexToolMode } - : effectiveProvider?.codexToolMode !== undefined - ? { codexToolMode: effectiveProvider.codexToolMode } - : {}), - }; - // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that - // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls, - // context, ...) so the generated catalog keeps advertising what the router actually provides. - // Explicit custom fields win by construction; this only fills gaps. Without it a - // noReasoningModels model loses its empty ladder and the catalog synthesizes the generic one, - // which Codex then rejects for spawn_agent with effort "none". - const replaced = replacedByRoutedSlug.get(routedSlug(cm.provider, cm.modelId)); - // The final ladder is what the catalog will advertise; the inherited default only rides - // along when it is actually a member — otherwise a provider default like "xhigh" would - // re-apply onto a narrower custom ladder and override the fallback in applyReasoningLevels. - const effectiveLadder = base.reasoningEfforts ?? replaced?.reasoningEfforts; - const mergedMaxInputCandidates = [base.maxInputTokens, replaced?.maxInputTokens] - .filter((value): value is number => typeof value === "number" && value > 0); - const mergedMaxInput = mergedMaxInputCandidates.length > 0 - ? Math.min(...mergedMaxInputCandidates) - : undefined; - const mergedMaxOutputCandidates = [base.maxOutputTokens, replaced?.maxOutputTokens] - .filter((value): value is number => typeof value === "number" && value > 0); - const mergedMaxOutput = mergedMaxOutputCandidates.length > 0 - ? Math.min(...mergedMaxOutputCandidates) - : undefined; - const merged: CatalogModel = replaced ? { - ...base, - ...(base.contextWindow === undefined && replaced.contextWindow !== undefined ? { contextWindow: replaced.contextWindow } : {}), - ...(mergedMaxInput !== undefined ? { maxInputTokens: mergedMaxInput } : {}), - ...(mergedMaxOutput !== undefined ? { maxOutputTokens: mergedMaxOutput } : {}), - ...(base.autoCompactTokenLimit === undefined && replaced.autoCompactTokenLimit !== undefined - ? { autoCompactTokenLimit: replaced.autoCompactTokenLimit } - : {}), - ...(base.inputModalities === undefined && replaced.inputModalities !== undefined ? { inputModalities: replaced.inputModalities } : {}), - ...(base.reasoningEfforts === undefined && replaced.reasoningEfforts !== undefined ? { reasoningEfforts: replaced.reasoningEfforts } : {}), - ...(base.defaultReasoningEffort === undefined && replaced.defaultReasoningEffort !== undefined - && Array.isArray(effectiveLadder) && effectiveLadder.includes(replaced.defaultReasoningEffort) - ? { defaultReasoningEffort: replaced.defaultReasoningEffort } : {}), - ...(base.parallelToolCalls === undefined && replaced.parallelToolCalls !== undefined ? { parallelToolCalls: replaced.parallelToolCalls } : {}), - ...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}), - ...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}), - ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}), - ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}), - } : base; - // Catalog-advertised efforts are bounded whenever the model id is a pinned native - // slug. Desktop validates that id, so a gateway such as YYLJ/gpt-6-astra still cannot - // advertise none/minimal. Full native identity stays behind the alias predicate. - const nativeEffortSource = hasNativeOpenAiCapabilityMetadata(cm.modelId); - const reasoningBounded = nativeEffortSource - ? boundCustomNativeReasoning( - merged, - nativeReasoningEfforts(cm.modelId), - nativeAliasDefaultEffort ?? nativeDefaultReasoningEffort(cm.modelId), - ) - : merged; - // Vision-sidecar coverage only: when the enriched provider's shared predicate matches - // noVisionModels or text-without-image modelInputModalities, advertise image input so the - // Codex app lets images reach the sidecar (#349/#344). Deliberately NOT the full - // applyProviderConfigHints pass — custom rows are a - // user override, so their explicit contextWindow / inputModalities / reasoning fields must be - // preserved verbatim (the hint pass would cap context and overwrite modalities from registry). - const mergedContext = typeof reasoningBounded.contextWindow === "number" && reasoningBounded.contextWindow > 0 - ? reasoningBounded.contextWindow - : undefined; - const boundedMergedMaxInput = typeof reasoningBounded.maxInputTokens === "number" && reasoningBounded.maxInputTokens > 0 - ? (mergedContext !== undefined ? Math.min(reasoningBounded.maxInputTokens, mergedContext) : reasoningBounded.maxInputTokens) - : undefined; - const mergedWithHardBounds = boundedMergedMaxInput !== undefined - && boundedMergedMaxInput !== reasoningBounded.maxInputTokens - ? { ...reasoningBounded, maxInputTokens: boundedMergedMaxInput } - : reasoningBounded; - const mergedSoftCandidates = [mergedWithHardBounds.autoCompactTokenLimit, configuredAutoCompact] - .filter((value): value is number => typeof value === "number" && value > 0); - const mergedWithAutoCompact: CatalogModel = mergedContext !== undefined && mergedSoftCandidates.length > 0 - ? { - ...mergedWithHardBounds, - autoCompactTokenLimit: clampAutoCompactTokenLimit( - mergedContext, - boundedMergedMaxInput, - Math.min(...mergedSoftCandidates), - ), - } - : mergedWithHardBounds; - const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider; - // Reuse the request-time consumer predicate so custom rows cannot drift from catalog hints. - if (enrichedProvider && isModelVisionSidecarConsumer(enrichedProvider, mergedWithAutoCompact.id)) { - const current = mergedWithAutoCompact.inputModalities ?? ["text"]; - if (!current.includes("image")) { - return { ...mergedWithAutoCompact, inputModalities: [...current, "image"] }; - } - } - return mergedWithAutoCompact; - }); - // Custom rows override discovered rows that encode to the same Codex-facing slug. - const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id))); - const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id))); - const models = [...deduped, ...customModels]; - // ponytail: catalog-scale scan; index ids by provider if catalog growth makes this measurable. - const aliasDisplayNames = new Map(activeProviders.flatMap(({ name, provider }) => { - const providerModels = models.filter(model => model.provider === name); - const aliases = [...effectiveModelAliases(config, provider, providerModels.map(model => model.id))]; - return aliases.flatMap(([id, { alias }]) => { - const exact = providerModels.filter(model => model.id === id); - const matches = exact.length > 0 - ? exact - : providerModels.filter(model => model.id.toLowerCase() === id.toLowerCase()); - return matches.length === 1 - ? [[`${name}/${matches[0]!.id}`, `${provider.alias || name}/${alias}`] as const] - : []; - }); - })); - const providerModelOutcomes = providerResults.map(result => ( - result.outcome.provider === OPENAI_API_PROVIDER_ID - && capture.openAiApiPolicy.state === "captured" - && capture.openAiApiPolicy.models !== undefined - ? { provider: result.outcome.provider, state: "authoritative" as const } - : result.outcome - )); - return { - models: models.map(model => { - const displayName = aliasDisplayNames.get(`${model.provider}/${model.id}`); - // #1711: one stamping point for every row this gather produces — routed, combo, and custom - // alike — because it is the only place that has both the finished list and the config the - // quota rules need. A combo votes over its own targets; anything else votes over the single - // provider that would serve it. - const targets = model.provider === COMBO_NAMESPACE - ? config.combos?.[model.id]?.targets ?? [] - : [{ provider: model.provider }]; - const inactive = quotaInactiveReason(config, targets); - const named = displayName && !model.displayName ? { ...model, displayName } : model; - return inactive ? { ...named, quotaInactiveReason: inactive } : named; - }), - comboOmissions: localOmissions, - providerAuthOutcomes: localProviderAuthOutcomes, - providerModelOutcomes, - discoveryPolicySnapshots: capture.discoveryPolicySnapshots, - }; -} - -export function augmentRoutedModelsWithRegistryOpenAiApiRows( - models: CatalogModel[], - config: OcxConfig, -): CatalogModel[] { - const configured = config.providers[OPENAI_API_PROVIDER_ID]; - if (!configured || configured.disabled === true || !providerMatchesRegistryTransport(OPENAI_API_PROVIDER_ID, configured)) return models; - return augmentRoutedModelsWithCapturedOpenAiApiRows( - models, - config, - captureTrustedOpenAiApiPolicy(OPENAI_API_PROVIDER_ID, true), - ); -} - -function augmentRoutedModelsWithCapturedOpenAiApiRows( - models: CatalogModel[], - config: OcxConfig, - policy: CatalogTrustedOpenAiApiPolicySnapshot, -): CatalogModel[] { - if (policy.state !== "captured" || !policy.models) return models; - const configured = config.providers[OPENAI_API_PROVIDER_ID]; - if (!configured || configured.disabled === true) return models; - - const existingById = new Map( - models.filter(model => model.provider === OPENAI_API_PROVIDER_ID).map(model => [model.id, model]), - ); - const trustedRows = policy.models.map((id): CatalogModel => { - const officialContext = policy.modelContextWindows?.[id]; - const officialMaxInput = policy.modelMaxInputTokens?.[id]; - const userContext = configured.modelContextWindows?.[id] ?? configured.contextWindow; - const userMaxInput = configured.modelMaxInputTokens?.[id]; - const providerCap = providerContextCap(config, OPENAI_API_PROVIDER_ID); - const contextWindow = typeof officialContext === "number" - ? Math.min(officialContext, userContext ?? officialContext, providerCap ?? officialContext) - : undefined; - const maxInputTokens = typeof officialMaxInput === "number" - ? Math.min( - officialMaxInput, - userMaxInput ?? officialMaxInput, - contextWindow ?? officialMaxInput, - ) - : undefined; - const configuredAutoCompact = configuredAutoCompactTokenLimit(configured, id); - const autoCompactTokenLimit = contextWindow !== undefined && configuredAutoCompact !== undefined - ? clampAutoCompactTokenLimit(contextWindow, maxInputTokens, configuredAutoCompact) - : undefined; - const maxOutputTokens = routedMaxOutputTokens( - OPENAI_API_PROVIDER_ID, - configured, - policy.modelMaxOutputTokens?.[id] !== undefined - ? { provider: OPENAI_API_PROVIDER_ID, id, maxOutputTokens: policy.modelMaxOutputTokens[id] } - : existingById.get(id) ?? { provider: OPENAI_API_PROVIDER_ID, id }, - policy.virtualModels?.[id]?.wireModelId ?? id, - ); - return { - provider: OPENAI_API_PROVIDER_ID, - id, - owned_by: OPENAI_API_PROVIDER_ID, - ...(contextWindow ? { contextWindow } : {}), - ...(maxInputTokens ? { maxInputTokens } : {}), - ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), - ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), - ...(policy.modelInputModalities?.[id] ? { inputModalities: [...policy.modelInputModalities[id]!] } : {}), - ...(policy.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...policy.modelReasoningEfforts[id]!] } : {}), - }; - }); - - for (const trusted of trustedRows) { - const live = existingById.get(trusted.id); - if (!live) continue; - const liveSignature = normalizedOpenAiApiSignature(live); - const trustedSignature = normalizedOpenAiApiSignature(trusted); - if (liveSignature === trustedSignature) continue; - const warningKey = `${trusted.provider}/${trusted.id}\n${liveSignature}\n${trustedSignature}`; - if (openAiApiCollisionWarnings.has(warningKey)) continue; - openAiApiCollisionWarnings.add(warningKey); - console.warn(`[opencodex] replacing conflicting live OpenAI API metadata for ${trusted.provider}/${trusted.id} with trusted registry metadata`); - } - - return [ - ...models.filter(model => model.provider !== OPENAI_API_PROVIDER_ID), - ...trustedRows, - ]; -} - -export function augmentRoutedModelsWithMetadata( - models: CatalogModel[], - providerNames: string[], - providers?: Record, - caps?: Pick, - metadataModelIdCaseFoldByProvider?: ReadonlyMap, -): CatalogModel[] { - const out = [...models]; - const seen = new Set(out.map(m => `${m.provider}/${m.id}`)); - for (const provider of providerNames) { - if (!JAWCODE_CATALOG_AUGMENT_PROVIDERS.has(provider)) continue; - if (providers?.[provider]?.liveModels === false) continue; - const jawcodeProvider = resolveMetadataProvider(provider); - if (!jawcodeProvider) continue; - for (const meta of listModelMetadata(jawcodeProvider)) { - const key = `${provider}/${meta.id}`; - if (seen.has(key)) continue; - seen.add(key); - const contextCap = caps ? providerContextCap(caps, provider) : undefined; - const model: CatalogModel = { - provider, - id: meta.id, - owned_by: provider, - ...(typeof meta.contextWindow === "number" && meta.contextWindow > 0 ? { contextWindow: meta.contextWindow } : {}), - ...(typeof meta.maxTokens === "number" && meta.maxTokens > 0 ? { maxOutputTokens: meta.maxTokens } : {}), - ...(Array.isArray(meta.input) && meta.input.length > 0 ? { inputModalities: [...meta.input] } : {}), - }; - out.push({ - ...model, - ...(providers?.[provider] - ? applyProviderConfigHints( - provider, - providers[provider], - model, - contextCap, - metadataModelIdCaseFoldByProvider?.get(provider), - ) - : {}), - }); - } - } - return out; -} +export type { + CatalogGatherProviderAuthOutcome, + CatalogGatherProviderModelOutcome, +} from "./gather-capture"; +export { createCatalogGatherAuthorityIdentity } from "./gather-capture"; + +export { + applyConfigHintsToCachedModels, + applyProviderConfigHints, + applyRegistryCapabilitySeedFill, + CALLABLE_CONFIGURED_COMPATIBILITY_MODELS, + catalogHintsFromModelsApiItem, + catalogHintsFromProviderConfig, + configuredAutoCompactTokenLimit, + configuredContextWindow, + configuredInputModalities, + configuredMaxInputTokens, + configuredModelDisplayName, + discoveredPricingStatus, + isGlm52ModelId, + isGlm53ModelId, + QUIET_AUTHORITATIVE_CATALOG_PROVIDERS, +} from "./model-hints"; + +export { + configuredComboTargetModelsByProvider, + resolveComboCatalogMember, +} from "./combo-member"; + +export { + filterCatalogVisibleModels, + isDatedVariantId, + lastDropWarnSignature, + mergeConfiguredModelsIntoLiveCatalog, + reconcileProviderFetchWarnings, + shouldExposeProviderModel, + shouldRetainConfiguredProviderModel, + warnDroppedConfiguredIdsOnce, +} from "./model-visibility"; + +export { fetchProviderModels } from "./provider-models"; + +export type { GatherRoutedModelsOptions } from "./routed-gather"; +export { + augmentRoutedModelsWithMetadata, + augmentRoutedModelsWithRegistryOpenAiApiRows, + CatalogGatherBusyError, + catalogGatherAdmissionMetrics, + clearGatherRoutedModelsInflight, + gatherRoutedModels, + gatherRoutedModelsForCatalogGather, +} from "./routed-gather"; diff --git a/src/codex/catalog/provider-models.ts b/src/codex/catalog/provider-models.ts new file mode 100644 index 0000000000..ced0cb939e --- /dev/null +++ b/src/codex/catalog/provider-models.ts @@ -0,0 +1,685 @@ +import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; +import { initialModelSelectionPending } from "../../providers/initial-model-selection"; +import { execFileSync } from "node:child_process"; +import { createHash, createHmac, randomBytes } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config"; +import { resolveProviderApiKey } from "../../providers/key-store"; +import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; +import { + clearModelCache, + clearProviderDiscoveryStatus, + captureModelCacheGeneration, + DEFAULT_MODEL_CACHE_TTL_MS, + getFreshCached, + getStaleCached, + isModelsFetchCoolingDown, + isModelCacheGenerationCurrent, + markModelsFetchFailure, + markProviderDiscoveryFailed, + markProviderDiscoveryOk, + shouldLogDiscoveryFailure, + setCached, + type ProviderModelDiscoveryFailure, +} from "../model-cache"; +import { + buildModelsRequest, + getValidAccessTokenSnapshot, + observeActiveOAuthAccessToken, + resolveModelsAuthToken, + type OAuthActiveTokenObservation, +} from "../../oauth"; +import type { OcxConfig, OcxProviderConfig } from "../../types"; +import { modelInList } from "../../types"; +import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; +import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; +import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; +import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; +import { + captureFastPolicyAuthority, + fastPolicyForModel, + serviceTierSupportFromPolicy, +} from "../../providers/service-tier"; +import type { FastPolicyAuthority } from "../../providers/fastwire"; +import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry"; +import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; +import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; +import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; +import { effectiveModelAliases } from "../../providers/default-aliases"; +import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; +import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; +import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; +import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; +import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; +import { fetchQoderModels } from "../../adapters/qoder/live-models"; +import { resolveQoderProfile } from "../../adapters/qoder/profiles"; +import { fetchDevinUsableModels } from "../../adapters/devin/live-models"; +import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { + COMBO_NAMESPACE, + comboModelId, + getCombo, + listComboIds, + quotaInactiveReason, + targetKey, +} from "../../combos"; +import type { NormalizedComboConfig } from "../../combos/types"; +import { + ProviderOutboundPolicyError, + providerOutboundGet, + providerOutboundPost, + providerRedirectError, +} from "../../lib/provider-outbound"; +import { redactSecretString } from "../../lib/redact"; +import { + extractProviderModelItems, + isRegistryModelDiscoveryUrl, + readBoundedDiscoveryJson, + resolveProviderModelDiscovery, + type ModelDiscoveryResponseFailure, + type ProviderModelsApiItem, + type ResolvedProviderModelDiscovery, +} from "../../providers/model-discovery"; +import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; +import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show"; +import upstreamModelsSnapshot from "../data/upstream-models.json"; +import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing"; +import type { CatalogModel } from "./parsing"; +import { disabledNativeSlugs, hasComboTargets, hasNativeOpenAiCapabilityMetadata, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeOpenAiCapabilityDisplayName, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; +import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; +import type { ComboCatalogOmission } from "./aggregation"; +import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; +import type { + CatalogAdmissionSnapshot, + CatalogDiscoveryPolicyField, + CatalogGatherAuthorityIdentity, + CatalogProviderDiscoveryPolicySnapshot, + CatalogProcessLocalEvidence, + CatalogSourceEvidence, + CatalogTrustedOpenAiApiPolicySnapshot, +} from "../convergence-types"; +import type { CapturedProviderGather, CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, ModelsAuthResolution, ModelsAuthResolver } from "./gather-capture"; +import { QUIET_AUTHORITATIVE_CATALOG_PROVIDERS, applyConfigHintsToCachedModels, applyProviderConfigHints, boundedOwnedBy, catalogHintsFromModelsApiItem, catalogHintsFromProviderConfig } from "./model-hints"; +import { mergeConfiguredModelsIntoLiveCatalog, shouldExposeProviderModel, warnDroppedConfiguredIdsOnce } from "./model-visibility"; +import { captureProviderGather, materializeCapturedHeaders } from "./gather-capture"; + +export interface ProviderModelsResult { + readonly models: CatalogModel[]; + readonly outcome: CatalogGatherProviderModelOutcome; +} +export const refreshingModelsAuthResolver: ModelsAuthResolver = { kind: "refreshing" }; + +export function observedModelsAuthResolver( + authStoreBuffer: Uint8Array | null, + outcomes: CatalogGatherProviderAuthOutcome[], +): ModelsAuthResolver { + return { + kind: "observed", + resolve(name, provider) { + if (provider.authMode === "forward") return { apiKey: undefined, observed: true }; + if (provider.authMode !== "oauth") { + return { apiKey: resolveProviderApiKey(provider.apiKey), observed: true }; + } + + const observation = observeActiveOAuthAccessToken(name, authStoreBuffer); + outcomes.push({ provider: name, state: observation.kind }); + if (observation.kind !== "available") return { apiKey: undefined, observed: true }; + return { + apiKey: observation.snapshot.accessToken, + observed: true, + ...(observation.snapshot.apiBaseUrl ? { oauthApiBaseUrl: observation.snapshot.apiBaseUrl } : {}), + ...(observation.snapshot.projectId ? { oauthProjectId: observation.snapshot.projectId } : {}), + }; + }, + }; +} +export async function fetchProviderModelsWithAuth( + captured: CapturedProviderGather, + ttlMs: number, + contextCap: number | undefined, + resolveAuth: ModelsAuthResolver, +): Promise { + const { name, provider: prov, discovery, request, metadataModelIdCaseFold } = captured; + const observed = ( + models: CatalogModel[], + state: CatalogGatherProviderModelOutcome["state"], + ): ProviderModelsResult => ({ models, outcome: { provider: name, state } }); + // Capture before any credential refresh or outbound await. OAuth account changes clear this + // generation, so a request started with the former account cannot later publish its result. + const cacheGeneration = captureModelCacheGeneration(name); + const isCurrentCacheGeneration = () => isModelCacheGenerationCurrent(name, cacheGeneration); + if (prov.authMode === "forward") return observed([], "authoritative"); // ChatGPT backend has no /models + const seedVertexDefault = prov.adapter === "google" + && prov.googleMode === "vertex" + && (prov.models?.length ?? 0) === 0 + && Boolean(prov.defaultModel); + const seedStaticDefault = prov.liveModels === false + && (prov.models?.length ?? 0) === 0 + && Boolean(prov.defaultModel); + // Ordered dedupe union: implicit default seed, then `models`, then `retainModels`. `configured` is the + // single seed for the static path, the degraded fallback, drop diagnostics, and provider hints, + // so a retain-only id must enter here or it never exists to be retained (#1690). + const configuredIds = [...new Set([ + ...((seedVertexDefault || seedStaticDefault) && prov.defaultModel ? [prov.defaultModel] : []), + ...(prov.models ?? []), + ...(prov.retainModels ?? []), + ])]; + const configured: CatalogModel[] = configuredIds.map(id => ({ + id, + provider: name, + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + })); + const withConfiguredRetention = ( + models: CatalogModel[], + options?: { retainComboTargets?: boolean; warnDrops?: boolean }, + ): CatalogModel[] => { + const { models: merged, droppedConfiguredIds } = mergeConfiguredModelsIntoLiveCatalog({ + name, + provider: prov, + models, + configured, + retainConfiguredModelIds: captured.retainConfiguredModelIds, + contextCap, + seedVertexDefault, + retainComboTargets: options?.retainComboTargets, + metadataModelIdCaseFold, + }); + if ( + options?.warnDrops === true + && droppedConfiguredIds.length > 0 + && name !== OPENAI_API_PROVIDER_ID + && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name) + ) { + warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds); + } + return merged; + }; + // Static catalogs never need an OAuth refresh or an upstream model request. Clear any + // discovery failure left by an older live configuration even when the account is logged out. + if (prov.liveModels === false) { + clearProviderDiscoveryStatus(name); + return observed(configured, "authoritative"); + } + const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing" + ? prov.authMode === "oauth" && effectiveGoogleMode(name, prov) === "cloud-code-assist" + ? await getValidAccessTokenSnapshot(name) + .then(snapshot => ({ + apiKey: snapshot.accessToken, + observed: false, + ...(snapshot.projectId ? { oauthProjectId: snapshot.projectId } : {}), + })) + .catch(() => ({ apiKey: undefined, observed: false })) + : { apiKey: await resolveModelsAuthToken(name, prov), observed: false } + : resolveAuth.resolve(name, prov)); + const apiKey = auth.apiKey; + // A configured default is a real callable selector and must remain discoverable when a + // compatible provider's live /models request fails (issue #308). Static providers already seed + // their default selector above when no explicit model list exists. + const failedDiscoveryConfigured = configured.length > 0 || !prov.defaultModel || prov.adapter !== "anthropic" + ? configured + : [{ + id: prov.defaultModel, + provider: name, + ...catalogHintsFromProviderConfig(name, prov, prov.defaultModel, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + }]; + const vertexDefaultSeed = seedVertexDefault ? configured[0] : undefined; + const withVertexDefaultSeed = (models: CatalogModel[]): CatalogModel[] => ( + vertexDefaultSeed && !models.some(model => model.id === vertexDefaultSeed.id) + ? [...models, vertexDefaultSeed] + : models + ); + if (prov.adapter === "qoder") { + if (!apiKey) return observed(configured, "degraded"); + const profile = resolveQoderProfile(prov.baseUrl); + if (!profile) return observed(configured, "degraded"); + // Qoder's model list is entitlement-specific. Bind cache reads/writes to an irreversible PAT + // fingerprint so an account switch cannot observe another account's roster, even if a caller + // bypasses the normal config mutation path that clears provider caches. + const authorityIdentity = createHash("sha256").update(apiKey).digest("hex"); + const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity); + if (fresh) { + return observed(withConfiguredRetention( + applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + ), "authoritative"); + } + const scopedStale = getStaleCached(name, authorityIdentity); + if (isModelsFetchCoolingDown(name) && scopedStale) { + return observed(withConfiguredRetention( + applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + ), "degraded"); + } + const live = await fetchQoderModels(profile, apiKey); + if (live.ok) { + const discovered = live.models.map(id => ({ + id, + provider: name, + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + })); + const forCache = withConfiguredRetention(discovered, { retainComboTargets: false }); + if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) { + return observed(withConfiguredRetention(configured), "degraded"); + } + markProviderDiscoveryOk(name, live.models.length); + return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); + } + if (isCurrentCacheGeneration()) { + markModelsFetchFailure(name); + markProviderDiscoveryFailed(name, { reason: "provider" }); + console.warn(`[opencodex] Qoder model discovery for "${name}" failed [${live.error}]${live.detail ? `: ${live.detail}` : ""}; using stale/static catalog degradation.`); + } + const stale = getStaleCached(name, authorityIdentity); + return observed(withConfiguredRetention( + stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured, + ), "degraded"); + } + if (prov.adapter === "devin") { + if (!apiKey) return observed(configured, "degraded"); + const cachedDevin = getFreshCached(name, ttlMs); + if (cachedDevin) { + return observed( + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedDevin)), + "authoritative", + ); + } + if (isModelsFetchCoolingDown(name)) { + const cooling = getStaleCached(name); + return observed( + withConfiguredRetention( + cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured, + ), + "degraded", + ); + } + const liveResult = await fetchDevinUsableModels({ apiKey, baseUrl: prov.baseUrl }); + if (liveResult.ok) { + // Live catalog is the source of truth — use the discovered base models + // directly, not a filtered subset of the static seed. + // + // That extends to the context window. Cognition publishes no window + // anywhere, so the per-account catalog is the only first-party number, + // and the shipped static table is a degraded-mode guess that was wrong + // for nine of its eleven rows. The live value is applied first and the + // config hints run after it, so an explicit per-model override and an + // enabled Context cap still win — this only replaces the number nobody + // chose. + const result = liveResult.models.map((id) => { + const liveWindow = liveResult.contextWindows[id]; + return { + id, + provider: name, + ...(liveWindow ? { contextWindow: liveWindow } : {}), + // The account catalog names the effort variants each base model has, so + // its ladder is measured rather than assumed. Without this the entry + // inherits the generic routed ladder and offers rungs the model rounds + // away, and every client that keys an effort control off this field — + // the Pi-shaped exports — renders no control at all. + ...(liveResult.efforts[id]?.length ? { reasoningEfforts: liveResult.efforts[id] } : {}), + // The account catalog's per-base supportsImages vote collapses to one + // modalities value. It spreads before the hints so exact + // modelCapabilities declarations, the legacy modelInputModalities + // record and the vision-sidecar rewrite keep winning — the live + // value survives only when none of them applies. + ...(liveResult.inputModalities[id]?.length ? { inputModalities: liveResult.inputModalities[id] } : {}), + ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias), + } as CatalogModel; + }); + const forCache = withConfiguredRetention(result, { retainComboTargets: false }); + if (!setCached(name, forCache, Date.now(), cacheGeneration)) { + return observed(withConfiguredRetention(configured), "degraded"); + } + markProviderDiscoveryOk(name, liveResult.models.length); + return observed(withConfiguredRetention(forCache), "authoritative"); + } + if (isCurrentCacheGeneration()) { + markModelsFetchFailure(name); + markProviderDiscoveryFailed(name, { reason: liveResult.error === "auth" ? "provider" : "invalid_response" }); + } + const stale = getStaleCached(name); + return observed( + withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale) : configured), + "degraded", + ); + } + if (prov.adapter === "cursor") { + if (!apiKey) return observed(configured, "degraded"); + // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed + // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort + // suffix) but filter the static seed to the bases the account actually has — so models not on the + // plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed. + const cachedCursor = getFreshCached(name, ttlMs); + if (cachedCursor) { + return observed( + withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cachedCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias)), + "authoritative", + ); + } + if (isModelsFetchCoolingDown(name)) { + const cooling = getStaleCached(name); + return observed( + withConfiguredRetention( + cooling ? applyConfigHintsToCachedModels(name, prov, cooling, undefined, metadataModelIdCaseFold, captured.effectiveAlias) : configured, + ), + "degraded", + ); + } + const cursorFetch = (prov as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch; + const liveResult = await fetchCursorUsableModels({ + apiKey, + baseUrl: prov.baseUrl, + upstreamHttpVersion: prov.upstreamHttpVersion, + ...(cursorFetch ? { fetch: cursorFetch } : {}), + }); + if (liveResult.ok) { + const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models); + const result = available.length > 0 ? available : configured; + // Cache the discovery-filtered roster without combo retention so a later + // gather can re-apply the current capture's retain set on read. + const forCache = withConfiguredRetention(result, { retainComboTargets: false }); + if (!setCached(name, forCache, Date.now(), cacheGeneration)) { + return observed(withConfiguredRetention(configured), "degraded"); + } + // Publish roster-derived state only for a discovery the cache accepted: a stale + // in-flight capture (generation revoked by a credential/config change) must not + // overwrite the spelling or Max-Mode evidence of the newer one. + recordLiveCursorClaudeModels(liveResult.models); + // Live Max-Mode evidence feeds the umbrella resolver's ultra gate + // (devlog 260828_cursor_umbrella_catalog; union with static evidence). + recordLiveCursorMaxModeModels(liveResult.maxModeModels ?? []); + markProviderDiscoveryOk(name, liveResult.models.length); + return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); + } + if (isCurrentCacheGeneration()) { + markModelsFetchFailure(name); + markProviderDiscoveryFailed(name, { reason: "provider" }); + console.warn( + `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`, + ); + } + const staleCursor = getStaleCached(name); + return observed( + withConfiguredRetention( + staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor, undefined, metadataModelIdCaseFold, captured.effectiveAlias) : configured, + ), + "degraded", + ); + } + if (prov.authMode === "oauth" && !apiKey) { + // No usable token (logged out, or account marked needsReauth). Still surface the + // configured static catalog so the GUI Models tab / rail counts are not empty — + // matching Cursor's !apiKey → configured degradation and fetch-failure fallback. + return observed(configured, "degraded"); + } + const cloudCodeAssist = effectiveGoogleMode(name, prov) === "cloud-code-assist"; + const project = prov.project ?? auth.oauthProjectId; + if (cloudCodeAssist && !project) return observed(configured, "degraded"); + const fresh = getFreshCached(name, ttlMs); + if (fresh) { + return observed( + withConfiguredRetention( + withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)), + ), + "authoritative", + ); // dedups Codex's frequent /v1/models polling within the TTL + } + if (isModelsFetchCoolingDown(name)) { + // A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the + // fetch timeout on every catalog poll — the dashboard polls this path per page load. + const stale = getStaleCached(name); + return observed( + withConfiguredRetention( + stale + ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)) + : failedDiscoveryConfigured, + ), + "degraded", + ); + } + const url = request.url; + let headers = materializeCapturedHeaders(request, apiKey); + // One Ollama authority contract: for canonical ollama-cloud/ollama-native rows, discovery + // (/v1/models), enrichment (/api/show) and inference (/api/chat) must all materialize the + // SAME effective credential/header authority. buildModelsRequest's generic tail writes the + // generated Bearer AFTER configured headers, but the native inference adapter applies + // provider.headers LAST (configured wins, case-insensitive collapse). Reapply the configured + // provider headers here so the whole Ollama request family shares that one authority. + if (ollamaShowEnrichable(name, prov)) { + headers = applyConfiguredHeadersLast(headers, prov.headers); + } + const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com") + ? "vertex-aiplatform" + : "provider-models"; + const failedDiscoveryFallback = ( + failure: ProviderModelDiscoveryFailure, + ): { models: CatalogModel[]; fallback: "stale" | "configured"; shouldLog: boolean } => { + if (!isCurrentCacheGeneration()) { + return { + models: withConfiguredRetention(failedDiscoveryConfigured), + fallback: "configured", + shouldLog: false, + }; + } + // Decide logging BEFORE recording the new status, so we can compare against the prior one and + // suppress an identical repeated failure (#395 log flood). The failure stays observable via the + // discovery-status API regardless. + const shouldLog = shouldLogDiscoveryFailure(name, failure); + markModelsFetchFailure(name); + markProviderDiscoveryFailed(name, failure); + const stale = getStaleCached(name); + return { + models: withConfiguredRetention( + stale + ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)) + : failedDiscoveryConfigured, + ), + fallback: stale ? "stale" : "configured", + shouldLog, + }; + }; + try { + // Canonical-URL TUN transparency for Clash/Surge/Mihomo fake-IP DNS: + // `isRegistryModelDiscoveryUrl` proves the FINAL request URL is the + // registry's own fixed discovery URL, so a purely-benchmark DNS answer may + // be pin-connected through the intercepting TUN without proxy env. The + // proof is on the URL — not the provider name — because an OAuth/forward + // name matches any baseUrl by design. Retargeted or renamed custom rows + // fetch a different URL and keep the rejection. + const outboundDependencies = { isCanonicalUrl: isRegistryModelDiscoveryUrl }; + const res = request.method === "POST" + ? await providerOutboundPost(name, prov, url, { + headers, + body: JSON.stringify({ project }), + signal: AbortSignal.timeout(8000), + }, outboundDependencies) + : await providerOutboundGet(name, prov, url, { + headers, + signal: AbortSignal.timeout(8000), + }, outboundDependencies); + const redirectError = await providerRedirectError(res, url); + if (redirectError) { + const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status }); + if (shouldLog) { + console.warn( + `[opencodex] Provider model discovery for "${name}" ${redirectError} [urlClass=${urlClass}, fallback=${fallback}].`, + ); + } + return observed(models, "degraded"); + } + if (!res.ok) { + const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status }); + if (shouldLog) { + console.warn( + `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`, + ); + } + return observed(models, "degraded"); + } + + const contentType = ( + res.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() || "missing" + ).slice(0, 80); + const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes); + if (!bounded.ok) { + const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); + const diagnostic = bounded.reason === "response_too_large" + ? `exceeded the ${discovery.maxResponseBytes}-byte response limit` + : contentType === "application/json" || contentType.endsWith("+json") + ? "returned invalid JSON in a 2xx response" + : "returned a non-JSON 2xx response"; + if (shouldLog) { + console.warn( + `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, + ); + } + return observed(models, "degraded"); + } + const antigravity = cloudCodeAssist + ? parseAntigravityAvailableModels(bounded.value, discovery.maxModels) + : undefined; + if (cloudCodeAssist && !antigravity) { + const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); + if (shouldLog) { + console.warn( + `[opencodex] Provider model discovery for "${name}" returned malformed CCA model data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, + ); + } + return observed(models, "degraded"); + } + if (antigravity) { + const live = antigravity.map(model => applyProviderConfigHints(name, prov, { + id: model.id, + provider: name, + // CCA only exposes a numeric thinking budget. Until the adapter owns an exact Codex + // effort-to-wire mapping for a newly discovered model, do not advertise a false ladder. + reasoningEfforts: [], + ...(model.contextWindow ? { contextWindow: model.contextWindow } : {}), + ...(model.inputModalities ? { inputModalities: model.inputModalities } : {}), + }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)); + const forCache = withConfiguredRetention(live, { retainComboTargets: false }); + if (!setCached(name, forCache, Date.now(), cacheGeneration)) { + return observed(withConfiguredRetention(configured), "degraded"); + } + registerAntigravityDiscoveredWireModels(prov.baseUrl, antigravity, { + provider: name, + cacheGeneration, + }); + markProviderDiscoveryOk(name, live.length); + return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative"); + } + const googleAiStudio = effectiveGoogleMode(name, prov) === "ai-studio" + ? extractGoogleAiStudioModelItems(bounded.value, discovery.maxModels) + : undefined; + // Native /v1beta/models wins; a google row served by an OpenAI-compatible + // gateway keeps the generic data[] / top-level-array contract. + const extracted = googleAiStudio?.ok + ? googleAiStudio + : extractProviderModelItems(bounded.value, discovery); + if (!extracted.ok) { + const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" }); + const diagnostic: Record = { + response_too_large: "returned an oversized 2xx response", + invalid_json: "returned invalid JSON in a 2xx response", + invalid_shape: "returned malformed 2xx data", + too_many_models: `exceeded the ${discovery.maxModels}-row model limit`, + }; + if (shouldLog) { + console.warn( + `[opencodex] Provider model discovery for "${name}" ${diagnostic[extracted.reason]} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, + ); + } + return observed(models, "degraded"); + } + const items = extracted.items; + // Ollama Cloud enrichment: /v1/models carries no per-model context or capability metadata, + // so a newly announced id would otherwise publish generic defaults. /api/show fills that + // per model, fail-soft, bounded, and cached with this gather's result. Explicit configured + // metadata keeps its normal precedence (applyProviderConfigHints applies the discovered + // window only where exact config is absent, and the provider context cap still caps it). + const showEnrichment = ollamaShowEnrichable(name, prov) + ? await fetchOllamaShowEnrichment({ + headers, + discoveryUrl: request.url, + modelIds: items.map(m => m.id), + provider: prov, + }).catch(() => undefined) + : undefined; + const live = items.map(m => { + const ownedBy = boundedOwnedBy(m.owned_by); + // Precedence: the authoritative /v1/models row wins; /api/show fills only metadata the + // models-API row does not carry. applyProviderConfigHints then applies explicit + // configured metadata over both, and the provider context cap still caps the result. + const modelsApiHints = catalogHintsFromModelsApiItem(name, m); + const show = showEnrichment?.metadata.get(m.id); + const discoveredHints = { + ...modelsApiHints, + ...(modelsApiHints.contextWindow === undefined && show?.contextWindow !== undefined + ? { contextWindow: show.contextWindow } + : {}), + ...(modelsApiHints.inputModalities === undefined && show?.nativeVision === true + ? { inputModalities: ["text", "image"] as string[] } + : {}), + }; + return applyProviderConfigHints(name, prov, { + id: m.id, + provider: name, + ...(ownedBy ? { owned_by: ownedBy } : {}), + ...discoveredHints, + }, contextCap, metadataModelIdCaseFold, captured.effectiveAlias); + }) + .filter(m => shouldExposeProviderModel(name, m.id)); + // Capture the count BEFORE the alias/configured augmentation below pushes extra rows into + // `live`; otherwise configured entries would be reported as discovered ones. + const liveModelCount = live.length; + // Dated-release aliases + configured retention (compat allow-list, combo targets, + // Vertex default). Cache without combo retention so a later gather re-applies the + // current capture's retain set on read (warm-cache OCX-111 / #1308). + const forCache = withConfiguredRetention(live, { retainComboTargets: false }); + const returned = withConfiguredRetention(forCache, { warnDrops: true }); + const droppedConfiguredIds = configured + .map(model => model.id) + .filter(id => !returned.some(model => model.id === id)); + if (returned.length === 0 && name !== OPENAI_API_PROVIDER_ID) { + console.warn( + `[opencodex] Provider model discovery for "${name}" returned an authoritative empty catalog; ${droppedConfiguredIds.length > 0 ? `dropping configured model ids: ${droppedConfiguredIds.join(", ")}` : "no models will be exposed"}.`, + ); + } + if (!setCached(name, forCache, Date.now(), cacheGeneration)) { + return observed(withConfiguredRetention(configured), "degraded"); + } + markProviderDiscoveryOk(name, liveModelCount); + return observed(returned, "authoritative"); + } catch (error) { + if (error instanceof ProviderOutboundPolicyError) { + const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" }); + if (shouldLog) { + console.warn( + `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${error.message} [urlClass=${urlClass}, fallback=${fallback}].`, + ); + } + return observed(models, "degraded"); + } + const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "network" }); + if (shouldLog) { + console.warn( + `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`, + ); + } + return observed(models, "degraded"); + } +} + +export async function fetchProviderModels( + name: string, + prov: OcxProviderConfig, + ttlMs: number, + contextCap?: number, +): Promise { + const captured = captureProviderGather(name, prov, refreshingModelsAuthResolver); + return (await fetchProviderModelsWithAuth( + captured, + ttlMs, + contextCap, + refreshingModelsAuthResolver, + )).models; +} diff --git a/src/codex/catalog/routed-gather.ts b/src/codex/catalog/routed-gather.ts new file mode 100644 index 0000000000..68dd1cc376 --- /dev/null +++ b/src/codex/catalog/routed-gather.ts @@ -0,0 +1,858 @@ +import { effectiveProviderAlias, effectiveProviderAliasDecision } from "../../providers/default-aliases"; +import { initialModelSelectionPending } from "../../providers/initial-model-selection"; +import { execFileSync } from "node:child_process"; +import { createHash, createHmac, randomBytes } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config"; +import { resolveProviderApiKey } from "../../providers/key-store"; +import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths"; +import { + clearModelCache, + clearProviderDiscoveryStatus, + captureModelCacheGeneration, + DEFAULT_MODEL_CACHE_TTL_MS, + getFreshCached, + getStaleCached, + isModelsFetchCoolingDown, + isModelCacheGenerationCurrent, + markModelsFetchFailure, + markProviderDiscoveryFailed, + markProviderDiscoveryOk, + shouldLogDiscoveryFailure, + setCached, + type ProviderModelDiscoveryFailure, +} from "../model-cache"; +import { + buildModelsRequest, + getValidAccessTokenSnapshot, + observeActiveOAuthAccessToken, + resolveModelsAuthToken, + type OAuthActiveTokenObservation, +} from "../../oauth"; +import type { OcxConfig, OcxProviderConfig } from "../../types"; +import { modelInList } from "../../types"; +import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; +import { isModelVisionSidecarConsumer } from "../../vision/eligibility"; +import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider, type ModelMetadata } from "../../generated/model-metadata"; +import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; +import { + captureFastPolicyAuthority, + fastPolicyForModel, + serviceTierSupportFromPolicy, +} from "../../providers/service-tier"; +import type { FastPolicyAuthority } from "../../providers/fastwire"; +import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry"; +import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; +import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; +import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; +import { effectiveModelAliases } from "../../providers/default-aliases"; +import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; +import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; +import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; +import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; +import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog"; +import { fetchQoderModels } from "../../adapters/qoder/live-models"; +import { resolveQoderProfile } from "../../adapters/qoder/profiles"; +import { fetchDevinUsableModels } from "../../adapters/devin/live-models"; +import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { + COMBO_NAMESPACE, + comboModelId, + getCombo, + listComboIds, + quotaInactiveReason, + targetKey, +} from "../../combos"; +import type { NormalizedComboConfig } from "../../combos/types"; +import { + ProviderOutboundPolicyError, + providerOutboundGet, + providerOutboundPost, + providerRedirectError, +} from "../../lib/provider-outbound"; +import { redactSecretString } from "../../lib/redact"; +import { + extractProviderModelItems, + isRegistryModelDiscoveryUrl, + readBoundedDiscoveryJson, + resolveProviderModelDiscovery, + type ModelDiscoveryResponseFailure, + type ProviderModelsApiItem, + type ResolvedProviderModelDiscovery, +} from "../../providers/model-discovery"; +import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery"; +import { applyConfiguredHeadersLast, fetchOllamaShowEnrichment, ollamaShowEnrichable } from "../../providers/ollama-show"; +import upstreamModelsSnapshot from "../data/upstream-models.json"; +import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing"; +import type { CatalogModel } from "./parsing"; +import { disabledNativeSlugs, hasComboTargets, hasNativeOpenAiCapabilityMetadata, NATIVE_GPT56_MAX_INPUT_TOKENS, nativeContextLimits, nativeOpenAiCapabilityDisplayName, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiAutoCompactTokenLimit, nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens, nativeOpenAiMaxOutputTokens, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; +import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; +import type { ComboCatalogOmission } from "./aggregation"; +import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; +import type { + CatalogAdmissionSnapshot, + CatalogDiscoveryPolicyField, + CatalogGatherAuthorityIdentity, + CatalogProviderDiscoveryPolicySnapshot, + CatalogProcessLocalEvidence, + CatalogSourceEvidence, + CatalogTrustedOpenAiApiPolicySnapshot, +} from "../convergence-types"; +import type { CatalogGatherProviderAuthOutcome, CatalogGatherProviderModelOutcome, GatherFlightCapture, ModelsAuthResolverFactory } from "./gather-capture"; +import { applyProviderConfigHints, configuredAutoCompactTokenLimit, configuredMaxInputTokens, configuredReasoningSummarySupport, modelInputModalities, routedMaxOutputTokens } from "./model-hints"; +import { resolveComboCatalogMember } from "./combo-member"; +import { captureGatherFlight, captureTrustedOpenAiApiPolicy, gatherFlightKey, keyedGatherBytesIdentity, withCanonicalOpenAiForwardAuthDefault } from "./gather-capture"; +import { fetchProviderModelsWithAuth, observedModelsAuthResolver, refreshingModelsAuthResolver } from "./provider-models"; + +export interface GatherRoutedModelsOptions { + comboOmissions?: ComboCatalogOmission[]; + providerAuthOutcomes?: CatalogGatherProviderAuthOutcome[]; + /** Flight-local authority of each provider's returned model rows. */ + providerModelOutcomes?: CatalogGatherProviderModelOutcome[]; + /** Internal convergence sink for the immutable policy that produced the returned rows. */ + discoveryPolicySnapshots?: CatalogProviderDiscoveryPolicySnapshot[]; +} + +interface GatherFlightResult { + models: CatalogModel[]; + comboOmissions: ComboCatalogOmission[]; + providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[]; + providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[]; + discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[]; +} +interface GatherInflightEntry { + readonly discoveryPolicyIdentity: string; + /** + * The credential half of the join decision. + * + * `gatherFlightKey`'s fingerprint carries endpoints and model lists but no + * `authMode`, key or headers, and discovery policy does not carry them either. + * Two admissions differing ONLY in credential therefore produced the same key + * and the same policy, so the second joined the first and published rows the + * old key had fetched — reproduced against the real routes by rotating a key + * through `/api/providers/keys` mid-flight. + * + * Now REDUNDANT with `providerGraphIdentity`, which hashes the whole provider + * row and therefore covers `apiKey` too: removing this term alone leaves the + * credential regression green. It is kept deliberately, for two reasons. It + * covers what the graph cannot — the RESOLVED auth (`observedAuth`) and the + * final materialized headers, which are derived rather than stored, so an + * OAuth token that changes while the row is byte-identical still separates + * admissions. And it states the credential rule where a reader looks for it, + * instead of leaving it as an emergent property of hashing everything. + */ + readonly authIdentity: string; + /** + * The whole admitted provider graph, not a chosen subset. + * + * `providerCatalogFingerprint` is an ALLOW-LIST, so every field it forgot was + * silently treated as equivalence: credentials leaked a flight until + * `authIdentity` landed, and `reasoningEfforts` leaked one after that — both + * reproduced against real routes. Enumerating fields cannot converge, because + * the next field added to a provider row inherits the same defect. This + * identity therefore covers the enriched, frozen provider objects the flight + * actually gathered from, so a join is refused unless the admissions agree on + * everything rather than on everything somebody remembered to list. + */ + readonly providerGraphIdentity: string; + readonly promise: Promise; +} +const gatherInflight = new Map(); +const MAX_CONCURRENT_CATALOG_GATHERS = 8; +const gatherGate = createAdmissionGate("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS); + +export class CatalogGatherBusyError extends ResourceAdmissionError { + override readonly code = "catalog_busy"; + readonly retryAfterSeconds = 1; + constructor() { + super("catalog_gathers", MAX_CONCURRENT_CATALOG_GATHERS); + this.name = "CatalogGatherBusyError"; + } +} + +export function catalogGatherAdmissionMetrics(): AdmissionMetrics { + return gatherGate.metrics(); +} +/** Drop in-flight gather so tests / full cache clears do not reuse a stale promise. */ +export function clearGatherRoutedModelsInflight(): void { + gatherInflight.clear(); +} +export async function gatherRoutedModels( + config: OcxConfig, + options?: GatherRoutedModelsOptions, +): Promise { + return gatherRoutedModelsWithAuth( + config, + `refreshing:${gatherFlightKey(config)}`, + () => refreshingModelsAuthResolver, + options, + ); +} + +/** + * Catalog-gather model discovery using only auth-store bytes already captured by the + * filesystem-evidence owner. This entry point never reaches the refreshing resolver. + */ +export async function gatherRoutedModelsForCatalogGather( + config: OcxConfig, + evidence: CatalogGatherProviderAuthEvidence, + options?: GatherRoutedModelsOptions, +): Promise { + const authStoreBuffer = evidence.authStoreBuffer === null + ? null + : Uint8Array.from(evidence.authStoreBuffer); + const authIdentity = authStoreBuffer === null + ? "absent" + : keyedGatherBytesIdentity("catalog-observed-auth-v1", authStoreBuffer); + return gatherRoutedModelsWithAuth( + config, + `observed:${authIdentity}:${gatherFlightKey(config)}`, + outcomes => observedModelsAuthResolver(authStoreBuffer, outcomes), + options, + ); +} + +async function gatherRoutedModelsWithAuth( + config: OcxConfig, + key: string, + createAuthResolver: ModelsAuthResolverFactory, + options?: GatherRoutedModelsOptions, +): Promise { + const capture = captureGatherFlight(config, createAuthResolver); + const bucket = gatherInflight.get(key) ?? []; + let entry = bucket.find(candidate => ( + candidate.discoveryPolicyIdentity === capture.discoveryPolicyIdentity + && candidate.authIdentity === capture.authIdentity + && candidate.providerGraphIdentity === capture.providerGraphIdentity + )); + if (!entry) { + const lease = gatherGate.tryAcquire(); + if (!lease) throw new CatalogGatherBusyError(); + // Claim the slot synchronously before any await so same-key callers join this flight. + // Distinct authorities retain separate entries even when their legacy bucket matches. + let ownedEntry!: GatherInflightEntry; + const flight = gatherRoutedModelsUncached(config, capture).finally(() => { + const current = gatherInflight.get(key); + const index = current?.indexOf(ownedEntry) ?? -1; + if (current && index >= 0) current.splice(index, 1); + if (current?.length === 0) gatherInflight.delete(key); + lease.release(); + }); + ownedEntry = Object.freeze({ + discoveryPolicyIdentity: capture.discoveryPolicyIdentity, + authIdentity: capture.authIdentity, + providerGraphIdentity: capture.providerGraphIdentity, + promise: flight, + }); + bucket.push(ownedEntry); + gatherInflight.set(key, bucket); + entry = ownedEntry; + } + const { + models, + comboOmissions, + providerAuthOutcomes, + providerModelOutcomes, + discoveryPolicySnapshots, + } = await entry.promise; + if (options?.comboOmissions) { + options.comboOmissions.length = 0; + options.comboOmissions.push(...comboOmissions); + } + if (options?.providerAuthOutcomes) { + options.providerAuthOutcomes.length = 0; + options.providerAuthOutcomes.push(...providerAuthOutcomes); + } + if (options?.providerModelOutcomes) { + options.providerModelOutcomes.length = 0; + options.providerModelOutcomes.push(...providerModelOutcomes); + } + if (options?.discoveryPolicySnapshots) { + options.discoveryPolicySnapshots.length = 0; + options.discoveryPolicySnapshots.push(...discoveryPolicySnapshots); + } + return models; +} + +/** Bound a custom row whose model id has pinned native Codex metadata, without changing stored configuration. */ +function boundCustomNativeReasoning( + model: CatalogModel, + allowed: readonly string[], + nativeDefault: string | undefined, +): CatalogModel { + if (allowed.length === 0 || model.reasoningEfforts === undefined) return model; + const bounded = { ...model }; + if (model.reasoningEfforts.length === 0) { + bounded.reasoningEfforts = []; + delete bounded.defaultReasoningEffort; + return bounded; + } + const declared = new Set(model.reasoningEfforts); + const surviving = [...new Set(allowed)].filter(effort => declared.has(effort)); + const fallback = nativeDefault && allowed.includes(nativeDefault) ? nativeDefault : allowed[0]!; + // A nonempty but incompatible declaration is not an explicit no-reasoning setting. + bounded.reasoningEfforts = surviving.length > 0 ? surviving : [fallback]; + bounded.defaultReasoningEffort = model.defaultReasoningEffort + && bounded.reasoningEfforts.includes(model.defaultReasoningEffort) + ? model.defaultReasoningEffort + : bounded.reasoningEfforts.includes(fallback) ? fallback : bounded.reasoningEfforts[0]!; + return bounded; +} + +async function gatherRoutedModelsUncached( + config: OcxConfig, + capture: GatherFlightCapture, +): Promise { + // Flight-local list: joiners copy from the resolved promise, not a process-global last write. + const localOmissions: ComboCatalogOmission[] = []; + const localProviderAuthOutcomes = capture.providerAuthOutcomes; + const resolveAuth = capture.authResolver; + const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS; + // Persisted provider entries can predate newer registry fields (noVisionModels, + // modelInputModalities, ...). The ROUTER merges registry seeds at request time + // (routedProviderConfig), so the proxy behaves correctly — the catalog listing must see the + // same merged view or its advertisements drift from actual proxy behavior (e.g. a + // vision-sidecar model advertised text-only, blocking image attachments app-side). + // Enrich a CLONE: hydrated defaults must never leak into the persisted config. + const activeProviders = capture.providers; + const providerResults = await Promise.all( + activeProviders.map(provider => fetchProviderModelsWithAuth( + provider, + ttlMs, + providerContextCap(config, provider.name), + resolveAuth, + )), + ); + const lists = providerResults.map(result => result.models); + const apiAugmented = augmentRoutedModelsWithCapturedOpenAiApiRows( + lists.flat(), + config, + capture.openAiApiPolicy, + ); + const apiProvider = activeProviders.find(provider => provider.name === OPENAI_API_PROVIDER_ID); + // Trusted reconstruction replaces whole rows, including the earlier Fast hints. + // Restore only that capability from the same captured authority used by discovery. + if (apiProvider) { + for (const model of apiAugmented) { + if (model.provider !== OPENAI_API_PROVIDER_ID) continue; + const policy = fastPolicyForModel(apiProvider.provider, model.id, apiProvider.name); + const supported = serviceTierSupportFromPolicy(policy); + if (supported !== undefined) model.supportsServiceTier = supported; + if (supported === true && policy.fastTierDescription !== undefined) model.fastTierDescription = policy.fastTierDescription; + } + } + const metadataModelIdCaseFoldByProvider = new Map( + activeProviders.map(provider => [provider.name, provider.metadataModelIdCaseFold]), + ); + const all = augmentRoutedModelsWithMetadata( + apiAugmented, + activeProviders.map(provider => provider.name), + config.providers, + config, + metadataModelIdCaseFoldByProvider, + ) + // Drop image/video generation models (e.g. Grok image/video) by default. Cursor's static catalog + // intentionally mirrors Cursor's public model table, including Gemini image preview, so the + // exposure decision goes through shouldExposeRoutedModel (single choke point). + .filter(shouldExposeRoutedModel); + const memberByKey = new Map(all.map(model => [`${model.provider}/${model.id}`, model])); + // [Decision Log] + // - 목적과 의도: 콤보 타겟에 native OpenAI(Codex login) 모델이 포함될 때 카탈로그에서 + // 누락되는 버그(issue #268)를 수정. "openai" provider는 forward-auth(Codex login + // passthrough)이므로 fetchProviderModels가 항상 []를 반환하고, native slugs는 + // 별도 정적 경로(nativeOpenAiSlugs)로만 노출됨. 따라서 memberByKey에 + // openai/ 키가 존재하지 않아 콤보가 조용히 drop됨. + // - 기존 구현 및 제약 조건: memberByKey는 routed provider /models fetch 결과로만 구성. + // - 검토한 주요 대안: (A) native slugs를 all 배열에 직접 push — /v1/models와 온디스크 + // 카탈로그에서 native 모델이 중복 노출되는 부작용 발생. (B) memberByKey에만 synthetic + // CatalogModel을 주입 — 콤보 멤버 해석에만 사용하고 all에는 추가하지 않으므로 기존 + // 노출 경로에 영향 없음. + // - 선택한 방식: (B) — synthetic entries를 memberByKey에만 주입. + // - 다른 대안 대신 이 방식을 선택한 이유: 기존 native 모델 노출 경로(/v1/models, 온디스크 + // 카탈로그 sync, management API)를 전혀 변경하지 않고 콤보 resolution만 수선하기 때문. + // - 장점, 단점 및 영향: 장점 — 최소 수정, 기존 경로 무변경. 단점 — synthetic entries의 + // capability 데이터가 static/upstream snapshot 기반이므로, 사용자가 커스텀 config + // 힌트(modelContextWindows 등)로 native 모델의 context window를 오버라이드한 경우 + // 반영되지 않음. 하지만 nativeOpenAiContextWindow가 이미 config 오버라이드를 + // 우선시하므로 실제 충돌 가능성은 낮음. + if (!hasComboTargets(config)) { + // Skip the native slug injection entirely when no combos are configured — avoids + // calling nativeOpenAiSlugs() (which reads the live Codex catalog from disk) for + // configs that will never need it. + } else { + const disabled = disabledNativeSlugs(config); + const openaiContextCap = nativeContextLimits(config); + const requiredNativeComboTargets = new Set(listComboIds(config).flatMap(id => { + const combo = getCombo(config, id); + return combo?.targets.flatMap(target => ( + target.provider === "openai" ? [target.model] : [] + )) ?? []; + })); + for (const slug of nativeOpenAiSlugs()) { + // A bare native disable key hides the native row, not a combo that targets it. + // Keep synthetic native metadata available to those combos. + if (disabled.has(slug) && !requiredNativeComboTargets.has(slug)) continue; + const contextWindow = nativeOpenAiContextWindow(slug, openaiContextCap); + if (contextWindow === undefined) continue; + const synthetic: CatalogModel = { + provider: "openai", + id: slug, + owned_by: "openai", + contextWindow, + // Input limit, not the total window. These coincide for native GPT-5.6 today (the + // advertised 922,000 window is already capped at its measured ceiling), but the two + // stay separate fields because routed/API rows of the same family run a wider window. + // Falls back to the window for slugs with no separate ceiling. + maxInputTokens: Math.min(nativeOpenAiMaxInputTokens(slug, openaiContextCap) ?? contextWindow, contextWindow), + ...(nativeOpenAiMaxOutputTokens(slug) !== undefined + ? { maxOutputTokens: nativeOpenAiMaxOutputTokens(slug) } + : {}), + autoCompactTokenLimit: nativeOpenAiAutoCompactTokenLimit(slug, openaiContextCap), + inputModalities: nativeInputModalities(slug), + reasoningEfforts: nativeReasoningEfforts(slug), + ...(nativeParallelToolCalls(slug) ? { parallelToolCalls: true } : {}), + }; + const key = `openai/${slug}`; + // Only inject when not already present from a routed provider (an API-key + // "openai" provider could shadow the native one). + if (!memberByKey.has(key)) memberByKey.set(key, synthetic); + } + } + // Enriched (registry-hydrated) provider clones — shared by combo member synthesis and + // custom-model vision-sidecar inheritance so both see the same merged registry view. + const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider])); + for (const id of listComboIds(config)) { + const combo = getCombo(config, id); + if (!combo) continue; + const comboNativeLimits = nativeContextLimits(config); + const nativeContextWindow = combo.nativeAlias && combo.alias + ? nativeOpenAiContextWindow(combo.alias, comboNativeLimits) + : undefined; + const nativeAliasMaxInput = combo.nativeAlias && combo.alias + ? (combo.alias.startsWith("gpt-5.6-") || combo.alias.includes("daybreak") + ? NATIVE_GPT56_MAX_INPUT_TOKENS + : nativeOpenAiMaxInputTokens(combo.alias, comboNativeLimits) ?? nativeOpenAiContextWindow(combo.alias, comboNativeLimits)) + : undefined; + const nativeAliasAutoCompact = combo.nativeAlias && combo.alias + ? nativeOpenAiAutoCompactTokenLimit(combo.alias, comboNativeLimits) + : undefined; + const nativeAliasFallback = combo.nativeAlias && combo.alias && nativeContextWindow !== undefined + ? { + contextWindow: nativeContextWindow, + ...(nativeAliasMaxInput !== undefined ? { maxInputTokens: nativeAliasMaxInput } : {}), + ...(nativeOpenAiMaxOutputTokens(combo.alias) !== undefined + ? { maxOutputTokens: nativeOpenAiMaxOutputTokens(combo.alias) } + : {}), + ...(nativeAliasAutoCompact !== undefined ? { autoCompactTokenLimit: nativeAliasAutoCompact } : {}), + inputModalities: nativeInputModalities(combo.alias), + reasoningEfforts: nativeReasoningEfforts(combo.alias), + } + : undefined; + const members = combo.targets + .map(target => resolveComboCatalogMember( + target, + memberByKey, + enrichedByName, + providerContextCap(config, target.provider), + nativeAliasFallback, + metadataModelIdCaseFoldByProvider.get(target.provider), + )) + .filter((member): member is CatalogModel => member !== undefined); + const derived = deriveComboCatalogModel(id, combo, members); + if (derived) { + const nativeDefault = combo.nativeAlias && combo.alias + ? nativeDefaultReasoningEffort(combo.alias) + : undefined; + if (combo.defaultEffort === null + && nativeDefault + && derived.reasoningEfforts?.includes(nativeDefault)) { + derived.defaultReasoningEffort = nativeDefault; + } + all.push(derived); + } + else warnUncataloguedComboOnce(id, combo, members, localOmissions); + } + replaceLastComboCatalogOmissions(localOmissions); + all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider))); + // Provider-derived rows keyed by their Codex-facing slug: a custom override replaces the row + // with the same slug below, so that row's provider capability metadata is the inheritance source. + const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model])); + const customModels = (config.customModels ?? []).map(cm => { + const rawProvider = config.providers[cm.provider]; + const effectiveProvider = enrichedByName.get(cm.provider) ?? rawProvider; + // Registry routing backfills an omitted authMode on the built-in OpenAI provider to + // forward. Keep the catalog projection on the same contract while still failing closed + // for every explicit non-forward mode and every non-canonical endpoint. + const providerForCanonicalCheck = rawProvider + ? withCanonicalOpenAiForwardAuthDefault(cm.provider, rawProvider) + : undefined; + const codexForwardNativeCapabilityAlias = cm.provider === OPENAI_CODEX_PROVIDER_ID + && providerForCanonicalCheck !== undefined + && isCanonicalOpenAiForwardProvider(providerForCanonicalCheck) + && hasNativeOpenAiCapabilityMetadata(cm.modelId); + const customNativeLimits = { + ...nativeContextLimits(config), + ...(typeof cm.contextWindow === "number" && cm.contextWindow > 0 + ? { modelWindows: { ...(nativeContextLimits(config).modelWindows ?? {}), [cm.modelId]: cm.contextWindow } } + : {}), + }; + const nativeAliasContextWindow = codexForwardNativeCapabilityAlias + ? nativeOpenAiContextWindow(cm.modelId, customNativeLimits) + : undefined; + const customContextWindow = cm.contextWindow + ? nativeAliasContextWindow !== undefined + ? nativeAliasContextWindow + : cm.contextWindow + : nativeAliasContextWindow; + const nativeAliasMaxInputTokens = codexForwardNativeCapabilityAlias + ? nativeOpenAiMaxInputTokens(cm.modelId, customNativeLimits) + : undefined; + const nativeAliasMaxOutputTokens = codexForwardNativeCapabilityAlias + ? nativeOpenAiMaxOutputTokens(cm.modelId) + : undefined; + const configuredMaxInput = rawProvider + ? configuredMaxInputTokens(rawProvider, cm.modelId) + : undefined; + const hardMaxCandidates = [nativeAliasMaxInputTokens, configuredMaxInput] + .filter((value): value is number => typeof value === "number" && value > 0); + const customMaxInputTokens = hardMaxCandidates.length > 0 + ? Math.min( + ...hardMaxCandidates, + ...(customContextWindow !== undefined ? [customContextWindow] : []), + ) + : undefined; + const customMaxOutputTokens = rawProvider + ? routedMaxOutputTokens(cm.provider, rawProvider, { + id: cm.modelId, + provider: cm.provider, + ...(nativeAliasMaxOutputTokens !== undefined ? { maxOutputTokens: nativeAliasMaxOutputTokens } : {}), + }, cm.modelId, metadataModelIdCaseFoldByProvider.get(cm.provider)) + : nativeAliasMaxOutputTokens; + const configuredAutoCompact = configuredAutoCompactTokenLimit(rawProvider, cm.modelId); + const customAutoCompactTokenLimit = codexForwardNativeCapabilityAlias + ? nativeOpenAiAutoCompactTokenLimit(cm.modelId, customNativeLimits) + : customContextWindow !== undefined && configuredAutoCompact !== undefined + ? clampAutoCompactTokenLimit(customContextWindow, customMaxInputTokens, configuredAutoCompact) + : undefined; + const nativeAliasDefaultEffort = codexForwardNativeCapabilityAlias + ? nativeDefaultReasoningEffort(cm.modelId) + : undefined; + const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId); + const fastPolicy = effectiveProvider + ? fastPolicyForModel(effectiveProvider, cm.modelId, cm.provider) + : undefined; + const supportsServiceTier = fastPolicy + ? serviceTierSupportFromPolicy(fastPolicy) + : undefined; + const base: CatalogModel = { + id: cm.modelId, + provider: cm.provider, + catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND, + // Display-only label: never feeds routing (customModels are keyed by routedSlug below). + ...(cm.displayName + ? { displayName: cm.displayName } + : codexForwardNativeCapabilityAlias + ? { displayName: nativeOpenAiCapabilityDisplayName(cm.modelId) ?? cm.modelId } : {}), + ...(customContextWindow !== undefined ? { contextWindow: customContextWindow } : {}), + ...(customMaxInputTokens !== undefined ? { maxInputTokens: customMaxInputTokens } : {}), + ...(customMaxOutputTokens !== undefined ? { maxOutputTokens: customMaxOutputTokens } : {}), + ...(customAutoCompactTokenLimit !== undefined ? { autoCompactTokenLimit: customAutoCompactTokenLimit } : {}), + ...(cm.inputModalities + ? { inputModalities: cm.inputModalities } + : codexForwardNativeCapabilityAlias ? { inputModalities: nativeInputModalities(cm.modelId) } : {}), + ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), + // Native-alias defaults apply only where the custom row declares nothing: the explicit + // spreads below must win (later in object order), so a stored `[]` stays empty and a + // declared ladder is narrowed to proven native capabilities after the merge below. + ...(codexForwardNativeCapabilityAlias + ? { + codexForwardNativeCapabilityAlias: true, + parallelToolCalls: nativeParallelToolCalls(cm.modelId), + ...(Array.isArray(cm.reasoningEfforts) + ? {} + : { + reasoningEfforts: nativeReasoningEfforts(cm.modelId), + ...(nativeAliasDefaultEffort ? { defaultReasoningEffort: nativeAliasDefaultEffort } : {}), + }), + } + : {}), + // Explicit custom-row ladder wins over the inherited provider row below: the merge only + // gap-fills, so a stored `[]` (explicit "no reasoning") or a declared ladder is kept + // instead of being replaced by that row's metadata. Capability-backed native model ids + // are bounded against their own pinned ladder after the merge, including gateways. + ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), + ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), + ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), + ...(supportsServiceTier === true && fastPolicy?.fastTierDescription !== undefined + ? { fastTierDescription: fastPolicy.fastTierDescription } + : {}), + ...(cm.codexToolMode !== undefined + ? { codexToolMode: cm.codexToolMode } + : effectiveProvider?.codexToolMode !== undefined + ? { codexToolMode: effectiveProvider.codexToolMode } + : {}), + }; + // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that + // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls, + // context, ...) so the generated catalog keeps advertising what the router actually provides. + // Explicit custom fields win by construction; this only fills gaps. Without it a + // noReasoningModels model loses its empty ladder and the catalog synthesizes the generic one, + // which Codex then rejects for spawn_agent with effort "none". + const replaced = replacedByRoutedSlug.get(routedSlug(cm.provider, cm.modelId)); + // The final ladder is what the catalog will advertise; the inherited default only rides + // along when it is actually a member — otherwise a provider default like "xhigh" would + // re-apply onto a narrower custom ladder and override the fallback in applyReasoningLevels. + const effectiveLadder = base.reasoningEfforts ?? replaced?.reasoningEfforts; + const mergedMaxInputCandidates = [base.maxInputTokens, replaced?.maxInputTokens] + .filter((value): value is number => typeof value === "number" && value > 0); + const mergedMaxInput = mergedMaxInputCandidates.length > 0 + ? Math.min(...mergedMaxInputCandidates) + : undefined; + const mergedMaxOutputCandidates = [base.maxOutputTokens, replaced?.maxOutputTokens] + .filter((value): value is number => typeof value === "number" && value > 0); + const mergedMaxOutput = mergedMaxOutputCandidates.length > 0 + ? Math.min(...mergedMaxOutputCandidates) + : undefined; + const merged: CatalogModel = replaced ? { + ...base, + ...(base.contextWindow === undefined && replaced.contextWindow !== undefined ? { contextWindow: replaced.contextWindow } : {}), + ...(mergedMaxInput !== undefined ? { maxInputTokens: mergedMaxInput } : {}), + ...(mergedMaxOutput !== undefined ? { maxOutputTokens: mergedMaxOutput } : {}), + ...(base.autoCompactTokenLimit === undefined && replaced.autoCompactTokenLimit !== undefined + ? { autoCompactTokenLimit: replaced.autoCompactTokenLimit } + : {}), + ...(base.inputModalities === undefined && replaced.inputModalities !== undefined ? { inputModalities: replaced.inputModalities } : {}), + ...(base.reasoningEfforts === undefined && replaced.reasoningEfforts !== undefined ? { reasoningEfforts: replaced.reasoningEfforts } : {}), + ...(base.defaultReasoningEffort === undefined && replaced.defaultReasoningEffort !== undefined + && Array.isArray(effectiveLadder) && effectiveLadder.includes(replaced.defaultReasoningEffort) + ? { defaultReasoningEffort: replaced.defaultReasoningEffort } : {}), + ...(base.parallelToolCalls === undefined && replaced.parallelToolCalls !== undefined ? { parallelToolCalls: replaced.parallelToolCalls } : {}), + ...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}), + ...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}), + ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}), + ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}), + } : base; + // Catalog-advertised efforts are bounded whenever the model id is a pinned native + // slug. Desktop validates that id, so a gateway such as YYLJ/gpt-6-astra still cannot + // advertise none/minimal. Full native identity stays behind the alias predicate. + const nativeEffortSource = hasNativeOpenAiCapabilityMetadata(cm.modelId); + const reasoningBounded = nativeEffortSource + ? boundCustomNativeReasoning( + merged, + nativeReasoningEfforts(cm.modelId), + nativeAliasDefaultEffort ?? nativeDefaultReasoningEffort(cm.modelId), + ) + : merged; + // Vision-sidecar coverage only: when the enriched provider's shared predicate matches + // noVisionModels or text-without-image modelInputModalities, advertise image input so the + // Codex app lets images reach the sidecar (#349/#344). Deliberately NOT the full + // applyProviderConfigHints pass — custom rows are a + // user override, so their explicit contextWindow / inputModalities / reasoning fields must be + // preserved verbatim (the hint pass would cap context and overwrite modalities from registry). + const mergedContext = typeof reasoningBounded.contextWindow === "number" && reasoningBounded.contextWindow > 0 + ? reasoningBounded.contextWindow + : undefined; + const boundedMergedMaxInput = typeof reasoningBounded.maxInputTokens === "number" && reasoningBounded.maxInputTokens > 0 + ? (mergedContext !== undefined ? Math.min(reasoningBounded.maxInputTokens, mergedContext) : reasoningBounded.maxInputTokens) + : undefined; + const mergedWithHardBounds = boundedMergedMaxInput !== undefined + && boundedMergedMaxInput !== reasoningBounded.maxInputTokens + ? { ...reasoningBounded, maxInputTokens: boundedMergedMaxInput } + : reasoningBounded; + const mergedSoftCandidates = [mergedWithHardBounds.autoCompactTokenLimit, configuredAutoCompact] + .filter((value): value is number => typeof value === "number" && value > 0); + const mergedWithAutoCompact: CatalogModel = mergedContext !== undefined && mergedSoftCandidates.length > 0 + ? { + ...mergedWithHardBounds, + autoCompactTokenLimit: clampAutoCompactTokenLimit( + mergedContext, + boundedMergedMaxInput, + Math.min(...mergedSoftCandidates), + ), + } + : mergedWithHardBounds; + const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider; + // Reuse the request-time consumer predicate so custom rows cannot drift from catalog hints. + if (enrichedProvider && isModelVisionSidecarConsumer(enrichedProvider, mergedWithAutoCompact.id)) { + const current = mergedWithAutoCompact.inputModalities ?? ["text"]; + if (!current.includes("image")) { + return { ...mergedWithAutoCompact, inputModalities: [...current, "image"] }; + } + } + return mergedWithAutoCompact; + }); + // Custom rows override discovered rows that encode to the same Codex-facing slug. + const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id))); + const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id))); + const models = [...deduped, ...customModels]; + // ponytail: catalog-scale scan; index ids by provider if catalog growth makes this measurable. + const aliasDisplayNames = new Map(activeProviders.flatMap(({ name, provider }) => { + const providerModels = models.filter(model => model.provider === name); + const aliases = [...effectiveModelAliases(config, provider, providerModels.map(model => model.id))]; + return aliases.flatMap(([id, { alias }]) => { + const exact = providerModels.filter(model => model.id === id); + const matches = exact.length > 0 + ? exact + : providerModels.filter(model => model.id.toLowerCase() === id.toLowerCase()); + return matches.length === 1 + ? [[`${name}/${matches[0]!.id}`, `${provider.alias || name}/${alias}`] as const] + : []; + }); + })); + const providerModelOutcomes = providerResults.map(result => ( + result.outcome.provider === OPENAI_API_PROVIDER_ID + && capture.openAiApiPolicy.state === "captured" + && capture.openAiApiPolicy.models !== undefined + ? { provider: result.outcome.provider, state: "authoritative" as const } + : result.outcome + )); + return { + models: models.map(model => { + const displayName = aliasDisplayNames.get(`${model.provider}/${model.id}`); + // #1711: one stamping point for every row this gather produces — routed, combo, and custom + // alike — because it is the only place that has both the finished list and the config the + // quota rules need. A combo votes over its own targets; anything else votes over the single + // provider that would serve it. + const targets = model.provider === COMBO_NAMESPACE + ? config.combos?.[model.id]?.targets ?? [] + : [{ provider: model.provider }]; + const inactive = quotaInactiveReason(config, targets); + const named = displayName && !model.displayName ? { ...model, displayName } : model; + return inactive ? { ...named, quotaInactiveReason: inactive } : named; + }), + comboOmissions: localOmissions, + providerAuthOutcomes: localProviderAuthOutcomes, + providerModelOutcomes, + discoveryPolicySnapshots: capture.discoveryPolicySnapshots, + }; +} + +export function augmentRoutedModelsWithRegistryOpenAiApiRows( + models: CatalogModel[], + config: OcxConfig, +): CatalogModel[] { + const configured = config.providers[OPENAI_API_PROVIDER_ID]; + if (!configured || configured.disabled === true || !providerMatchesRegistryTransport(OPENAI_API_PROVIDER_ID, configured)) return models; + return augmentRoutedModelsWithCapturedOpenAiApiRows( + models, + config, + captureTrustedOpenAiApiPolicy(OPENAI_API_PROVIDER_ID, true), + ); +} + +function augmentRoutedModelsWithCapturedOpenAiApiRows( + models: CatalogModel[], + config: OcxConfig, + policy: CatalogTrustedOpenAiApiPolicySnapshot, +): CatalogModel[] { + if (policy.state !== "captured" || !policy.models) return models; + const configured = config.providers[OPENAI_API_PROVIDER_ID]; + if (!configured || configured.disabled === true) return models; + + const existingById = new Map( + models.filter(model => model.provider === OPENAI_API_PROVIDER_ID).map(model => [model.id, model]), + ); + const trustedRows = policy.models.map((id): CatalogModel => { + const officialContext = policy.modelContextWindows?.[id]; + const officialMaxInput = policy.modelMaxInputTokens?.[id]; + const userContext = configured.modelContextWindows?.[id] ?? configured.contextWindow; + const userMaxInput = configured.modelMaxInputTokens?.[id]; + const providerCap = providerContextCap(config, OPENAI_API_PROVIDER_ID); + const contextWindow = typeof officialContext === "number" + ? Math.min(officialContext, userContext ?? officialContext, providerCap ?? officialContext) + : undefined; + const maxInputTokens = typeof officialMaxInput === "number" + ? Math.min( + officialMaxInput, + userMaxInput ?? officialMaxInput, + contextWindow ?? officialMaxInput, + ) + : undefined; + const configuredAutoCompact = configuredAutoCompactTokenLimit(configured, id); + const autoCompactTokenLimit = contextWindow !== undefined && configuredAutoCompact !== undefined + ? clampAutoCompactTokenLimit(contextWindow, maxInputTokens, configuredAutoCompact) + : undefined; + const maxOutputTokens = routedMaxOutputTokens( + OPENAI_API_PROVIDER_ID, + configured, + policy.modelMaxOutputTokens?.[id] !== undefined + ? { provider: OPENAI_API_PROVIDER_ID, id, maxOutputTokens: policy.modelMaxOutputTokens[id] } + : existingById.get(id) ?? { provider: OPENAI_API_PROVIDER_ID, id }, + policy.virtualModels?.[id]?.wireModelId ?? id, + ); + return { + provider: OPENAI_API_PROVIDER_ID, + id, + owned_by: OPENAI_API_PROVIDER_ID, + ...(contextWindow ? { contextWindow } : {}), + ...(maxInputTokens ? { maxInputTokens } : {}), + ...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}), + ...(autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit } : {}), + ...(policy.modelInputModalities?.[id] ? { inputModalities: [...policy.modelInputModalities[id]!] } : {}), + ...(policy.modelReasoningEfforts?.[id] ? { reasoningEfforts: [...policy.modelReasoningEfforts[id]!] } : {}), + }; + }); + + for (const trusted of trustedRows) { + const live = existingById.get(trusted.id); + if (!live) continue; + const liveSignature = normalizedOpenAiApiSignature(live); + const trustedSignature = normalizedOpenAiApiSignature(trusted); + if (liveSignature === trustedSignature) continue; + const warningKey = `${trusted.provider}/${trusted.id}\n${liveSignature}\n${trustedSignature}`; + if (openAiApiCollisionWarnings.has(warningKey)) continue; + openAiApiCollisionWarnings.add(warningKey); + console.warn(`[opencodex] replacing conflicting live OpenAI API metadata for ${trusted.provider}/${trusted.id} with trusted registry metadata`); + } + + return [ + ...models.filter(model => model.provider !== OPENAI_API_PROVIDER_ID), + ...trustedRows, + ]; +} + +export function augmentRoutedModelsWithMetadata( + models: CatalogModel[], + providerNames: string[], + providers?: Record, + caps?: Pick, + metadataModelIdCaseFoldByProvider?: ReadonlyMap, +): CatalogModel[] { + const out = [...models]; + const seen = new Set(out.map(m => `${m.provider}/${m.id}`)); + for (const provider of providerNames) { + if (!JAWCODE_CATALOG_AUGMENT_PROVIDERS.has(provider)) continue; + if (providers?.[provider]?.liveModels === false) continue; + const jawcodeProvider = resolveMetadataProvider(provider); + if (!jawcodeProvider) continue; + for (const meta of listModelMetadata(jawcodeProvider)) { + const key = `${provider}/${meta.id}`; + if (seen.has(key)) continue; + seen.add(key); + const contextCap = caps ? providerContextCap(caps, provider) : undefined; + const model: CatalogModel = { + provider, + id: meta.id, + owned_by: provider, + ...(typeof meta.contextWindow === "number" && meta.contextWindow > 0 ? { contextWindow: meta.contextWindow } : {}), + ...(typeof meta.maxTokens === "number" && meta.maxTokens > 0 ? { maxOutputTokens: meta.maxTokens } : {}), + ...(Array.isArray(meta.input) && meta.input.length > 0 ? { inputModalities: [...meta.input] } : {}), + }; + out.push({ + ...model, + ...(providers?.[provider] + ? applyProviderConfigHints( + provider, + providers[provider], + model, + contextCap, + metadataModelIdCaseFoldByProvider?.get(provider), + ) + : {}), + }); + } + } + return out; +} diff --git a/src/config.ts b/src/config.ts index 935fce734f..510501c507 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,126 +1,20 @@ -import { modelCapabilitiesConfigError, mergeModelCapabilities, sanitizeModelCapabilitiesForLoad } from "./config/provider-validation"; -import { createHash } from "node:crypto"; -import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { Database } from "bun:sqlite"; -import * as z from "zod/v4"; -import { isValidProviderName, hasOwnProvider } from "./config/provider-name"; -import { MULTI_AGENT_SURFACE_ADVISORY_VERSION } from "./config/multi-agent-surface"; -import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "./config/subagent-models"; -export { DEFAULT_SUBAGENT_MODELS } from "./config/subagent-models"; -import { - apiKeyTransportConfigError, - booleanRecordConfigError, - configReasoningPinsConfigError, - modelPinnedEffortsConfigError, - pinnedReasoningEffortConfigError, - modelAdapterRecordConfigError, - modelDisplayNamesConfigError, - autoReviewModelOverridesConfigError, - autoReviewModelTargetConfigError, - nonBlankStringArrayConfigError, - normalizeNonBlankStringArray, - normalizeAutoReviewModelOverrides, - positiveIntegerConfigError, - positiveIntegerRecordConfigError, - providerBaseUrlConfigError, - providerHeadersConfigError, - reasoningSummaryDeliveryRecordConfigError, - upstreamHttpVersionConfigError, -} from "./config/provider-validation"; -import { - bumpConfigGenerationAtPath, - bumpCurrentConfigGeneration, - initializeConfigGeneration, - observeConfigGenerationAtPath, - readConfigGenerationAtPath, - readConfigGenerationInTransaction, - type ConfigGenerationObservation, -} from "./codex/generation"; -import type { - BumpConfigGeneration, - ConfigGeneration, - ReadConfigGeneration, - WithExpectedConfigGenerationSync, -} from "./codex/convergence-types"; -import { - CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, - codexAccountNamespaceForModel, - codexProviderNamespaceKey, - isValidCodexAccountNamespaceTarget, - MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, -} from "./codex/account-namespace-match"; -import { isCodexAccountPriorityKey } from "./codex/account-priority"; -import { loopbackCompanionAllowed } from "./codex/loopback-target"; -import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "./codex/upstream-host-health"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { OcxConfig } from "./types"; +import { configReasoningPinsConfigError } from "./config/provider-validation"; +import { recordOwnedConfigPath } from "./lib/config-ownership"; +import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; import { adoptCustomModelCatalogMigration, projectCustomModelCatalogMigration, } from "./codex/custom-model-catalog-migration"; -import { parseAccountPriority } from "./codex/pool-rotation"; -import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types"; -import { routingProfileIssues } from "./routing/profile"; -import { credentialGroupIssues } from "./routing/identity-domains"; -import { POLICY_NAMESPACE } from "./routing/profile-namespace"; -import { - forgetEphemeralSecretPath, - hardenSecretDir, - hardenSecretPath, - windowsSecretAclApplies, -} from "./lib/windows-secret-acl"; -import { recordOwnedConfigPath } from "./lib/config-ownership"; -import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; -import { providerDestinationConfigError } from "./lib/destination-policy"; -import { redactSecretString } from "./lib/redact"; -import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; -import { MODEL_ALIAS_PATTERN } from "./providers/default-aliases"; -import { MODEL_DISCOVERY_MAX_MODELS } from "./providers/model-discovery-limits"; -import { vercelGatewayRoutingConfigError } from "./providers/vercel-gateway-routing"; -import { - MODEL_ADAPTER_OVERRIDE_ALLOWED, - OPENAI_PROVIDER_TIER_VERSION, - pinnedWireAdapter, - PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS, - UPSTREAM_HTTP_VERSION_VALUES, - type OcxClaudeCodeConfig, - type OcxConfig, - type OcxApiKeyEntry, - type OcxProviderConfig, - type FastWire, - type ProviderCostOverlay, -} from "./types"; -import type { OcxRuntimeRole } from "./types/config"; -import { OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; -import { modelAutoCompactTokenLimitsConfigError } from "./providers/auto-compact-budget"; -import { fastWireDeclarationError, hasFastWireCapabilityConflict } from "./providers/fastwire"; -import { - getProviderRegistryEntry, - providerMatchesRegistryTransport, - providerModelWireDefault, - registryModelServiceTierCapabilityApplies, -} from "./providers/registry"; -import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; -import { parseDesktopProfile } from "./claude/desktop-profile"; -import { isCodexReasoningEffort } from "./reasoning-effort"; -import { - COST4_RATE_KEYS, - isValidCost4Rate, - refreshPreservedProviderOwner, - refreshUserCostOverlays, - withPreservedDiskOnlyProviders, -} from "./usage/user-cost-overlays"; -import { MAX_COST4_RATE } from "./usage/expected-prices"; +import { refreshUserCostOverlays } from "./usage/user-cost-overlays"; import { - DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, - MAX_APP_OWNED_MEMORY_BUDGET_MB, - MIN_APP_OWNED_MEMORY_BUDGET_MB, -} from "./lib/app-owned-memory"; -import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy"; -import { - atomicWriteFile, - isMissingPathError, - nextAtomicTempSequence, -} from "./config/atomic-write"; + clearPendingConfigTopLevelDeletions, + projectConfigRebaseProvenance, +} from "./config/rebase-provenance"; +import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths"; +export { DEFAULT_SUBAGENT_MODELS } from "./config/subagent-models"; export { AtomicWriteResidualTempError, AtomicWriteSecretResidualError, @@ -133,13 +27,6 @@ export { type AtomicWriteAsyncTestSeam, type AtomicWriteIO, } from "./config/atomic-write"; -import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths"; -import { InitialConfigPublicationError, publishInitialConfigNoReplace, type InitialConfigPublicationIO } from "./config/initialize"; -import { - describeProxyForLog, - readWindowsSystemProxy, - type WindowsProxyRegistryReader, -} from "./lib/windows-system-proxy"; export { expandUserPath, getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths"; export { getPidPath, @@ -165,558 +52,15 @@ export { writeRuntimePort, type RuntimePortState, } from "./config/process-state"; -import { - clearPendingConfigTopLevelDeletions, - configHasRebaseProvenance, - configRebaseDeletionKeys, - CONFIG_REBASE_PROVENANCE_KEY, - deleteConfigTopLevelKey, - projectConfigRebaseProvenance, -} from "./config/rebase-provenance"; export { deleteConfigTopLevelKey } from "./config/rebase-provenance"; - -export class OpenAiTierBackupCleanupError extends Error { - constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; } -} - -export class OpenAiTierBackupRollbackError extends Error { - constructor() { super("OpenAI tier backup rollback failed"); this.name = "OpenAiTierBackupRollbackError"; } -} - -export class OpenAiTierBackupCollisionError extends Error { - readonly configPath?: string; - constructor(configPath?: string) { - super("Existing OpenAI tier backup differs from the current config"); - this.name = "OpenAiTierBackupCollisionError"; - this.configPath = configPath; - } -} - -export class OpenAiTierRollbackPreserveError extends Error { - readonly code?: "missing" | "not-rollback" | "mismatch" | "exhausted"; - constructor(message: string, options?: ErrorOptions & { code?: OpenAiTierRollbackPreserveError["code"] }) { - super(message, options); - this.name = "OpenAiTierRollbackPreserveError"; - this.code = options?.code; - } -} - -export class OpenAiTierBackupSecretResidualError extends Error { - constructor(readonly tempPath: string, options?: ErrorOptions) { - super("OpenAI tier backup could not scrub or remove a secret-bearing temporary file", options); - this.name = "OpenAiTierBackupSecretResidualError"; - } -} - -export interface OpenAiTierBackupIO { - exists(path: string): boolean; - read(path: string): Uint8Array; - createExclusive(path: string): void; - write(path: string, bytes: Uint8Array): void; - harden(path: string): void; - publishNoReplace(temp: string, backup: string): void; - truncate(path: string): void; - unlink(path: string): void; -} - -function sameBytes(left: Uint8Array, right: Uint8Array): boolean { - return left.byteLength === right.byteLength && left.every((value, index) => value === right[index]); -} - -function isAlreadyExistsError(error: unknown): boolean { - return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST"; -} - -/** - * Classify an existing `.pre-openai-tiers-v2.bak` snapshot. - * - * - `"stale"`: unparseable JSON (not written by us / truncated) or already a - * post-migration (tier v2) snapshot — safe to delete or replace. - * - `"rollback"`: parses as a valid pre-migration (v1) config — a - * user-intentional rollback point that must never be silently destroyed. - * - * Shared by the startup migration backup path and `ocx init` cleanup so both - * apply the same preservation policy (issue #257 / sol review 260722). - */ -export function classifyOpenAiTierBackup(backupBytes: Uint8Array): "stale" | "rollback" { - try { - // Use Buffer.from to ensure proper UTF-8 decoding from Uint8Array/Buffer. - const parsed = JSON.parse(Buffer.from(backupBytes).toString("utf8")) as Record; - return parsed.openaiProviderTierVersion === 2 ? "stale" : "rollback"; - } catch { - // Unparseable: not a config file we created, treat as stale. - return "stale"; - } -} - -export function backupConfigBeforeOpenAiTierMigration( - configPath = getConfigPath(), - io: OpenAiTierBackupIO = { - exists: existsSync, - read: target => readFileSync(target), - createExclusive: target => { writeFileSync(target, new Uint8Array(), { flag: "wx", mode: 0o600 }); }, - write: (target, bytes) => writeFileSync(target, bytes), - harden: target => { - try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } - // Soft-fail: a wedged/failed icacls on CI temp volumes must not abort - // startServer mid-suite (timeout + EBUSY cascade on shared TEST_DIR). - // chmod above still applies; live credential writes keep required:true. - if (process.platform === "win32") hardenSecretPath(target, { required: false }); - }, - publishNoReplace: (temp, backup) => linkSync(temp, backup), - truncate: target => truncateSync(target, 0), - unlink: unlinkSync, - }, -): "absent" | "created" | "reused" { - const source = configPath; - if (!io.exists(source)) return "absent"; - const original = io.read(source); - // v2 snapshot path. The historical `.pre-openai-tiers-v1.bak` is read only by restore - // docs/fixtures and is never reused or overwritten as the v2 snapshot. - const backup = `${source}.pre-openai-tiers-v2.bak`; - if (io.exists(backup)) { - if (!sameBytes(original, io.read(backup))) { - // The backup differs from the current config. Only treat it as stale when it is - // clearly not a user-intentional rollback point: - // - unparseable JSON: written by a different tool or truncated - // - already at tier version 2: the backup is from a post-migration config (e.g. - // ocx init wrote a fresh v2 config, making the old backup obsolete) - // A backup that parses as a valid pre-migration (v1) config is kept as-is and - // we throw a collision error, because silently replacing a user-created rollback - // point would be surprising and potentially destructive. - const backupBytes = io.read(backup); - if (classifyOpenAiTierBackup(backupBytes) === "rollback") { - throw new OpenAiTierBackupCollisionError(source); - } - console.warn("[openai-provider-migration] Replacing stale pre-migration backup (post-migration config was rewritten since last migration)."); - io.unlink(backup); - } else { - return "reused"; - } - } - const temp = `${backup}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; - let published = false; - let cleanupAttempted = false; - - const scrubUnpublishedTemp = (): void => { - cleanupAttempted = true; - let scrubbed = false; - try { - io.truncate(temp); - scrubbed = true; - } catch (error) { - if (isMissingPathError(error)) scrubbed = true; - else { - try { io.write(temp, new Uint8Array()); scrubbed = true; } catch { /* removal may still succeed */ } - } - } - let removed = false; - try { - io.unlink(temp); - removed = true; - } catch (error) { - if (isMissingPathError(error)) { - removed = true; - } - else { - try { io.unlink(temp); removed = true; } - catch (retryError) { - if (isMissingPathError(retryError)) { - removed = true; - } - } - } - } - if (removed) forgetEphemeralSecretPath(temp); - if (!removed && !scrubbed) throw new OpenAiTierBackupSecretResidualError(temp); - if (!removed) throw new OpenAiTierBackupCleanupError(); - }; - - try { - io.createExclusive(temp); - io.write(temp, original); - io.harden(temp); - try { - io.publishNoReplace(temp, backup); - } catch (cause) { - if (!isAlreadyExistsError(cause)) throw cause; - const winner = io.read(backup); - if (!sameBytes(original, winner)) throw new OpenAiTierBackupCollisionError(source); - scrubUnpublishedTemp(); - return "reused"; - } - published = true; - try { - io.unlink(temp); - forgetEphemeralSecretPath(temp); - } catch (firstError) { - if (isMissingPathError(firstError)) { - forgetEphemeralSecretPath(temp); - } else try { - io.unlink(temp); - forgetEphemeralSecretPath(temp); - } catch (secondError) { - if (isMissingPathError(secondError)) { - forgetEphemeralSecretPath(temp); - return "created"; - } - // temp and backup are hard links to the same inode. Roll back the backup - // link before any truncation so the downgrade snapshot is never zeroed. - try { io.unlink(backup); } catch { throw new OpenAiTierBackupRollbackError(); } - published = false; - scrubUnpublishedTemp(); - throw new OpenAiTierBackupCleanupError(); - } - } - return "created"; - } catch (cause) { - if (!published && !cleanupAttempted) { - scrubUnpublishedTemp(); - } - throw cause; - } -} - -export interface OpenAiTierRollbackPreserveIO { - exists(path: string): boolean; - read(path: string): Uint8Array; - copyExclusive(source: string, destination: string): void; - unlink(path: string): void; -} - -const DEFAULT_ROLLBACK_PRESERVE_IO: OpenAiTierRollbackPreserveIO = { - exists: existsSync, - read: target => readFileSync(target), - copyExclusive: (source, destination) => { - copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); - }, - unlink: unlinkSync, -}; - -const OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS = 16; - -/** - * Copy a rollback-classified `.pre-openai-tiers-v2.bak` to a unique - * `.pre-openai-tiers-v1-rollback.[suffix].bak` path, then unlink the - * blocking v2 name. The original bytes are copied with no-replace publication; - * the v2 path is removed only after the copy is verified. Shared by startup - * migration recovery and `ocx init` cleanup so the two paths cannot drift. - */ -export function preserveOpenAiTierRollbackSnapshot( - configPath = getConfigPath(), - io: OpenAiTierRollbackPreserveIO = DEFAULT_ROLLBACK_PRESERVE_IO, -): string { - const backup = `${configPath}.pre-openai-tiers-v2.bak`; - if (!io.exists(backup)) { - throw new OpenAiTierRollbackPreserveError("OpenAI tier rollback backup is missing", { code: "missing" }); - } - const original = io.read(backup); - if (classifyOpenAiTierBackup(original) !== "rollback") { - throw new OpenAiTierRollbackPreserveError("OpenAI tier backup is not a rollback snapshot", { code: "not-rollback" }); - } - for (let attempt = 0; attempt < OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS; attempt++) { - const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`; - try { - io.copyExclusive(backup, preserved); - } catch (error) { - if (isAlreadyExistsError(error)) continue; - throw error; - } - let copied: Uint8Array; - try { - copied = io.read(preserved); - } catch (error) { - throw new OpenAiTierRollbackPreserveError("Failed to read preserved rollback snapshot", { cause: error, code: "mismatch" }); - } - if (!sameBytes(original, copied)) { - try { io.unlink(preserved); } catch { /* keep the original backup; incomplete copy is best-effort */ } - throw new OpenAiTierRollbackPreserveError("Preserved rollback snapshot does not match source bytes", { code: "mismatch" }); - } - io.unlink(backup); - return preserved; - } - throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback snapshot path", { code: "exhausted" }); -} - -const warnedConfigFallbacks = new Set(); -const warnedInheritedFastWireConflicts = new Set(); -let lastWarningReconciledGeneration = 0; - -export function reconcileConfigWarningMemos(generation: number): number { - if (generation <= lastWarningReconciledGeneration) return 0; - const removed = warnedConfigFallbacks.size + warnedInheritedFastWireConflicts.size; - warnedConfigFallbacks.clear(); - warnedInheritedFastWireConflicts.clear(); - lastWarningReconciledGeneration = generation; - return removed; -} - -/** - * Bounds for the opt-in same-target 429 wait-and-retry policy. Single source of truth - * shared by the config schema, the load-time sanitizer, and the management write - * boundary. Strict, so an unknown key is rejected at every validation boundary instead - * of being silently ignored (the load-time sanitizer still degrades unknown keys with a - * warning before schema validation, so hand-edited configs keep loading). - */ -const retryOn429PolicySchema = z.object({ - enabled: z.boolean().optional(), - attempts: z.number().int().min(1).max(20).optional(), - intervalMs: z.number().int().min(100).max(600_000).optional(), - // The effective cap for a single wait is MAX_COOLDOWN_MS (10 min) in key-failover.ts; - // larger configured values would be dead config. - maxIntervalMs: z.number().int().min(100).max(600_000).optional(), - respectRetryAfter: z.boolean().optional(), -}).strict(); - -/** - * `transientRetryOn5xx` accepts only these keys. `attempts` is a TOTAL send budget shared by - * both retry layers, so the ceiling is deliberately lower than `retryOn429`'s: 10 total sends - * against an already-failing provider is already generous. - */ -const transientRetryOn5xxPolicySchema = z.object({ - enabled: z.boolean().optional(), - attempts: z.number().int().min(1).max(10).optional(), -}).strict(); - -const requestPacingRuleSchema = z.object({ - // Keep the RPM-derived timer within the same one-hour bound as minIntervalMs. - requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), - minIntervalMs: z.number().int().min(1).max(3_600_000).optional(), -}).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined, { - message: "request pacing rules need requestsPerMinute or minIntervalMs", -}); - -const requestPacingSchema = z.object({ - enabled: z.boolean(), - requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), - minIntervalMs: z.number().int().min(1).max(3_600_000).optional(), - models: z.record(z.string().trim().min(1), requestPacingRuleSchema).optional(), -}).strict().refine(value => value.enabled === false - || value.requestsPerMinute !== undefined - || value.minIntervalMs !== undefined - || (value.models !== undefined && Object.keys(value.models).length > 0), { - message: "enabled request pacing needs a provider rule or model override", -}); - -export function requestPacingConfigError(value: unknown): string | null { - if (value === undefined) return null; - const parsed = requestPacingSchema.safeParse(value); - if (parsed.success) return null; - return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides"; -} - -/** - * Bounds for the opt-in passthrough web-search bridge (`providers..webSearchBridge`, - * #3761). Strict for the same reason `retryOn429` is: a misspelled key here would silently - * leave the bridge disarmed while the operator believes they enabled it. - * - * `endpoint` names the destination that receives this provider's API key, so it gets the same - * literal destination assessment `baseUrl` gets (#4519) — see `providerWebSearchBridgeConfigError` - * below. This schema itself still only shape-checks: it is `.catch(undefined)` at the provider - * row, and a hand-edited config file never reaches the error function at all. The authorization - * boundary is therefore `resolveOllamaWebSearchEndpoint`, which runs the same assessment and is - * the only reader of this field in the tree; config validation is where an operator is told why, - * not what makes the value safe. - */ -const providerWebSearchBridgeSchema = z.object({ - enabled: z.boolean().optional(), - backend: z.enum(PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS).optional(), - maxSearches: z.number().int().min(1).max(10).optional(), - timeoutMs: z.number().int().min(1_000).max(600_000).optional(), - endpoint: z.string().min(1).optional(), -}).strict(); - -export function providerWebSearchBridgeConfigError( - value: unknown, - providerName: string, - provider: Pick, -): string | null { - if (value === undefined) return null; - if (!value || typeof value !== "object" || Array.isArray(value)) { - return "webSearchBridge must be a plain object"; - } - const parsed = providerWebSearchBridgeSchema.safeParse(value); - if (!parsed.success) { - return "webSearchBridge accepts only enabled (boolean), backend " - + `(${PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS.join("|")}), maxSearches (1..10), ` - + "timeoutMs (1000..600000), and endpoint (absolute http(s) URL)"; - } - const endpoint = parsed.data.endpoint; - if (endpoint !== undefined) { - let url: URL; - try { - url = new URL(endpoint); - } catch { - return "webSearchBridge.endpoint must be an absolute http(s) URL"; - } - if (url.protocol !== "https:" && url.protocol !== "http:") { - return "webSearchBridge.endpoint must be an absolute http(s) URL"; - } - // Same classifier baseUrl uses, so a metadata address is refused outright and loopback or - // private space needs the provider's allowPrivateNetwork opt-in (or a registry entry that is - // local by definition, which is what keeps a self-hosted Ollama working). Literal-only and - // synchronous, exactly as at the baseUrl boundary: no DNS is resolved here. - const destinationError = providerDestinationConfigError(providerName, { - baseUrl: endpoint, - allowPrivateNetwork: provider.allowPrivateNetwork, - }); - if (destinationError) { - return destinationError.replace(/^baseUrl/, "webSearchBridge.endpoint"); - } - } - return null; -} - -const fastWireSchema = z.object({ - kind: z.string(), - canonicalToWire: z.record(z.string().trim(), z.string().trim()), - foreignCallerTiers: z.string(), - betas: z.array(z.string().trim()).optional(), -}).strict().superRefine((fastWire, ctx) => { - const error = fastWireDeclarationError({ fastWire }); - if (error) ctx.addIssue({ code: "custom", message: error }); -}).transform(fastWire => fastWire as FastWire); - -const modelDisplayNamesSchema = z.unknown().superRefine((value, ctx) => { - const error = modelDisplayNamesConfigError(value); - if (error) ctx.addIssue({ code: "custom", message: error }); -}).transform(value => { - const labels = Object.create(null) as Record; - for (const [modelId, displayName] of Object.entries(value as Record)) { - labels[modelId] = displayName; - } - return labels; -}); - -const pinnedReasoningEffortSchema = z.unknown().superRefine((value, ctx) => { - const error = pinnedReasoningEffortConfigError(value); - if (error) ctx.addIssue({ code: "custom", message: error }); -}).transform(value => value as string); - -const modelPinnedEffortsSchema = z.unknown().superRefine((value, ctx) => { - const error = modelPinnedEffortsConfigError(value); - if (error) ctx.addIssue({ code: "custom", message: error }); -}).transform(value => Object.fromEntries( - Object.entries(value as Record).map(([key, effort]) => [key.trim(), effort]), -)); - -const autoReviewModelSchema = z.unknown().superRefine((value, ctx) => { - const error = autoReviewModelTargetConfigError(value, "autoReviewModel", true); - if (error) ctx.addIssue({ code: "custom", message: error }); -}).transform(value => { - if (typeof value !== "string") return undefined; - const trimmed = value.trim(); - return trimmed ? trimmed : undefined; -}); - -const autoReviewModelOverridesSchema = z.unknown().superRefine((value, ctx) => { - const error = autoReviewModelOverridesConfigError(value, "autoReviewModelOverrides", true); - if (error) ctx.addIssue({ code: "custom", message: error }); -}).transform(value => normalizeAutoReviewModelOverrides(value)); - -const modelCapabilitiesSchema = z.unknown().superRefine((value, ctx) => { - const error = modelCapabilitiesConfigError(value); - if (error) ctx.addIssue({ code: "custom", message: error }); -}).transform(value => mergeModelCapabilities(undefined, value)); - -/** - * Zod schema for one provider entry: known fields are validated strictly while unknown - * fields pass through (preserved for runtime extensions). - */ -const providerConfigSchema = z.object({ - modelCapabilities: modelCapabilitiesSchema.optional(), - pinnedReasoningEffort: pinnedReasoningEffortSchema.optional(), - modelPinnedReasoningEfforts: modelPinnedEffortsSchema.optional(), - // Validated rather than left to passthrough: an unrecognized strategy would otherwise - // load silently and then be ignored at selection time, which reads as a broken feature - // rather than a rejected setting. - apiKeyPoolStrategy: z.enum(["round-robin", "fill-first", "quota"]).optional(), - autoReviewModel: autoReviewModelSchema.optional(), - autoReviewModelOverrides: autoReviewModelOverridesSchema.optional(), - adapter: z.string().min(1), - baseUrl: z.string().min(1), - alias: z.string().optional(), - modelAliases: z.record(z.string(), z.string()).optional(), - modelDisplayNames: modelDisplayNamesSchema.optional(), - defaultAliases: z.boolean().optional(), - initialModelSelection: z.object({ - version: z.literal(1), - registrationId: z.uuid(), - status: z.enum(["pending", "ready", "all-off"]), - modelCount: z.number().int().nonnegative().optional(), - }).optional().catch(undefined), - requestPacing: requestPacingSchema.optional().catch(undefined), - mcpMaxTools: z.number().int().positive().optional(), - mcpMaxSchemaBytes: z.number().int().positive().optional(), - mcpMaxResultBytes: z.number().int().positive().optional(), - apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(), - responsesPath: z.string().min(1).optional(), - chatCompletionsPath: z.string().min(1).optional(), - statelessResponses: z.boolean().optional(), - requiresAdjacentResponsesToolResults: z.boolean().optional(), - annotateEmptyToolOutputs: z.boolean().optional(), - fastWire: fastWireSchema.nullable().optional(), - supportsServiceTier: z.boolean().optional(), - modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), - preserveResponsesReasoningContent: z.boolean().optional(), - decodesNativeCompactionBlobs: z.boolean().optional(), - allowEncryptedV2AgentTasks: z.boolean().optional(), - allowPrivateNetwork: z.boolean().optional(), - // The management API accepts `null` as "clear this", so a config written before the POST - // canonicalization below can hold one on disk. Rejecting it here would send the operator - // through invalid-config recovery for a value the API told them was fine. - upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES) - .nullish() - .transform(value => value ?? undefined), - // Opt-in upstream Responses WebSocket for OpenAI-compatible providers (e.g. - // aggregators whose WebSocket ingress is measurably faster than SSE). The - // canonical ChatGPT backend WS selection is independent of this flag. - upstreamWebsocket: z.boolean().optional(), - directGeminiWireRenames: z.boolean().optional(), - noStructuredOutputModels: z.array(z.string().min(1)) - .transform(normalizeNonBlankStringArray) - .optional(), - noJsonSchemaModels: z.array(z.string().min(1)) - .transform(normalizeNonBlankStringArray) - .optional(), - retainModels: z.array(z.string().min(1)) - .transform(normalizeNonBlankStringArray) - .optional(), - omitReasoningEffortWithToolsModels: z.array(z.string().min(1)) - .transform(normalizeNonBlankStringArray) - .optional(), - retryOn429: retryOn429PolicySchema.optional(), - transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(), - codexAccountMode: z.enum(["pool", "direct"]).optional(), - // Validated rather than passed through: this schema ends in `.passthrough()`, so an - // undeclared key survives verbatim. A misspelled `codexToolMode` therefore used to be - // accepted, persisted, and then silently resolved to the `code_mode_only` default — the - // operator asked for shell mode, got code mode, and was told nothing (#2106). - codexToolMode: z.enum(["code_mode_only", "shell"]).optional(), - responsesItemIdRepair: z.object({ - message: z.array(z.string().min(1)).optional(), - reasoning: z.array(z.string().min(1)).optional(), - repairMissingTerminalIds: z.boolean().optional(), - repairInvalidIds: z.boolean().optional(), - }).strict().optional(), - responsesSnapshotRepair: z.boolean().optional(), - // Invalid blocks degrade to "absent" rather than failing the whole config load: an unusable - // bridge block must never send an operator through invalid-config recovery for an opt-in - // feature that is off by default. The management write boundary still rejects it loudly. - webSearchBridge: providerWebSearchBridgeSchema.optional().catch(undefined), - xaiResponsesXSearch: z.boolean().optional(), - xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined), - zaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined), -}).passthrough(); - export { isValidProviderName, hasOwnProvider } from "./config/provider-name"; export { apiKeyTransportConfigError, booleanRecordConfigError, modelAdapterRecordConfigError, + modelDisplayNamesConfigError, autoReviewModelOverridesConfigError, autoReviewModelTargetConfigError, - modelDisplayNamesConfigError, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, normalizeAutoReviewModelOverrides, @@ -727,2646 +71,153 @@ export { reasoningSummaryDeliveryRecordConfigError, upstreamHttpVersionConfigError, } from "./config/provider-validation"; +export { reconcileConfigWarningMemos } from "./config/warn-memo"; +export { + OpenAiTierBackupCleanupError, + OpenAiTierBackupRollbackError, + OpenAiTierBackupCollisionError, + OpenAiTierRollbackPreserveError, + OpenAiTierBackupSecretResidualError, + classifyOpenAiTierBackup, + backupConfigBeforeOpenAiTierMigration, + preserveOpenAiTierRollbackSnapshot, + type OpenAiTierBackupIO, + type OpenAiTierRollbackPreserveIO, +} from "./config/openai-tier-backup"; +export { + websocketsEnabled, + ultraFastTierEnabled, + CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS, + CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS, + isCatalogAutoRefreshEnabled, + resolveCatalogAutoRefreshIntervalMs, +} from "./config/feature-flags"; +export { + codexAutoStartEnabled, + CODEX_SHIM_AUTO_RESTORE_ENV, + codexShimAutoRestoreEnabled, + multiAgentGuidanceEnabled, + runtimeRole, + getDefaultConfig, + resolveEnvValue, + applyProxyEnv, + applyProxyEnvWith, +} from "./config/proxy-env"; +export { + requestPacingConfigError, + providerWebSearchBridgeConfigError, + providerModelCostsConfigError, + sanitizeModelCostsForDisplay, + modelPreferHostedToolsConfigError, +} from "./config/schema/leaf-validators"; +export { hardenExistingSecret, retryOn429PolicyConfigError } from "./config/load-degrade"; +export { backupInvalidConfig } from "./config/salvage"; +export type { ConfigDiagnostics, ConfigAdmissionSnapshot } from "./config/diagnostics"; +export { + subagentDefaultSyncEffective, + loopbackCompanionBindError, + validateConfigCandidate, + readConfigDiagnostics, + observeInitialConfigState, + readConfigAdmissionSnapshot, +} from "./config/diagnostics"; +export { + ConfigMutationLockError, + NestedConfigMutationError, + prepareConfigMutationDatabasePathForWrite, + withConfigMutationLockSync, + readConfigGeneration, + observeConfigGeneration, + readConfigGenerationInCurrentMutationTransaction, + bumpConfigGeneration, + withExpectedConfigGenerationSync, +} from "./config/mutation-lock"; +export { + armClaudeCodeBaseline, + adoptPersistedProviderIntoLiveConfig, + claudeCodeBaselineArmed, + reconcileLiveConfigFromDisk, + saveConfigPreservingClaudeCode, +} from "./config/live-reconcile"; + +// create-only path — never persist-unlocked / atomicWriteFile +import { InitialConfigPublicationError, publishInitialConfigNoReplace, type InitialConfigPublicationIO } from "./config/initialize"; +import { observeInitialConfigState } from "./config/diagnostics"; +import { + configDiagnosticsFromRaw, + mergeConfigDefaults, + readConfigFileSnapshot, + validateConfigCandidate, + type ConfigFileSnapshot, +} from "./config/diagnostics"; + +// replace path — never publishInitialConfigNoReplace +import { persistConfigUnlocked, readRawConfigJson } from "./config/persist-unlocked"; + +import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } from "./config/mutation-lock"; +import { getDefaultConfig } from "./config/proxy-env"; +import { configSchema } from "./config/schema/config-schema"; +import { + hardenExistingSecret, + normalizeApiKeyIds, + normalizeClaudeSubagentEffort, + normalizeNativeSubagentSync, + sanitizeAliasesForLoad, + sanitizeReasoningPinsForLoad, + sanitizeModelDisplayNamesForLoad, + sanitizeAutoReviewForLoad, + sanitizeRetryOn429ForLoad, + sanitizeModelCostsForLoad, + sanitizeCapabilityDeclarationsForLoad, + warnInheritedFastWireConflicts, + warnDegradedStreamMode, + warnDegradedHostname, + warnDegradedListeners, + warnDegradedApiKeys, + warnDegradedCodexAccountPriorities, + warnDegradedCodexQuotaAutoRefresh, + warnDegradedClaudeSubagentEffort, + warnDegradedNativeSubagentConfig, + warnDegradedCodexAccountPicker, + warnDegradedUpstreamHostCircuitThreshold, + warnDegradedPlaintextV2AgentMessages, + warnDegradedAgentTaskRecovery, + warnDegradedRuntimeRole, + warnDegradedOptionalRemoteBlocks, + warnDegradedQuotaResetNotify, + warnDegradedCatalogAutoRefresh, + warnDegradedCodexPool, + warnDegradedCredentialGroups, + withRefreshedCostOverlays, +} from "./config/load-degrade"; +import { + salvageConfigCandidate, + warnConfigRepaired, + warnDroppedConfigSections, + warnAndBackupInvalidConfig, +} from "./config/salvage"; /** - * Shared shape check for the two relative send-path overrides. `field` names the - * offending key so the message stays specific to what the user actually wrote. - */ -function providerRelativeSendPathConfigError(field: string, value: string | undefined): string | null { - if (value === undefined) return null; - if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || value.includes("://")) { - return `${field} must be a relative path without a URL scheme`; - } - if (!value.startsWith("/")) return `${field} must start with /`; - if (value.includes("?") || value.includes("#")) { - return `${field} must not include query strings or fragments`; - } - return null; -} - -/** - * Validate `providers..modelCosts`: a plain object keyed by exact model - * id, each value a 4-tuple of non-negative finite USD-per-1M-token rates. - * Returns null when valid/absent, else a human-readable error. - */ -export function providerModelCostsConfigError(value: unknown, field = "modelCosts"): string | null { - if (value === undefined) return null; - if (!value || typeof value !== "object" || Array.isArray(value)) { - return `${field} must be a plain object keyed by model id`; - } - for (const [modelId, entry] of Object.entries(value)) { - if (!modelId.trim()) return `${field} keys must be nonblank model ids`; - // Redact secret-shaped model ids and JSON-escape control characters so a - // malformed write cannot echo a pasted key/secret back through the - // management API response. - const safeModelId = JSON.stringify(redactSecretString(modelId)); - if (!entry || typeof entry !== "object" || Array.isArray(entry)) { - return `${field}.${safeModelId} must be an object with input, output, cacheRead, and cacheWrite (USD per 1M tokens)`; - } - const rates = entry as Record; - for (const key of COST4_RATE_KEYS) { - const rate = rates[key]; - if (!isValidCost4Rate(rate)) { - return `${field}.${safeModelId}.${key} must be a non-negative finite number at most ${MAX_COST4_RATE} (USD per 1M tokens)`; - } - } - // Reject unknown fields: a misplaced apiKey/apiKeyPool under a cost row - // would otherwise be persisted and echoed verbatim by display paths that - // mask only top-level provider secrets. - const extraKeys = Object.keys(rates) - .filter((key) => !(COST4_RATE_KEYS as readonly string[]).includes(key)); - if (extraKeys.length > 0) { - return `${field}.${safeModelId} has unexpected fields ${JSON.stringify(extraKeys.map(redactSecretString).join(", "))} — only input, output, cacheRead, and cacheWrite are allowed (USD per 1M tokens)`; - } - } - return null; -} - -/** - * Serialize `providers..modelCosts` for display: copy ONLY the four - * numeric rate fields per model and DROP secret-shaped model ids, so a pasted - * API key in a key position cannot be echoed back by CLI/DTO display paths. - * The result uses a null prototype so "__proto__" remains an own row. + * Load and validate config.json into an OcxConfig. Missing files reset to + * defaults and clear stale overlays. Broken existing files also fall back to + * default routing (after backup), but keep the last-good cost-overlay registry + * until a valid config or a genuinely missing file is observed. A partially- + * invalid config is merged with defaults so providers and pool accounts survive. */ -export function sanitizeModelCostsForDisplay(costs: unknown): Record | undefined { - if (!costs || typeof costs !== "object" || Array.isArray(costs)) return undefined; - const out = Object.create(null) as Record; - for (const [modelId, entry] of Object.entries(costs)) { - if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; - const rates = entry as Record; - const input = rates.input; - const output = rates.output; - const cacheRead = rates.cacheRead; - const cacheWrite = rates.cacheWrite; - if ( - isValidCost4Rate(input) - && isValidCost4Rate(output) - && isValidCost4Rate(cacheRead) - && isValidCost4Rate(cacheWrite) - ) { - // Secret-shaped ids are DROPPED rather than mapped to "[REDACTED]" so - // distinct rows cannot collapse into one placeholder key. - if (redactSecretString(modelId) !== modelId) continue; - out[modelId] = { input, output, cacheRead, cacheWrite }; - } - } - return Object.keys(out).length > 0 ? out : undefined; -} - -const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]); - -export function modelPreferHostedToolsConfigError( - value: unknown, - field: string, - providerName: string, - provider: { adapter?: unknown; authMode?: unknown; modelAdapters?: unknown; baseUrl?: unknown }, -): string | null { - if (value === undefined) return null; - if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`; - const entries = Object.entries(value); - const registry = getProviderRegistryEntry(providerName); - // Effective transport: a `preserveCustomDestination` registry row reused under a - // different endpoint keeps its own adapter AND its own auth at runtime, because - // `routedProviderConfig()` honors `providerMatchesRegistryTransport()`. Both the - // wire check below and the forward-auth check here have to start from the same - // decision, or validation accepts a preference the adapter never applies — - // `preferConfiguredHostedTools()` runs only on the non-forward branch. - const registryTransportMatches = typeof provider.baseUrl === "string" - && providerMatchesRegistryTransport(providerName, { - baseUrl: provider.baseUrl, - adapter: provider.adapter as OcxProviderConfig["adapter"], - ...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}), - }); - const effectiveForwardAuth = registryTransportMatches - ? registry?.authKind === "forward" - : provider.authMode === "forward"; - if (entries.length > 0 && effectiveForwardAuth) { - return `${field} is not supported on forward-auth Responses providers`; - } - const requestedWireFor = (modelId: string): unknown => provider.modelAdapters - && typeof provider.modelAdapters === "object" - && !Array.isArray(provider.modelAdapters) - ? (provider.modelAdapters as Record)[modelId] - : undefined; - const resolveEffectiveWire = (modelId: string, currentWire: unknown): unknown => { - const pinned = pinnedWireAdapter(providerName, modelId); - if (pinned) return pinned; - const requestedWire = requestedWireFor(modelId); - if (typeof requestedWire === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requestedWire)) { - return requestedWire; - } - // No explicit override: fall back to the registry's per-model wire default before - // the provider-wide adapter, because that is the order `resolveModelAdapter()` - // uses at request time (src/server/adapter-resolve.ts:38-48). Skipping it rejected - // preferences the runtime would have honored — DeepSeek routes `deepseek-v4-flash` - // over native Responses for a Responses inbound while the provider-wide wire stays - // openai-chat. Hosted-tool preferences only apply to Responses traffic, so the - // inbound to ask about is "responses". - const registryDefault = typeof currentWire === "string" && typeof provider.baseUrl === "string" - ? providerModelWireDefault( - providerName, - { - baseUrl: provider.baseUrl, - adapter: currentWire, - ...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}), - }, - modelId, - MODEL_ADAPTER_OVERRIDE_ALLOWED, - "responses", - ) - : undefined; - return registryDefault ?? currentWire; - }; - for (const [key, entry] of entries) { - if (!key.trim()) return `${field} keys must be nonblank model ids`; - if (!Array.isArray(entry)) return `${field}.${key} must be an array`; - if (entry.length === 0) return `${field}.${key} must include image_generation`; - for (const tool of entry) { - if (typeof tool !== "string" || !SUPPORTED_PREFERRED_HOSTED_TOOLS.has(tool)) { - return `${field}.${key} supports only image_generation`; - } - if (isHostedToolUnsupportedForModel(key, tool)) { - return `${field}.${key} cannot prefer ${tool}: the model does not support it`; - } - } - // Same `registryTransportMatches` decision the forward-auth check above uses: - // start from the registry adapter only when this config still points at the - // registry's documented transport. - const baseWire = registryTransportMatches ? registry?.adapter ?? provider.adapter : provider.adapter; - let effectiveWire = resolveEffectiveWire(key, baseWire); - const virtualWireModel = resolveOpenAiVirtualModel(providerName, key)?.wireModelId; - if (virtualWireModel && virtualWireModel !== key) { - effectiveWire = resolveEffectiveWire(virtualWireModel, effectiveWire); - } - if (effectiveWire !== "openai-responses") { - return `${field}.${key} requires the openai-responses wire`; - } - } - return null; -} - -const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR = - "codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids"; -const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR = - "account selectors must use 1-64 letters, numbers, dots, underscores, or hyphens and cannot be reserved JavaScript object keys"; -const CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR = - "account selector targets must be @main or valid Codex pool-account ids"; -const CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR = - "account selectors must not collide with configured Codex pool-account ids or account selector targets"; - -function configuredCodexPoolAccountIds(value: unknown): Set { - const accountIds = new Set(); - if (!Array.isArray(value)) return accountIds; - for (const account of value) { - if (!account || typeof account !== "object" || Array.isArray(account)) continue; - const { id, isMain } = account as { id?: unknown; isMain?: unknown }; - if (typeof id === "string" && isMain !== true) accountIds.add(id); - } - return accountIds; -} - -const codexAccountNamespacesSchema = z.custom>( - (value): value is Record => !!value - && typeof value === "object" - && !Array.isArray(value) - && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null), - { error: CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR }, -).superRefine((accountNamespaces, ctx) => { - // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys. - for (const [namespace, accountId] of Object.entries(accountNamespaces)) { - if (!isValidProviderName(namespace)) { - ctx.addIssue({ - code: "custom", - path: [namespace], - message: CODEX_ACCOUNT_NAMESPACE_KEY_ERROR, - }); - } - if (!isValidCodexAccountNamespaceTarget(accountId)) { - ctx.addIssue({ - code: "custom", - path: [namespace], - message: CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR, - }); - } - } -}).pipe(z.record(z.string(), z.string())); - -const CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR = - "codexAccountPriorities must be a plain object mapping Codex account ids to selection-order integers"; -const CODEX_ACCOUNT_PRIORITY_KEY_ERROR = - "selection-order keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys"; -const CODEX_ACCOUNT_PRIORITY_VALUE_ERROR = - "selection order must be an integer between -100 and 100"; - -const CODEX_ACCOUNT_PIN_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/; - -const codexAccountPrioritiesSchema = z.custom>( - (value): value is Record => !!value - && typeof value === "object" - && !Array.isArray(value) - && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null), - { error: CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR }, -).superRefine((priorities, ctx) => { - // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys. - for (const [accountId, priority] of Object.entries(priorities)) { - if (!isCodexAccountPriorityKey(accountId)) { - ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_KEY_ERROR }); - } - if (parseAccountPriority(priority) === null) { - ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_VALUE_ERROR }); - } - } -}).pipe(z.record(z.string(), z.number().int())); - -const codexQuotaAutoRefreshEntrySchema = z.object({ - fiveHour: z.boolean().optional(), - weekly: z.boolean().optional(), - lastFiveHourResetAt: z.number().finite().nonnegative().optional(), - lastWeeklyResetAt: z.number().finite().nonnegative().optional(), - nextFiveHourResetAt: z.number().finite().nonnegative().optional(), - nextWeeklyResetAt: z.number().finite().nonnegative().optional(), -}).strict(); -const CODEX_QUOTA_AUTO_REFRESH_KEY_ERROR = - "quota auto-refresh keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys"; - -const codexQuotaAutoRefreshSchema = z.custom>( - (value): value is Record => !!value - && typeof value === "object" - && !Array.isArray(value) - && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null), - { error: "codexQuotaAutoRefresh must be a plain object" }, -).superRefine((settings, ctx) => { - // Inspect own entries before z.record parses them; Zod omits __proto__ record keys. - for (const [accountId, setting] of Object.entries(settings)) { - if (!isCodexAccountPriorityKey(accountId)) { - ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_QUOTA_AUTO_REFRESH_KEY_ERROR }); - } - const parsed = codexQuotaAutoRefreshEntrySchema.safeParse(setting); - if (!parsed.success) { - ctx.addIssue({ code: "custom", path: [accountId], message: "invalid quota auto-refresh setting" }); - } +export function loadConfig(): OcxConfig { + const dir = getConfigDir(); + const configPath = getConfigPath(); + hardenConfigDir(); + hardenExistingSecret(configPath); + hardenExistingSecret(join(dir, "auth.json")); + if (!existsSync(configPath)) { + return withRefreshedCostOverlays(getDefaultConfig()); } -}).pipe(z.record(z.string(), codexQuotaAutoRefreshEntrySchema)); - -/** - * Deliberately permissive. A user's config is not ours to invalidate: a strict - * entry fails the whole parse, and loadConfig's fallback then backs the file up - * and returns defaults — losing providers and pool accounts because one key name - * was too long. Length and charset rules live at the POST/PATCH boundary, where - * rejecting produces a 400 instead. `.passthrough()` keeps unknown per-key - * properties across a load -> mutate -> save round trip. - * - * Only `key` is load-bearing: admission compares that string and nothing else - * (src/server/auth-cors.ts isDataPlaneAdmissionSecret). So the secret is the one - * field that must be a usable string, and every piece of metadata around it - * degrades instead of taking the credential down with it. Dropping a working key - * because its `name` was hand-edited to a number would be a silent revocation — - * and on a remote bind, potentially a server that refuses to start. - * - * "Usable" matches admission exactly. The presented token is trimmed before the - * comparison but the stored value is not, so a key with surrounding whitespace - * can never match either form of itself. Keeping one would be worse than dropping - * it: `system-env.ts` and `cli/claude.ts` hand `apiKeys[0].key` to launched - * clients, so a junk first entry would mask a valid later one. - */ -const pendingApiKeyRotationSchema = z.object({ - id: z.string().trim().min(1).max(256), - key: z.string().refine(isUsableApiKeySecret), - createdAt: z.string().datetime({ offset: true }), - expiresAt: z.string().datetime({ offset: true }), -}).strict(); - -const apiKeyEntrySchema = z.object({ - key: z.string().refine(isUsableApiKeySecret), - // Degrades to "" here; every schema consumer then runs `normalizeApiKeyIds`, - // which fills it deterministically so the id is stable across loads. - id: z.string().catch(""), - name: z.string().catch(""), - createdAt: z.string().catch(""), - // A damaged overlap record must never discard the still-authoritative key. - pendingRotation: pendingApiKeyRotationSchema.optional().catch(undefined), -}).passthrough(); - -/** - * Durable per-client intent. - * - * `.passthrough()` is load-bearing: a binary that only knows `codex` must not - * erase a key a later version wrote during a field-scoped mutation. And each key - * degrades on its own — a hand edit of `{"codex": "false", "future": false}` - * drops `codex` to absent (which reads as ON) and keeps `future`, rather than - * invalidating the object or, worse, the whole config. - */ -const clientIntegrationsSchema = z.object({ - codex: z.boolean().optional().catch(undefined), - grok: z.boolean().optional().catch(undefined), - "claude-desktop": z.boolean().optional().catch(undefined), -}).passthrough(); - -const asideProfileSyncSchema = z.object({ - allProfiles: z.boolean().optional(), - profiles: z.record( - z.string().regex(/^(0|[1-9][0-9]*)$/).refine(value => Number.isSafeInteger(Number(value))), - z.boolean(), - ).optional(), - legacyProfileId: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable().optional(), -}).passthrough(); - -const agentTaskRecoverySchema = z.object({ - enabled: z.boolean().optional(), - model: z.string().trim().min(1).optional(), - timeoutMs: z.number().int().min(1_000).max(120_000).optional(), - cacheEntries: z.number().int().min(1).max(512).optional(), -}).strict(); - -const runtimeRoleSchema = z.enum(["standalone", "hub", "client"]); - -function canonicalHttpOrigin(value: string): string | null { try { - const parsed = new URL(value); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; - if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null; - return parsed.origin; - } catch { - return null; - } -} - -const managementIngressSchema = z.union([ - z.object({ enabled: z.literal(false) }).strict(), - z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }).strict(), -]); - -const hubConfigSchema = z.object({ - managementPublicOrigin: z.string().transform((value, ctx) => { - const origin = canonicalHttpOrigin(value); - if (!origin) { - ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" }); - return z.NEVER; - } - return origin; - }).optional(), - // Same canonical-origin rule as managementPublicOrigin, and deliberately NOT `.catch`ed: - // a mistyped data origin must be rejected at write time, because silently dropping it - // makes `ocx hub invite` print the `http://:` fallback that the operator - // set this field precisely to replace. - dataPublicOrigin: z.string().transform((value, ctx) => { - const origin = canonicalHttpOrigin(value); - if (!origin) { - ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" }); - return z.NEVER; - } - return origin; - }).optional(), - // A malformed hand edit disables only the optional ingress. Live writes are rejected by - // managementIngressConfigError before this load-time degradation can hide the mistake. - managementIngress: managementIngressSchema.optional().catch(undefined), -}).strict(); - -const tailscaleUserSchema = z.string().trim().min(1).superRefine((value, ctx) => { - if (new TextEncoder().encode(value).byteLength > 320) { - ctx.addIssue({ code: "custom", message: "must be at most 320 UTF-8 bytes" }); - } - if (/[\x00-\x1f\x7f]/.test(value)) { - ctx.addIssue({ code: "custom", message: "must not contain ASCII control characters" }); - } -}); - -const remoteGuiConfigSchema = z.object({ - allowedTailscaleUsers: z.array(tailscaleUserSchema).max(64).superRefine((users, ctx) => { - const seen = new Set(); - for (let index = 0; index < users.length; index++) { - const user = users[index]!; - if (seen.has(user)) { - ctx.addIssue({ code: "custom", path: [index], message: "must contain unique users after trimming" }); - } - seen.add(user); - } - }).optional(), - // Retired (see OcxRemoteGuiConfig): accepted so an existing file still loads, ignored by - // the pairing path. Removing it from a strict schema would reject the whole config. - allowInsecureHttp: z.boolean().optional(), -}).strict(); - -const connectedClientIdSchema = z.enum(["codex", "claude"]); -const clientTimestampSchema = z.string().datetime({ offset: true }); -const clientOriginSchema = z.string().transform((value, ctx) => { - const origin = canonicalHttpOrigin(value); - if (!origin) { - ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" }); - return z.NEVER; - } - return origin; -}); -const clientConnectionSchema = z.object({ - serverUrl: clientOriginSchema, - managementUrl: clientOriginSchema, - managementTransport: z.enum(["direct", "relay"]), - selectedClients: z.array(connectedClientIdSchema).min(1).max(2).superRefine((clients, ctx) => { - if (new Set(clients).size !== clients.length) { - ctx.addIssue({ code: "custom", message: "must contain unique client ids" }); - } - }), - tokenEnv: z.literal("OPENCODEX_API_AUTH_TOKEN"), - apiKeyId: z.string().trim().min(1).max(256), - tokenFingerprint: z.string().regex(/^[a-f0-9]{64}$/), - protocolVersion: z.literal(1), - connectedAt: clientTimestampSchema, - catalogFingerprint: z.string().min(1).max(512).optional(), - // base64 of the pre-connect catalog, or "" for "there was none". Bounded above the - // catalog size cap so a legitimate snapshot round-trips. - priorCatalog: z.string().max(64 * 1024 * 1024).optional(), - catalogSyncedAt: clientTimestampSchema.optional(), - pendingOperation: z.object({ - kind: z.literal("rotate"), - rotationId: z.string().trim().min(1).max(256), - newKeyIssuedAt: clientTimestampSchema, - oldKeyBackupPath: z.string().min(1), - }).strict().superRefine((operation, ctx) => { - const expected = join(getConfigDir(), "service-api-token.prev"); - if (operation.oldKeyBackupPath !== expected) { - ctx.addIssue({ code: "custom", path: ["oldKeyBackupPath"], message: `must equal ${expected}` }); - } - }).optional(), -}).strict(); - -/** - * Codex pool selection policy section. - * - * `.strict()` like its neighbour: a typo in an optional feature section should surface as a - * rejected write rather than a silently ignored key that leaves the operator believing they - * excluded something. - */ -const codexPoolSchema = z.object({ - excludedPlans: z.array(z.string().trim().min(1)).optional(), -}).strict(); - -/** - * Shape guard for the cross-element checks below. Zod runs an array-level check even - * when an element failed its own validation, and a failed element is not the shape the - * checker expects — reading `credentials.length` off it would throw out of `safeParse` - * and take the whole config load with it. Those elements already carry their own issues. - */ -function isCredentialGroupShape(value: unknown): value is { id: string; credentials: string[] } { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const group = value as { id?: unknown; credentials?: unknown }; - return typeof group.id === "string" - && Array.isArray(group.credentials) - && group.credentials.every(member => typeof member === "string"); -} - -/** - * Operator-declared quota domains (`pool.credentialGroups`). - * - * Loose enough to hand-write, strict enough that it cannot mean two things: unique group - * ids, a non-empty member list, provider-qualified members, and each credential in at - * most one group. Those are not tidiness rules. `classifyCredential` keys a declared - * domain by group id, so a duplicate id or a credential listed twice merges two quota - * domains the operator never said were one -- after which the pool counts real capacity - * once and declines to rotate into it. A bare credential id is ambiguous for the same - * reason ids are provider-scoped in the auth store, so members carry their provider. - * {@link credentialGroupIssues} is the single definition, shared with the classifier. - */ -const credentialGroupsSchema = z.array(z.object({ - id: z.string().trim().min(1), - credentials: z.array(z.string().trim().min(1)).min(1), - note: z.string().optional(), -})).superRefine((groups, ctx) => { - if (!Array.isArray(groups) || !groups.every(isCredentialGroupShape)) return; - for (const message of credentialGroupIssues(groups)) { - ctx.addIssue({ code: "custom", message }); - } -}); - -/** - * Quota-reset notification section. - * - * `.strict()` like its neighbour: a typo in an optional feature section should surface as a - * rejected write rather than a silently ignored key that leaves the operator believing they - * enabled something. - * - * `pollSeconds` admits 0 (passive-only, no timer) and the resolver clamps anything between 1 - * and the 60-second floor. Bounds live in the resolver rather than here so a hand-edited value - * degrades to a sane one instead of discarding the whole section. - */ -const quotaResetNotifySchema = z.object({ - enabled: z.boolean().optional(), - kinds: z.array(z.enum(["scheduled", "surprise"])).optional(), - pollSeconds: z.number().int().min(0).optional(), - // `z.string().url()` accepts any scheme. The payload carries account identity and the hook - // URL is frequently a bearer-equivalent secret, so an http: sink puts both in cleartext. - webhookUrl: z.string().url().refine( - value => { try { return new URL(value).protocol === "https:"; } catch { return false; } }, - { message: "webhookUrl must use https" }, - ).optional(), - allowPrivateNetwork: z.boolean().optional(), - timeoutMs: z.number().int().positive().optional(), - command: z.array(z.string()).optional(), -}).strict(); - -/** - * Catalog auto-refresh section (issue #3630). - * - * `.strict()` like its neighbour: a typo in an optional feature section should surface as a - * rejected write rather than a silently ignored key that leaves the operator believing they - * enabled something. - * - * `intervalMinutes` admits 0 (configured but dormant, no timer) and the resolver clamps - * anything between 1 and the 15-minute floor. Bounds live in the resolver rather than here - * so a hand-edited value degrades to a sane one instead of discarding the whole section. - * The 1440 ceiling keeps a hand edit from scheduling the refresh further out than a day, - * which is operator error far more often than intent. - */ -const catalogAutoRefreshSchema = z.object({ - enabled: z.boolean().optional(), - intervalMinutes: z.number().int().min(0).max(1440).optional(), -}).strict(); - -const configSchema = z.object({ - port: z.number().int().min(0).max(65535).default(10100), - // A malformed hand edit must disable only remote-role behavior, not discard - // providers or data-plane keys. Live writes are rejected explicitly below. - runtimeRole: runtimeRoleSchema.optional().catch(undefined), - // Malformed optional remote blocks disable only remote GUI behavior. Live - // candidates are rejected explicitly by remoteGuiConfigError below. - hub: hubConfigSchema.optional().catch(undefined), - remoteGui: remoteGuiConfigSchema.optional().catch(undefined), - // A malformed privacy block must never be read as "unmask": .catch(undefined) drops it and - // emailMaskingEnabled then falls back to masked, which is also what an absent block means. - privacy: z.object({ maskEmails: z.boolean().optional() }).strict().optional().catch(undefined), - // A malformed present client block must remain diagnosable from raw config and - // fail closed through src/client/state.ts; unrelated provider state still loads. - client: clientConnectionSchema.optional().catch(undefined), - managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024).describe( - "Deprecated compatibility limit for bounded legacy usage readers; GET /api/usage always aggregates the complete ledger", - ), - // Invalid hand edits disable only this opt-in circuit. Live writes remain strict. - upstreamHostCircuitThreshold: z.number().int() - .min(0) - .max(UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) - .optional() - .catch(undefined), - // Opt-in outbound body ceiling. An invalid hand edit disables only this guard, matching the - // circuit threshold above: a malformed number must not make the proxy refuse traffic. - maxUpstreamBodyBytes: z.number().int() - .min(0) - .optional() - .catch(undefined), - // Opt-in inbound body ceiling (#3573). An invalid hand edit degrades to the 256 MiB default - // rather than failing the parse, matching the outbound guard above: a malformed number must - // not change what the proxy admits. The hard ceiling is NOT enforced here — because of that - // `.catch`, and because a config object can be built without this schema at all — but in - // `resolveInboundBodyLimitBytes()`, which every reader goes through. - maxInboundBodyBytes: z.number().int() - .min(0) - .optional() - .catch(undefined), - appOwnedMemoryBudgetMb: z.number().int() - .min(MIN_APP_OWNED_MEMORY_BUDGET_MB) - .max(MAX_APP_OWNED_MEMORY_BUDGET_MB) - .default(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024)) - .catch(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024)), - // A blank hostname degrades to undefined rather than failing the parse. `getDefaultConfig()` - // carries no `hostname` key, so the backup-and-defaults repair path below cannot merge one - // away — a hand-edited `"hostname": ""` would fail twice and reset providers/apiKeys to - // defaults, which is strictly worse than the bind bug this validation exists for. Degrading - // is safe: startServer() already falls back to 127.0.0.1 for a missing hostname. Write-time - // rejection lives in validateConfigCandidate() so bad values still surface to the caller. - hostname: z.string().trim().min(1).optional().catch(undefined), - // Discriminated on `enabled` so a disabled entry cannot be forced to carry a port (#1102). - // An enabled one MAY omit it: that is the companion form, which binds 127.0.0.1 on the proxy - // port and is legal only off a loopback/wildcard bind — a relationship between two fields, so - // it is enforced in validateConfigCandidate() and again at startup, not here (#4236). - // A malformed value degrades to undefined rather than failing the whole parse: this is an - // opt-in convenience surface, and a hand-edit typo here must never reset providers/apiKeys - // through the backup-and-defaults repair path. - unauthenticatedLoopbackListener: z.union([ - z.object({ enabled: z.literal(false) }), - z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535).optional() }), - ]).optional().catch(undefined), - providers: z.record(z.string(), providerConfigSchema), - modelPinnedEfforts: modelPinnedEffortsSchema.optional(), - defaultProvider: z.string().min(1).default("openai"), - defaultModelAliases: z.boolean().optional(), - // Malformed hand edits disable this opt-in projection without rejecting providers. - cursorEffortRows: z.boolean().optional().catch(false), - // Fast selectors default on; malformed hand edits disable them without rejecting providers. - fastRows: z.boolean().default(true).catch(false), - // Ultra Fast is opt-in for the same reason and degrades the same way: a malformed hand - // edit turns the tier off rather than rejecting the config that carries it. - ultraFastTier: z.boolean().optional().catch(false), - codexMainAccountHardLock: z.boolean().optional().catch(false), - // Future versions remain opaque through passthrough-compatible whole-config saves. - // Only version 1 grants deletion authority in the rebase path. - configRebaseProvenance: z.unknown().optional(), - // A retry can be billable, so absence and malformed hand edits both stay off. - emptyCompletionRetry: z.boolean().optional().catch(false), - // Header suppression changes what Codex sees, so absence and malformed edits stay off. - dropCodexSafetyBuffering: z.boolean().optional().catch(false), - // A malformed hand edit must not silently stop opening the browser: fall back - // to undefined, which resolves to the historical auto-open behavior. - oauthOpenBrowser: z.boolean().optional().catch(undefined), - openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(), - // Invalid hand edits must not discard an otherwise usable config. - googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined), - subagentModelsVersion: z.number().int().positive().optional().catch(undefined), - subagentModels: z.array(z.string().min(1)).optional().catch(undefined), - // A hand-edited advisory version must not cost the operator their providers; a bad - // value degrades to undefined, which simply raises the notice again. - multiAgentSurfaceAdvisoryVersion: z.number().int().nonnegative().optional().catch(undefined), - clientIntegrations: clientIntegrationsSchema.optional().catch(undefined), - // A malformed profile policy must not fall back to legacy all-profile activation. - asideProfileSync: asideProfileSyncSchema.optional().catch({ allProfiles: false }), - providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), - providerContextCapValues: z.record(z.string(), z.number().int().positive()).optional(), - contextCapValue: z.number().int().positive().optional(), - multiAgentGuidanceEnabled: z.boolean().optional(), - // Invalid optional recovery config must not discard unrelated provider/account state. - plaintextV2AgentMessages: z.boolean().optional().catch(undefined), - agentTaskRecovery: agentTaskRecoverySchema.optional().catch(undefined), - // Same rationale: a bad notify section must not cost the operator their providers. - quotaResetNotify: quotaResetNotifySchema.optional().catch(undefined), - // Same rationale: a bad auto-refresh section must not cost the operator their providers. - catalogAutoRefresh: catalogAutoRefreshSchema.optional().catch(undefined), - // These selections pre-date schema validation and used to pass through as - // unknown fields. Invalid hand edits must disable only the optional - // delegation/native-default feature, not reject the whole config and hide - // otherwise valid providers, accounts, or the configured listen port. - injectionModel: z.string().optional().catch(undefined), - injectionEffort: z.string().optional().catch(undefined), - syncCodexSubagentDefaults: z.boolean().optional().catch(undefined), - // Per-primary-model fallback chains. Values must be non-empty string arrays; - // malformed entries degrade to undefined rather than rejecting the whole config. - subagentModelFallbackByModel: z.record( - z.string(), - z.array(z.string().trim().min(1)).min(1), - ).optional().catch(undefined), - codexShimAutoRestore: z.boolean().optional(), - codexDesktopAuthless: z.boolean().optional().catch(undefined), - codexClientCompaction: z.boolean().optional().catch(undefined), - pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), - // A malformed policy degrades to "no policy" rather than failing the parse, so a hand-edited - // typo cannot trip the backup-and-defaults repair path and wipe providers or pool accounts. - // Silently ignoring it would be its own trap, so the write path rejects it and loadConfig warns. - codexPool: codexPoolSchema.optional().catch(undefined), - codexQuotaAutoRefresh: codexQuotaAutoRefreshSchema.optional().catch(undefined), - codexAccountNamespaces: codexAccountNamespacesSchema.optional(), - // Selection order is a preference, not a safety control like pause: a malformed - // map degrades to "no ordering" rather than failing the parse, so a hand-edited - // typo cannot trip the backup-and-defaults repair path and wipe providers or - // pool accounts. Warning emitted in loadConfig. - codexAccountPriorities: codexAccountPrioritiesSchema.optional().catch(undefined), - activeCodexAccountPinned: z.string().regex(CODEX_ACCOUNT_PIN_PATTERN).optional().catch(undefined), - // A malformed hand edit must degrade to false without discarding providers, accounts, - // or the exact selector map. Live writes remain strict. - codexAccountPickerEnabled: z.boolean().optional().catch(false), - resetCreditAutoRedeem: z.object({ - enabled: z.boolean().optional(), - leadTimeMinutes: z.number().int().min(1).max(60).optional(), - }).optional().catch(undefined), - // Same degrade-to-off rule as the flags above: a hand-edited typo in an opt-in pool - // feature must never cost the operator their providers. - pool: z.object({ - kernel: z.boolean().optional(), - cacheAffinity: z.boolean().optional(), - // The catch belongs on the list, not on `pool`. Left to the outer catch below, one - // malformed group failed this nested object and dropped the whole `pool` -- taking - // `kernel` and `cacheAffinity` with it, which is a live routing change the operator - // never made. Scoped here, a malformed or ambiguous group costs only the declared - // grouping: loadConfig warns, and the write path rejects it outright. - credentialGroups: credentialGroupsSchema.optional().catch(undefined), - }).optional().catch(undefined), - // Model ids excluded from the Grok Build managed block (dashboard switches). - grokExcludedModels: z.array(z.string()).optional(), - // Invalid values degrade to undefined ("auto") instead of failing the whole - // parse: a hand-edited typo must never trip the backup-and-defaults repair - // path below and wipe providers/pool accounts. Warning emitted in loadConfig. - streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined), - blockedModelRedirects: z.record(z.string(), z.string()).optional().catch(undefined), - // Same degrade-don't-reject rationale as the fields above: a hand-edited - // non-string must not trip the backup-and-defaults repair path. Unset then - // takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot). - experimentalRealtimeWsBaseUrl: z.string().optional().catch(undefined), - // Salvage element by element, and never fail the parse. Two spellings were - // measured on this zod version and both lose data: - // `z.array(entry).catch(undefined)` -> one bad entry discards EVERY key - // `z.array(z.unknown())` -> a non-array value still raises - // invalid_type, reaching the - // backup-and-defaults repair path - // Starting from `unknown` is what makes both survivable. A key the user still - // has deployed must not be collateral damage for one bad neighbour, and on a - // remote bind an emptied array is worse than cosmetic: assertServerAuthConfig - // refuses to start without a data credential. - apiKeys: z.unknown().optional().transform(value => { - if (value === undefined) return undefined; - if (!Array.isArray(value)) return undefined; - return value - .filter(row => apiKeyEntrySchema.safeParse(row).success) - .map(row => apiKeyEntrySchema.parse(row) as OcxApiKeyEntry); - }), -}).passthrough().superRefine((config, ctx) => { - const claudeCode = (config as { claudeCode?: unknown }).claudeCode; - if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) { - ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" }); - } else if (claudeCode) { - const claude = claudeCode as { desktopProfile?: unknown }; - if (claude.desktopProfile !== undefined) { - try { - parseDesktopProfile(claude.desktopProfile); - } catch (error) { - ctx.addIssue({ - code: "custom", - path: ["claudeCode", "desktopProfile"], - message: error instanceof Error ? error.message : String(error), - }); - } - } - } - - const accountNamespaces = config.codexAccountNamespaces; - if (accountNamespaces) { - const configuredAccountIds = configuredCodexPoolAccountIds(config.codexAccounts); - const configuredProviderNamespaces = new Set([ - COMBO_NAMESPACE, - OPENAI_CODEX_PROVIDER_ID, - POLICY_NAMESPACE, - ...Object.keys(config.providers), - ].map(codexProviderNamespaceKey)); - const namespaceTargets = new Set( - Object.values(accountNamespaces) - .filter(accountId => accountId !== MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET), - ); - for (const namespace of Object.keys(accountNamespaces)) { - if (configuredProviderNamespaces.has(codexProviderNamespaceKey(namespace))) { - ctx.addIssue({ - code: "custom", - path: ["codexAccountNamespaces", namespace], - message: "account selectors must not collide with configured provider, combo, or routing policy namespaces", - }); - } - if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) { - ctx.addIssue({ - code: "custom", - path: ["codexAccountNamespaces", namespace], - message: CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR, - }); - } - } - } - for (const name of Object.keys(config.providers)) { - if (!isValidProviderName(name)) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name)], - message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys or routing namespaces (policy)", - }); - } - const provider = config.providers[name]; - if (hasFastWireCapabilityConflict(provider)) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "fastWire"], - message: "fastWire=null conflicts with supportsServiceTier=true", - }); - } - const openRouterRoutingError = openRouterRoutingConfigError(provider); - if (openRouterRoutingError) { - ctx.addIssue({ - code: "custom", - path: [ - "providers", - redactSecretString(name), - openRouterRoutingError.startsWith("modelOpenRouterRouting") - ? "modelOpenRouterRouting" - : "openRouterRouting", - ], - message: openRouterRoutingError, - }); - } - const vercelRoutingError = vercelGatewayRoutingConfigError(provider); - if (vercelRoutingError) { - ctx.addIssue({ - code: "custom", - path: [ - "providers", - redactSecretString(name), - vercelRoutingError.startsWith("modelVercelGatewayRouting") - ? "modelVercelGatewayRouting" - : "vercelGatewayRouting", - ], - message: vercelRoutingError, - }); - } - if (Object.hasOwn(provider, "virtualModels")) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "virtualModels"], - message: "virtualModels is registry-only and must not be persisted", - }); - } - const baseUrlError = providerBaseUrlConfigError(provider.baseUrl); - if (baseUrlError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "baseUrl"], - message: baseUrlError, - }); - } else { - const destinationError = providerDestinationConfigError(name, provider); - if (destinationError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "baseUrl"], - message: destinationError, - }); - } - } - for (const field of ["responsesPath", "chatCompletionsPath"] as const) { - const sendPathError = providerRelativeSendPathConfigError(field, provider[field]); - if (sendPathError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), field], - message: sendPathError, - }); - } - } - const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers); - if (headersError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "headers"], - message: headersError, - }); - } - const modelCostsError = providerModelCostsConfigError((provider as { modelCosts?: unknown }).modelCosts); - if (modelCostsError) { - ctx.addIssue({ - code: "custom", - // The provider key is caller-controlled and can be token-shaped; redact it - // before schemaDiagnosticsError serializes the path (ocx config validate/import). - path: ["providers", redactSecretString(name), "modelCosts"], - message: modelCostsError, - }); - } - const modelDisplayNamesError = modelDisplayNamesConfigError( - (provider as { modelDisplayNames?: unknown }).modelDisplayNames, - ); - if (modelDisplayNamesError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "modelDisplayNames"], - message: modelDisplayNamesError, - }); - } - const apiKeyTransportError = apiKeyTransportConfigError(provider as OcxProviderConfig); - if (apiKeyTransportError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "apiKeyTransport"], - message: apiKeyTransportError, - }); - } - const modelAdaptersError = modelAdapterRecordConfigError( - (provider as { modelAdapters?: unknown }).modelAdapters, - "modelAdapters", - name, - provider, - ); - if (modelAdaptersError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "modelAdapters"], - message: modelAdaptersError, - }); - } - const preferHostedToolsError = modelPreferHostedToolsConfigError( - (provider as { modelPreferHostedTools?: unknown }).modelPreferHostedTools, - "modelPreferHostedTools", - name, - provider, - ); - if (preferHostedToolsError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "modelPreferHostedTools"], - message: preferHostedToolsError, - }); - } - const maxInputError = positiveIntegerRecordConfigError( - (provider as { modelMaxInputTokens?: unknown }).modelMaxInputTokens, - "modelMaxInputTokens", - ); - if (maxInputError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "modelMaxInputTokens"], - message: maxInputError, - }); - } - const autoCompactError = modelAutoCompactTokenLimitsConfigError( - (provider as { modelAutoCompactTokenLimits?: unknown }).modelAutoCompactTokenLimits, - { requireNativeIds: name === OPENAI_CODEX_PROVIDER_ID }, - ); - if (autoCompactError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "modelAutoCompactTokenLimits"], - message: autoCompactError, - }); - } - const reasoningSummariesError = booleanRecordConfigError( - (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries, - "modelSupportsReasoningSummaries", - ); - if (reasoningSummariesError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "modelSupportsReasoningSummaries"], - message: reasoningSummariesError, - }); - } - const verbositySupportError = booleanRecordConfigError( - (provider as { modelSupportsVerbosity?: unknown }).modelSupportsVerbosity, - "modelSupportsVerbosity", - ); - if (verbositySupportError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "modelSupportsVerbosity"], - message: verbositySupportError, - }); - } - const serviceTierModelsError = booleanRecordConfigError( - (provider as { modelSupportsServiceTier?: unknown }).modelSupportsServiceTier, - "modelSupportsServiceTier", - ); - if (serviceTierModelsError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "modelSupportsServiceTier"], - message: serviceTierModelsError, - }); - } - const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError( - (provider as { modelReasoningSummaryDelivery?: unknown }).modelReasoningSummaryDelivery, - (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries, - ); - if (reasoningSummaryDeliveryError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "modelReasoningSummaryDelivery"], - message: reasoningSummaryDeliveryError, - }); - } - const defaultMaxOutputError = positiveIntegerConfigError( - (provider as { defaultMaxOutputTokens?: unknown }).defaultMaxOutputTokens, - "defaultMaxOutputTokens", - ); - if (defaultMaxOutputError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "defaultMaxOutputTokens"], - message: defaultMaxOutputError, - }); - } - const maxOutputError = positiveIntegerRecordConfigError( - (provider as { modelMaxOutputTokens?: unknown }).modelMaxOutputTokens, - "modelMaxOutputTokens", - ); - if (maxOutputError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "modelMaxOutputTokens"], - message: maxOutputError, - }); - } - const structuredOutputOptOutError = nonBlankStringArrayConfigError( - (provider as { noStructuredOutputModels?: unknown }).noStructuredOutputModels, - "noStructuredOutputModels", - ); - if (structuredOutputOptOutError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "noStructuredOutputModels"], - message: structuredOutputOptOutError, - }); - } - const jsonSchemaOptOutError = nonBlankStringArrayConfigError( - (provider as { noJsonSchemaModels?: unknown }).noJsonSchemaModels, - "noJsonSchemaModels", - ); - if (jsonSchemaOptOutError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "noJsonSchemaModels"], - message: jsonSchemaOptOutError, - }); - } - const retainModelsError = nonBlankStringArrayConfigError( - (provider as { retainModels?: unknown }).retainModels, - "retainModels", - ); - if (retainModelsError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "retainModels"], - message: retainModelsError, - }); - } - const toolReasoningOptOutError = nonBlankStringArrayConfigError( - (provider as { omitReasoningEffortWithToolsModels?: unknown }).omitReasoningEffortWithToolsModels, - "omitReasoningEffortWithToolsModels", - ); - if (toolReasoningOptOutError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "omitReasoningEffortWithToolsModels"], - message: toolReasoningOptOutError, - }); - } - if (Object.hasOwn(provider, "codexAccountMode") && provider.codexAccountMode !== undefined) { - // Persisted account mode is valid ONLY on the canonical built-in `openai` forward provider. - // Old openai-multi rows stay parseable (they never carry a mode) so startup can migrate them. - const canonicalOpenAiShape = name === "openai" - && provider.adapter === "openai-responses" - && (provider as { authMode?: unknown }).authMode === "forward" - && typeof provider.baseUrl === "string" - && provider.baseUrl.replace(/\/+$/, "") === "https://chatgpt.com/backend-api/codex"; - if (!canonicalOpenAiShape) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "codexAccountMode"], - message: "codexAccountMode is valid only on the canonical built-in openai provider", - }); - } - } - } - if (!hasOwnProvider(config.providers, config.defaultProvider)) { - ctx.addIssue({ - code: "custom", - path: ["defaultProvider"], - message: "defaultProvider must exist in providers", - }); - } - const combos = (config as { combos?: unknown }).combos; - if (combos !== undefined) { - if (!combos || typeof combos !== "object" || Array.isArray(combos)) { - ctx.addIssue({ code: "custom", path: ["combos"], message: "combos must be an object" }); - } else { - for (const [id, raw] of Object.entries(combos as Record)) { - const alias = raw && typeof raw === "object" && !Array.isArray(raw) - ? (raw as { alias?: unknown }).alias - : undefined; - if (typeof alias === "string" && codexAccountNamespaceForModel(accountNamespaces, alias.trim())) { - ctx.addIssue({ - code: "custom", - path: ["combos", id, "alias"], - message: CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, - }); - } - // Pass the full map so cross-combo rules (alias uniqueness) apply at load time - // too, not just via the management API; each combo is excluded from its own check. - for (const issue of comboConfigIssues(id, raw, config.providers, { - combos: combos as Record, - excludeComboId: id, - })) { - ctx.addIssue({ - code: "custom", - path: ["combos", id, ...issue.path], - message: issue.message, - }); - } - } - } - } - const routingProfiles = (config as { routingProfiles?: unknown }).routingProfiles; - if (routingProfiles !== undefined) { - if (!routingProfiles || typeof routingProfiles !== "object" || Array.isArray(routingProfiles)) { - ctx.addIssue({ code: "custom", path: ["routingProfiles"], message: "routingProfiles must be an object" }); - } else { - for (const [id, raw] of Object.entries(routingProfiles as Record)) { - for (const issue of routingProfileIssues(id, raw, { - providers: config.providers, - combos: combos as Record | undefined, - routingProfiles: routingProfiles as Record, - codexAccountNamespaces: accountNamespaces, - }, { excludeProfileId: id })) { - ctx.addIssue({ - code: "custom", - path: ["routingProfiles", id, ...issue.path], - message: issue.message, - }); - } - } - } - } -}); - -export function hardenExistingSecret(path: string): void { - if (existsSync(path)) { - try { chmodSync(path, 0o600); } catch { /* best-effort */ } - if (process.platform === "win32") { - hardenSecretPath(path, { required: false }); - } - } -} -/** Load only: discard invalid optional pins without rewriting the file or losing providers. */ -function sanitizeReasoningPinsForLoad(parsed: unknown): void { - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return; - const root = parsed as Record; - let degraded = false; - const sanitizeMap = (owner: Record, field: string) => { - const value = owner[field]; - if (value === undefined) return; - if (!value || typeof value !== "object" || Array.isArray(value) - || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) { - delete owner[field]; - degraded = true; - return; - } - const counts = new Map(); - for (const key of Object.keys(value)) counts.set(key.trim(), (counts.get(key.trim()) ?? 0) + 1); - const valid: Record = Object.create(null); - for (const [key, effort] of Object.entries(value)) { - if (counts.get(key.trim()) !== 1 || modelPinnedEffortsConfigError({ [key]: effort }) !== null) { - degraded = true; - continue; - } - valid[key.trim()] = effort as string; - } - if (Object.keys(valid).length) owner[field] = valid; - else delete owner[field]; - }; - sanitizeMap(root, "modelPinnedEfforts"); - if (root.providers && typeof root.providers === "object" && !Array.isArray(root.providers)) { - for (const value of Object.values(root.providers)) { - if (!value || typeof value !== "object" || Array.isArray(value)) continue; - const provider = value as Record; - if (pinnedReasoningEffortConfigError(provider.pinnedReasoningEffort)) { - delete provider.pinnedReasoningEffort; - degraded = true; - } - sanitizeMap(provider, "modelPinnedReasoningEfforts"); - } - } - // Never include a provider/model name or value: malformed pins can contain secrets. - if (degraded) console.warn("config.json contains invalid optional reasoning pins — ignoring invalid fields or entries"); -} - -/** - * The schema's `.catch(undefined)` silently degrades an invalid persisted - * `streamMode` to "auto"; surface that once so a hand-edited typo (e.g. - * "legacy_tee") is discoverable instead of silently changing stream shape. - */ -function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void { - if (!rawParsed || typeof rawParsed !== "object") return; - const raw = (rawParsed as Record).streamMode; - if (raw !== undefined && validated.streamMode === undefined) { - console.warn(`⚠️ config.json streamMode ${JSON.stringify(raw)} is invalid (expected "auto", "legacy-tee", or "eager-relay") — falling back to "auto"`); - } -} - -/** - * Load-time degradation for `retryOn429` (loadConfig only): one hand-edited invalid optional - * field (e.g. `attempts: 0` or a string) must not trip the whole provider schema and hide every - * provider/key behind a default config. Invalid fields are dropped with a warning; the management - * write boundary still rejects invalid policies explicitly. - */ -function sanitizeRetryOn429ForLoad(parsed: unknown): void { - if (!parsed || typeof parsed !== "object") return; - const root = parsed as Record; - const providers = root.providers; - if (!providers || typeof providers !== "object" || Array.isArray(providers)) return; - for (const [name, provider] of Object.entries(providers as Record)) { - // This sanitizer runs BEFORE schema validation, so the provider name is untrusted: redact - // secret-shaped names and JSON-escape control characters before it reaches any warning. - const safeProviderName = JSON.stringify(redactSecretString(name)); - if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue; - const p = provider as Record; - const policy = p.retryOn429; - if (policy === undefined) continue; - if (!policy || typeof policy !== "object" || Array.isArray(policy)) { - delete p.retryOn429; - // Never serialize the value: an accidental `retryOn429: "sk-..."` would leak the secret. - console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 (${typeof policy}) is invalid — ignoring the policy`); - continue; - } - const policyRecord = policy as Record; - // An explicitly present but invalid master switch must not silently default to ENABLED: - // drop the whole policy so a hand-edit that tried to disable retries stays disabled. - if ("enabled" in policyRecord && typeof policyRecord.enabled !== "boolean") { - delete p.retryOn429; - console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.enabled (${typeof policyRecord.enabled}) is invalid — ignoring the whole policy`); - continue; - } - // Field checks derive from the shared policy schema so the bounds cannot drift - // between the load-time sanitizer, the config schema, and the write boundary. - const policyShape = retryOn429PolicySchema.shape; - const hadPolicyEntries = Object.keys(policyRecord).length > 0; - const cleaned: Record = {}; - for (const [key, fieldSchema] of Object.entries(policyShape)) { - const value = policyRecord[key]; - if (value === undefined) continue; - if (fieldSchema.safeParse(value).success) cleaned[key] = value; - // Log only the received type, never the value (provider config can hold secrets). - else console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${key} (${typeof value}) is invalid — ignoring the field`); - } - const knownKeys = new Set(Object.keys(policyShape)); - for (const key of Object.keys(policyRecord)) { - if (!knownKeys.has(key)) { - // Redact the field NAME before logging: a malformed hand-edit can place a secret in a - // property name (`retryOn429: { "sk-...": true }`). Ordinary typos (e.g. `attempt`) - // stay readable, secret-shaped names become [REDACTED]. JSON-escape afterwards so a - // control-character property name (newline/ANSI) can never forge a log line. - console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${JSON.stringify(redactSecretString(key))} is not a recognized field — ignoring it`); - } - } - if (hadPolicyEntries && Object.keys(cleaned).length === 0) { - // Every supplied field was invalid: drop the whole policy. Persisting `{}` here would - // opt IN to retries with defaults, which is the opposite of what a malformed - // disable-oriented edit (`retryOn429: { enabled: "false" }`, `attempts: 0`) asked for. - delete p.retryOn429; - console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 has no valid fields left — removing the policy (an empty policy would enable retries with defaults)`); - } else { - // Preserve an intentionally empty `retryOn429: {}` (presence = opt-in with defaults). - p.retryOn429 = cleaned; - } - } -} - -/** - * Management write-boundary validation for `retryOn429` (fail closed). Unlike the - * lenient load-time sanitizer, invalid values and unknown keys are rejected outright so - * a POST/PATCH cannot persist a policy the proxy would then silently degrade. Reuses the - * shared policy schema. Never echoes values, and secret-shaped unknown field names are - * redacted (a malformed write can place a secret in a property name). - */ -export function retryOn429PolicyConfigError(policy: unknown): string | null { - if (policy === undefined) return null; - const result = retryOn429PolicySchema.safeParse(policy); - if (result.success) return null; - const first = result.error.issues[0]; - if (!first) return "retryOn429 is invalid"; - if (first.code === "unrecognized_keys") { - const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", "); - return `retryOn429 has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`; - } - if (first.path.length === 0) return `retryOn429 is invalid (${first.message})`; - const field = String(first.path[first.path.length - 1]); - return `retryOn429.${field} is invalid (${first.message})`; -} - -function sanitizeCapabilityDeclarationsForLoad(parsed: unknown): void { - if (!parsed || typeof parsed !== "object") return; - const providers = (parsed as Record).providers; - if (!providers || typeof providers !== "object" || Array.isArray(providers)) return; - for (const [name, value] of Object.entries(providers)) { - if (!value || typeof value !== "object" || Array.isArray(value)) continue; - const provider = value as Record; - if (provider.modelCapabilities === undefined) continue; - if (modelCapabilitiesConfigError(provider.modelCapabilities) !== null) { - console.warn(`config.json provider ${JSON.stringify(redactSecretString(name))} has malformed modelCapabilities; retaining valid axes and restricting malformed input modalities to text`); - const repaired = sanitizeModelCapabilitiesForLoad(provider.modelCapabilities); - if (repaired) provider.modelCapabilities = repaired; - else delete provider.modelCapabilities; - } - } -} - -/** - * Load-time degradation for `providers..modelCosts`, mirroring - * {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row - * must not fail the whole config parse — that would back up config.json and - * fall back to defaults, dropping otherwise valid providers and the default - * route for a typo in a non-runtime display field. Invalid rows are dropped - * with a warning; strict rejection stays at the management/write boundary - * (providerManagementConfigError). - */ -function sanitizeModelCostsForLoad(parsed: unknown): void { - if (!parsed || typeof parsed !== "object") return; - const root = parsed as Record; - const providers = root.providers; - if (!providers || typeof providers !== "object" || Array.isArray(providers)) return; - for (const [name, provider] of Object.entries(providers as Record)) { - // Runs before schema validation, so the provider name is untrusted: redact - // secret-shaped names and JSON-escape control characters for the warning. - const safeProviderName = JSON.stringify(redactSecretString(name)); - if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue; - const p = provider as Record; - const costs = p.modelCosts; - if (costs === undefined) continue; - if (!costs || typeof costs !== "object" || Array.isArray(costs)) { - delete p.modelCosts; - console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts (${typeof costs}) is invalid — ignoring the overlay`); - continue; - } - const costsRecord = costs as Record; - const hadEntries = Object.keys(costsRecord).length > 0; - let kept = 0; - for (const [modelId, entry] of Object.entries(costsRecord)) { - // Reuse the shared per-row shape contract so the load-time sanitizer - // cannot drift from the schema and the write boundary. - if (providerModelCostsConfigError({ [modelId]: entry }) === null) { - kept++; - continue; - } - delete costsRecord[modelId]; - // Redact the model id: a hand-edit can place a secret in a key name. - console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts.${JSON.stringify(redactSecretString(modelId))} is invalid — ignoring the row`); - } - if (hadEntries && kept === 0) { - delete p.modelCosts; - console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts has no valid rows left — removing the overlay`); - } - } -} - -/** - * Load-time degradation for provider-scoped auto-review selectors. A malformed - * hand edit must not fail the whole config parse; the management boundary stays - * strict and rejects the same shapes before they can be written. - */ -function sanitizeAutoReviewForLoad(parsed: unknown): void { - if (!parsed || typeof parsed !== "object") return; - const root = parsed as Record; - const providers = root.providers; - if (!providers || typeof providers !== "object" || Array.isArray(providers)) return; - for (const [name, providerValue] of Object.entries(providers as Record)) { - if (!providerValue || typeof providerValue !== "object" || Array.isArray(providerValue)) continue; - const provider = providerValue as Record; - const safeProviderName = JSON.stringify(redactSecretString(name)); - if (name === "openai") { - delete provider.autoReviewModel; - delete provider.autoReviewModelOverrides; - continue; - } - if (provider.autoReviewModel !== undefined - && autoReviewModelTargetConfigError(provider.autoReviewModel, "autoReviewModel", true) !== null) { - console.warn(`⚠️ config.json providers.${safeProviderName}.autoReviewModel is invalid — ignoring the selector`); - delete provider.autoReviewModel; - } - if (provider.autoReviewModelOverrides !== undefined) { - const overridesError = autoReviewModelOverridesConfigError( - provider.autoReviewModelOverrides, - "autoReviewModelOverrides", - true, - ); - if (overridesError) { - console.warn(`⚠️ config.json providers.${safeProviderName}.autoReviewModelOverrides is invalid — ignoring the map`); - delete provider.autoReviewModelOverrides; - } - } - } -} - -/** - * Companion to {@link warnDegradedStreamMode} for a blank persisted `hostname`. The bind - * falls back to loopback, which is the safe direction but not what the file asked for — - * say so once instead of silently ignoring the field. - */ -function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void { - if (!rawParsed || typeof rawParsed !== "object") return; - const raw = (rawParsed as Record).hostname; - if (raw !== undefined && validated.hostname === undefined) { - console.warn(`⚠️ config.json hostname ${JSON.stringify(raw)} is not a usable bind address — falling back to 127.0.0.1`); - } -} - -function degradedListenerWarnings(rawParsed: unknown, validated: OcxConfig): string[] { - const raw = rawConfigRecord(rawParsed); - if (!raw) return []; - const warnings: string[] = []; - if (raw.unauthenticatedLoopbackListener !== undefined && validated.unauthenticatedLoopbackListener === undefined) { - warnings.push("unauthenticatedLoopbackListener ignored: invalid listener configuration; repair config.json before enabling the listener"); - } - const hub = rawConfigRecord(raw.hub); - if (hub?.managementIngress !== undefined && !managementIngressSchema.safeParse(hub.managementIngress).success) { - warnings.push("hub.managementIngress ignored: invalid management listener configuration; repair config.json before enabling the listener"); - } - return warnings; -} - -function warnDegradedListeners(rawParsed: unknown, validated: OcxConfig): void { - for (const warning of degradedListenerWarnings(rawParsed, validated)) { - console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); - } -} - -/** - * Companion to {@link warnDegradedStreamMode} for a malformed selection-order map. - * Priority is a preference, so the schema drops the whole map rather than failing - * the parse — say so once, otherwise the pool silently reverts to flat ordering. - */ -function degradedCodexAccountPriorityWarnings(rawParsed: unknown, validated: OcxConfig): string[] { - const record = rawConfigRecord(rawParsed); - const warnings: string[] = []; - // The pin degrades silently otherwise, which reads as the manual selection simply - // not having survived the restart. - if (record?.activeCodexAccountPinned !== undefined && validated.activeCodexAccountPinned === undefined) { - warnings.push("activeCodexAccountPinned is not a valid account id — the manually selected account is no longer pinned"); - } - const raw = record?.codexAccountPriorities; - if (raw !== undefined && validated.codexAccountPriorities === undefined) { - warnings.push("codexAccountPriorities is invalid (expected account ids mapped to integers between -100 and 100) — account selection order is disabled"); - } - return warnings; -} - -function warnDegradedCodexAccountPriorities(rawParsed: unknown, validated: OcxConfig): void { - for (const warning of degradedCodexAccountPriorityWarnings(rawParsed, validated)) { - console.warn(`⚠️ config.json ${warning}`); - } -} - -function degradedCodexQuotaAutoRefreshWarning(rawParsed: unknown, validated: OcxConfig): string | null { - const raw = rawConfigRecord(rawParsed)?.codexQuotaAutoRefresh; - if (raw === undefined || validated.codexQuotaAutoRefresh !== undefined) return null; - return "codexQuotaAutoRefresh is invalid — automatic quota-window activation is disabled"; -} - -function warnDegradedCodexQuotaAutoRefresh(rawParsed: unknown, validated: OcxConfig): void { - const warning = degradedCodexQuotaAutoRefreshWarning(rawParsed, validated); - if (warning) console.warn(`⚠️ config.json ${warning}`); -} - -/** - * Companion to the degrade warnings above, for a malformed or ambiguous declared - * grouping. The list now degrades on its own so the rest of `pool` survives, which is - * also why it needs a voice: nothing else about the config looks different afterwards, - * and silently ungrouped credentials read as capacity the pool does not have. - */ -function degradedCredentialGroupsWarning(rawParsed: unknown): string | null { - const pool = rawConfigRecord(rawConfigRecord(rawParsed)?.pool); - if (!pool || pool.credentialGroups === undefined) return null; - const parsed = credentialGroupsSchema.safeParse(pool.credentialGroups); - if (parsed.success) return null; - // Every issue message is redacted before it is joined. The custom messages embed the - // offending member through `JSON.stringify`, so a malformed credential string that - // happens to carry secret material would otherwise be printed verbatim at config load - // — a config file is exactly where a pasted token ends up in the wrong field. - const details = parsed.error.issues.map(issue => redactSecretString(issue.message)).join("; "); - return `pool.credentialGroups is invalid (${details}) — declared quota grouping is disabled; other pool settings were preserved`; -} - -function warnDegradedCredentialGroups(rawParsed: unknown): void { - const warning = degradedCredentialGroupsWarning(rawParsed); - if (warning) console.warn(`⚠️ config.json ${warning}`); -} - -/** - * The apiKeys schema salvages entry by entry rather than failing the parse, so a - * dropped key is otherwise invisible — and it will not be re-saved by the next - * mutation. Say so out loud. Compares the raw array against the validated one, - * the same shape as the degrade warnings above. - */ -/** One definition of "usable secret", shared by the schema and the warnings. */ -function isUsableApiKeySecret(value: unknown): value is string { - return typeof value === "string" && value.length > 0 && value === value.trim(); -} - -/** - * Give every salvaged key a stable, targetable id. - * - * Pure and deterministic on purpose. Two earlier spellings were wrong: minting a - * UUID inside the schema transform handed out a different id on every parse, and - * repairing-then-writing during `loadConfig` put a file write on the read path, - * where it could clobber a concurrent legitimate save with a stale snapshot. - * - * So the replacement id is derived from the entry's position, which is already - * how the file orders these rows: same file in, same ids out, no I/O and no - * randomness. It is not derived from the secret — a public identifier should - * never be a function of key material. - */ -function normalizeApiKeyIds(config: OcxConfig): OcxConfig { - const keys = config.apiKeys; - if (!keys?.length) return config; - // Reserve every explicit id BEFORE synthesizing any, or a synthetic - // `salvaged-1` assigned to row 1 would push a row that legitimately owns that - // id onto `salvaged-2`. An id the user already has is the one thing this - // repair must never take away. - const reserved = new Set(); - for (const entry of keys) { - if (entry.id) reserved.add(entry.id); - } - const taken = new Set(reserved); - const kept = new Set(); - keys.forEach((entry, index) => { - // The first row holding an explicit id keeps it; later collisions are the - // ones that move. - if (entry.id && !kept.has(entry.id)) { - kept.add(entry.id); - return; - } - let candidate = `salvaged-${index + 1}`; - let suffix = 1; - while (taken.has(candidate)) candidate = `salvaged-${index + 1}-${++suffix}`; - entry.id = candidate; - taken.add(candidate); - kept.add(candidate); - }); - return config; -} - -function warnDegradedApiKeys(rawParsed: unknown, validated: OcxConfig): void { - if (!rawParsed || typeof rawParsed !== "object") return; - const raw = (rawParsed as Record).apiKeys; - if (raw === undefined) return; - if (!Array.isArray(raw)) { - console.warn(`⚠️ config.json apiKeys is not an array — ignoring it; generate a new key from the API tab`); - return; - } - const dropped = raw.length - (validated.apiKeys?.length ?? 0); - if (dropped > 0) { - console.warn(`⚠️ config.json apiKeys: skipped ${dropped} malformed entr${dropped === 1 ? "y" : "ies"} — the remaining keys still work`); - } - // Same-length repairs are invisible to the count above, and they are the ones - // that show up as a blank name or an unknown date in the dashboard. Say so. - const repaired = raw.filter(row => { - if (!row || typeof row !== "object") return false; - const entry = row as Record; - // Must match the schema exactly: a row whose key is unusable was DROPPED, and - // saying "the key still works" about it would be a lie. - if (!isUsableApiKeySecret(entry.key)) return false; - return typeof entry.id !== "string" || !entry.id - || typeof entry.name !== "string" - || typeof entry.createdAt !== "string"; - }).length; - if (repaired > 0) { - console.warn(`⚠️ config.json apiKeys: repaired metadata on ${repaired} entr${repaired === 1 ? "y" : "ies"} — the key still works, but its name or date may read as unknown`); - } - // A duplicate id is repaired too, and it is not visible in either count above. - const ids = raw.filter(row => row && typeof row === "object" && isUsableApiKeySecret((row as Record).key)) - .map(row => (row as Record).id) - .filter((id): id is string => typeof id === "string" && !!id); - const duplicates = ids.length - new Set(ids).size; - if (duplicates > 0) { - console.warn(`⚠️ config.json apiKeys: ${duplicates} entr${duplicates === 1 ? "y" : "ies"} shared an id — reassigned so each key can be renamed and revoked on its own`); - } -} - -const CLAUDE_SUBAGENT_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; - -function isClaudeSubagentEffort(value: unknown): value is NonNullable { - return typeof value === "string" && CLAUDE_SUBAGENT_EFFORTS.includes(value as typeof CLAUDE_SUBAGENT_EFFORTS[number]); -} - -function rawClaudeSubagentEffort(rawParsed: unknown): unknown { - const raw = rawConfigRecord(rawParsed); - const claudeCode = raw?.claudeCode; - if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) return undefined; - return (claudeCode as Record).subagentEffort; -} - -function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["claudeCode"] { - if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) { - return claudeCode as OcxConfig["claudeCode"]; - } - const normalized = { ...claudeCode } as Record; - if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) { - delete normalized.subagentEffort; - } - // A hand-authored config never passes through the management validator, so coerce here too. - // A malformed classifierFallbacks (a bare string, or an array with non-string entries) would - // otherwise reach the resolver unchecked. - if (Object.hasOwn(normalized, "classifierModel")) { - const value = typeof normalized.classifierModel === "string" ? normalized.classifierModel.trim() : ""; - if (value.length > 0) normalized.classifierModel = value; - else delete normalized.classifierModel; - } - if (Object.hasOwn(normalized, "classifierFallbacks")) { - const raw = normalized.classifierFallbacks; - const kept = Array.isArray(raw) - ? raw.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map(entry => entry.trim()) - : []; - if (kept.length > 0) normalized.classifierFallbacks = kept; - else delete normalized.classifierFallbacks; - } - const desktopProfile = normalized.desktopProfile; - if (desktopProfile && typeof desktopProfile === "object" && !Array.isArray(desktopProfile)) { - const profile = { ...desktopProfile } as Record; - if (typeof profile.appliedFingerprint !== "string") delete profile.appliedFingerprint; - if (typeof profile.appliedAt !== "string") delete profile.appliedAt; - normalized.desktopProfile = profile; - } - return normalized as OcxConfig["claudeCode"]; -} - -function normalizeClaudeSubagentEffort(config: OcxConfig, _rawParsed: unknown): OcxConfig { - // Unconditional. This used to short-circuit when `subagentEffort` was absent or already valid, - // which meant a config whose ONLY defect was elsewhere in `claudeCode` was never normalized. - // The specialized subagentEffort WARNING is a separate concern and stays exactly as it is. - if (!config.claudeCode) return config; - return { ...config, claudeCode: normalizePersistedClaudeCode(config.claudeCode) }; -} - -function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void { - const rawEffort = rawClaudeSubagentEffort(rawParsed); - if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) { - console.warn(`⚠️ config.json claudeCode.subagentEffort is invalid (expected ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}) — ignoring it. Other settings were preserved.`); - } -} - -function malformedUpstreamHostCircuitThresholdWarning(rawParsed: unknown): string | null { - const raw = rawConfigRecord(rawParsed); - if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null; - const threshold = raw.upstreamHostCircuitThreshold; - if (threshold === undefined) return null; - if (typeof threshold === "number" - && Number.isInteger(threshold) - && threshold >= 0 - && threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null; - return `upstreamHostCircuitThreshold ignored: expected an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`; -} - -function warnDegradedUpstreamHostCircuitThreshold(rawParsed: unknown): void { - const warning = malformedUpstreamHostCircuitThresholdWarning(rawParsed); - if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); -} - -function malformedPlaintextV2AgentMessagesWarning(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || raw.plaintextV2AgentMessages === undefined || typeof raw.plaintextV2AgentMessages === "boolean") return null; - return "plaintextV2AgentMessages ignored: expected a boolean"; -} - -function warnDegradedPlaintextV2AgentMessages(value: unknown): void { - const warning = malformedPlaintextV2AgentMessagesWarning(value); - if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); -} - -function malformedAgentTaskRecoveryWarning(rawParsed: unknown): string | null { - const raw = rawConfigRecord(rawParsed); - if (!raw || !Object.hasOwn(raw, "agentTaskRecovery")) return null; - const result = agentTaskRecoverySchema.safeParse(raw.agentTaskRecovery); - if (result.success) return null; - const field = result.error.issues[0]?.path.join("."); - return `agentTaskRecovery${field ? `.${field}` : ""} ignored: invalid experimental recovery configuration`; -} - -function warnDegradedAgentTaskRecovery(rawParsed: unknown): void { - const warning = malformedAgentTaskRecoveryWarning(rawParsed); - if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); -} - -function malformedRuntimeRoleWarning(rawParsed: unknown): string | null { - const raw = rawConfigRecord(rawParsed); - if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null; - if (runtimeRoleSchema.safeParse(raw.runtimeRole).success) return null; - return 'runtimeRole ignored: expected "standalone", "hub", or "client"; falling back to "standalone"'; -} - -function warnDegradedRuntimeRole(rawParsed: unknown): void { - const warning = malformedRuntimeRoleWarning(rawParsed); - if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); -} - -function malformedOptionalRemoteBlockWarning( - rawParsed: unknown, - key: "hub" | "remoteGui", -): string | null { - const raw = rawConfigRecord(rawParsed); - if (!raw || !Object.hasOwn(raw, key) || raw[key] === undefined) return null; - const schema = key === "hub" ? hubConfigSchema : remoteGuiConfigSchema; - const result = schema.safeParse(raw[key]); - if (result.success) return null; - const field = result.error.issues[0]?.path.join("."); - return `${key}${field ? `.${field}` : ""} ignored: invalid remote GUI configuration`; -} - -function malformedClientConnectionWarning(rawParsed: unknown): string | null { - const raw = rawConfigRecord(rawParsed); - if (!raw || !Object.hasOwn(raw, "client") || raw.client === undefined) return null; - const result = clientConnectionSchema.safeParse(raw.client); - if (result.success) return null; - const field = result.error.issues[0]?.path.join("."); - return `client${field ? `.${field}` : ""} invalid: remote client mode is disabled until config.json is repaired`; -} - -function warnDegradedOptionalRemoteBlocks(rawParsed: unknown): void { - for (const key of ["hub", "remoteGui"] as const) { - const warning = malformedOptionalRemoteBlockWarning(rawParsed, key); - if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); - } -} - -function malformedQuotaResetNotifyWarning(rawParsed: unknown): string | null { - const raw = rawConfigRecord(rawParsed); - if (!raw || !Object.hasOwn(raw, "quotaResetNotify")) return null; - const result = quotaResetNotifySchema.safeParse(raw.quotaResetNotify); - if (result.success) return null; - const field = result.error.issues[0]?.path.join("."); - return `quotaResetNotify${field ? `.${field}` : ""} ignored: invalid quota-reset notification configuration`; -} - -function malformedCatalogAutoRefreshWarning(rawParsed: unknown): string | null { - const raw = rawConfigRecord(rawParsed); - if (!raw || !Object.hasOwn(raw, "catalogAutoRefresh")) return null; - const result = catalogAutoRefreshSchema.safeParse(raw.catalogAutoRefresh); - if (result.success) return null; - const field = result.error.issues[0]?.path.join("."); - return `catalogAutoRefresh${field ? `.${field}` : ""} ignored: invalid catalog auto-refresh configuration`; -} - -/** - * Same silent-in-the-wrong-direction failure as the notification block: a dropped pool policy means - * the accounts the operator meant to exclude keep taking traffic, and the only visible symptom is - * traffic going somewhere it was supposed to stop going. - */ -function malformedCodexPoolWarning(rawParsed: unknown): string | null { - const raw = rawConfigRecord(rawParsed); - if (!raw || !Object.hasOwn(raw, "codexPool")) return null; - const result = codexPoolSchema.safeParse(raw.codexPool); - if (result.success) return null; - const field = result.error.issues[0]?.path.join("."); - return `codexPool${field ? `.${field}` : ""} ignored: invalid Codex pool selection policy`; -} - -/** - * Warn once per load that the section was dropped. - * - * This matters more than a usual degradation notice: the failure is SILENT in the direction - * that hurts. A dropped section means notifications are off, so the operator sees nothing — - * which is exactly what they would see if the feature were working and no reset had happened. - */ -function warnDegradedQuotaResetNotify(rawParsed: unknown): void { - const warning = malformedQuotaResetNotifyWarning(rawParsed); - if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); -} - -/** - * Warn once per load that the section was dropped. - * - * Same silent-in-the-wrong-direction failure as the notification block: a dropped section - * means the scheduler never starts, so the operator sees a stale catalog — which is exactly - * what they would see if the feature were working and no new models had shipped. - */ -function warnDegradedCatalogAutoRefresh(rawParsed: unknown): void { - const warning = malformedCatalogAutoRefreshWarning(rawParsed); - if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); -} - -/** - * Warn once per load that the pool policy was dropped. - * - * `.catch(undefined)` turns a malformed policy into a SUCCESSFUL parse, so without this the proxy - * starts, rotates onto the accounts the operator meant to exclude, and prints nothing. The visible - * symptom would be traffic going exactly where it was told not to go. - */ -function warnDegradedCodexPool(rawParsed: unknown): void { - const warning = malformedCodexPoolWarning(rawParsed); - if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); -} - -type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"; - -function rawConfigRecord(rawParsed: unknown): Record | null { - return rawParsed !== null && typeof rawParsed === "object" && !Array.isArray(rawParsed) - ? rawParsed as Record - : null; -} - -function malformedNativeSubagentFields(rawParsed: unknown): NativeSubagentPersistedField[] { - const raw = rawConfigRecord(rawParsed); - if (!raw) return []; - const malformed: NativeSubagentPersistedField[] = []; - if (Object.hasOwn(raw, "injectionModel") && typeof raw.injectionModel !== "string") { - malformed.push("injectionModel"); - } - if (Object.hasOwn(raw, "injectionEffort") && typeof raw.injectionEffort !== "string") { - malformed.push("injectionEffort"); - } - if (Object.hasOwn(raw, "syncCodexSubagentDefaults") && typeof raw.syncCodexSubagentDefaults !== "boolean") { - malformed.push("syncCodexSubagentDefaults"); - } - return malformed; -} - -function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField): string { - const expected = field === "syncCodexSubagentDefaults" ? "a boolean" : "a string"; - return `${field} ignored: expected ${expected}`; -} - -function malformedCodexAccountPickerWarning(rawParsed: unknown): string | null { - const raw = rawConfigRecord(rawParsed); - if (!raw || !Object.hasOwn(raw, "codexAccountPickerEnabled")) return null; - if (typeof raw.codexAccountPickerEnabled === "boolean") return null; - return "codexAccountPickerEnabled ignored: expected a boolean"; -} - -function warnDegradedCodexAccountPicker(rawParsed: unknown): void { - const warning = malformedCodexAccountPickerWarning(rawParsed); - if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); -} - -function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null { - if (config.syncCodexSubagentDefaults !== true) return null; - const malformed = malformedNativeSubagentFields(rawParsed); - if (malformed.includes("injectionModel")) return "injectionModel must be a string"; - if (!config.injectionModel?.trim()) return "a nonblank injectionModel is required"; - if (malformed.includes("injectionEffort")) return "injectionEffort must be a string or omitted"; - if (config.injectionEffort !== undefined && !isCodexReasoningEffort(config.injectionEffort)) { - return "injectionEffort must be a supported Codex reasoning effort"; - } - return null; -} - -function normalizeNativeSubagentSync(config: OcxConfig, rawParsed?: unknown): OcxConfig { - if (!nativeSubagentSyncDisabledReason(config, rawParsed)) return config; - const normalized = { ...config }; - delete normalized.syncCodexSubagentDefaults; - return normalized; -} - -function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig): void { - for (const field of malformedNativeSubagentFields(rawParsed)) { - console.warn(`⚠️ config.json ${malformedNativeSubagentFieldWarning(field)}. Other settings were preserved.`); - } - const reason = nativeSubagentSyncDisabledReason(config, rawParsed); - if (reason) { - console.warn(`⚠️ config.json syncCodexSubagentDefaults was disabled: ${reason}. Other settings were preserved.`); - } -} - -/** - * Registry metadata can gain service-tier capability after a config was written. An explicit - * `fastWire: null` remains authoritative on load and on whole-document writes; rejecting either - * would discard or lock access to unrelated providers and API keys. Direct contradictions within - * one provider row remain schema errors through the outer config refinement, where the dynamic - * provider name can be redacted before it reaches diagnostics. - */ -function inheritedFastWireConflictProviderNames( - config: Pick, -): string[] { - const conflicts: string[] = []; - for (const [name, provider] of Object.entries(config.providers)) { - if (provider.fastWire !== null || provider.supportsServiceTier === false) continue; - const registry = providerMatchesRegistryTransport(name, provider) - ? getProviderRegistryEntry(name) - : undefined; - if (!registry) continue; - const effectiveProviderCapability = provider.supportsServiceTier ?? registry.supportsServiceTier; - const effectiveModelCapabilities = { - ...(registryModelServiceTierCapabilityApplies(registry, provider) - ? registry.modelSupportsServiceTier ?? {} - : {}), - ...(provider.modelSupportsServiceTier ?? {}), - }; - if ( - effectiveProviderCapability === true - || Object.values(effectiveModelCapabilities).some(value => value === true) - ) { - conflicts.push(name); - } - } - return conflicts; -} - -function inheritedFastWireConflictWarning(name: string): string { - return `providers.${redactSecretString(name)}.fastWire=null overrides service-tier capability inherited from the matching registry entry`; -} - -function warnInheritedFastWireConflicts(configPath: string, config: OcxConfig): void { - const names = inheritedFastWireConflictProviderNames(config); - if (names.length === 0 || warnedInheritedFastWireConflicts.has(configPath)) return; - warnedInheritedFastWireConflicts.add(configPath); - console.warn( - `⚠️ config.json ${names.map(inheritedFastWireConflictWarning).join("; ")}. ` - + "The persisted providers and API keys were preserved.", - ); -} - -/** - * Load and validate config.json into an OcxConfig. Missing files reset to - * defaults and clear stale overlays. Broken existing files also fall back to - * default routing (after backup), but keep the last-good cost-overlay registry - * until a valid config or a genuinely missing file is observed. A partially- - * invalid config is merged with defaults so providers and pool accounts survive. - */ -export function loadConfig(): OcxConfig { - const dir = getConfigDir(); - const configPath = getConfigPath(); - hardenConfigDir(); - hardenExistingSecret(configPath); - hardenExistingSecret(join(dir, "auth.json")); - if (!existsSync(configPath)) { - return withRefreshedCostOverlays(getDefaultConfig()); - } - try { - const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); - const parsed = JSON.parse(raw); - sanitizeAliasesForLoad(parsed); - sanitizeReasoningPinsForLoad(parsed); - sanitizeModelDisplayNamesForLoad(parsed); - sanitizeAutoReviewForLoad(parsed); - sanitizeRetryOn429ForLoad(parsed); - sanitizeModelCostsForLoad(parsed); - sanitizeCapabilityDeclarationsForLoad(parsed); - const result = configSchema.safeParse(parsed); - if (result.success) { - const config = normalizeApiKeyIds(result.data as OcxConfig); - warnInheritedFastWireConflicts(configPath, config); - warnDegradedStreamMode(parsed, config); - warnDegradedHostname(parsed, config); - warnDegradedListeners(parsed, config); - warnDegradedApiKeys(parsed, config); - warnDegradedCodexAccountPriorities(parsed, config); - warnDegradedCodexQuotaAutoRefresh(parsed, config); - warnDegradedClaudeSubagentEffort(parsed); - warnDegradedNativeSubagentConfig(parsed, config); - warnDegradedCodexAccountPicker(parsed); - warnDegradedUpstreamHostCircuitThreshold(parsed); - warnDegradedPlaintextV2AgentMessages(parsed); - warnDegradedAgentTaskRecovery(parsed); - warnDegradedRuntimeRole(parsed); - warnDegradedOptionalRemoteBlocks(parsed); - warnDegradedQuotaResetNotify(parsed); - warnDegradedCatalogAutoRefresh(parsed); - warnDegradedCodexPool(parsed); - warnDegradedCredentialGroups(parsed); - return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); - } - // Schema validation failed — merge defaults into the raw object instead of - // discarding it entirely, so pool accounts and providers survive a missing - // field like defaultProvider. - const defaults = getDefaultConfig(); - // Pin the keys whose ABSENCE is meaningful. Spreading defaults underneath means any - // key the stored document lacks is inherited, which is right for additive defaults and - // wrong for a behavioral mode: a config that reaches this path only because it lost - // `defaultProvider` would be repaired into v1 sub-agents and a pre-answered advisory, - // silently changing a setting its operator never touched. - const merged = { - ...defaults, - ...parsed, - subagentModelsVersion: parsed.subagentModelsVersion, - multiAgentMode: parsed.multiAgentMode, - multiAgentSurfaceAdvisoryVersion: parsed.multiAgentSurfaceAdvisoryVersion, - }; - // Ensure providers from both sides survive - if (parsed.providers && defaults.providers) { - merged.providers = { ...defaults.providers, ...parsed.providers }; - } - const retryResult = configSchema.safeParse(merged); - if (retryResult.success) { - warnConfigRepaired(configPath, result.error); - const config = normalizeApiKeyIds(retryResult.data as OcxConfig); - warnInheritedFastWireConflicts(configPath, config); - warnDegradedHostname(parsed, config); - warnDegradedListeners(parsed, config); - warnDegradedApiKeys(parsed, config); - warnDegradedCodexAccountPriorities(parsed, config); - warnDegradedCodexQuotaAutoRefresh(parsed, config); - warnDegradedClaudeSubagentEffort(parsed); - warnDegradedNativeSubagentConfig(parsed, config); - warnDegradedCodexAccountPicker(parsed); - warnDegradedUpstreamHostCircuitThreshold(parsed); - warnDegradedPlaintextV2AgentMessages(parsed); - warnDegradedAgentTaskRecovery(parsed); - warnDegradedRuntimeRole(parsed); - warnDegradedOptionalRemoteBlocks(parsed); - warnDegradedQuotaResetNotify(parsed); - warnDegradedCatalogAutoRefresh(parsed); - warnDegradedCodexPool(parsed); - warnDegradedCredentialGroups(parsed); - return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); - } - // Still failing, but if every complaint is about one or more named entries - // in an independent section, drop exactly those and keep the rest. Falling - // back to defaults here would silently retire the operator's providers, - // keys and prices over a mistake in one routing profile. - const salvaged = salvageConfigCandidate(merged, retryResult.error); - if (salvaged) { - { - warnDroppedConfigSections(configPath, salvaged.dropped, salvaged.issues); - const config = normalizeApiKeyIds(salvaged.parsed); - warnInheritedFastWireConflicts(configPath, config); - warnDegradedHostname(parsed, config); - warnDegradedListeners(parsed, config); - warnDegradedApiKeys(parsed, config); - warnDegradedCodexAccountPriorities(parsed, config); - warnDegradedCodexQuotaAutoRefresh(parsed, config); - warnDegradedClaudeSubagentEffort(parsed); - warnDegradedNativeSubagentConfig(parsed, config); - warnDegradedCodexAccountPicker(parsed); - warnDegradedUpstreamHostCircuitThreshold(parsed); - warnDegradedPlaintextV2AgentMessages(parsed); - warnDegradedAgentTaskRecovery(parsed); - warnDegradedRuntimeRole(parsed); - warnDegradedOptionalRemoteBlocks(parsed); - warnDegradedQuotaResetNotify(parsed); - warnDegradedCatalogAutoRefresh(parsed); - warnDegradedCodexPool(parsed); - warnDegradedCredentialGroups(parsed); - return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); - } - } - // Merge couldn't fix it — truly broken config - warnAndBackupInvalidConfig(configPath, result.error); - return getDefaultConfig(); - } catch (error) { - warnAndBackupInvalidConfig(configPath, error); - return getDefaultConfig(); - } -} - -/** Hand-edited alias mistakes disable only the bad alias; providers and routing survive. */ -function sanitizeAliasesForLoad(raw: unknown): void { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return; - const root = raw as Record; - if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return; - const providers = root.providers as Record>; - const providerNames = new Set(Object.keys(providers).map(name => name.toLowerCase())); - const claimedProviders = new Set(); - const comboAliases = new Set(Object.values((root.combos as Record | undefined) ?? {}) - .map(combo => typeof combo?.alias === "string" ? combo.alias.toLowerCase() : "").filter(Boolean)); - const accountNamespaces = new Set(Object.keys((root.codexAccountNamespaces as Record | undefined) ?? {}).map(name => name.toLowerCase())); - for (const provider of Object.values(providers)) { - const alias = provider.alias; - if (typeof alias !== "string" || !isValidProviderName(alias) - || providerNames.has(alias.toLowerCase()) || claimedProviders.has(alias.toLowerCase()) - || comboAliases.has(alias.toLowerCase()) || accountNamespaces.has(alias.toLowerCase())) { - if (alias !== undefined) console.warn("Ignoring invalid or colliding provider alias in config.json"); - delete provider.alias; - } else claimedProviders.add(alias.toLowerCase()); - if (!provider.modelAliases || typeof provider.modelAliases !== "object" || Array.isArray(provider.modelAliases)) { - if (provider.modelAliases !== undefined) delete provider.modelAliases; - continue; - } - const aliases = provider.modelAliases as Record; - const nativeIds = new Set((Array.isArray(provider.models) ? provider.models : []).filter((id): id is string => typeof id === "string").map(id => id.toLowerCase())); - const claimed = new Set(); - for (const [id, value] of Object.entries(aliases)) { - const lower = typeof value === "string" ? value.toLowerCase() : ""; - if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value) || claimed.has(lower) - || nativeIds.has(lower) || comboAliases.has(lower) || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) { - console.warn(`Ignoring invalid or colliding model alias for ${id} in config.json`); - delete aliases[id]; - } else claimed.add(lower); - } - } -} - -/** Hand-edited display-name mistakes disable only the bad label. */ -function sanitizeModelDisplayNamesForLoad(raw: unknown): void { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return; - const root = raw as Record; - if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return; - for (const [providerName, providerValue] of Object.entries(root.providers as Record)) { - if (!providerValue || typeof providerValue !== "object" || Array.isArray(providerValue)) continue; - const provider = providerValue as Record; - const value = provider.modelDisplayNames; - if (value === undefined) continue; - const providerLabel = JSON.stringify(redactSecretString(providerName)); - if (!value || typeof value !== "object" || Array.isArray(value) - || Object.entries(value).length > MODEL_DISCOVERY_MAX_MODELS) { - console.warn(`Ignoring invalid modelDisplayNames map for provider ${providerLabel} in config.json`); - delete provider.modelDisplayNames; - continue; - } - const labels = value as Record; - for (const [modelId, rawDisplayName] of Object.entries(labels)) { - const displayName = typeof rawDisplayName === "string" ? rawDisplayName.trim() : rawDisplayName; - if (modelDisplayNamesConfigError({ [modelId]: displayName })) { - const safeModelId = JSON.stringify(redactSecretString(modelId)); - console.warn(`Ignoring invalid modelDisplayNames entry ${safeModelId} for provider ${providerLabel} in config.json`); - delete labels[modelId]; - } else { - labels[modelId] = displayName; - } - } - if (Object.keys(labels).length === 0) delete provider.modelDisplayNames; - } -} - -/** Refresh the user cost-overlay registry from `config` and return it unchanged. */ -function withRefreshedCostOverlays(config: OcxConfig): OcxConfig { - refreshUserCostOverlays(config); - return config; -} - -export type ConfigDiagnostics = { - config: OcxConfig; - source: "default" | "file" | "fallback"; - error: string | null; - /** Non-fatal config concerns; absent when there are no warnings. */ - warnings?: string[]; -}; - -type ConfigFileSnapshot = { - diagnostics: ConfigDiagnostics; - /** Exact file contents, including a possible BOM, used as the optimistic revision. */ - raw?: string; -}; - -function configPlaceholderWarnings(config: OcxConfig): string[] { - const warnings: string[] = []; - for (const [name, provider] of Object.entries(config.providers)) { - const placeholder = provider.baseUrl.match(/\{[^}]*\}/)?.[0]; - if (placeholder) { - warnings.push(`providers.${name}.baseUrl contains unresolved ${placeholder}; set the real provider URL`); - } - } - return warnings; -} - -function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): ConfigDiagnostics { - // Unsafe hand-edited optional values are disabled in memory instead of rejecting - // the entire config, which would hide unrelated providers/accounts. The next - // ordinary save persists the normalized absence. - const syncDisabledReason = nativeSubagentSyncDisabledReason(config, rawParsed); - const rawEffort = rawClaudeSubagentEffort(rawParsed); - const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed); - const warnings = configPlaceholderWarnings(normalized); - warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning)); - warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized)); - warnings.push(...degradedListenerWarnings(rawParsed, normalized)); - const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized); - if (quotaAutoRefreshWarning) warnings.push(quotaAutoRefreshWarning); - if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) { - warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`); - } - warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning)); - const pickerWarning = malformedCodexAccountPickerWarning(rawParsed); - if (pickerWarning) warnings.push(pickerWarning); - const hostCircuitWarning = malformedUpstreamHostCircuitThresholdWarning(rawParsed); - if (hostCircuitWarning) warnings.push(hostCircuitWarning); - const recoveryWarning = malformedAgentTaskRecoveryWarning(rawParsed); - if (recoveryWarning) warnings.push(recoveryWarning); - const runtimeRoleWarning = malformedRuntimeRoleWarning(rawParsed); - if (runtimeRoleWarning) warnings.push(runtimeRoleWarning); - const hubWarning = malformedOptionalRemoteBlockWarning(rawParsed, "hub"); - if (hubWarning) warnings.push(hubWarning); - const remoteGuiWarning = malformedOptionalRemoteBlockWarning(rawParsed, "remoteGui"); - if (remoteGuiWarning) warnings.push(remoteGuiWarning); - const clientWarning = malformedClientConnectionWarning(rawParsed); - if (clientWarning) warnings.push(clientWarning); - const notifyWarning = malformedQuotaResetNotifyWarning(rawParsed); - if (notifyWarning) warnings.push(notifyWarning); - const catalogRefreshWarning = malformedCatalogAutoRefreshWarning(rawParsed); - if (catalogRefreshWarning) warnings.push(catalogRefreshWarning); - const codexPoolWarning = malformedCodexPoolWarning(rawParsed); - if (codexPoolWarning) warnings.push(codexPoolWarning); - const plaintextWarning = malformedPlaintextV2AgentMessagesWarning(rawParsed); - if (plaintextWarning) warnings.push(plaintextWarning); - if (syncDisabledReason) { - warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`); - } - return { - config: normalized, - source: "file", - error: null, - ...(warnings.length > 0 ? { warnings } : {}), - }; -} - -export function subagentDefaultSyncEffective( - config: Pick, -): boolean { - return config.syncCodexSubagentDefaults === true && Boolean(config.injectionModel?.trim()); -} - -function mergeConfigDefaults(parsed: unknown): unknown { - if (!parsed || typeof parsed !== "object") return parsed; - const defaults = getDefaultConfig(); - const raw = parsed as Record; - // Same absence-is-meaningful pin as the repair merge above. - const merged: Record = { - ...defaults, - ...raw, - subagentModelsVersion: raw.subagentModelsVersion, - multiAgentMode: raw.multiAgentMode, - multiAgentSurfaceAdvisoryVersion: raw.multiAgentSurfaceAdvisoryVersion, - }; - if (raw.providers && typeof raw.providers === "object" && defaults.providers) { - merged.providers = { ...defaults.providers, ...(raw.providers as Record) }; - } - return merged; -} - -function schemaDiagnosticsError(error: z.ZodError): string { - const details = error.issues.map(issue => { - const path = issue.path.join(".") || "config"; - return `${path}: ${issue.message}`; - }); - return details.length > 0 ? `schema_invalid: ${details.join("; ")}` : "schema_invalid"; -} - -/** - * Reject a hostname the schema deliberately degrades on read. Load-time has to keep a - * blank value non-fatal (see the `hostname` field comment), but an incoming write is a - * live caller who can be told the value is wrong — silently rewriting it to loopback - * would look like the bind succeeded on the address they asked for. - */ -function blankHostnameError(value: unknown): string | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const hostname = (value as Record).hostname; - if (hostname === undefined) return null; - if (typeof hostname !== "string" || !hostname.trim()) { - return "schema_invalid: hostname: must be a nonblank bind address"; - } - return null; -} - -function claudeSubagentEffortError(value: unknown): string | null { - const effort = rawClaudeSubagentEffort(value); - if (effort === undefined || isClaudeSubagentEffort(effort)) return null; - return `schema_invalid: claudeCode.subagentEffort: must be one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`; -} - -function appOwnedMemoryBudgetError(value: unknown): string | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const budget = (value as Record).appOwnedMemoryBudgetMb; - if (budget === undefined) return null; - if (typeof budget !== "number" || !Number.isInteger(budget) - || budget < MIN_APP_OWNED_MEMORY_BUDGET_MB || budget > MAX_APP_OWNED_MEMORY_BUDGET_MB) { - return `schema_invalid: appOwnedMemoryBudgetMb: must be an integer from ${MIN_APP_OWNED_MEMORY_BUDGET_MB} to ${MAX_APP_OWNED_MEMORY_BUDGET_MB}`; - } - return null; -} - -function upstreamHostCircuitThresholdError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null; - const threshold = raw.upstreamHostCircuitThreshold; - if (threshold === undefined) return null; - if (typeof threshold === "number" - && Number.isInteger(threshold) - && threshold >= 0 - && threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null; - return `schema_invalid: upstreamHostCircuitThreshold: must be an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`; -} - -function plaintextV2AgentMessagesError(value: unknown): string | null { - return malformedPlaintextV2AgentMessagesWarning(value) - ? "schema_invalid: plaintextV2AgentMessages: must be a boolean or omitted" - : null; -} - -function agentTaskRecoveryError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "agentTaskRecovery") || raw.agentTaskRecovery === undefined) return null; - const result = agentTaskRecoverySchema.safeParse(raw.agentTaskRecovery); - if (result.success) return null; - const issue = result.error.issues[0]; - const field = issue?.path.join("."); - return `schema_invalid: agentTaskRecovery${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; -} - -function runtimeRoleError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null; - if (runtimeRoleSchema.safeParse(raw.runtimeRole).success) return null; - return 'schema_invalid: runtimeRole: must be one of "standalone", "hub", or "client"'; -} - -function remoteGuiConfigError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw) return null; - for (const [key, schema] of [ - ["hub", hubConfigSchema], - ["remoteGui", remoteGuiConfigSchema], - ] as const) { - if (!Object.hasOwn(raw, key) || raw[key] === undefined) continue; - const result = schema.safeParse(raw[key]); - if (result.success) continue; - const issue = result.error.issues[0]; - const field = issue?.path.join("."); - return `schema_invalid: ${key}${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; - } - return null; -} - -function clientConnectionConfigError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "client") || raw.client === undefined) return null; - const result = clientConnectionSchema.safeParse(raw.client); - if (result.success) return null; - const issue = result.error.issues[0]; - const field = issue?.path.join("."); - return `schema_invalid: client${field ? `.${field}` : ""}: ${issue?.message ?? "invalid client connection"}`; -} - -function clientRolePairError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw) return null; - const hasClient = Object.hasOwn(raw, "client") && raw.client !== undefined; - if (raw.runtimeRole === "client" && !hasClient) { - return "schema_invalid: runtimeRole client requires a complete client connection"; - } - if (hasClient && raw.runtimeRole !== "client") { - return "schema_invalid: client connection requires runtimeRole client"; - } - return null; -} - -function quotaResetNotifyError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "quotaResetNotify") || raw.quotaResetNotify === undefined) return null; - const result = quotaResetNotifySchema.safeParse(raw.quotaResetNotify); - if (result.success) return null; - const issue = result.error.issues[0]; - const field = issue?.path.join("."); - return `schema_invalid: quotaResetNotify${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; -} - -function catalogAutoRefreshError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "catalogAutoRefresh") || raw.catalogAutoRefresh === undefined) return null; - const result = catalogAutoRefreshSchema.safeParse(raw.catalogAutoRefresh); - if (result.success) return null; - const issue = result.error.issues[0]; - const field = issue?.path.join("."); - return `schema_invalid: catalogAutoRefresh${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; -} - -/** - * The read path degrades a malformed pool policy to undefined, which for an exclusion policy means - * the excluded accounts quietly keep serving traffic. Reject it on write so `ocx config set` cannot - * create a policy that looks applied and is not. - */ -function codexPoolError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "codexPool") || raw.codexPool === undefined) return null; - const result = codexPoolSchema.safeParse(raw.codexPool); - if (result.success) return null; - const issue = result.error.issues[0]; - const field = issue?.path.join("."); - return `schema_invalid: codexPool${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; -} - -/** - * Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a - * malformed selection-order map to undefined, which on a write would drop every entry the - * user had accumulated and still report success. A load-time degrade leaves the raw map in - * the file to be repaired by hand; a degraded write erases it. One bad `ocx config set` - * must not cost the whole map, so a live caller is told instead. - */ -function codexAccountPrioritiesError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw) return null; - if (raw.codexAccountPriorities !== undefined) { - const parsed = codexAccountPrioritiesSchema.safeParse(raw.codexAccountPriorities); - if (!parsed.success) { - return schemaDiagnosticsError(parsed.error).replace("schema_invalid: ", "schema_invalid: codexAccountPriorities."); - } - } - // Tested as a string rather than coerced: `String(123)` matches the id pattern, so a - // coercing guard waves a non-string pin through to the schema, where `.catch(undefined)` - // drops it and reports the write as a success — the exact silent-degrade this guards. - const pin = raw.activeCodexAccountPinned; - if (pin !== undefined && (typeof pin !== "string" || !CODEX_ACCOUNT_PIN_PATTERN.test(pin))) { - return "schema_invalid: activeCodexAccountPinned: must be an account id"; - } - return null; -} - -/** - * Same reasoning as {@link codexAccountPrioritiesError}, plus one of its own. The read - * path drops an invalid grouping, so a degraded write would erase a declaration the - * operator is still editing and still report success. And an ambiguous declaration -- - * one id used twice, one credential in two groups -- has no safe silent answer at all: - * resolving it by list order would quietly merge two quota domains. A live caller is - * told which group is the problem instead. - */ -function poolCredentialGroupsError(value: unknown): string | null { - const pool = rawConfigRecord(rawConfigRecord(value)?.pool); - if (!pool || pool.credentialGroups === undefined) return null; - const parsed = credentialGroupsSchema.safeParse(pool.credentialGroups); - if (parsed.success) return null; - const details = parsed.error.issues.map(issue => { - const path = issue.path.join("."); - return path ? `${path}: ${issue.message}` : issue.message; - }).join("; "); - return `schema_invalid: pool.credentialGroups: ${details}`; -} - -function codexQuotaAutoRefreshError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || raw.codexQuotaAutoRefresh === undefined) return null; - const parsed = codexQuotaAutoRefreshSchema.safeParse(raw.codexQuotaAutoRefresh); - if (parsed.success) return null; - const details = parsed.error.issues.map(issue => { - const path = issue.path.join("."); - const message = path === "" - ? issue.message.replace(/^codexQuotaAutoRefresh\s*/, "") - : issue.message; - return `codexQuotaAutoRefresh${path ? `.${path}` : ""}: ${message}`; - }); - return `schema_invalid: ${details.join("; ")}`; -} - -function googleAntigravityStaticCatalogVersionError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "googleAntigravityStaticCatalogVersion")) return null; - const version = raw.googleAntigravityStaticCatalogVersion; - if (version === undefined || version === 1 || version === 2) return null; - return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1, 2, or omitted"; -} - -function codexAccountPickerEnabledError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw) return null; - const descriptor = Object.getOwnPropertyDescriptor(raw, "codexAccountPickerEnabled"); - if (!descriptor) { - return "codexAccountPickerEnabled" in raw - ? "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted" - : null; - } - if (!("value" in descriptor)) { - return "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted"; - } - const enabled = descriptor.value; - if (enabled === undefined || typeof enabled === "boolean") return null; - return "schema_invalid: codexAccountPickerEnabled: must be a boolean or omitted"; -} - -function emptyCompletionRetryError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "emptyCompletionRetry")) return null; - const enabled = raw.emptyCompletionRetry; - if (enabled === undefined || typeof enabled === "boolean") return null; - return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted"; -} - -function dropCodexSafetyBufferingError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "dropCodexSafetyBuffering")) return null; - const enabled = raw.dropCodexSafetyBuffering; - if (enabled === undefined || typeof enabled === "boolean") return null; - return "schema_invalid: dropCodexSafetyBuffering: must be a boolean or omitted"; -} - -function oauthOpenBrowserError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw || !Object.hasOwn(raw, "oauthOpenBrowser")) return null; - const enabled = raw.oauthOpenBrowser; - if (enabled === undefined || typeof enabled === "boolean") return null; - return "schema_invalid: oauthOpenBrowser: must be a boolean or omitted"; -} - -/** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */ -/** - * Reject a loopback-listener port that collides with the proxy port (#1102), and a port-less - * companion listener on a bind address that already owns 127.0.0.1 (#4236). - * - * The schema can only check the shape of each field on its own; the two ports being distinct — - * and the port-less form being compatible with `hostname` — are relationships between fields. - * Letting either through would surface as a startup failure after the public listener already - * bound, which reads like an unrelated port conflict. - * - * Both keys are read from the same candidate, so `ocx config set hostname 127.0.0.1` on a host - * whose listener is already the companion form is refused by this same check, with the same - * message, rather than breaking the next start. - * - * This is write-time only, matching `blankHostnameError`: a live caller can be told the value - * is wrong, whereas a hand-edited config on the read path degrades to undefined rather than - * resetting the whole file. `assertLoopbackListenerBindable` repeats the decision at startup so - * a hand edit that skipped this boundary fails with the same sentence instead of EADDRINUSE. - */ -function loopbackListenerPortError(value: unknown): string | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - const listener = (value as Record).unauthenticatedLoopbackListener; - if (listener === undefined) return null; - if (!listener || typeof listener !== "object" || Array.isArray(listener)) { - return "schema_invalid: unauthenticatedLoopbackListener: must be an object or omitted"; - } - const entry = listener as Record; - // `enabled` must be a real boolean. The schema's `.catch(undefined)` would otherwise DELETE - // a `"true"` string entry and report success, leaving an operator convinced they enabled an - // unauthenticated listener that is in fact off. Load-time still degrades quietly — a hand - // edit must not reset the file — but a live caller gets told. - if (typeof entry.enabled !== "boolean") { - return "schema_invalid: unauthenticatedLoopbackListener.enabled: must be a boolean"; - } - if (entry.enabled !== true) return null; - const hostname = typeof (value as Record).hostname === "string" - ? (value as Record).hostname as string - : undefined; - const proxyPort = (value as Record).port; - const listenerPort = entry.port; - // The companion form. `port` omitted means "same port as the public listener, on 127.0.0.1", - // which only exists as a free address when the public listener is bound somewhere else. - if (listenerPort === undefined) { - return loopbackCompanionBindError( - hostname, - typeof proxyPort === "number" ? proxyPort : 10100, - ); - } - if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) { - return "schema_invalid: unauthenticatedLoopbackListener.port: must be an integer port when enabled, or omitted to share the proxy port"; - } - if (typeof proxyPort === "number" && proxyPort === listenerPort) { - return "schema_invalid: unauthenticatedLoopbackListener.port: must differ from the proxy port"; - } - return null; -} - -/** - * The one sentence both the write boundary and startup use for an impossible companion bind. - * - * Exported so `startServer` can fail with the identical text: an operator who hand-edited the - * file past `validateConfigCandidate` must read the same diagnosis, not EADDRINUSE. - */ -export function loopbackCompanionBindError( - hostname: string | undefined, - proxyPort: number, -): string | null { - if (loopbackCompanionAllowed(hostname)) return null; - const bind = (hostname ?? "").trim() || "127.0.0.1"; - return "schema_invalid: unauthenticatedLoopbackListener: a port-less listener binds " - + `127.0.0.1:${proxyPort}, which the public listener on hostname "${bind}" already holds. ` - + "Either set a distinct unauthenticatedLoopbackListener.port, or remove the listener — a " - + "loopback bind already admits local callers without a credential."; -} - -/** - * Validate the hub management ingress at the live-write boundary. - * - * The persisted schema intentionally degrades a malformed hand edit to disabled so a typo in - * this opt-in listener cannot discard providers or credentials. A live config mutation must not - * get that leniency: it receives an exact field error before the degrading schema is applied. - */ -function managementIngressConfigError(value: unknown): string | null { - const raw = rawConfigRecord(value); - if (!raw) return null; - const hub = rawConfigRecord(raw.hub); - if (!hub || !Object.hasOwn(hub, "managementIngress") || hub.managementIngress === undefined) return null; - const ingress = rawConfigRecord(hub.managementIngress); - if (!ingress) { - return "schema_invalid: hub.managementIngress: must be an object or omitted"; - } - if (typeof ingress.enabled !== "boolean") { - return "schema_invalid: hub.managementIngress.enabled: must be a boolean"; - } - const keys = Object.keys(ingress); - if (ingress.enabled === false) { - return keys.length === 1 - ? null - : "schema_invalid: hub.managementIngress: disabled ingress accepts only enabled"; - } - if (keys.some(key => key !== "enabled" && key !== "port")) { - return "schema_invalid: hub.managementIngress: contains an unsupported field"; - } - const ingressPort = ingress.port; - if (typeof ingressPort !== "number" || !Number.isInteger(ingressPort) || ingressPort < 1 || ingressPort > 65535) { - return "schema_invalid: hub.managementIngress.port: must be an integer port when enabled"; - } - if (raw.runtimeRole !== "hub") { - return "schema_invalid: hub.managementIngress: enabled ingress requires runtimeRole hub"; - } - const proxyPort = typeof raw.port === "number" ? raw.port : 10100; - if (proxyPort === ingressPort) { - return "schema_invalid: hub.managementIngress.port: must differ from the proxy port"; - } - const loopback = rawConfigRecord(raw.unauthenticatedLoopbackListener); - if (loopback?.enabled === true && loopback.port === ingressPort) { - return "schema_invalid: hub.managementIngress.port: must differ from unauthenticatedLoopbackListener.port"; - } - return null; -} - -export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { - const boundaryError = configReasoningPinsConfigError(value) - ?? blankHostnameError(value) - ?? claudeSubagentEffortError(value) - ?? appOwnedMemoryBudgetError(value) - ?? upstreamHostCircuitThresholdError(value) - ?? plaintextV2AgentMessagesError(value) - ?? agentTaskRecoveryError(value) - ?? quotaResetNotifyError(value) - ?? catalogAutoRefreshError(value) - ?? codexPoolError(value) - ?? googleAntigravityStaticCatalogVersionError(value) - ?? codexAccountPrioritiesError(value) - ?? poolCredentialGroupsError(value) - ?? codexQuotaAutoRefreshError(value) - ?? codexAccountPickerEnabledError(value) - ?? emptyCompletionRetryError(value) - ?? dropCodexSafetyBufferingError(value) - ?? oauthOpenBrowserError(value) - ?? runtimeRoleError(value) - ?? remoteGuiConfigError(value) - ?? clientConnectionConfigError(value) - ?? clientRolePairError(value) - ?? loopbackListenerPortError(value) - ?? managementIngressConfigError(value); - if (boundaryError) return { ok: false, error: boundaryError }; - const result = configSchema.safeParse(value); - if (result.success) { - const config = normalizeApiKeyIds(result.data as OcxConfig); - return { ok: true, config }; - } - return { ok: false, error: schemaDiagnosticsError(result.error) }; -} - -function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { - try { - const parsed = JSON.parse(raw.replace(/^\uFEFF/, "")); + const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); + const parsed = JSON.parse(raw); + sanitizeAliasesForLoad(parsed); sanitizeReasoningPinsForLoad(parsed); - // Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the - // schema and send the caller a default-config fallback (the config command could then - // persist that fallback over the user's providers/keys). sanitizeModelDisplayNamesForLoad(parsed); sanitizeAutoReviewForLoad(parsed); sanitizeRetryOn429ForLoad(parsed); @@ -3374,385 +225,93 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { sanitizeCapabilityDeclarationsForLoad(parsed); const result = configSchema.safeParse(parsed); if (result.success) { - return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed); + const config = normalizeApiKeyIds(result.data as OcxConfig); + warnInheritedFastWireConflicts(configPath, config); + warnDegradedStreamMode(parsed, config); + warnDegradedHostname(parsed, config); + warnDegradedListeners(parsed, config); + warnDegradedApiKeys(parsed, config); + warnDegradedCodexAccountPriorities(parsed, config); + warnDegradedCodexQuotaAutoRefresh(parsed, config); + warnDegradedClaudeSubagentEffort(parsed); + warnDegradedNativeSubagentConfig(parsed, config); + warnDegradedCodexAccountPicker(parsed); + warnDegradedUpstreamHostCircuitThreshold(parsed); + warnDegradedPlaintextV2AgentMessages(parsed); + warnDegradedAgentTaskRecovery(parsed); + warnDegradedRuntimeRole(parsed); + warnDegradedOptionalRemoteBlocks(parsed); + warnDegradedQuotaResetNotify(parsed); + warnDegradedCatalogAutoRefresh(parsed); + warnDegradedCodexPool(parsed); + warnDegradedCredentialGroups(parsed); + return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } - + // Schema validation failed — merge defaults into the raw object instead of + // discarding it entirely, so pool accounts and providers survive a missing + // field like defaultProvider. const merged = mergeConfigDefaults(parsed); const retryResult = configSchema.safeParse(merged); if (retryResult.success) { - return validFileConfigDiagnostics(normalizeApiKeyIds(retryResult.data as OcxConfig), parsed); - } - - // #1785: one invalid routing profile must not make diagnostics report the built-in - // defaults AS the config, because a later config write persists those defaults over the - // operator's providers, keys and prices. - // - // The failure is still reported. `source` stays "fallback" and `error` keeps the real - // schema message -- diagnostics is the surface that tells callers the file is invalid, - // and every consumer that must refuse an invalid config (provider reload, catalog sync, - // cost reconcile, codex admission) gates on exactly those two fields. Only `config` - // changes: it carries the salvaged document instead of factory defaults, so a caller - // that ignores the error and writes it back preserves what the operator configured. - const salvaged = salvageConfigCandidate(merged, retryResult.error); - if (salvaged) { - const config = normalizeApiKeyIds(salvaged.parsed); - const warnings = degradedListenerWarnings(parsed, config); - return { - config, - source: "fallback", - error: schemaDiagnosticsError(result.error), - ...(warnings.length > 0 ? { warnings } : {}), - }; - } - - return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) }; - } catch { - return { config: getDefaultConfig(), source: "fallback", error: "invalid_json" }; - } -} - -function readConfigFileSnapshot(): ConfigFileSnapshot { - try { - const raw = readFileSync(getConfigPath(), "utf-8"); - return { diagnostics: configDiagnosticsFromRaw(raw), raw }; - } catch (error) { - if (isMissingPathError(error)) { - return { - diagnostics: { config: getDefaultConfig(), source: "default", error: null }, - }; - } - return { - diagnostics: { config: getDefaultConfig(), source: "fallback", error: "invalid_json" }, - }; - } -} - -export function readConfigDiagnostics(): ConfigDiagnostics { - return readConfigFileSnapshot().diagnostics; -} - -/** Read-only init preflight. Occupied unsafe entries are never treated as absence. */ -export function observeInitialConfigState(): "missing" | "exists" | "invalid" { - try { - if (!lstatSync(getConfigPath()).isFile()) return "invalid"; - } catch (error) { - return isMissingPathError(error) ? "missing" : "invalid"; - } - return readConfigFileSnapshot().diagnostics.source === "file" ? "exists" : "invalid"; -} - -/** - * The persisted config, plus a digest of the EXACT bytes it was parsed from. - * - * A union rather than a nullable digest, because `{ kind: "read" }` with no - * digest is a state that cannot occur — and a state that cannot occur should - * not be a state that can be written down. Refusing it at runtime is a check - * somebody eventually forgets; making it unrepresentable is not. - * - * Why a byte digest at all: the Codex write lock compares an authority snapshot - * taken before the lock against one taken while holding it, and its config - * component used to hash the PARSED object. Two files that differ only in - * whitespace or key order parse identically, so a non-cooperating writer could - * rewrite the file between admission and commit and the comparison would see - * nothing. Hashing what was actually read closes that. - * - * `readConfigFileSnapshot` stays private on purpose. Its `raw` carries provider - * API keys and admission tokens, and `privacy:scan` reads tracked source text, - * not runtime values — so it would not catch a caller that logged or serialized - * that string. The digest travels; the bytes do not. - */ -export type ConfigAdmissionSnapshot = - | Readonly<{ kind: "read"; diagnostics: ConfigDiagnostics; contentSha256: string }> - | Readonly<{ kind: "unreadable"; diagnostics: ConfigDiagnostics; contentSha256: null }>; - -export function readConfigAdmissionSnapshot(): ConfigAdmissionSnapshot { - let bytes: Buffer; - try { - // ONE read. Hashing the file and then reading it again to parse would leave - // a window for the two to disagree, which is the exact hazard this exists - // to detect — the check would become a second chance to be wrong. - bytes = readFileSync(getConfigPath()); - } catch (error) { - return { - kind: "unreadable", - diagnostics: isMissingPathError(error) - ? { config: getDefaultConfig(), source: "default", error: null } - : { config: getDefaultConfig(), source: "fallback", error: "invalid_json" }, - contentSha256: null, - }; - } - return { - kind: "read", - // Decoded from the same buffer that was hashed, not re-read from disk. - diagnostics: configDiagnosticsFromRaw(bytes.toString("utf-8")), - contentSha256: createHash("sha256").update(bytes).digest("hex"), - }; -} - -const CONFIG_MUTATION_DB_FILENAME = "config-mutation.sqlite"; -const CONFIG_MUTATION_DB_SIDECARS = ["-journal", "-wal", "-shm"] as const; -let warnedConfigMutationDirectoryAcl = false; - -export class ConfigMutationLockError extends Error { - readonly code = "CONFIG_MUTATION_LOCK_UNAVAILABLE"; - - constructor(message: string, options?: { cause?: unknown }) { - super(message, options); - this.name = "ConfigMutationLockError"; - } -} - -function configMutationDatabasePath(): string { - const dir = getConfigDir(); - // First statement on purpose: a rejected mutation must leave nothing behind, not a - // freshly created/chmod'd directory or database. See src/lib/test-home-guard.ts. - assertNotRealHomeUnderTest(dir); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - } else { - try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ } - } - if (windowsSecretAclApplies()) { - try { - // Distinct timeout memo from management-token directory harden: a required - // management-dir timeout must not poison config mutation on the same home - // (windows-latest server-management-auth cases). - hardenSecretDir(dir, { required: true, timeoutMemoKey: `${dir}::config-mutation` }); - } catch (error) { - if (!warnedConfigMutationDirectoryAcl) { - warnedConfigMutationDirectoryAcl = true; - const diagnostics = error instanceof Error ? error.message : "ACL hardening failed"; - console.warn( - `[opencodex] Config mutation coordination directory ACL hardening did not complete; continuing without it. ${diagnostics}`, - ); - } - } - } - const path = join(dir, CONFIG_MUTATION_DB_FILENAME); - recordOwnedConfigPath(dir, path); - for (const suffix of CONFIG_MUTATION_DB_SIDECARS) { - recordOwnedConfigPath(dir, `${path}${suffix}`); - } - return path; -} - -/** Raised when an independent config-mutation transaction is requested recursively. */ -export class NestedConfigMutationError extends Error { - constructor() { - super("prepareConfigMutationDatabasePathForWrite must not run inside withConfigMutationLockSync"); - this.name = "NestedConfigMutationError"; - } -} - -/** - * Prepare the shared config-mutation database path for an independent top-level - * SQLite transaction. Callers must not invoke this while holding - * {@link withConfigMutationLockSync}; a second `BEGIN IMMEDIATE` deliberately - * fails busy instead of joining an uncommitted transaction. - * - * @throws {NestedConfigMutationError} If a config mutation lock is already held. - */ -export function prepareConfigMutationDatabasePathForWrite(): string { - if (configMutationLockDepth > 0) { - throw new NestedConfigMutationError(); - } - return configMutationDatabasePath(); -} - -let configMutationLockDepth = 0; -let configMutationDatabase: Database | null = null; - -/** - * Serialize synchronous config and Codex credential-generation commits across processes with an - * OS-backed SQLite write transaction. `busy_timeout=0` is deliberate: runtime request paths must - * fail immediately under contention rather than freeze the Bun event loop. Process exit releases - * SQLite locks without stale-owner deletion or lease recovery races. - * - * Reentrancy is limited to the current synchronous call stack; never return a Promise from `fn`. - */ -export function withConfigMutationLockSync(fn: () => T): T { - if (configMutationLockDepth > 0) { - configMutationLockDepth += 1; - try { - return fn(); - } finally { - configMutationLockDepth -= 1; - } - } - const path = configMutationDatabasePath(); - let database: Database | undefined; - let transactionOpen = false; - try { - database = new Database(path, { create: true }); - try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ } - database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); - transactionOpen = true; - initializeConfigGeneration(database); - } catch (cause) { - if (transactionOpen) { - try { database?.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ } - } - try { database?.close(); } catch { /* acquisition already failed */ } - const code = cause && typeof cause === "object" && "code" in cause - ? String((cause as { code?: unknown }).code) - : ""; - throw new ConfigMutationLockError( - code === "SQLITE_BUSY" ? "Config mutation already in progress" : "Could not acquire config mutation transaction", - { cause }, - ); - } - - configMutationLockDepth = 1; - configMutationDatabase = database; - try { - const value = fn(); - database.exec("COMMIT"); - transactionOpen = false; - return value; - } catch (error) { - if (transactionOpen) { - try { database.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ } - transactionOpen = false; - } - throw error; - } finally { - configMutationLockDepth = 0; - configMutationDatabase = null; - try { database.close(); } catch { /* the OS lock is released with the handle */ } - } -} - -function bumpGenerationForCooperatingConfigWrite(): void { - if (!configMutationDatabase) { - throw new Error("A cooperating config write requires the config mutation transaction."); - } - bumpCurrentConfigGeneration(configMutationDatabase); -} - -export const readConfigGeneration: ReadConfigGeneration = () => { - try { - return readConfigGenerationAtPath(configMutationDatabasePath()); - } catch { - return { kind: "unavailable", reason: "database" }; - } -}; - -export function observeConfigGeneration(): ConfigGenerationObservation { - return observeConfigGenerationAtPath(join(getConfigDir(), CONFIG_MUTATION_DB_FILENAME)); -} - -/** - * Read the generation from the transaction that is open RIGHT NOW. - * - * The observer cannot do this job. On the very first acquisition the - * `BEGIN IMMEDIATE` that creates the table has not committed yet, so a separate - * read-only connection cannot read a generation from it — measured, not - * assumed. A caller that compared a pre-lock observation against an observer - * re-read would therefore refuse every first write as stale. - * - * Throwing when no transaction is open is deliberate. Being called outside the - * lock is broken plumbing, and returning a typed "unavailable" would let that - * bug arrive disguised as an environmental failure — retried forever, on a - * machine where nothing is wrong. - */ -export function readConfigGenerationInCurrentMutationTransaction(): ConfigGeneration { - if (configMutationLockDepth < 1 || !configMutationDatabase) { - throw new Error( - "readConfigGenerationInCurrentMutationTransaction requires an open config mutation transaction.", - ); - } - return readConfigGenerationInTransaction(configMutationDatabase); -} - -export const bumpConfigGeneration: BumpConfigGeneration = expected => { - try { - return bumpConfigGenerationAtPath(configMutationDatabasePath(), expected); - } catch { - return { kind: "unavailable", reason: "database" }; - } -}; - -function configGenerationFailureReason(error: unknown): "busy" | "database" { - const cause = error instanceof ConfigMutationLockError ? error.cause : error; - const code = cause && typeof cause === "object" && "code" in cause - ? String((cause as { code?: unknown }).code) - : ""; - const message = cause instanceof Error ? cause.message : ""; - return code === "SQLITE_BUSY" - || code === "SQLITE_LOCKED" - || /database (?:is|table is) locked/i.test(message) - ? "busy" - : "database"; -} - -export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync = ( - expected, - commit, -) => { - let callbackThrew = false; - let callbackError: unknown; - try { - return withConfigMutationLockSync(() => { - const database = configMutationDatabase; - if (!database) throw new Error("Config mutation transaction database is unavailable."); - const current = readConfigGenerationInTransaction(database); - if (current.value !== expected.value) return { kind: "conflict", current }; - try { - return { kind: "matched", generation: current, value: commit() }; - } catch (error) { - callbackThrew = true; - callbackError = error; - throw error; + warnConfigRepaired(configPath, result.error); + const config = normalizeApiKeyIds(retryResult.data as OcxConfig); + warnInheritedFastWireConflicts(configPath, config); + warnDegradedHostname(parsed, config); + warnDegradedListeners(parsed, config); + warnDegradedApiKeys(parsed, config); + warnDegradedCodexAccountPriorities(parsed, config); + warnDegradedCodexQuotaAutoRefresh(parsed, config); + warnDegradedClaudeSubagentEffort(parsed); + warnDegradedNativeSubagentConfig(parsed, config); + warnDegradedCodexAccountPicker(parsed); + warnDegradedUpstreamHostCircuitThreshold(parsed); + warnDegradedPlaintextV2AgentMessages(parsed); + warnDegradedAgentTaskRecovery(parsed); + warnDegradedRuntimeRole(parsed); + warnDegradedOptionalRemoteBlocks(parsed); + warnDegradedQuotaResetNotify(parsed); + warnDegradedCatalogAutoRefresh(parsed); + warnDegradedCodexPool(parsed); + warnDegradedCredentialGroups(parsed); + return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); + } + // Still failing, but if every complaint is about one or more named entries + // in an independent section, drop exactly those and keep the rest. Falling + // back to defaults here would silently retire the operator's providers, + // keys and prices over a mistake in one routing profile. + const salvaged = salvageConfigCandidate(merged, retryResult.error); + if (salvaged) { + { + warnDroppedConfigSections(configPath, salvaged.dropped, salvaged.issues); + const config = normalizeApiKeyIds(salvaged.parsed); + warnInheritedFastWireConflicts(configPath, config); + warnDegradedHostname(parsed, config); + warnDegradedListeners(parsed, config); + warnDegradedApiKeys(parsed, config); + warnDegradedCodexAccountPriorities(parsed, config); + warnDegradedCodexQuotaAutoRefresh(parsed, config); + warnDegradedClaudeSubagentEffort(parsed); + warnDegradedNativeSubagentConfig(parsed, config); + warnDegradedCodexAccountPicker(parsed); + warnDegradedUpstreamHostCircuitThreshold(parsed); + warnDegradedPlaintextV2AgentMessages(parsed); + warnDegradedAgentTaskRecovery(parsed); + warnDegradedRuntimeRole(parsed); + warnDegradedOptionalRemoteBlocks(parsed); + warnDegradedQuotaResetNotify(parsed); + warnDegradedCatalogAutoRefresh(parsed); + warnDegradedCodexPool(parsed); + warnDegradedCredentialGroups(parsed); + return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)); } - }); - } catch (error) { - if (callbackThrew && error === callbackError) throw error; - return { kind: "unavailable", reason: configGenerationFailureReason(error) }; - } -}; - -/** - * Atomic config.json write WITHOUT the mutation lock; callers must hold - * `withConfigMutationLockSync`. Returns true when bytes changed. Refreshes the - * cost-overlay registry from the persisted config so runtime estimates follow - * every save path. - */ -function persistConfigUnlocked(config: OcxConfig): boolean { - const pinError = configReasoningPinsConfigError(config); - if (pinError) throw new Error(pinError); - const configPath = getConfigPath(); - const rawBeforeWrite = readRawConfigJson(); - const clientPersistenceError = failClosedClientPersistenceError(rawBeforeWrite, config); - if (clientPersistenceError) throw new Error(clientPersistenceError); - // External editors can add provider rows the live config deliberately does - // not route with yet; merge them at the serialization boundary so an - // unrelated in-process save cannot erase the provider or its overlay. - // Provider preservation reads symbol-keyed live-owner state, which structuredClone - // intentionally drops. Resolve that ownership before projecting JSON provenance. - const provenanceProjection = projectConfigRebaseProvenance(config); - const persisted = withPreservedDiskOnlyProviders(config); - if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance; - else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance; - const bytes = JSON.stringify(persisted, null, 2) + "\n"; - let unchanged = false; - try { - unchanged = readFileSync(configPath, "utf8") === bytes; + } + // Merge couldn't fix it — truly broken config + warnAndBackupInvalidConfig(configPath, result.error); + return getDefaultConfig(); } catch (error) { - if (!isMissingPathError(error)) throw error; - } - // Keep the runtime overlay registry in sync with EVERY persist path, - // including byte-identical saves: a cooperating CLI process may have written - // the same bytes (e.g. before a proxy notification), and Logs/Usage must - // adopt the overlay without waiting for a changed save or restart. - if (unchanged) { - refreshUserCostOverlays(persisted); - return false; + warnAndBackupInvalidConfig(configPath, error); + return getDefaultConfig(); } - atomicWriteFile(configPath, bytes); - // For changed saves, refresh only AFTER the write succeeded so a failed - // write cannot leave estimates reflecting configuration never persisted. - refreshUserCostOverlays(persisted); - return true; } export type PersistedConfigInitializationOutcome = "created" | "exists" | "invalid"; @@ -3899,901 +458,3 @@ export function mutatePersistedConfig( return { status: "unavailable", reason: "conflict" }; }); } - -function failClosedClientPersistenceError( - raw: Record | undefined, - candidate: OcxConfig, -): string | null { - if (!raw) return null; - const rawHasClient = Object.hasOwn(raw, "client") && raw.client !== undefined; - const rawRole = raw.runtimeRole; - const rawRoleValid = rawRole === undefined - || rawRole === "standalone" - || rawRole === "hub" - || rawRole === "client"; - const rawClientValid = !rawHasClient || clientConnectionSchema.safeParse(raw.client).success; - const rawPairValid = rawRoleValid - && ((rawRole === "client" && rawHasClient && rawClientValid) - || (rawRole !== "client" && !rawHasClient)); - if (rawPairValid) return null; - - const candidateValid = candidate.runtimeRole === "client" - && clientConnectionSchema.safeParse(candidate.client).success; - const deletions = configRebaseDeletionKeys(candidate); - const explicitClear = deletions.has("client") && deletions.has("runtimeRole"); - if (candidateValid || explicitClear) return null; - return "config write refused: malformed or mismatched remote client state must be repaired or explicitly cleared"; -} - -export function websocketsEnabled(config: Pick): boolean { - return config.websockets === true; -} - -/** - * Opt-in Ultra Fast, read with the house `=== true` idiom so an absent key and a - * malformed one both mean off. - */ -export function ultraFastTierEnabled(config: Pick): boolean { - return config.ultraFastTier === true; -} - -/** - * Default cadence for the opt-in catalog auto-refresh (issue #3630): one converge pass - * per hour. Each pass spends a live /models call against every enabled provider, and - * provider catalogs are themselves cached upstream for minutes, so an hour is fresh - * enough for newly released models to appear without an `ocx sync`. - */ -export const CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS: number = 60 * 60_000; - -/** - * Floor under the configured cadence, for the same reason src/quota/reset-poller.ts has - * MIN_INTERVAL_MS: below this the refresh buys no freshness — upstream caches have not - * moved — and only multiplies the chance of a rate limit across every enabled provider. - */ -export const CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS: number = 15 * 60_000; - -/** - * Opt-in master switch, read with the house `=== true` idiom so an absent key and a - * malformed one both mean off. Pure on purpose: the scheduler calls this from a - * dynamically imported context, so it takes an explicit config slice and reads nothing - * global. - */ -export function isCatalogAutoRefreshEnabled( - config: Pick, -): boolean { - return config.catalogAutoRefresh?.enabled === true; -} - -/** - * Resolved tick interval in milliseconds. An explicit `intervalMinutes: 0` returns 0 — - * the section stays configured but the timer stays dormant — and any other value is - * clamped up to CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS so a hand edit cannot outrun the - * upstream catalog caches. Absent means the hourly default. - */ -export function resolveCatalogAutoRefreshIntervalMs( - config: Pick, -): number { - const minutes = config.catalogAutoRefresh?.intervalMinutes; - if (minutes === undefined) return CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS; - if (minutes === 0) return 0; - return Math.max(CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS, Math.floor(minutes * 60_000)); -} - -// --------------------------------------------------------------------------- -// Hand-edit protection for the `claudeCode` subtree (devlog 260726_claude_auth_auto/040 H1). -// -// `saveConfig` serializes the WHOLE config object, so ANY service-time save — a model -// visibility toggle, a 429 key rotation on the request path — rewrites `claudeCode` -// from whatever the long-lived server config happens to hold. A user who hand-edits -// `config.json` while the proxy runs then watches their edit vanish for no visible -// reason (issue #488). Enumerating `claudeCode` mutators cannot fix that; the guard has -// to live in ONE save wrapper that every live-config writer goes through. -// --------------------------------------------------------------------------- - -/** - * Baseline keyed on the CONFIG INSTANCE, never a module global: a second `loadConfig()` - * elsewhere must not refresh the baseline the long-lived server config is judged - * against, or a later stale save would masquerade as "our own change". - */ -const claudeCodeBaseline = new WeakMap(); -/** - * Full live-config baseline used to rebase unrelated cooperating writes. The - * Claude subtree and the bound listener fields remain on their dedicated - * reconciliation paths below. - */ -const liveConfigBaseline = new WeakMap(); -/** - * The live config retains the address of the socket Bun actually opened, while - * this map retains the operator's desired address for the next process start. - * Keeping them separate prevents an unrelated live save from restoring a stale - * externally exposed bind after OAuth adopted a newer loopback disk config. - */ -type PersistedServerBinding = Pick; - -const persistedLiveServerBinding = new WeakMap(); - -/** - * Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on - * first save — arming lazily would lose exactly the hand edit made before that first - * save, which is the case the guard exists for. - */ -export function armClaudeCodeBaseline(config: OcxConfig): void { - liveConfigBaseline.set(config, structuredClone(config)); - claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); -} - -/** - * Adopt one schema-validated provider that was read from the authoritative disk - * config into a long-lived server config without rebasing any unrelated field. - * Updating the matching baseline row keeps a later guarded save from treating the - * adopted provider as an unsaved live edit that should defeat a newer disk change. - */ -export function adoptPersistedProviderIntoLiveConfig( - config: OcxConfig, - name: string, - provider: OcxProviderConfig, - persistedConfig?: OcxConfig, -): void { - config.providers[name] = structuredClone(provider); - const baseline = liveConfigBaseline.get(config); - if (baseline) baseline.providers[name] = structuredClone(provider); - if (persistedConfig) refreshPreservedProviderOwner(config, persistedConfig); -} - -/** Test seam only: is this instance armed? */ -export function claudeCodeBaselineArmed(config: OcxConfig): boolean { - return claudeCodeBaseline.has(config); -} - -/** - * Structural compare of parsed subtrees. NOT `JSON.stringify`: key order must not - * decide whether a user's hand edit survives. - */ -function deepEqual(a: unknown, b: unknown): boolean { - if (a === b) return true; - if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false; - if (Array.isArray(a) !== Array.isArray(b)) return false; - if (Array.isArray(a) && Array.isArray(b)) { - return a.length === b.length && a.every((item, index) => deepEqual(item, b[index])); - } - const left = a as Record; - const right = b as Record; - // `undefined` values and absent keys are the same thing after a JSON round-trip. - const keys = new Set([...Object.keys(left), ...Object.keys(right)]); - for (const key of keys) { - if (left[key] === undefined && right[key] === undefined) continue; - if (!deepEqual(left[key], right[key])) return false; - } - return true; -} - -const MISSING_CONFIG_VALUE = Symbol("missing-config-value"); -type ConfigMergeValue = unknown | typeof MISSING_CONFIG_VALUE; - -function isPlainConfigRecord(value: ConfigMergeValue): value is Record { - if (!value || typeof value !== "object" || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -} - -function ownConfigValue(record: Record, key: string): ConfigMergeValue { - return Object.hasOwn(record, key) ? record[key] : MISSING_CONFIG_VALUE; -} - -function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue { - return value === MISSING_CONFIG_VALUE ? value : structuredClone(value); -} - -type IndexedCustomModels = { - order: string[]; - byId: Map>; -}; - -function indexCustomModels(value: ConfigMergeValue): IndexedCustomModels | null { - if (!Array.isArray(value)) return null; - const order: string[] = []; - const byId = new Map>(); - for (const item of value) { - if (!isPlainConfigRecord(item) || typeof item.id !== "string" || item.id.length === 0 || byId.has(item.id)) { - return null; - } - order.push(item.id); - byId.set(item.id, item); - } - return { order, byId }; -} - -/** - * Merge custom-model rows by their stable id instead of treating the array as - * one opaque value. A row changed only on disk is adopted, a row changed only - * in the live config is retained, and disjoint edits to the same row recurse - * through the normal three-way object merge. A newer persisted row deletion - * wins over a stale live edit to that row. - */ -function reconcileCustomModels( - baseline: ConfigMergeValue, - live: ConfigMergeValue, - persisted: ConfigMergeValue, -): ConfigMergeValue | null { - const baselineRows = indexCustomModels(baseline); - const liveRows = indexCustomModels(live); - const persistedRows = indexCustomModels(persisted); - if (!baselineRows || !liveRows || !persistedRows) return null; - - const order = [...liveRows.order, ...persistedRows.order.filter(id => !liveRows.byId.has(id))]; - const merged: Array> = []; - for (const id of order) { - const baselineRow = baselineRows.byId.get(id) ?? MISSING_CONFIG_VALUE; - const persistedRow = persistedRows.byId.get(id) ?? MISSING_CONFIG_VALUE; - const row = baselineRow !== MISSING_CONFIG_VALUE && persistedRow === MISSING_CONFIG_VALUE - ? MISSING_CONFIG_VALUE - : reconcileConfigValue( - baselineRow, - liveRows.byId.get(id) ?? MISSING_CONFIG_VALUE, - persistedRow, - ); - if (row !== MISSING_CONFIG_VALUE) merged.push(row as Record); - } - return merged; -} - -function reconcileConfigRecord( - live: Record, - baseline: Record, - persisted: Record, - skippedKeys?: ReadonlySet, - persistedDeletionsWin = false, -): void { - const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]); - for (const key of keys) { - if (skippedKeys?.has(key)) continue; - const baselineValue = ownConfigValue(baseline, key); - const liveValue = ownConfigValue(live, key); - const persistedValue = ownConfigValue(persisted, key); - const merged = persistedDeletionsWin - && baselineValue !== MISSING_CONFIG_VALUE - && persistedValue === MISSING_CONFIG_VALUE - ? MISSING_CONFIG_VALUE - : key === "customModels" - ? reconcileCustomModels(baselineValue, liveValue, persistedValue) - ?? reconcileConfigValue(baselineValue, liveValue, persistedValue) - : reconcileConfigValue(baselineValue, liveValue, persistedValue, key === "providers"); - if (merged === MISSING_CONFIG_VALUE) delete live[key]; - else live[key] = merged; - } -} - -function reconcileConfigValue( - baseline: ConfigMergeValue, - live: ConfigMergeValue, - persisted: ConfigMergeValue, - persistedChildDeletionsWin = false, -): ConfigMergeValue { - const liveChanged = !deepEqual(live, baseline); - const persistedChanged = !deepEqual(persisted, baseline); - - if (!liveChanged) { - if (live !== MISSING_CONFIG_VALUE && Array.isArray(live) && Array.isArray(persisted)) { - live.splice(0, live.length, ...structuredClone(persisted)); - return live; - } - if (isPlainConfigRecord(live) && isPlainConfigRecord(persisted)) { - reconcileConfigRecord( - live, - isPlainConfigRecord(baseline) ? baseline : {}, - persisted, - ); - return live; - } - return cloneConfigValue(persisted); - } - - if (!persistedChanged) return live; - - if (isPlainConfigRecord(live) - && isPlainConfigRecord(persisted) - && (baseline === MISSING_CONFIG_VALUE || isPlainConfigRecord(baseline))) { - reconcileConfigRecord( - live, - isPlainConfigRecord(baseline) ? baseline : {}, - persisted, - undefined, - persistedChildDeletionsWin, - ); - } - // Same-leaf conflicts prefer the pending live management mutation. - return live; -} - -/** - * Reconcile an async OAuth disk commit into the shared live config without erasing - * management mutations that have not saved yet. The baseline is a normalized disk - * snapshot from immediately before login; disjoint object edits merge recursively, - * while same-leaf conflicts prefer live state. - */ -export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline: OcxConfig): void { - const diagnostics = readConfigDiagnostics(); - if (diagnostics.source === "fallback") { - throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`); - } - const persisted = diagnostics.config; - const claudeGuardArmed = claudeCodeBaseline.has(config); - const pendingLiveClaudeMutation = claudeGuardArmed - && !deepEqual(config.claudeCode, claudeCodeBaseline.get(config)); - - persistedLiveServerBinding.set(config, { - port: persisted.port, - ...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}), - }); - - reconcileConfigRecord( - config as unknown as Record, - persistedBaseline as unknown as Record, - persisted as unknown as Record, - new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]), - ); - - if (claudeGuardArmed && !pendingLiveClaudeMutation) { - if (persisted.claudeCode === undefined) delete config.claudeCode; - else config.claudeCode = structuredClone(persisted.claudeCode); - claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); - } - // The reconciliation may have adopted a providers..modelCosts edit made - // by a cooperating process while the OAuth login was pending; keep the overlay - // registry (and the usage-cache overlay version) in sync with the live config. - refreshUserCostOverlays(config); -} - -/** The literal file, with no schema merge or default injection. */ -function readRawConfigJson(): Record | undefined { - try { - const configPath = getConfigPath(); - if (!existsSync(configPath)) return undefined; - const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; - return parsed as Record; - } catch { - // Unreadable or corrupt: behave exactly as before. Never fail a save over protection. - return undefined; - } -} - -/** - * Read only schema-valid binding fields from the literal file. Missing fields mean - * their schema defaults; malformed fields keep the last known persisted value. - */ -function readPersistedServerBinding( - raw: Record, - baseline: PersistedServerBinding, -): PersistedServerBinding { - const port = raw.port === undefined - ? 10100 - : (typeof raw.port === "number" - && Number.isInteger(raw.port) - && raw.port >= 0 - && raw.port <= 65535 - ? raw.port - : baseline.port); - const hostname = raw.hostname === undefined - ? undefined - : (typeof raw.hostname === "string" ? raw.hostname : baseline.hostname); - return { port, ...(hostname !== undefined ? { hostname } : {}) }; -} - -/** - * The save entry point for every writer holding a LIVE server config. - * - * Conflict policy, chosen deliberately: - * - disk changed, we did not → their hand edit wins; - * - disk changed AND we changed → disjoint fields are merged, while a same-leaf - * conflict keeps the live value; - * - a provider or custom-model row deleted on disk stays deleted even if stale - * live state edited that same row; - * - file missing/unreadable → save what we have, no throw. - * - * Custom-model rows are merged by their stable `id`, preserving independent - * edits and deletions across stale whole-config saves. - */ -export function saveConfigPreservingClaudeCode(config: OcxConfig): void { - const pinError = configReasoningPinsConfigError(config); - if (pinError) throw new Error(pinError); - withConfigMutationLockSync(() => { - const bindingBaseline = persistedLiveServerBinding.get(config); - // One authoritative pre-write read feeds both the live-config reconciliation and - // custom-model deletion migration. A second read could observe different bytes. - const onDisk = readRawConfigJson(); - const baseline = liveConfigBaseline.get(config); - if (baseline && onDisk !== undefined) { - const persistedDiagnostics = configDiagnosticsFromRaw(JSON.stringify(onDisk)); - if (persistedDiagnostics.source === "file") { - const deletedKeys = configRebaseDeletionKeys(config); - const provenanceExists = configHasRebaseProvenance(config); - // Only keys this live config is actually known to have diverged on may be - // rebased. The baseline is captured once when the server arms it, so any key - // that appeared on disk afterwards — through saveConfig(), a hand edit, or - // another process — is absent from the baseline as well as from the live - // config. Reconciling those keys reads "live never changed this" and adopts - // the disk value, which resurrects a field the live writer had deliberately - // deleted (#1462 regression: PUT /api/grok/selection with an empty list). - // Restrict the merge to keys the baseline knew about, plus keys the live - // config still carries; a key that exists only on disk is left to the - // ordinary whole-config write below. - const rebaseableKeys = new Set([ - ...Object.keys(baseline as unknown as Record), - ...Object.keys(config as unknown as Record), - ...(provenanceExists - ? Object.keys(persistedDiagnostics.config as unknown as Record) - : []), - ]); - const skipped = new Set(["hostname", "port", "claudeCode", CONFIG_REBASE_PROVENANCE_KEY]); - for (const key of Object.keys(persistedDiagnostics.config as unknown as Record)) { - if (!rebaseableKeys.has(key)) skipped.add(key); - } - reconcileConfigRecord( - config as unknown as Record, - baseline as unknown as Record, - persistedDiagnostics.config as unknown as Record, - skipped, - ); - for (const key of deletedKeys) delete (config as unknown as Record)[key]; - } - } - if (claudeCodeBaseline.has(config)) { - if (onDisk !== undefined) { - const baseline = claudeCodeBaseline.get(config); - const persistedClaudeCode = normalizePersistedClaudeCode(onDisk.claudeCode); - const diskChanged = !deepEqual(persistedClaudeCode, baseline); - const weChanged = !deepEqual(config.claudeCode, baseline); - if (diskChanged && !weChanged) { - config.claudeCode = persistedClaudeCode; - } - } - } - const provenanceProjection = projectConfigRebaseProvenance(config); - const projectedConfig = projectCustomModelCatalogMigration( - onDisk, - config, - ); - if (provenanceProjection.configRebaseProvenance === undefined) delete projectedConfig.configRebaseProvenance; - else projectedConfig.configRebaseProvenance = provenanceProjection.configRebaseProvenance; - const persistedBinding = bindingBaseline && onDisk - ? readPersistedServerBinding(onDisk, bindingBaseline) - : bindingBaseline; - if (persistedBinding) { - const persistedConfig: OcxConfig = { ...projectedConfig, port: persistedBinding.port }; - if (persistedBinding.hostname === undefined) delete persistedConfig.hostname; - else persistedConfig.hostname = persistedBinding.hostname; - if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite(); - persistedLiveServerBinding.set(config, persistedBinding); - } else { - if (persistConfigUnlocked(projectedConfig)) bumpGenerationForCooperatingConfigWrite(); - } - adoptCustomModelCatalogMigration(config, projectedConfig); - if (claudeCodeBaseline.has(config)) { - claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); - } - if (liveConfigBaseline.has(config)) { - if (projectedConfig.configRebaseProvenance === undefined) delete config.configRebaseProvenance; - else config.configRebaseProvenance = structuredClone(projectedConfig.configRebaseProvenance); - liveConfigBaseline.set(config, structuredClone(projectedConfig)); - } - clearPendingConfigTopLevelDeletions(config); - }); -} - -export function codexAutoStartEnabled(config: Pick): boolean { - return config.codexAutoStart !== false; -} - -export const CODEX_SHIM_AUTO_RESTORE_ENV = "OPENCODEX_CODEX_SHIM_AUTO_RESTORE"; - -export function codexShimAutoRestoreEnabled( - config: Pick, - env: NodeJS.ProcessEnv = process.env, -): boolean { - return config.codexShimAutoRestore !== false && env[CODEX_SHIM_AUTO_RESTORE_ENV] !== "0"; -} - -export function multiAgentGuidanceEnabled( - config: Pick, -): boolean { - return config.multiAgentGuidanceEnabled !== false; -} - -export function runtimeRole(config: Pick): OcxRuntimeRole { - return config.runtimeRole ?? "standalone"; -} - -export function getDefaultConfig(): OcxConfig { - // Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key). - // gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend. - // Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice. - return { - port: 10100, - emptyCompletionRetry: false, - dropCodexSafetyBuffering: false, - fastRows: true, - managementUsageMaxReadBytes: 64 * 1024 * 1024, - appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024), - // Fresh/re-initialized configs are already written in the current three-tier - // OpenAI shape. Mark them as such so startup does not mistake them for a - // legacy config and collide with an immutable backup from an earlier setup. - openaiProviderTierVersion: OPENAI_PROVIDER_TIER_VERSION, - providers: { - openai: { - adapter: "openai-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", - authMode: "forward", - codexAccountMode: "pool", - }, - }, - defaultProvider: "openai", - subagentModels: [...DEFAULT_SUBAGENT_MODELS], - subagentModelsVersion: SUBAGENT_MODELS_VERSION, - // v1 is the shipped surface while a v2 native-to-routed task is undeliverable - // ciphertext. Written explicitly rather than left absent, because an absent key - // means base everywhere else. A fresh install starts already acknowledged: there is - // nothing to advise an operator who is on the recommended surface. - multiAgentMode: "v1", - multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION, - multiAgentGuidanceEnabled: true, - websockets: false, - codexAutoStart: true, - codexShimAutoRestore: true, - }; -} - -export function resolveEnvValue(value: string | undefined): string | undefined { - if (!value) return undefined; - const match = value.match(/^\$\{(\w+)\}$/); - if (match) return process.env[match[1]]; - if (value.startsWith("$")) return process.env[value.slice(1)]; - return value; -} - -const warnedProxyConfigDiscards = new Set<"proxy" | "noProxy" | "noProxyElements">(); - -function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements"): void { - if (warnedProxyConfigDiscards.has(kind)) return; - warnedProxyConfigDiscards.add(kind); - if (kind === "proxy") { - console.warn( - "⚠️ config.json proxy was discarded because it is not a non-empty resolved string — configured proxy routing is disabled; existing proxy environment variables remain authoritative, otherwise outbound requests use direct egress", - ); - } else if (kind === "noProxy") { - console.warn( - "⚠️ config.json noProxy was discarded because it is not a string, string array, or resolved environment reference — existing NO_PROXY and loopback bypasses remain", - ); - } else { - console.warn( - "⚠️ config.json noProxy contains invalid elements — invalid elements were ignored; valid entries, existing NO_PROXY, and loopback bypasses remain", - ); - } -} - -/** - * Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports - * such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY - * variables win; config fills missing scheme proxies, which take precedence over ALL_PROXY for WS. - * localhost/127.0.0.1 are appended to NO_PROXY so the CLI's own health checks and - * running-proxy API calls stay direct. Call once per process entry that makes outbound provider - * requests (server start, catalog sync). - */ -export function applyProxyEnv(config: OcxConfig): void { - applyProxyEnvWith(config); -} - -/** Test seam for `proxy: "auto"`: the registry reader and platform are injectable. */ -export function applyProxyEnvWith( - config: OcxConfig, - auto: { reader?: WindowsProxyRegistryReader; platform?: NodeJS.Platform } = {}, -): void { - // `proxy` and `noProxy` are not declared in the top-level schema, which ends in - // `.passthrough()`, so whatever is on disk arrives here verbatim. A non-string value - // reached string-only methods and threw out of this function, and it runs once per - // process entry point — the failure was a startup crash, not a degraded proxy. Ignore - // malformed values with a privacy-safe warning instead: they cannot express a routing - // intent, and refusing to start is a worse answer than starting without them. - const rawProxy = config.proxy; - let proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined; - if (!proxy) { - if (rawProxy !== undefined) warnProxyConfigDiscardOnce("proxy"); - return; - } - if (proxy.trim().toLowerCase() === "auto") { - // #1525 slice 1: one startup read of the Windows static proxy. Never copy the literal - // "auto" into HTTP_PROXY; every non-proxy outcome leaves outbound routing as it was. - if (process.env.HTTP_PROXY?.trim() || process.env.http_proxy?.trim() - || process.env.HTTPS_PROXY?.trim() || process.env.https_proxy?.trim()) { - console.log("[opencodex] proxy \"auto\": existing HTTP_PROXY/HTTPS_PROXY environment wins; system proxy not consulted"); - proxy = undefined; - } else { - const found = readWindowsSystemProxy(auto.reader, auto.platform); - if (found.kind === "proxy") { - console.log(`[opencodex] proxy "auto": using Windows system proxy ${describeProxyForLog(found.url)}`); - proxy = found.url; - } else { - const reason = found.kind === "unsupported" - ? "only Windows system proxy discovery is supported; using direct egress on this OS" - : found.kind === "disabled" - ? "Windows system proxy is disabled; using direct egress" - : found.kind === "socks-only" - ? "Windows system proxy is SOCKS-only, which HTTP_PROXY cannot express; using direct egress" - : "Windows proxy settings could not be read; using direct egress"; - console.log(`[opencodex] proxy "auto": ${reason}`); - proxy = undefined; - } - } - } - if (proxy) { - if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy; - if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy; - } - const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? ""; - const entries = existing.split(",").map(s => s.trim()).filter(Boolean); - const seen = new Set(entries.map(e => e.toLowerCase())); - // Configured entries first, then loopback: loopback is unconditional, so appending it last - // keeps it present even when the operator lists a loopback host themselves. - const raw = config.noProxy; - let configuredEntries: string[]; - if (Array.isArray(raw)) { - // One unusable element must not discard the operator's other entries. - if (raw.some(entry => typeof entry !== "string")) warnProxyConfigDiscardOnce("noProxyElements"); - configuredEntries = raw.filter((entry): entry is string => typeof entry === "string"); - } else if (typeof raw === "string") { - const resolved = resolveEnvValue(raw); - if (raw && resolved === undefined) warnProxyConfigDiscardOnce("noProxy"); - configuredEntries = (resolved ?? "").split(","); - } else { - if (raw !== undefined) warnProxyConfigDiscardOnce("noProxy"); - configuredEntries = []; - } - const configured = configuredEntries - .map(entry => entry.trim()) - .filter(Boolean); - for (const host of [...configured, "localhost", "127.0.0.1", "::1", "[::1]"]) { - const key = host.toLowerCase(); - if (!seen.has(key)) { - entries.push(host); - seen.add(key); - } - } - process.env.NO_PROXY = entries.join(","); -} - -function warnConfigRepaired(configPath: string, error: z.ZodError): void { - if (warnedConfigFallbacks.has(configPath)) return; - warnedConfigFallbacks.add(configPath); - const fields = error.issues.map(i => i.path.join(".") || "config").join(", "); - console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`); -} - -/** - * Sections whose entries are independent of one another, so one bad entry is - * safe to drop without changing what the rest mean. - * - * Both are validated entry-by-entry in the `superRefine` above, which raises - * every finding as a *document*-level issue. That is what made a single routing - * candidate naming a disabled provider discard the operator's whole config — - * all eleven providers, every API key, and the entire `modelCosts` table — - * while the proxy carried on serving from built-in defaults and reporting - * healthy. - */ -const SALVAGEABLE_CONFIG_SECTIONS = ["routingProfiles", "combos"] as const; - -/** Optional nested fields that can be dropped whole without changing the rest of the document. */ -const SALVAGEABLE_OPTIONAL_FIELDS: ReadonlyArray = [ - ["claudeCode", "desktopProfile"], -]; - -function isSalvageableConfigPath(section: string, id: string): boolean { - if ((SALVAGEABLE_CONFIG_SECTIONS as readonly string[]).includes(section)) return true; - return SALVAGEABLE_OPTIONAL_FIELDS.some(path => path[0] === section && path[1] === id); -} - -/** - * Drop just the named entries a parse failure blamed, so the rest of the - * document survives. - * - * Returns `null` when the failure was not confined to those sections — the - * caller then keeps its existing behaviour rather than guessing. - * - * The whole entry goes, not the individual offending candidate. A routing - * profile that quietly loses one candidate still routes, just not where the - * operator said it should, and a policy that silently changed shape is a worse - * outcome than one that is plainly absent. Absent is also the loud option: a - * dry-run against it answers `unknown_profile`, which — paired with the warning - * this emits — points at the real mistake. - */ -function dropInvalidConfigSections( - parsed: unknown, - error: z.ZodError, -): { candidate: Record; dropped: string[] } | null { - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; - - const doomed = new Map>(); - for (const issue of error.issues) { - if (isUnsalvageableIssue(issue)) return null; - const [section, id] = issue.path; - if (typeof section !== "string" || typeof id !== "string") return null; - if (!isSalvageableConfigPath(section, id)) return null; - // A complaint about the container itself ("combos must be an object") is - // not about one entry, so there is nothing selective to drop. - if (issue.path.length < 2) return null; - let ids = doomed.get(section); - if (!ids) doomed.set(section, ids = new Set()); - ids.add(id); - } - if (doomed.size === 0) return null; - - const candidate: Record = { ...(parsed as Record) }; - const dropped: string[] = []; - for (const [section, ids] of doomed) { - const current = candidate[section]; - if (!current || typeof current !== "object" || Array.isArray(current)) return null; - const kept: Record = {}; - for (const [key, value] of Object.entries(current as Record)) { - if (ids.has(key)) dropped.push(`${section}.${key}`); - else kept[key] = value; - } - candidate[section] = kept; - } - return dropped.length > 0 ? { candidate, dropped } : null; -} - -/** - * Salvage until the document parses, not just once. - * - * One pass is not enough because the sections depend on each other: routing - * profiles are validated against the combo map, so dropping an invalid combo can - * expose a profile that referenced it. A single-pass salvage sees that second - * failure and gives up, discarding the whole config -- the exact outcome this - * code exists to prevent. - * - * `rawDocument` is the operator's document before defaults were merged in. When - * supplied, the same entries are deleted from it too, so a diagnostics caller can - * still tell an absent optional setting from one we injected. - */ - -/** - * Findings that must never be salvaged away. - * - * Salvage removes the entry a finding blamed, which is right for an ordinary - * validation mistake and wrong for a namespace collision: the collision is a - * *relationship* between a combo/profile and a Codex account selector, and it is - * reported on the combo. Dropping that combo makes the document parse and quietly - * admits the account selector the schema just refused, turning a hard admission - * boundary into a config that loads. Refuse the whole document instead. - */ -const UNSALVAGEABLE_ISSUE_MESSAGES: readonly string[] = [ - CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, -]; - -function isUnsalvageableIssue(issue: z.ZodIssue): boolean { - return UNSALVAGEABLE_ISSUE_MESSAGES.some(message => issue.message.includes(message)); -} -function salvageConfigCandidate( - merged: unknown, - initialError: z.ZodError, - rawDocument?: unknown, -): { - candidate: Record; - rawCandidate: unknown; - parsed: OcxConfig; - dropped: string[]; - issues: z.ZodIssue[]; -} | null { - let candidate: unknown = merged; - let rawCandidate: unknown = rawDocument; - let error = initialError; - const dropped: string[] = []; - const issues: z.ZodIssue[] = []; - // Bounded by construction: every pass must remove at least one entry, and there - // are only so many entries to remove. - const budget = countSalvageableEntries(merged) + 1; - for (let pass = 0; pass < budget; pass++) { - const step = dropInvalidConfigSections(candidate, error); - if (!step || step.dropped.length === 0) return null; - dropped.push(...step.dropped); - issues.push(...error.issues); - candidate = step.candidate; - rawCandidate = deleteEntryPaths(rawCandidate, step.dropped); - const result = configSchema.safeParse(candidate); - if (result.success) { - return { candidate: step.candidate, rawCandidate, parsed: result.data as OcxConfig, dropped, issues }; - } - error = result.error; - } - return null; -} - -function countSalvageableEntries(document: unknown): number { - if (!document || typeof document !== "object" || Array.isArray(document)) return 0; - let total = 0; - for (const section of SALVAGEABLE_CONFIG_SECTIONS) { - const value = (document as Record)[section]; - if (value && typeof value === "object" && !Array.isArray(value)) { - total += Object.keys(value as Record).length; - } - } - for (const [section, id] of SALVAGEABLE_OPTIONAL_FIELDS) { - const container = (document as Record)[section]; - if (container && typeof container === "object" && !Array.isArray(container) - && Object.hasOwn(container as Record, id)) { - total += 1; - } - } - return total; -} - -/** Delete `section.id` entries from a copy of the raw document. */ -function deleteEntryPaths(document: unknown, entryPaths: readonly string[]): unknown { - if (!document || typeof document !== "object" || Array.isArray(document)) return document; - const next: Record = { ...(document as Record) }; - for (const entryPath of entryPaths) { - const separator = entryPath.indexOf("."); - if (separator <= 0) continue; - const section = entryPath.slice(0, separator); - const id = entryPath.slice(separator + 1); - const container = next[section]; - if (!container || typeof container !== "object" || Array.isArray(container)) continue; - const kept: Record = { ...(container as Record) }; - delete kept[id]; - next[section] = kept; - } - return next; -} - -/** - * Entry ids are operator-chosen and can be token-shaped, so nothing dynamic reaches - * the log unredacted. Static section names stay readable -- they are the part that - * tells the operator where to look. - */ -function redactEntryPath(entryPath: string): string { - const separator = entryPath.indexOf("."); - if (separator <= 0) return redactSecretString(entryPath); - return entryPath.slice(0, separator) + "." + redactSecretString(entryPath.slice(separator + 1)); -} - -function redactIssuePath(path: readonly PropertyKey[]): string { - return path - .map((segment, index) => (index === 0 && typeof segment === "string" ? segment : redactSecretString(String(segment)))) - .join("."); -} - -function warnDroppedConfigSections(configPath: string, dropped: string[], issues: readonly z.ZodIssue[]): void { - if (warnedConfigFallbacks.has(configPath)) return; - warnedConfigFallbacks.add(configPath); - const reasons = issues - .map(issue => `${redactIssuePath(issue.path)}: ${redactSecretString(issue.message)}`) - .join("; "); - console.error( - `opencodex config at ${configPath}: dropped [${dropped.map(redactEntryPath).join(", ")}] and loaded the rest — ${reasons}. ` - + "Everything else in your config, including providers and modelCosts, is preserved.", - ); -} - -function warnAndBackupInvalidConfig(configPath: string, error: unknown): void { - if (warnedConfigFallbacks.has(configPath)) return; - warnedConfigFallbacks.add(configPath); - - const backupPath = backupInvalidConfig(configPath); - const reason = error instanceof z.ZodError - ? error.issues.map(issue => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ") - : error instanceof Error ? error.message : String(error); - const backupNote = backupPath ? ` A backup was written to ${backupPath}.` : ""; - console.error(`Could not load opencodex config at ${configPath}: ${reason}. Using default config.${backupNote}`); -} - -export function backupInvalidConfig(configPath: string): string | null { - if (!existsSync(configPath)) return null; - const backupPath = `${configPath}.invalid-${new Date().toISOString().replace(/[:.]/g, "-")}`; - try { - copyFileSync(configPath, backupPath); - try { chmodSync(backupPath, 0o600); } catch { /* best-effort */ } - return backupPath; - } catch { - return null; - } -} diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts new file mode 100644 index 0000000000..28a645605d --- /dev/null +++ b/src/config/diagnostics.ts @@ -0,0 +1,705 @@ +import { createHash } from "node:crypto"; +import { lstatSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import * as z from "zod/v4"; +import type { OcxConfig } from "../types"; +import { configReasoningPinsConfigError } from "./provider-validation"; +import { loopbackCompanionAllowed } from "../codex/loopback-target"; +import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "../codex/upstream-host-health"; +import { MAX_APP_OWNED_MEMORY_BUDGET_MB, MIN_APP_OWNED_MEMORY_BUDGET_MB } from "../lib/app-owned-memory"; +import { isMissingPathError } from "./atomic-write"; +import { getConfigPath } from "./paths"; +import { getDefaultConfig } from "./proxy-env"; +import { salvageConfigCandidate } from "./salvage"; +import { + sanitizeReasoningPinsForLoad, + sanitizeRetryOn429ForLoad, + sanitizeCapabilityDeclarationsForLoad, + sanitizeModelCostsForLoad, + sanitizeAutoReviewForLoad, + degradedListenerWarnings, + degradedCodexAccountPriorityWarnings, + degradedCodexQuotaAutoRefreshWarning, + normalizeApiKeyIds, + CLAUDE_SUBAGENT_EFFORTS, + isClaudeSubagentEffort, + rawClaudeSubagentEffort, + normalizeClaudeSubagentEffort, + malformedUpstreamHostCircuitThresholdWarning, + malformedPlaintextV2AgentMessagesWarning, + malformedAgentTaskRecoveryWarning, + malformedRuntimeRoleWarning, + malformedOptionalRemoteBlockWarning, + malformedClientConnectionWarning, + malformedQuotaResetNotifyWarning, + malformedCatalogAutoRefreshWarning, + malformedCodexPoolWarning, + rawConfigRecord, + malformedNativeSubagentFields, + malformedNativeSubagentFieldWarning, + malformedCodexAccountPickerWarning, + nativeSubagentSyncDisabledReason, + normalizeNativeSubagentSync, + inheritedFastWireConflictProviderNames, + inheritedFastWireConflictWarning, + sanitizeModelDisplayNamesForLoad, +} from "./load-degrade"; +import { configSchema } from "./schema/config-schema"; +import { + agentTaskRecoverySchema, + catalogAutoRefreshSchema, + clientConnectionSchema, + CODEX_ACCOUNT_PIN_PATTERN, + codexAccountPrioritiesSchema, + codexPoolSchema, + codexQuotaAutoRefreshSchema, + credentialGroupsSchema, + hubConfigSchema, + quotaResetNotifySchema, + remoteGuiConfigSchema, + runtimeRoleSchema, +} from "./schema/leaf-validators"; + +export type ConfigDiagnostics = { + config: OcxConfig; + source: "default" | "file" | "fallback"; + error: string | null; + /** Non-fatal config concerns; absent when there are no warnings. */ + warnings?: string[]; +}; + +export type ConfigFileSnapshot = { + diagnostics: ConfigDiagnostics; + /** Exact file contents, including a possible BOM, used as the optimistic revision. */ + raw?: string; +}; + +function configPlaceholderWarnings(config: OcxConfig): string[] { + const warnings: string[] = []; + for (const [name, provider] of Object.entries(config.providers)) { + const placeholder = provider.baseUrl.match(/\{[^}]*\}/)?.[0]; + if (placeholder) { + warnings.push(`providers.${name}.baseUrl contains unresolved ${placeholder}; set the real provider URL`); + } + } + return warnings; +} + +function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): ConfigDiagnostics { + // Unsafe hand-edited optional values are disabled in memory instead of rejecting + // the entire config, which would hide unrelated providers/accounts. The next + // ordinary save persists the normalized absence. + const syncDisabledReason = nativeSubagentSyncDisabledReason(config, rawParsed); + const rawEffort = rawClaudeSubagentEffort(rawParsed); + const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed); + const warnings = configPlaceholderWarnings(normalized); + warnings.push(...inheritedFastWireConflictProviderNames(normalized).map(inheritedFastWireConflictWarning)); + warnings.push(...degradedCodexAccountPriorityWarnings(rawParsed, normalized)); + warnings.push(...degradedListenerWarnings(rawParsed, normalized)); + const quotaAutoRefreshWarning = degradedCodexQuotaAutoRefreshWarning(rawParsed, normalized); + if (quotaAutoRefreshWarning) warnings.push(quotaAutoRefreshWarning); + if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) { + warnings.push(`claudeCode.subagentEffort ignored: expected one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`); + } + warnings.push(...malformedNativeSubagentFields(rawParsed).map(malformedNativeSubagentFieldWarning)); + const pickerWarning = malformedCodexAccountPickerWarning(rawParsed); + if (pickerWarning) warnings.push(pickerWarning); + const hostCircuitWarning = malformedUpstreamHostCircuitThresholdWarning(rawParsed); + if (hostCircuitWarning) warnings.push(hostCircuitWarning); + const recoveryWarning = malformedAgentTaskRecoveryWarning(rawParsed); + if (recoveryWarning) warnings.push(recoveryWarning); + const runtimeRoleWarning = malformedRuntimeRoleWarning(rawParsed); + if (runtimeRoleWarning) warnings.push(runtimeRoleWarning); + const hubWarning = malformedOptionalRemoteBlockWarning(rawParsed, "hub"); + if (hubWarning) warnings.push(hubWarning); + const remoteGuiWarning = malformedOptionalRemoteBlockWarning(rawParsed, "remoteGui"); + if (remoteGuiWarning) warnings.push(remoteGuiWarning); + const clientWarning = malformedClientConnectionWarning(rawParsed); + if (clientWarning) warnings.push(clientWarning); + const notifyWarning = malformedQuotaResetNotifyWarning(rawParsed); + if (notifyWarning) warnings.push(notifyWarning); + const catalogRefreshWarning = malformedCatalogAutoRefreshWarning(rawParsed); + if (catalogRefreshWarning) warnings.push(catalogRefreshWarning); + const codexPoolWarning = malformedCodexPoolWarning(rawParsed); + if (codexPoolWarning) warnings.push(codexPoolWarning); + const plaintextWarning = malformedPlaintextV2AgentMessagesWarning(rawParsed); + if (plaintextWarning) warnings.push(plaintextWarning); + if (syncDisabledReason) { + warnings.push(`syncCodexSubagentDefaults ignored: ${syncDisabledReason}`); + } + return { + config: normalized, + source: "file", + error: null, + ...(warnings.length > 0 ? { warnings } : {}), + }; +} + +export function subagentDefaultSyncEffective( + config: Pick, +): boolean { + return config.syncCodexSubagentDefaults === true && Boolean(config.injectionModel?.trim()); +} + +export function mergeConfigDefaults(parsed: unknown): unknown { + if (!parsed || typeof parsed !== "object") return parsed; + const defaults = getDefaultConfig(); + const raw = parsed as Record; + // Same absence-is-meaningful pin as the repair merge above. + const merged: Record = { + ...defaults, + ...raw, + subagentModelsVersion: raw.subagentModelsVersion, + multiAgentMode: raw.multiAgentMode, + multiAgentSurfaceAdvisoryVersion: raw.multiAgentSurfaceAdvisoryVersion, + }; + if (raw.providers && typeof raw.providers === "object" && defaults.providers) { + merged.providers = { ...defaults.providers, ...(raw.providers as Record) }; + } + return merged; +} + +function schemaDiagnosticsError(error: z.ZodError): string { + const details = error.issues.map(issue => { + const path = issue.path.join(".") || "config"; + return `${path}: ${issue.message}`; + }); + return details.length > 0 ? `schema_invalid: ${details.join("; ")}` : "schema_invalid"; +} + +/** + * Reject a hostname the schema deliberately degrades on read. Load-time has to keep a + * blank value non-fatal (see the `hostname` field comment), but an incoming write is a + * live caller who can be told the value is wrong — silently rewriting it to loopback + * would look like the bind succeeded on the address they asked for. + */ +function blankHostnameError(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const hostname = (value as Record).hostname; + if (hostname === undefined) return null; + if (typeof hostname !== "string" || !hostname.trim()) { + return "schema_invalid: hostname: must be a nonblank bind address"; + } + return null; +} + +function claudeSubagentEffortError(value: unknown): string | null { + const effort = rawClaudeSubagentEffort(value); + if (effort === undefined || isClaudeSubagentEffort(effort)) return null; + return `schema_invalid: claudeCode.subagentEffort: must be one of ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}`; +} + +function appOwnedMemoryBudgetError(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const budget = (value as Record).appOwnedMemoryBudgetMb; + if (budget === undefined) return null; + if (typeof budget !== "number" || !Number.isInteger(budget) + || budget < MIN_APP_OWNED_MEMORY_BUDGET_MB || budget > MAX_APP_OWNED_MEMORY_BUDGET_MB) { + return `schema_invalid: appOwnedMemoryBudgetMb: must be an integer from ${MIN_APP_OWNED_MEMORY_BUDGET_MB} to ${MAX_APP_OWNED_MEMORY_BUDGET_MB}`; + } + return null; +} + +function upstreamHostCircuitThresholdError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null; + const threshold = raw.upstreamHostCircuitThreshold; + if (threshold === undefined) return null; + if (typeof threshold === "number" + && Number.isInteger(threshold) + && threshold >= 0 + && threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null; + return `schema_invalid: upstreamHostCircuitThreshold: must be an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`; +} + +function plaintextV2AgentMessagesError(value: unknown): string | null { + return malformedPlaintextV2AgentMessagesWarning(value) + ? "schema_invalid: plaintextV2AgentMessages: must be a boolean or omitted" + : null; +} + +function agentTaskRecoveryError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "agentTaskRecovery") || raw.agentTaskRecovery === undefined) return null; + const result = agentTaskRecoverySchema.safeParse(raw.agentTaskRecovery); + if (result.success) return null; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return `schema_invalid: agentTaskRecovery${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; +} + +function runtimeRoleError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null; + if (runtimeRoleSchema.safeParse(raw.runtimeRole).success) return null; + return 'schema_invalid: runtimeRole: must be one of "standalone", "hub", or "client"'; +} + +function remoteGuiConfigError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw) return null; + for (const [key, schema] of [ + ["hub", hubConfigSchema], + ["remoteGui", remoteGuiConfigSchema], + ] as const) { + if (!Object.hasOwn(raw, key) || raw[key] === undefined) continue; + const result = schema.safeParse(raw[key]); + if (result.success) continue; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return `schema_invalid: ${key}${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; + } + return null; +} + +function clientConnectionConfigError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "client") || raw.client === undefined) return null; + const result = clientConnectionSchema.safeParse(raw.client); + if (result.success) return null; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return `schema_invalid: client${field ? `.${field}` : ""}: ${issue?.message ?? "invalid client connection"}`; +} + +function clientRolePairError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw) return null; + const hasClient = Object.hasOwn(raw, "client") && raw.client !== undefined; + if (raw.runtimeRole === "client" && !hasClient) { + return "schema_invalid: runtimeRole client requires a complete client connection"; + } + if (hasClient && raw.runtimeRole !== "client") { + return "schema_invalid: client connection requires runtimeRole client"; + } + return null; +} + +function quotaResetNotifyError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "quotaResetNotify") || raw.quotaResetNotify === undefined) return null; + const result = quotaResetNotifySchema.safeParse(raw.quotaResetNotify); + if (result.success) return null; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return `schema_invalid: quotaResetNotify${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; +} + +function catalogAutoRefreshError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "catalogAutoRefresh") || raw.catalogAutoRefresh === undefined) return null; + const result = catalogAutoRefreshSchema.safeParse(raw.catalogAutoRefresh); + if (result.success) return null; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return `schema_invalid: catalogAutoRefresh${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; +} + +/** + * The read path degrades a malformed pool policy to undefined, which for an exclusion policy means + * the excluded accounts quietly keep serving traffic. Reject it on write so `ocx config set` cannot + * create a policy that looks applied and is not. + */ +function codexPoolError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "codexPool") || raw.codexPool === undefined) return null; + const result = codexPoolSchema.safeParse(raw.codexPool); + if (result.success) return null; + const issue = result.error.issues[0]; + const field = issue?.path.join("."); + return `schema_invalid: codexPool${field ? `.${field}` : ""}: ${issue?.message ?? "invalid configuration"}`; +} + +/** + * Same reasoning as {@link blankHostnameError}, and more urgent: the read path degrades a + * malformed selection-order map to undefined, which on a write would drop every entry the + * user had accumulated and still report success. A load-time degrade leaves the raw map in + * the file to be repaired by hand; a degraded write erases it. One bad `ocx config set` + * must not cost the whole map, so a live caller is told instead. + */ +function codexAccountPrioritiesError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw) return null; + if (raw.codexAccountPriorities !== undefined) { + const parsed = codexAccountPrioritiesSchema.safeParse(raw.codexAccountPriorities); + if (!parsed.success) { + return schemaDiagnosticsError(parsed.error).replace("schema_invalid: ", "schema_invalid: codexAccountPriorities."); + } + } + // Tested as a string rather than coerced: `String(123)` matches the id pattern, so a + // coercing guard waves a non-string pin through to the schema, where `.catch(undefined)` + // drops it and reports the write as a success — the exact silent-degrade this guards. + const pin = raw.activeCodexAccountPinned; + if (pin !== undefined && (typeof pin !== "string" || !CODEX_ACCOUNT_PIN_PATTERN.test(pin))) { + return "schema_invalid: activeCodexAccountPinned: must be an account id"; + } + return null; +} + +/** + * Same reasoning as {@link codexAccountPrioritiesError}, plus one of its own. The read + * path drops an invalid grouping, so a degraded write would erase a declaration the + * operator is still editing and still report success. And an ambiguous declaration -- + * one id used twice, one credential in two groups -- has no safe silent answer at all: + * resolving it by list order would quietly merge two quota domains. A live caller is + * told which group is the problem instead. + */ +export function poolCredentialGroupsError(value: unknown): string | null { + const pool = rawConfigRecord(rawConfigRecord(value)?.pool); + if (!pool || pool.credentialGroups === undefined) return null; + const parsed = credentialGroupsSchema.safeParse(pool.credentialGroups); + if (parsed.success) return null; + const details = parsed.error.issues.map(issue => { + const path = issue.path.join("."); + return path ? `${path}: ${issue.message}` : issue.message; + }).join("; "); + return `schema_invalid: pool.credentialGroups: ${details}`; +} + +function codexQuotaAutoRefreshError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || raw.codexQuotaAutoRefresh === undefined) return null; + const parsed = codexQuotaAutoRefreshSchema.safeParse(raw.codexQuotaAutoRefresh); + if (parsed.success) return null; + const details = parsed.error.issues.map(issue => { + const path = issue.path.join("."); + const message = path === "" + ? issue.message.replace(/^codexQuotaAutoRefresh\s*/, "") + : issue.message; + return `codexQuotaAutoRefresh${path ? `.${path}` : ""}: ${message}`; + }); + return `schema_invalid: ${details.join("; ")}`; +} + +function googleAntigravityStaticCatalogVersionError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "googleAntigravityStaticCatalogVersion")) return null; + const version = raw.googleAntigravityStaticCatalogVersion; + if (version === undefined || version === 1 || version === 2) return null; + return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1, 2, or omitted"; +} + +function codexAccountPickerEnabledError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw) return null; + const descriptor = Object.getOwnPropertyDescriptor(raw, "codexAccountPickerEnabled"); + if (!descriptor) { + return "codexAccountPickerEnabled" in raw + ? "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted" + : null; + } + if (!("value" in descriptor)) { + return "schema_invalid: codexAccountPickerEnabled: must be an own boolean data property or omitted"; + } + const enabled = descriptor.value; + if (enabled === undefined || typeof enabled === "boolean") return null; + return "schema_invalid: codexAccountPickerEnabled: must be a boolean or omitted"; +} + +function emptyCompletionRetryError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "emptyCompletionRetry")) return null; + const enabled = raw.emptyCompletionRetry; + if (enabled === undefined || typeof enabled === "boolean") return null; + return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted"; +} + +function dropCodexSafetyBufferingError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "dropCodexSafetyBuffering")) return null; + const enabled = raw.dropCodexSafetyBuffering; + if (enabled === undefined || typeof enabled === "boolean") return null; + return "schema_invalid: dropCodexSafetyBuffering: must be a boolean or omitted"; +} + +function oauthOpenBrowserError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "oauthOpenBrowser")) return null; + const enabled = raw.oauthOpenBrowser; + if (enabled === undefined || typeof enabled === "boolean") return null; + return "schema_invalid: oauthOpenBrowser: must be a boolean or omitted"; +} + +/** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */ +/** + * Reject a loopback-listener port that collides with the proxy port (#1102), and a port-less + * companion listener on a bind address that already owns 127.0.0.1 (#4236). + * + * The schema can only check the shape of each field on its own; the two ports being distinct — + * and the port-less form being compatible with `hostname` — are relationships between fields. + * Letting either through would surface as a startup failure after the public listener already + * bound, which reads like an unrelated port conflict. + * + * Both keys are read from the same candidate, so `ocx config set hostname 127.0.0.1` on a host + * whose listener is already the companion form is refused by this same check, with the same + * message, rather than breaking the next start. + * + * This is write-time only, matching `blankHostnameError`: a live caller can be told the value + * is wrong, whereas a hand-edited config on the read path degrades to undefined rather than + * resetting the whole file. `assertLoopbackListenerBindable` repeats the decision at startup so + * a hand edit that skipped this boundary fails with the same sentence instead of EADDRINUSE. + */ +function loopbackListenerPortError(value: unknown): string | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const listener = (value as Record).unauthenticatedLoopbackListener; + if (listener === undefined) return null; + if (!listener || typeof listener !== "object" || Array.isArray(listener)) { + return "schema_invalid: unauthenticatedLoopbackListener: must be an object or omitted"; + } + const entry = listener as Record; + // `enabled` must be a real boolean. The schema's `.catch(undefined)` would otherwise DELETE + // a `"true"` string entry and report success, leaving an operator convinced they enabled an + // unauthenticated listener that is in fact off. Load-time still degrades quietly — a hand + // edit must not reset the file — but a live caller gets told. + if (typeof entry.enabled !== "boolean") { + return "schema_invalid: unauthenticatedLoopbackListener.enabled: must be a boolean"; + } + if (entry.enabled !== true) return null; + const hostname = typeof (value as Record).hostname === "string" + ? (value as Record).hostname as string + : undefined; + const proxyPort = (value as Record).port; + const listenerPort = entry.port; + // The companion form. `port` omitted means "same port as the public listener, on 127.0.0.1", + // which only exists as a free address when the public listener is bound somewhere else. + if (listenerPort === undefined) { + return loopbackCompanionBindError( + hostname, + typeof proxyPort === "number" ? proxyPort : 10100, + ); + } + if (typeof listenerPort !== "number" || !Number.isInteger(listenerPort) || listenerPort < 1 || listenerPort > 65535) { + return "schema_invalid: unauthenticatedLoopbackListener.port: must be an integer port when enabled, or omitted to share the proxy port"; + } + if (typeof proxyPort === "number" && proxyPort === listenerPort) { + return "schema_invalid: unauthenticatedLoopbackListener.port: must differ from the proxy port"; + } + return null; +} + +/** + * The one sentence both the write boundary and startup use for an impossible companion bind. + * + * Exported so `startServer` can fail with the identical text: an operator who hand-edited the + * file past `validateConfigCandidate` must read the same diagnosis, not EADDRINUSE. + */ +export function loopbackCompanionBindError( + hostname: string | undefined, + proxyPort: number, +): string | null { + if (loopbackCompanionAllowed(hostname)) return null; + const bind = (hostname ?? "").trim() || "127.0.0.1"; + return "schema_invalid: unauthenticatedLoopbackListener: a port-less listener binds " + + `127.0.0.1:${proxyPort}, which the public listener on hostname "${bind}" already holds. ` + + "Either set a distinct unauthenticatedLoopbackListener.port, or remove the listener — a " + + "loopback bind already admits local callers without a credential."; +} + +/** + * Validate the hub management ingress at the live-write boundary. + * + * The persisted schema intentionally degrades a malformed hand edit to disabled so a typo in + * this opt-in listener cannot discard providers or credentials. A live config mutation must not + * get that leniency: it receives an exact field error before the degrading schema is applied. + */ +function managementIngressConfigError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw) return null; + const hub = rawConfigRecord(raw.hub); + if (!hub || !Object.hasOwn(hub, "managementIngress") || hub.managementIngress === undefined) return null; + const ingress = rawConfigRecord(hub.managementIngress); + if (!ingress) { + return "schema_invalid: hub.managementIngress: must be an object or omitted"; + } + if (typeof ingress.enabled !== "boolean") { + return "schema_invalid: hub.managementIngress.enabled: must be a boolean"; + } + const keys = Object.keys(ingress); + if (ingress.enabled === false) { + return keys.length === 1 + ? null + : "schema_invalid: hub.managementIngress: disabled ingress accepts only enabled"; + } + if (keys.some(key => key !== "enabled" && key !== "port")) { + return "schema_invalid: hub.managementIngress: contains an unsupported field"; + } + const ingressPort = ingress.port; + if (typeof ingressPort !== "number" || !Number.isInteger(ingressPort) || ingressPort < 1 || ingressPort > 65535) { + return "schema_invalid: hub.managementIngress.port: must be an integer port when enabled"; + } + if (raw.runtimeRole !== "hub") { + return "schema_invalid: hub.managementIngress: enabled ingress requires runtimeRole hub"; + } + const proxyPort = typeof raw.port === "number" ? raw.port : 10100; + if (proxyPort === ingressPort) { + return "schema_invalid: hub.managementIngress.port: must differ from the proxy port"; + } + const loopback = rawConfigRecord(raw.unauthenticatedLoopbackListener); + if (loopback?.enabled === true && loopback.port === ingressPort) { + return "schema_invalid: hub.managementIngress.port: must differ from unauthenticatedLoopbackListener.port"; + } + return null; +} + +export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } { + const boundaryError = configReasoningPinsConfigError(value) + ?? blankHostnameError(value) + ?? claudeSubagentEffortError(value) + ?? appOwnedMemoryBudgetError(value) + ?? upstreamHostCircuitThresholdError(value) + ?? plaintextV2AgentMessagesError(value) + ?? agentTaskRecoveryError(value) + ?? quotaResetNotifyError(value) + ?? catalogAutoRefreshError(value) + ?? codexPoolError(value) + ?? googleAntigravityStaticCatalogVersionError(value) + ?? codexAccountPrioritiesError(value) + ?? poolCredentialGroupsError(value) + ?? codexQuotaAutoRefreshError(value) + ?? codexAccountPickerEnabledError(value) + ?? emptyCompletionRetryError(value) + ?? dropCodexSafetyBufferingError(value) + ?? oauthOpenBrowserError(value) + ?? runtimeRoleError(value) + ?? remoteGuiConfigError(value) + ?? clientConnectionConfigError(value) + ?? clientRolePairError(value) + ?? loopbackListenerPortError(value) + ?? managementIngressConfigError(value); + if (boundaryError) return { ok: false, error: boundaryError }; + const result = configSchema.safeParse(value); + if (result.success) { + const config = normalizeApiKeyIds(result.data as OcxConfig); + return { ok: true, config }; + } + return { ok: false, error: schemaDiagnosticsError(result.error) }; +} + +export function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { + try { + const parsed = JSON.parse(raw.replace(/^\uFEFF/, "")); + sanitizeReasoningPinsForLoad(parsed); + // Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the + // schema and send the caller a default-config fallback (the config command could then + // persist that fallback over the user's providers/keys). + sanitizeModelDisplayNamesForLoad(parsed); + sanitizeAutoReviewForLoad(parsed); + sanitizeRetryOn429ForLoad(parsed); + sanitizeModelCostsForLoad(parsed); + sanitizeCapabilityDeclarationsForLoad(parsed); + const result = configSchema.safeParse(parsed); + if (result.success) { + return validFileConfigDiagnostics(normalizeApiKeyIds(result.data as OcxConfig), parsed); + } + + const merged = mergeConfigDefaults(parsed); + const retryResult = configSchema.safeParse(merged); + if (retryResult.success) { + return validFileConfigDiagnostics(normalizeApiKeyIds(retryResult.data as OcxConfig), parsed); + } + + // #1785: one invalid routing profile must not make diagnostics report the built-in + // defaults AS the config, because a later config write persists those defaults over the + // operator's providers, keys and prices. + // + // The failure is still reported. `source` stays "fallback" and `error` keeps the real + // schema message -- diagnostics is the surface that tells callers the file is invalid, + // and every consumer that must refuse an invalid config (provider reload, catalog sync, + // cost reconcile, codex admission) gates on exactly those two fields. Only `config` + // changes: it carries the salvaged document instead of factory defaults, so a caller + // that ignores the error and writes it back preserves what the operator configured. + const salvaged = salvageConfigCandidate(merged, retryResult.error); + if (salvaged) { + const config = normalizeApiKeyIds(salvaged.parsed); + const warnings = degradedListenerWarnings(parsed, config); + return { + config, + source: "fallback", + error: schemaDiagnosticsError(result.error), + ...(warnings.length > 0 ? { warnings } : {}), + }; + } + + return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) }; + } catch { + return { config: getDefaultConfig(), source: "fallback", error: "invalid_json" }; + } +} + +export function readConfigFileSnapshot(): ConfigFileSnapshot { + try { + const raw = readFileSync(getConfigPath(), "utf-8"); + return { diagnostics: configDiagnosticsFromRaw(raw), raw }; + } catch (error) { + if (isMissingPathError(error)) { + return { + diagnostics: { config: getDefaultConfig(), source: "default", error: null }, + }; + } + return { + diagnostics: { config: getDefaultConfig(), source: "fallback", error: "invalid_json" }, + }; + } +} + +export function readConfigDiagnostics(): ConfigDiagnostics { + return readConfigFileSnapshot().diagnostics; +} + +/** Read-only init preflight. Occupied unsafe entries are never treated as absence. */ +export function observeInitialConfigState(): "missing" | "exists" | "invalid" { + try { + if (!lstatSync(getConfigPath()).isFile()) return "invalid"; + } catch (error) { + return isMissingPathError(error) ? "missing" : "invalid"; + } + return readConfigFileSnapshot().diagnostics.source === "file" ? "exists" : "invalid"; +} + +/** + * The persisted config, plus a digest of the EXACT bytes it was parsed from. + * + * A union rather than a nullable digest, because `{ kind: "read" }` with no + * digest is a state that cannot occur — and a state that cannot occur should + * not be a state that can be written down. Refusing it at runtime is a check + * somebody eventually forgets; making it unrepresentable is not. + * + * Why a byte digest at all: the Codex write lock compares an authority snapshot + * taken before the lock against one taken while holding it, and its config + * component used to hash the PARSED object. Two files that differ only in + * whitespace or key order parse identically, so a non-cooperating writer could + * rewrite the file between admission and commit and the comparison would see + * nothing. Hashing what was actually read closes that. + * + * `readConfigFileSnapshot` stays private on purpose. Its `raw` carries provider + * API keys and admission tokens, and `privacy:scan` reads tracked source text, + * not runtime values — so it would not catch a caller that logged or serialized + * that string. The digest travels; the bytes do not. + */ +export type ConfigAdmissionSnapshot = + | Readonly<{ kind: "read"; diagnostics: ConfigDiagnostics; contentSha256: string }> + | Readonly<{ kind: "unreadable"; diagnostics: ConfigDiagnostics; contentSha256: null }>; + +export function readConfigAdmissionSnapshot(): ConfigAdmissionSnapshot { + let bytes: Buffer; + try { + // ONE read. Hashing the file and then reading it again to parse would leave + // a window for the two to disagree, which is the exact hazard this exists + // to detect — the check would become a second chance to be wrong. + bytes = readFileSync(getConfigPath()); + } catch (error) { + return { + kind: "unreadable", + diagnostics: isMissingPathError(error) + ? { config: getDefaultConfig(), source: "default", error: null } + : { config: getDefaultConfig(), source: "fallback", error: "invalid_json" }, + contentSha256: null, + }; + } + return { + kind: "read", + // Decoded from the same buffer that was hashed, not re-read from disk. + diagnostics: configDiagnosticsFromRaw(bytes.toString("utf-8")), + contentSha256: createHash("sha256").update(bytes).digest("hex"), + }; +} diff --git a/src/config/feature-flags.ts b/src/config/feature-flags.ts new file mode 100644 index 0000000000..01d60adbf2 --- /dev/null +++ b/src/config/feature-flags.ts @@ -0,0 +1,55 @@ +import type { OcxConfig } from "../types"; + +export function websocketsEnabled(config: Pick): boolean { + return config.websockets === true; +} + +/** + * Opt-in Ultra Fast, read with the house `=== true` idiom so an absent key and a + * malformed one both mean off. + */ +export function ultraFastTierEnabled(config: Pick): boolean { + return config.ultraFastTier === true; +} + +/** + * Default cadence for the opt-in catalog auto-refresh (issue #3630): one converge pass + * per hour. Each pass spends a live /models call against every enabled provider, and + * provider catalogs are themselves cached upstream for minutes, so an hour is fresh + * enough for newly released models to appear without an `ocx sync`. + */ +export const CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS: number = 60 * 60_000; + +/** + * Floor under the configured cadence, for the same reason src/quota/reset-poller.ts has + * MIN_INTERVAL_MS: below this the refresh buys no freshness — upstream caches have not + * moved — and only multiplies the chance of a rate limit across every enabled provider. + */ +export const CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS: number = 15 * 60_000; + +/** + * Opt-in master switch, read with the house `=== true` idiom so an absent key and a + * malformed one both mean off. Pure on purpose: the scheduler calls this from a + * dynamically imported context, so it takes an explicit config slice and reads nothing + * global. + */ +export function isCatalogAutoRefreshEnabled( + config: Pick, +): boolean { + return config.catalogAutoRefresh?.enabled === true; +} + +/** + * Resolved tick interval in milliseconds. An explicit `intervalMinutes: 0` returns 0 — + * the section stays configured but the timer stays dormant — and any other value is + * clamped up to CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS so a hand edit cannot outrun the + * upstream catalog caches. Absent means the hourly default. + */ +export function resolveCatalogAutoRefreshIntervalMs( + config: Pick, +): number { + const minutes = config.catalogAutoRefresh?.intervalMinutes; + if (minutes === undefined) return CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS; + if (minutes === 0) return 0; + return Math.max(CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS, Math.floor(minutes * 60_000)); +} diff --git a/src/config/live-reconcile.ts b/src/config/live-reconcile.ts new file mode 100644 index 0000000000..8715b1f146 --- /dev/null +++ b/src/config/live-reconcile.ts @@ -0,0 +1,403 @@ +import type { OcxConfig, OcxProviderConfig } from "../types"; +import { configReasoningPinsConfigError } from "./provider-validation"; +import { adoptCustomModelCatalogMigration, projectCustomModelCatalogMigration } from "../codex/custom-model-catalog-migration"; +import { refreshPreservedProviderOwner, refreshUserCostOverlays } from "../usage/user-cost-overlays"; +import { + clearPendingConfigTopLevelDeletions, + configHasRebaseProvenance, + configRebaseDeletionKeys, + CONFIG_REBASE_PROVENANCE_KEY, + projectConfigRebaseProvenance, +} from "./rebase-provenance"; +import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } from "./mutation-lock"; +import { persistConfigUnlocked, readRawConfigJson } from "./persist-unlocked"; +import { configDiagnosticsFromRaw, readConfigDiagnostics } from "./diagnostics"; +import { normalizePersistedClaudeCode } from "./load-degrade"; + +// --------------------------------------------------------------------------- +// Hand-edit protection for the `claudeCode` subtree (devlog 260726_claude_auth_auto/040 H1). +// +// `saveConfig` serializes the WHOLE config object, so ANY service-time save — a model +// visibility toggle, a 429 key rotation on the request path — rewrites `claudeCode` +// from whatever the long-lived server config happens to hold. A user who hand-edits +// `config.json` while the proxy runs then watches their edit vanish for no visible +// reason (issue #488). Enumerating `claudeCode` mutators cannot fix that; the guard has +// to live in ONE save wrapper that every live-config writer goes through. +// --------------------------------------------------------------------------- + +/** + * Baseline keyed on the CONFIG INSTANCE, never a module global: a second `loadConfig()` + * elsewhere must not refresh the baseline the long-lived server config is judged + * against, or a later stale save would masquerade as "our own change". + */ +const claudeCodeBaseline = new WeakMap(); +/** + * Full live-config baseline used to rebase unrelated cooperating writes. The + * Claude subtree and the bound listener fields remain on their dedicated + * reconciliation paths below. + */ +const liveConfigBaseline = new WeakMap(); +/** + * The live config retains the address of the socket Bun actually opened, while + * this map retains the operator's desired address for the next process start. + * Keeping them separate prevents an unrelated live save from restoring a stale + * externally exposed bind after OAuth adopted a newer loopback disk config. + */ +type PersistedServerBinding = Pick; + +const persistedLiveServerBinding = new WeakMap(); + +/** + * Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on + * first save — arming lazily would lose exactly the hand edit made before that first + * save, which is the case the guard exists for. + */ +export function armClaudeCodeBaseline(config: OcxConfig): void { + liveConfigBaseline.set(config, structuredClone(config)); + claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); +} + +/** + * Adopt one schema-validated provider that was read from the authoritative disk + * config into a long-lived server config without rebasing any unrelated field. + * Updating the matching baseline row keeps a later guarded save from treating the + * adopted provider as an unsaved live edit that should defeat a newer disk change. + */ +export function adoptPersistedProviderIntoLiveConfig( + config: OcxConfig, + name: string, + provider: OcxProviderConfig, + persistedConfig?: OcxConfig, +): void { + config.providers[name] = structuredClone(provider); + const baseline = liveConfigBaseline.get(config); + if (baseline) baseline.providers[name] = structuredClone(provider); + if (persistedConfig) refreshPreservedProviderOwner(config, persistedConfig); +} + +/** Test seam only: is this instance armed? */ +export function claudeCodeBaselineArmed(config: OcxConfig): boolean { + return claudeCodeBaseline.has(config); +} + +/** + * Structural compare of parsed subtrees. NOT `JSON.stringify`: key order must not + * decide whether a user's hand edit survives. + */ +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, index) => deepEqual(item, b[index])); + } + const left = a as Record; + const right = b as Record; + // `undefined` values and absent keys are the same thing after a JSON round-trip. + const keys = new Set([...Object.keys(left), ...Object.keys(right)]); + for (const key of keys) { + if (left[key] === undefined && right[key] === undefined) continue; + if (!deepEqual(left[key], right[key])) return false; + } + return true; +} + +const MISSING_CONFIG_VALUE = Symbol("missing-config-value"); +type ConfigMergeValue = unknown | typeof MISSING_CONFIG_VALUE; + +function isPlainConfigRecord(value: ConfigMergeValue): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function ownConfigValue(record: Record, key: string): ConfigMergeValue { + return Object.hasOwn(record, key) ? record[key] : MISSING_CONFIG_VALUE; +} + +function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue { + return value === MISSING_CONFIG_VALUE ? value : structuredClone(value); +} + +type IndexedCustomModels = { + order: string[]; + byId: Map>; +}; + +function indexCustomModels(value: ConfigMergeValue): IndexedCustomModels | null { + if (!Array.isArray(value)) return null; + const order: string[] = []; + const byId = new Map>(); + for (const item of value) { + if (!isPlainConfigRecord(item) || typeof item.id !== "string" || item.id.length === 0 || byId.has(item.id)) { + return null; + } + order.push(item.id); + byId.set(item.id, item); + } + return { order, byId }; +} + +/** + * Merge custom-model rows by their stable id instead of treating the array as + * one opaque value. A row changed only on disk is adopted, a row changed only + * in the live config is retained, and disjoint edits to the same row recurse + * through the normal three-way object merge. A newer persisted row deletion + * wins over a stale live edit to that row. + */ +function reconcileCustomModels( + baseline: ConfigMergeValue, + live: ConfigMergeValue, + persisted: ConfigMergeValue, +): ConfigMergeValue | null { + const baselineRows = indexCustomModels(baseline); + const liveRows = indexCustomModels(live); + const persistedRows = indexCustomModels(persisted); + if (!baselineRows || !liveRows || !persistedRows) return null; + + const order = [...liveRows.order, ...persistedRows.order.filter(id => !liveRows.byId.has(id))]; + const merged: Array> = []; + for (const id of order) { + const baselineRow = baselineRows.byId.get(id) ?? MISSING_CONFIG_VALUE; + const persistedRow = persistedRows.byId.get(id) ?? MISSING_CONFIG_VALUE; + const row = baselineRow !== MISSING_CONFIG_VALUE && persistedRow === MISSING_CONFIG_VALUE + ? MISSING_CONFIG_VALUE + : reconcileConfigValue( + baselineRow, + liveRows.byId.get(id) ?? MISSING_CONFIG_VALUE, + persistedRow, + ); + if (row !== MISSING_CONFIG_VALUE) merged.push(row as Record); + } + return merged; +} + +function reconcileConfigRecord( + live: Record, + baseline: Record, + persisted: Record, + skippedKeys?: ReadonlySet, + persistedDeletionsWin = false, +): void { + const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]); + for (const key of keys) { + if (skippedKeys?.has(key)) continue; + const baselineValue = ownConfigValue(baseline, key); + const liveValue = ownConfigValue(live, key); + const persistedValue = ownConfigValue(persisted, key); + const merged = persistedDeletionsWin + && baselineValue !== MISSING_CONFIG_VALUE + && persistedValue === MISSING_CONFIG_VALUE + ? MISSING_CONFIG_VALUE + : key === "customModels" + ? reconcileCustomModels(baselineValue, liveValue, persistedValue) + ?? reconcileConfigValue(baselineValue, liveValue, persistedValue) + : reconcileConfigValue(baselineValue, liveValue, persistedValue, key === "providers"); + if (merged === MISSING_CONFIG_VALUE) delete live[key]; + else live[key] = merged; + } +} + +function reconcileConfigValue( + baseline: ConfigMergeValue, + live: ConfigMergeValue, + persisted: ConfigMergeValue, + persistedChildDeletionsWin = false, +): ConfigMergeValue { + const liveChanged = !deepEqual(live, baseline); + const persistedChanged = !deepEqual(persisted, baseline); + + if (!liveChanged) { + if (live !== MISSING_CONFIG_VALUE && Array.isArray(live) && Array.isArray(persisted)) { + live.splice(0, live.length, ...structuredClone(persisted)); + return live; + } + if (isPlainConfigRecord(live) && isPlainConfigRecord(persisted)) { + reconcileConfigRecord( + live, + isPlainConfigRecord(baseline) ? baseline : {}, + persisted, + ); + return live; + } + return cloneConfigValue(persisted); + } + + if (!persistedChanged) return live; + + if (isPlainConfigRecord(live) + && isPlainConfigRecord(persisted) + && (baseline === MISSING_CONFIG_VALUE || isPlainConfigRecord(baseline))) { + reconcileConfigRecord( + live, + isPlainConfigRecord(baseline) ? baseline : {}, + persisted, + undefined, + persistedChildDeletionsWin, + ); + } + // Same-leaf conflicts prefer the pending live management mutation. + return live; +} + +/** + * Reconcile an async OAuth disk commit into the shared live config without erasing + * management mutations that have not saved yet. The baseline is a normalized disk + * snapshot from immediately before login; disjoint object edits merge recursively, + * while same-leaf conflicts prefer live state. + */ +export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline: OcxConfig): void { + const diagnostics = readConfigDiagnostics(); + if (diagnostics.source === "fallback") { + throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`); + } + const persisted = diagnostics.config; + const claudeGuardArmed = claudeCodeBaseline.has(config); + const pendingLiveClaudeMutation = claudeGuardArmed + && !deepEqual(config.claudeCode, claudeCodeBaseline.get(config)); + + persistedLiveServerBinding.set(config, { + port: persisted.port, + ...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}), + }); + + reconcileConfigRecord( + config as unknown as Record, + persistedBaseline as unknown as Record, + persisted as unknown as Record, + new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]), + ); + + if (claudeGuardArmed && !pendingLiveClaudeMutation) { + if (persisted.claudeCode === undefined) delete config.claudeCode; + else config.claudeCode = structuredClone(persisted.claudeCode); + claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); + } + // The reconciliation may have adopted a providers..modelCosts edit made + // by a cooperating process while the OAuth login was pending; keep the overlay + // registry (and the usage-cache overlay version) in sync with the live config. + refreshUserCostOverlays(config); +} + +/** + * Read only schema-valid binding fields from the literal file. Missing fields mean + * their schema defaults; malformed fields keep the last known persisted value. + */ +function readPersistedServerBinding( + raw: Record, + baseline: PersistedServerBinding, +): PersistedServerBinding { + const port = raw.port === undefined + ? 10100 + : (typeof raw.port === "number" + && Number.isInteger(raw.port) + && raw.port >= 0 + && raw.port <= 65535 + ? raw.port + : baseline.port); + const hostname = raw.hostname === undefined + ? undefined + : (typeof raw.hostname === "string" ? raw.hostname : baseline.hostname); + return { port, ...(hostname !== undefined ? { hostname } : {}) }; +} + +/** + * The save entry point for every writer holding a LIVE server config. + * + * Conflict policy, chosen deliberately: + * - disk changed, we did not → their hand edit wins; + * - disk changed AND we changed → disjoint fields are merged, while a same-leaf + * conflict keeps the live value; + * - a provider or custom-model row deleted on disk stays deleted even if stale + * live state edited that same row; + * - file missing/unreadable → save what we have, no throw. + * + * Custom-model rows are merged by their stable `id`, preserving independent + * edits and deletions across stale whole-config saves. + */ +export function saveConfigPreservingClaudeCode(config: OcxConfig): void { + const pinError = configReasoningPinsConfigError(config); + if (pinError) throw new Error(pinError); + withConfigMutationLockSync(() => { + const bindingBaseline = persistedLiveServerBinding.get(config); + // One authoritative pre-write read feeds both the live-config reconciliation and + // custom-model deletion migration. A second read could observe different bytes. + const onDisk = readRawConfigJson(); + const baseline = liveConfigBaseline.get(config); + if (baseline && onDisk !== undefined) { + const persistedDiagnostics = configDiagnosticsFromRaw(JSON.stringify(onDisk)); + if (persistedDiagnostics.source === "file") { + const deletedKeys = configRebaseDeletionKeys(config); + const provenanceExists = configHasRebaseProvenance(config); + // Only keys this live config is actually known to have diverged on may be + // rebased. The baseline is captured once when the server arms it, so any key + // that appeared on disk afterwards — through saveConfig(), a hand edit, or + // another process — is absent from the baseline as well as from the live + // config. Reconciling those keys reads "live never changed this" and adopts + // the disk value, which resurrects a field the live writer had deliberately + // deleted (#1462 regression: PUT /api/grok/selection with an empty list). + // Restrict the merge to keys the baseline knew about, plus keys the live + // config still carries; a key that exists only on disk is left to the + // ordinary whole-config write below. + const rebaseableKeys = new Set([ + ...Object.keys(baseline as unknown as Record), + ...Object.keys(config as unknown as Record), + ...(provenanceExists + ? Object.keys(persistedDiagnostics.config as unknown as Record) + : []), + ]); + const skipped = new Set(["hostname", "port", "claudeCode", CONFIG_REBASE_PROVENANCE_KEY]); + for (const key of Object.keys(persistedDiagnostics.config as unknown as Record)) { + if (!rebaseableKeys.has(key)) skipped.add(key); + } + reconcileConfigRecord( + config as unknown as Record, + baseline as unknown as Record, + persistedDiagnostics.config as unknown as Record, + skipped, + ); + for (const key of deletedKeys) delete (config as unknown as Record)[key]; + } + } + if (claudeCodeBaseline.has(config)) { + if (onDisk !== undefined) { + const baseline = claudeCodeBaseline.get(config); + const persistedClaudeCode = normalizePersistedClaudeCode(onDisk.claudeCode); + const diskChanged = !deepEqual(persistedClaudeCode, baseline); + const weChanged = !deepEqual(config.claudeCode, baseline); + if (diskChanged && !weChanged) { + config.claudeCode = persistedClaudeCode; + } + } + } + const provenanceProjection = projectConfigRebaseProvenance(config); + const projectedConfig = projectCustomModelCatalogMigration( + onDisk, + config, + ); + if (provenanceProjection.configRebaseProvenance === undefined) delete projectedConfig.configRebaseProvenance; + else projectedConfig.configRebaseProvenance = provenanceProjection.configRebaseProvenance; + const persistedBinding = bindingBaseline && onDisk + ? readPersistedServerBinding(onDisk, bindingBaseline) + : bindingBaseline; + if (persistedBinding) { + const persistedConfig: OcxConfig = { ...projectedConfig, port: persistedBinding.port }; + if (persistedBinding.hostname === undefined) delete persistedConfig.hostname; + else persistedConfig.hostname = persistedBinding.hostname; + if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite(); + persistedLiveServerBinding.set(config, persistedBinding); + } else { + if (persistConfigUnlocked(projectedConfig)) bumpGenerationForCooperatingConfigWrite(); + } + adoptCustomModelCatalogMigration(config, projectedConfig); + if (claudeCodeBaseline.has(config)) { + claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); + } + if (liveConfigBaseline.has(config)) { + if (projectedConfig.configRebaseProvenance === undefined) delete config.configRebaseProvenance; + else config.configRebaseProvenance = structuredClone(projectedConfig.configRebaseProvenance); + liveConfigBaseline.set(config, structuredClone(projectedConfig)); + } + clearPendingConfigTopLevelDeletions(config); + }); +} diff --git a/src/config/load-degrade.ts b/src/config/load-degrade.ts new file mode 100644 index 0000000000..84ca9fb35f --- /dev/null +++ b/src/config/load-degrade.ts @@ -0,0 +1,880 @@ +import { chmodSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { + modelPinnedEffortsConfigError, + pinnedReasoningEffortConfigError, + autoReviewModelOverridesConfigError, + autoReviewModelTargetConfigError, + modelCapabilitiesConfigError, + sanitizeModelCapabilitiesForLoad, + modelDisplayNamesConfigError, +} from "./provider-validation"; +import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "../codex/upstream-host-health"; +import { hardenSecretPath } from "../lib/windows-secret-acl"; +import { redactSecretString } from "../lib/redact"; +import { isValidProviderName } from "./provider-name"; +import { MODEL_ALIAS_PATTERN } from "../providers/default-aliases"; +import { MODEL_DISCOVERY_MAX_MODELS } from "../providers/model-discovery-limits"; +import { getProviderRegistryEntry, providerMatchesRegistryTransport, registryModelServiceTierCapabilityApplies } from "../providers/registry"; +import { isCodexReasoningEffort } from "../reasoning-effort"; +import { refreshUserCostOverlays } from "../usage/user-cost-overlays"; +import { type OcxClaudeCodeConfig, type OcxConfig } from "../types"; +import { + agentTaskRecoverySchema, + catalogAutoRefreshSchema, + clientConnectionSchema, + isUsableApiKeySecret, + managementIngressSchema, + codexPoolSchema, + providerModelCostsConfigError, + credentialGroupsSchema, + hubConfigSchema, + quotaResetNotifySchema, + remoteGuiConfigSchema, + retryOn429PolicySchema, + runtimeRoleSchema, +} from "./schema/leaf-validators"; +import { hasWarnedInheritedFastWireConflict, markWarnedInheritedFastWireConflict } from "./warn-memo"; + +export function hardenExistingSecret(path: string): void { + if (existsSync(path)) { + try { chmodSync(path, 0o600); } catch { /* best-effort */ } + if (process.platform === "win32") { + hardenSecretPath(path, { required: false }); + } + } +} +/** Load only: discard invalid optional pins without rewriting the file or losing providers. */ +export function sanitizeReasoningPinsForLoad(parsed: unknown): void { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return; + const root = parsed as Record; + let degraded = false; + const sanitizeMap = (owner: Record, field: string) => { + const value = owner[field]; + if (value === undefined) return; + if (!value || typeof value !== "object" || Array.isArray(value) + || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) { + delete owner[field]; + degraded = true; + return; + } + const counts = new Map(); + for (const key of Object.keys(value)) counts.set(key.trim(), (counts.get(key.trim()) ?? 0) + 1); + const valid: Record = Object.create(null); + for (const [key, effort] of Object.entries(value)) { + if (counts.get(key.trim()) !== 1 || modelPinnedEffortsConfigError({ [key]: effort }) !== null) { + degraded = true; + continue; + } + valid[key.trim()] = effort as string; + } + if (Object.keys(valid).length) owner[field] = valid; + else delete owner[field]; + }; + sanitizeMap(root, "modelPinnedEfforts"); + if (root.providers && typeof root.providers === "object" && !Array.isArray(root.providers)) { + for (const value of Object.values(root.providers)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const provider = value as Record; + if (pinnedReasoningEffortConfigError(provider.pinnedReasoningEffort)) { + delete provider.pinnedReasoningEffort; + degraded = true; + } + sanitizeMap(provider, "modelPinnedReasoningEfforts"); + } + } + // Never include a provider/model name or value: malformed pins can contain secrets. + if (degraded) console.warn("config.json contains invalid optional reasoning pins — ignoring invalid fields or entries"); +} + +/** + * The schema's `.catch(undefined)` silently degrades an invalid persisted + * `streamMode` to "auto"; surface that once so a hand-edited typo (e.g. + * "legacy_tee") is discoverable instead of silently changing stream shape. + */ +export function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void { + if (!rawParsed || typeof rawParsed !== "object") return; + const raw = (rawParsed as Record).streamMode; + if (raw !== undefined && validated.streamMode === undefined) { + console.warn(`⚠️ config.json streamMode ${JSON.stringify(raw)} is invalid (expected "auto", "legacy-tee", or "eager-relay") — falling back to "auto"`); + } +} + +/** + * Load-time degradation for `retryOn429` (loadConfig only): one hand-edited invalid optional + * field (e.g. `attempts: 0` or a string) must not trip the whole provider schema and hide every + * provider/key behind a default config. Invalid fields are dropped with a warning; the management + * write boundary still rejects invalid policies explicitly. + */ +export function sanitizeRetryOn429ForLoad(parsed: unknown): void { + if (!parsed || typeof parsed !== "object") return; + const root = parsed as Record; + const providers = root.providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return; + for (const [name, provider] of Object.entries(providers as Record)) { + // This sanitizer runs BEFORE schema validation, so the provider name is untrusted: redact + // secret-shaped names and JSON-escape control characters before it reaches any warning. + const safeProviderName = JSON.stringify(redactSecretString(name)); + if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue; + const p = provider as Record; + const policy = p.retryOn429; + if (policy === undefined) continue; + if (!policy || typeof policy !== "object" || Array.isArray(policy)) { + delete p.retryOn429; + // Never serialize the value: an accidental `retryOn429: "sk-..."` would leak the secret. + console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 (${typeof policy}) is invalid — ignoring the policy`); + continue; + } + const policyRecord = policy as Record; + // An explicitly present but invalid master switch must not silently default to ENABLED: + // drop the whole policy so a hand-edit that tried to disable retries stays disabled. + if ("enabled" in policyRecord && typeof policyRecord.enabled !== "boolean") { + delete p.retryOn429; + console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.enabled (${typeof policyRecord.enabled}) is invalid — ignoring the whole policy`); + continue; + } + // Field checks derive from the shared policy schema so the bounds cannot drift + // between the load-time sanitizer, the config schema, and the write boundary. + const policyShape = retryOn429PolicySchema.shape; + const hadPolicyEntries = Object.keys(policyRecord).length > 0; + const cleaned: Record = {}; + for (const [key, fieldSchema] of Object.entries(policyShape)) { + const value = policyRecord[key]; + if (value === undefined) continue; + if (fieldSchema.safeParse(value).success) cleaned[key] = value; + // Log only the received type, never the value (provider config can hold secrets). + else console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${key} (${typeof value}) is invalid — ignoring the field`); + } + const knownKeys = new Set(Object.keys(policyShape)); + for (const key of Object.keys(policyRecord)) { + if (!knownKeys.has(key)) { + // Redact the field NAME before logging: a malformed hand-edit can place a secret in a + // property name (`retryOn429: { "sk-...": true }`). Ordinary typos (e.g. `attempt`) + // stay readable, secret-shaped names become [REDACTED]. JSON-escape afterwards so a + // control-character property name (newline/ANSI) can never forge a log line. + console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429.${JSON.stringify(redactSecretString(key))} is not a recognized field — ignoring it`); + } + } + if (hadPolicyEntries && Object.keys(cleaned).length === 0) { + // Every supplied field was invalid: drop the whole policy. Persisting `{}` here would + // opt IN to retries with defaults, which is the opposite of what a malformed + // disable-oriented edit (`retryOn429: { enabled: "false" }`, `attempts: 0`) asked for. + delete p.retryOn429; + console.warn(`⚠️ config.json providers.${safeProviderName}.retryOn429 has no valid fields left — removing the policy (an empty policy would enable retries with defaults)`); + } else { + // Preserve an intentionally empty `retryOn429: {}` (presence = opt-in with defaults). + p.retryOn429 = cleaned; + } + } +} + +/** + * Management write-boundary validation for `retryOn429` (fail closed). Unlike the + * lenient load-time sanitizer, invalid values and unknown keys are rejected outright so + * a POST/PATCH cannot persist a policy the proxy would then silently degrade. Reuses the + * shared policy schema. Never echoes values, and secret-shaped unknown field names are + * redacted (a malformed write can place a secret in a property name). + */ +export function retryOn429PolicyConfigError(policy: unknown): string | null { + if (policy === undefined) return null; + const result = retryOn429PolicySchema.safeParse(policy); + if (result.success) return null; + const first = result.error.issues[0]; + if (!first) return "retryOn429 is invalid"; + if (first.code === "unrecognized_keys") { + const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", "); + return `retryOn429 has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`; + } + if (first.path.length === 0) return `retryOn429 is invalid (${first.message})`; + const field = String(first.path[first.path.length - 1]); + return `retryOn429.${field} is invalid (${first.message})`; +} + +export function sanitizeCapabilityDeclarationsForLoad(parsed: unknown): void { + if (!parsed || typeof parsed !== "object") return; + const providers = (parsed as Record).providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return; + for (const [name, value] of Object.entries(providers)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const provider = value as Record; + if (provider.modelCapabilities === undefined) continue; + if (modelCapabilitiesConfigError(provider.modelCapabilities) !== null) { + console.warn(`config.json provider ${JSON.stringify(redactSecretString(name))} has malformed modelCapabilities; retaining valid axes and restricting malformed input modalities to text`); + const repaired = sanitizeModelCapabilitiesForLoad(provider.modelCapabilities); + if (repaired) provider.modelCapabilities = repaired; + else delete provider.modelCapabilities; + } + } +} + +/** + * Load-time degradation for `providers..modelCosts`, mirroring + * {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row + * must not fail the whole config parse — that would back up config.json and + * fall back to defaults, dropping otherwise valid providers and the default + * route for a typo in a non-runtime display field. Invalid rows are dropped + * with a warning; strict rejection stays at the management/write boundary + * (providerManagementConfigError). + */ +export function sanitizeModelCostsForLoad(parsed: unknown): void { + if (!parsed || typeof parsed !== "object") return; + const root = parsed as Record; + const providers = root.providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return; + for (const [name, provider] of Object.entries(providers as Record)) { + // Runs before schema validation, so the provider name is untrusted: redact + // secret-shaped names and JSON-escape control characters for the warning. + const safeProviderName = JSON.stringify(redactSecretString(name)); + if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue; + const p = provider as Record; + const costs = p.modelCosts; + if (costs === undefined) continue; + if (!costs || typeof costs !== "object" || Array.isArray(costs)) { + delete p.modelCosts; + console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts (${typeof costs}) is invalid — ignoring the overlay`); + continue; + } + const costsRecord = costs as Record; + const hadEntries = Object.keys(costsRecord).length > 0; + let kept = 0; + for (const [modelId, entry] of Object.entries(costsRecord)) { + // Reuse the shared per-row shape contract so the load-time sanitizer + // cannot drift from the schema and the write boundary. + if (providerModelCostsConfigError({ [modelId]: entry }) === null) { + kept++; + continue; + } + delete costsRecord[modelId]; + // Redact the model id: a hand-edit can place a secret in a key name. + console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts.${JSON.stringify(redactSecretString(modelId))} is invalid — ignoring the row`); + } + if (hadEntries && kept === 0) { + delete p.modelCosts; + console.warn(`⚠️ config.json providers.${safeProviderName}.modelCosts has no valid rows left — removing the overlay`); + } + } +} + +/** + * Load-time degradation for provider-scoped auto-review selectors. A malformed + * hand edit must not fail the whole config parse; the management boundary stays + * strict and rejects the same shapes before they can be written. + */ +export function sanitizeAutoReviewForLoad(parsed: unknown): void { + if (!parsed || typeof parsed !== "object") return; + const root = parsed as Record; + const providers = root.providers; + if (!providers || typeof providers !== "object" || Array.isArray(providers)) return; + for (const [name, providerValue] of Object.entries(providers as Record)) { + if (!providerValue || typeof providerValue !== "object" || Array.isArray(providerValue)) continue; + const provider = providerValue as Record; + const safeProviderName = JSON.stringify(redactSecretString(name)); + if (name === "openai") { + delete provider.autoReviewModel; + delete provider.autoReviewModelOverrides; + continue; + } + if (provider.autoReviewModel !== undefined + && autoReviewModelTargetConfigError(provider.autoReviewModel, "autoReviewModel", true) !== null) { + console.warn(`⚠️ config.json providers.${safeProviderName}.autoReviewModel is invalid — ignoring the selector`); + delete provider.autoReviewModel; + } + if (provider.autoReviewModelOverrides !== undefined) { + const overridesError = autoReviewModelOverridesConfigError( + provider.autoReviewModelOverrides, + "autoReviewModelOverrides", + true, + ); + if (overridesError) { + console.warn(`⚠️ config.json providers.${safeProviderName}.autoReviewModelOverrides is invalid — ignoring the map`); + delete provider.autoReviewModelOverrides; + } + } + } +} + +/** + * Companion to {@link warnDegradedStreamMode} for a blank persisted `hostname`. The bind + * falls back to loopback, which is the safe direction but not what the file asked for — + * say so once instead of silently ignoring the field. + */ +export function warnDegradedHostname(rawParsed: unknown, validated: OcxConfig): void { + if (!rawParsed || typeof rawParsed !== "object") return; + const raw = (rawParsed as Record).hostname; + if (raw !== undefined && validated.hostname === undefined) { + console.warn(`⚠️ config.json hostname ${JSON.stringify(raw)} is not a usable bind address — falling back to 127.0.0.1`); + } +} + +export function degradedListenerWarnings(rawParsed: unknown, validated: OcxConfig): string[] { + const raw = rawConfigRecord(rawParsed); + if (!raw) return []; + const warnings: string[] = []; + if (raw.unauthenticatedLoopbackListener !== undefined && validated.unauthenticatedLoopbackListener === undefined) { + warnings.push("unauthenticatedLoopbackListener ignored: invalid listener configuration; repair config.json before enabling the listener"); + } + const hub = rawConfigRecord(raw.hub); + if (hub?.managementIngress !== undefined && !managementIngressSchema.safeParse(hub.managementIngress).success) { + warnings.push("hub.managementIngress ignored: invalid management listener configuration; repair config.json before enabling the listener"); + } + return warnings; +} + +export function warnDegradedListeners(rawParsed: unknown, validated: OcxConfig): void { + for (const warning of degradedListenerWarnings(rawParsed, validated)) { + console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); + } +} + +/** + * Companion to {@link warnDegradedStreamMode} for a malformed selection-order map. + * Priority is a preference, so the schema drops the whole map rather than failing + * the parse — say so once, otherwise the pool silently reverts to flat ordering. + */ +export function degradedCodexAccountPriorityWarnings(rawParsed: unknown, validated: OcxConfig): string[] { + const record = rawConfigRecord(rawParsed); + const warnings: string[] = []; + // The pin degrades silently otherwise, which reads as the manual selection simply + // not having survived the restart. + if (record?.activeCodexAccountPinned !== undefined && validated.activeCodexAccountPinned === undefined) { + warnings.push("activeCodexAccountPinned is not a valid account id — the manually selected account is no longer pinned"); + } + const raw = record?.codexAccountPriorities; + if (raw !== undefined && validated.codexAccountPriorities === undefined) { + warnings.push("codexAccountPriorities is invalid (expected account ids mapped to integers between -100 and 100) — account selection order is disabled"); + } + return warnings; +} + +export function warnDegradedCodexAccountPriorities(rawParsed: unknown, validated: OcxConfig): void { + for (const warning of degradedCodexAccountPriorityWarnings(rawParsed, validated)) { + console.warn(`⚠️ config.json ${warning}`); + } +} + +export function degradedCodexQuotaAutoRefreshWarning(rawParsed: unknown, validated: OcxConfig): string | null { + const raw = rawConfigRecord(rawParsed)?.codexQuotaAutoRefresh; + if (raw === undefined || validated.codexQuotaAutoRefresh !== undefined) return null; + return "codexQuotaAutoRefresh is invalid — automatic quota-window activation is disabled"; +} + +export function warnDegradedCodexQuotaAutoRefresh(rawParsed: unknown, validated: OcxConfig): void { + const warning = degradedCodexQuotaAutoRefreshWarning(rawParsed, validated); + if (warning) console.warn(`⚠️ config.json ${warning}`); +} + +/** + * Companion to the degrade warnings above, for a malformed or ambiguous declared + * grouping. The list now degrades on its own so the rest of `pool` survives, which is + * also why it needs a voice: nothing else about the config looks different afterwards, + * and silently ungrouped credentials read as capacity the pool does not have. + */ +export function degradedCredentialGroupsWarning(rawParsed: unknown): string | null { + const pool = rawConfigRecord(rawConfigRecord(rawParsed)?.pool); + if (!pool || pool.credentialGroups === undefined) return null; + const parsed = credentialGroupsSchema.safeParse(pool.credentialGroups); + if (parsed.success) return null; + // Every issue message is redacted before it is joined. The custom messages embed the + // offending member through `JSON.stringify`, so a malformed credential string that + // happens to carry secret material would otherwise be printed verbatim at config load + // — a config file is exactly where a pasted token ends up in the wrong field. + const details = parsed.error.issues.map(issue => redactSecretString(issue.message)).join("; "); + return `pool.credentialGroups is invalid (${details}) — declared quota grouping is disabled; other pool settings were preserved`; +} + +export function warnDegradedCredentialGroups(rawParsed: unknown): void { + const warning = degradedCredentialGroupsWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}`); +} + +/** + * The apiKeys schema salvages entry by entry rather than failing the parse, so a + * dropped key is otherwise invisible — and it will not be re-saved by the next + * mutation. Say so out loud. Compares the raw array against the validated one, + * the same shape as the degrade warnings above. + */ +/** + * Give every salvaged key a stable, targetable id. + * + * Pure and deterministic on purpose. Two earlier spellings were wrong: minting a + * UUID inside the schema transform handed out a different id on every parse, and + * repairing-then-writing during `loadConfig` put a file write on the read path, + * where it could clobber a concurrent legitimate save with a stale snapshot. + * + * So the replacement id is derived from the entry's position, which is already + * how the file orders these rows: same file in, same ids out, no I/O and no + * randomness. It is not derived from the secret — a public identifier should + * never be a function of key material. + */ +export function normalizeApiKeyIds(config: OcxConfig): OcxConfig { + const keys = config.apiKeys; + if (!keys?.length) return config; + // Reserve every explicit id BEFORE synthesizing any, or a synthetic + // `salvaged-1` assigned to row 1 would push a row that legitimately owns that + // id onto `salvaged-2`. An id the user already has is the one thing this + // repair must never take away. + const reserved = new Set(); + for (const entry of keys) { + if (entry.id) reserved.add(entry.id); + } + const taken = new Set(reserved); + const kept = new Set(); + keys.forEach((entry, index) => { + // The first row holding an explicit id keeps it; later collisions are the + // ones that move. + if (entry.id && !kept.has(entry.id)) { + kept.add(entry.id); + return; + } + let candidate = `salvaged-${index + 1}`; + let suffix = 1; + while (taken.has(candidate)) candidate = `salvaged-${index + 1}-${++suffix}`; + entry.id = candidate; + taken.add(candidate); + kept.add(candidate); + }); + return config; +} + +export function warnDegradedApiKeys(rawParsed: unknown, validated: OcxConfig): void { + if (!rawParsed || typeof rawParsed !== "object") return; + const raw = (rawParsed as Record).apiKeys; + if (raw === undefined) return; + if (!Array.isArray(raw)) { + console.warn(`⚠️ config.json apiKeys is not an array — ignoring it; generate a new key from the API tab`); + return; + } + const dropped = raw.length - (validated.apiKeys?.length ?? 0); + if (dropped > 0) { + console.warn(`⚠️ config.json apiKeys: skipped ${dropped} malformed entr${dropped === 1 ? "y" : "ies"} — the remaining keys still work`); + } + // Same-length repairs are invisible to the count above, and they are the ones + // that show up as a blank name or an unknown date in the dashboard. Say so. + const repaired = raw.filter(row => { + if (!row || typeof row !== "object") return false; + const entry = row as Record; + // Must match the schema exactly: a row whose key is unusable was DROPPED, and + // saying "the key still works" about it would be a lie. + if (!isUsableApiKeySecret(entry.key)) return false; + return typeof entry.id !== "string" || !entry.id + || typeof entry.name !== "string" + || typeof entry.createdAt !== "string"; + }).length; + if (repaired > 0) { + console.warn(`⚠️ config.json apiKeys: repaired metadata on ${repaired} entr${repaired === 1 ? "y" : "ies"} — the key still works, but its name or date may read as unknown`); + } + // A duplicate id is repaired too, and it is not visible in either count above. + const ids = raw.filter(row => row && typeof row === "object" && isUsableApiKeySecret((row as Record).key)) + .map(row => (row as Record).id) + .filter((id): id is string => typeof id === "string" && !!id); + const duplicates = ids.length - new Set(ids).size; + if (duplicates > 0) { + console.warn(`⚠️ config.json apiKeys: ${duplicates} entr${duplicates === 1 ? "y" : "ies"} shared an id — reassigned so each key can be renamed and revoked on its own`); + } +} + +export const CLAUDE_SUBAGENT_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; + +export function isClaudeSubagentEffort(value: unknown): value is NonNullable { + return typeof value === "string" && CLAUDE_SUBAGENT_EFFORTS.includes(value as typeof CLAUDE_SUBAGENT_EFFORTS[number]); +} + +export function rawClaudeSubagentEffort(rawParsed: unknown): unknown { + const raw = rawConfigRecord(rawParsed); + const claudeCode = raw?.claudeCode; + if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) return undefined; + return (claudeCode as Record).subagentEffort; +} + +export function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["claudeCode"] { + if (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode)) { + return claudeCode as OcxConfig["claudeCode"]; + } + const normalized = { ...claudeCode } as Record; + if (Object.hasOwn(normalized, "subagentEffort") && !isClaudeSubagentEffort(normalized.subagentEffort)) { + delete normalized.subagentEffort; + } + // A hand-authored config never passes through the management validator, so coerce here too. + // A malformed classifierFallbacks (a bare string, or an array with non-string entries) would + // otherwise reach the resolver unchecked. + if (Object.hasOwn(normalized, "classifierModel")) { + const value = typeof normalized.classifierModel === "string" ? normalized.classifierModel.trim() : ""; + if (value.length > 0) normalized.classifierModel = value; + else delete normalized.classifierModel; + } + if (Object.hasOwn(normalized, "classifierFallbacks")) { + const raw = normalized.classifierFallbacks; + const kept = Array.isArray(raw) + ? raw.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map(entry => entry.trim()) + : []; + if (kept.length > 0) normalized.classifierFallbacks = kept; + else delete normalized.classifierFallbacks; + } + const desktopProfile = normalized.desktopProfile; + if (desktopProfile && typeof desktopProfile === "object" && !Array.isArray(desktopProfile)) { + const profile = { ...desktopProfile } as Record; + if (typeof profile.appliedFingerprint !== "string") delete profile.appliedFingerprint; + if (typeof profile.appliedAt !== "string") delete profile.appliedAt; + normalized.desktopProfile = profile; + } + return normalized as OcxConfig["claudeCode"]; +} + +export function normalizeClaudeSubagentEffort(config: OcxConfig, _rawParsed: unknown): OcxConfig { + // Unconditional. This used to short-circuit when `subagentEffort` was absent or already valid, + // which meant a config whose ONLY defect was elsewhere in `claudeCode` was never normalized. + // The specialized subagentEffort WARNING is a separate concern and stays exactly as it is. + if (!config.claudeCode) return config; + return { ...config, claudeCode: normalizePersistedClaudeCode(config.claudeCode) }; +} + +export function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void { + const rawEffort = rawClaudeSubagentEffort(rawParsed); + if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) { + console.warn(`⚠️ config.json claudeCode.subagentEffort is invalid (expected ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}) — ignoring it. Other settings were preserved.`); + } +} + +export function malformedUpstreamHostCircuitThresholdWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "upstreamHostCircuitThreshold")) return null; + const threshold = raw.upstreamHostCircuitThreshold; + if (threshold === undefined) return null; + if (typeof threshold === "number" + && Number.isInteger(threshold) + && threshold >= 0 + && threshold <= UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) return null; + return `upstreamHostCircuitThreshold ignored: expected an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`; +} + +export function warnDegradedUpstreamHostCircuitThreshold(rawParsed: unknown): void { + const warning = malformedUpstreamHostCircuitThresholdWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + +export function malformedPlaintextV2AgentMessagesWarning(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || raw.plaintextV2AgentMessages === undefined || typeof raw.plaintextV2AgentMessages === "boolean") return null; + return "plaintextV2AgentMessages ignored: expected a boolean"; +} + +export function warnDegradedPlaintextV2AgentMessages(value: unknown): void { + const warning = malformedPlaintextV2AgentMessagesWarning(value); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + +export function malformedAgentTaskRecoveryWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "agentTaskRecovery")) return null; + const result = agentTaskRecoverySchema.safeParse(raw.agentTaskRecovery); + if (result.success) return null; + const field = result.error.issues[0]?.path.join("."); + return `agentTaskRecovery${field ? `.${field}` : ""} ignored: invalid experimental recovery configuration`; +} + +export function warnDegradedAgentTaskRecovery(rawParsed: unknown): void { + const warning = malformedAgentTaskRecoveryWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + +export function malformedRuntimeRoleWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "runtimeRole") || raw.runtimeRole === undefined) return null; + if (runtimeRoleSchema.safeParse(raw.runtimeRole).success) return null; + return 'runtimeRole ignored: expected "standalone", "hub", or "client"; falling back to "standalone"'; +} + +export function warnDegradedRuntimeRole(rawParsed: unknown): void { + const warning = malformedRuntimeRoleWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + +export function malformedOptionalRemoteBlockWarning( + rawParsed: unknown, + key: "hub" | "remoteGui", +): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, key) || raw[key] === undefined) return null; + const schema = key === "hub" ? hubConfigSchema : remoteGuiConfigSchema; + const result = schema.safeParse(raw[key]); + if (result.success) return null; + const field = result.error.issues[0]?.path.join("."); + return `${key}${field ? `.${field}` : ""} ignored: invalid remote GUI configuration`; +} + +export function malformedClientConnectionWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "client") || raw.client === undefined) return null; + const result = clientConnectionSchema.safeParse(raw.client); + if (result.success) return null; + const field = result.error.issues[0]?.path.join("."); + return `client${field ? `.${field}` : ""} invalid: remote client mode is disabled until config.json is repaired`; +} + +export function warnDegradedOptionalRemoteBlocks(rawParsed: unknown): void { + for (const key of ["hub", "remoteGui"] as const) { + const warning = malformedOptionalRemoteBlockWarning(rawParsed, key); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); + } +} + +export function malformedQuotaResetNotifyWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "quotaResetNotify")) return null; + const result = quotaResetNotifySchema.safeParse(raw.quotaResetNotify); + if (result.success) return null; + const field = result.error.issues[0]?.path.join("."); + return `quotaResetNotify${field ? `.${field}` : ""} ignored: invalid quota-reset notification configuration`; +} + +export function malformedCatalogAutoRefreshWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "catalogAutoRefresh")) return null; + const result = catalogAutoRefreshSchema.safeParse(raw.catalogAutoRefresh); + if (result.success) return null; + const field = result.error.issues[0]?.path.join("."); + return `catalogAutoRefresh${field ? `.${field}` : ""} ignored: invalid catalog auto-refresh configuration`; +} + +/** + * Same silent-in-the-wrong-direction failure as the notification block: a dropped pool policy means + * the accounts the operator meant to exclude keep taking traffic, and the only visible symptom is + * traffic going somewhere it was supposed to stop going. + */ +export function malformedCodexPoolWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "codexPool")) return null; + const result = codexPoolSchema.safeParse(raw.codexPool); + if (result.success) return null; + const field = result.error.issues[0]?.path.join("."); + return `codexPool${field ? `.${field}` : ""} ignored: invalid Codex pool selection policy`; +} + +/** + * Warn once per load that the section was dropped. + * + * This matters more than a usual degradation notice: the failure is SILENT in the direction + * that hurts. A dropped section means notifications are off, so the operator sees nothing — + * which is exactly what they would see if the feature were working and no reset had happened. + */ +export function warnDegradedQuotaResetNotify(rawParsed: unknown): void { + const warning = malformedQuotaResetNotifyWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + +/** + * Warn once per load that the section was dropped. + * + * Same silent-in-the-wrong-direction failure as the notification block: a dropped section + * means the scheduler never starts, so the operator sees a stale catalog — which is exactly + * what they would see if the feature were working and no new models had shipped. + */ +export function warnDegradedCatalogAutoRefresh(rawParsed: unknown): void { + const warning = malformedCatalogAutoRefreshWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + +/** + * Warn once per load that the pool policy was dropped. + * + * `.catch(undefined)` turns a malformed policy into a SUCCESSFUL parse, so without this the proxy + * starts, rotates onto the accounts the operator meant to exclude, and prints nothing. The visible + * symptom would be traffic going exactly where it was told not to go. + */ +export function warnDegradedCodexPool(rawParsed: unknown): void { + const warning = malformedCodexPoolWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + +type NativeSubagentPersistedField = "injectionModel" | "injectionEffort" | "syncCodexSubagentDefaults"; + +export function rawConfigRecord(rawParsed: unknown): Record | null { + return rawParsed !== null && typeof rawParsed === "object" && !Array.isArray(rawParsed) + ? rawParsed as Record + : null; +} + +export function malformedNativeSubagentFields(rawParsed: unknown): NativeSubagentPersistedField[] { + const raw = rawConfigRecord(rawParsed); + if (!raw) return []; + const malformed: NativeSubagentPersistedField[] = []; + if (Object.hasOwn(raw, "injectionModel") && typeof raw.injectionModel !== "string") { + malformed.push("injectionModel"); + } + if (Object.hasOwn(raw, "injectionEffort") && typeof raw.injectionEffort !== "string") { + malformed.push("injectionEffort"); + } + if (Object.hasOwn(raw, "syncCodexSubagentDefaults") && typeof raw.syncCodexSubagentDefaults !== "boolean") { + malformed.push("syncCodexSubagentDefaults"); + } + return malformed; +} + +export function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField): string { + const expected = field === "syncCodexSubagentDefaults" ? "a boolean" : "a string"; + return `${field} ignored: expected ${expected}`; +} + +export function malformedCodexAccountPickerWarning(rawParsed: unknown): string | null { + const raw = rawConfigRecord(rawParsed); + if (!raw || !Object.hasOwn(raw, "codexAccountPickerEnabled")) return null; + if (typeof raw.codexAccountPickerEnabled === "boolean") return null; + return "codexAccountPickerEnabled ignored: expected a boolean"; +} + +export function warnDegradedCodexAccountPicker(rawParsed: unknown): void { + const warning = malformedCodexAccountPickerWarning(rawParsed); + if (warning) console.warn(`⚠️ config.json ${warning}. Other settings were preserved.`); +} + +export function nativeSubagentSyncDisabledReason(config: OcxConfig, rawParsed?: unknown): string | null { + if (config.syncCodexSubagentDefaults !== true) return null; + const malformed = malformedNativeSubagentFields(rawParsed); + if (malformed.includes("injectionModel")) return "injectionModel must be a string"; + if (!config.injectionModel?.trim()) return "a nonblank injectionModel is required"; + if (malformed.includes("injectionEffort")) return "injectionEffort must be a string or omitted"; + if (config.injectionEffort !== undefined && !isCodexReasoningEffort(config.injectionEffort)) { + return "injectionEffort must be a supported Codex reasoning effort"; + } + return null; +} + +export function normalizeNativeSubagentSync(config: OcxConfig, rawParsed?: unknown): OcxConfig { + if (!nativeSubagentSyncDisabledReason(config, rawParsed)) return config; + const normalized = { ...config }; + delete normalized.syncCodexSubagentDefaults; + return normalized; +} + +export function warnDegradedNativeSubagentConfig(rawParsed: unknown, config: OcxConfig): void { + for (const field of malformedNativeSubagentFields(rawParsed)) { + console.warn(`⚠️ config.json ${malformedNativeSubagentFieldWarning(field)}. Other settings were preserved.`); + } + const reason = nativeSubagentSyncDisabledReason(config, rawParsed); + if (reason) { + console.warn(`⚠️ config.json syncCodexSubagentDefaults was disabled: ${reason}. Other settings were preserved.`); + } +} + +/** + * Registry metadata can gain service-tier capability after a config was written. An explicit + * `fastWire: null` remains authoritative on load and on whole-document writes; rejecting either + * would discard or lock access to unrelated providers and API keys. Direct contradictions within + * one provider row remain schema errors through the outer config refinement, where the dynamic + * provider name can be redacted before it reaches diagnostics. + */ +export function inheritedFastWireConflictProviderNames( + config: Pick, +): string[] { + const conflicts: string[] = []; + for (const [name, provider] of Object.entries(config.providers)) { + if (provider.fastWire !== null || provider.supportsServiceTier === false) continue; + const registry = providerMatchesRegistryTransport(name, provider) + ? getProviderRegistryEntry(name) + : undefined; + if (!registry) continue; + const effectiveProviderCapability = provider.supportsServiceTier ?? registry.supportsServiceTier; + const effectiveModelCapabilities = { + ...(registryModelServiceTierCapabilityApplies(registry, provider) + ? registry.modelSupportsServiceTier ?? {} + : {}), + ...(provider.modelSupportsServiceTier ?? {}), + }; + if ( + effectiveProviderCapability === true + || Object.values(effectiveModelCapabilities).some(value => value === true) + ) { + conflicts.push(name); + } + } + return conflicts; +} + +export function inheritedFastWireConflictWarning(name: string): string { + return `providers.${redactSecretString(name)}.fastWire=null overrides service-tier capability inherited from the matching registry entry`; +} + +export function warnInheritedFastWireConflicts(configPath: string, config: OcxConfig): void { + const names = inheritedFastWireConflictProviderNames(config); + if (names.length === 0 || hasWarnedInheritedFastWireConflict(configPath)) return; + markWarnedInheritedFastWireConflict(configPath); + console.warn( + `⚠️ config.json ${names.map(inheritedFastWireConflictWarning).join("; ")}. ` + + "The persisted providers and API keys were preserved.", + ); +} + +/** Hand-edited alias mistakes disable only the bad alias; providers and routing survive. */ +export function sanitizeAliasesForLoad(raw: unknown): void { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return; + const root = raw as Record; + if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return; + const providers = root.providers as Record>; + const providerNames = new Set(Object.keys(providers).map(name => name.toLowerCase())); + const claimedProviders = new Set(); + const comboAliases = new Set(Object.values((root.combos as Record | undefined) ?? {}) + .map(combo => typeof combo?.alias === "string" ? combo.alias.toLowerCase() : "").filter(Boolean)); + const accountNamespaces = new Set(Object.keys((root.codexAccountNamespaces as Record | undefined) ?? {}).map(name => name.toLowerCase())); + for (const provider of Object.values(providers)) { + const alias = provider.alias; + if (typeof alias !== "string" || !isValidProviderName(alias) + || providerNames.has(alias.toLowerCase()) || claimedProviders.has(alias.toLowerCase()) + || comboAliases.has(alias.toLowerCase()) || accountNamespaces.has(alias.toLowerCase())) { + if (alias !== undefined) console.warn("Ignoring invalid or colliding provider alias in config.json"); + delete provider.alias; + } else claimedProviders.add(alias.toLowerCase()); + if (!provider.modelAliases || typeof provider.modelAliases !== "object" || Array.isArray(provider.modelAliases)) { + if (provider.modelAliases !== undefined) delete provider.modelAliases; + continue; + } + const aliases = provider.modelAliases as Record; + const nativeIds = new Set((Array.isArray(provider.models) ? provider.models : []).filter((id): id is string => typeof id === "string").map(id => id.toLowerCase())); + const claimed = new Set(); + for (const [id, value] of Object.entries(aliases)) { + const lower = typeof value === "string" ? value.toLowerCase() : ""; + if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value) || claimed.has(lower) + || nativeIds.has(lower) || comboAliases.has(lower) || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) { + console.warn(`Ignoring invalid or colliding model alias for ${id} in config.json`); + delete aliases[id]; + } else claimed.add(lower); + } + } +} + +/** Hand-edited display-name mistakes disable only the bad label. */ +export function sanitizeModelDisplayNamesForLoad(raw: unknown): void { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return; + const root = raw as Record; + if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return; + for (const [providerName, providerValue] of Object.entries(root.providers as Record)) { + if (!providerValue || typeof providerValue !== "object" || Array.isArray(providerValue)) continue; + const provider = providerValue as Record; + const value = provider.modelDisplayNames; + if (value === undefined) continue; + const providerLabel = JSON.stringify(redactSecretString(providerName)); + if (!value || typeof value !== "object" || Array.isArray(value) + || Object.entries(value).length > MODEL_DISCOVERY_MAX_MODELS) { + console.warn(`Ignoring invalid modelDisplayNames map for provider ${providerLabel} in config.json`); + delete provider.modelDisplayNames; + continue; + } + const labels = value as Record; + for (const [modelId, rawDisplayName] of Object.entries(labels)) { + const displayName = typeof rawDisplayName === "string" ? rawDisplayName.trim() : rawDisplayName; + if (modelDisplayNamesConfigError({ [modelId]: displayName })) { + const safeModelId = JSON.stringify(redactSecretString(modelId)); + console.warn(`Ignoring invalid modelDisplayNames entry ${safeModelId} for provider ${providerLabel} in config.json`); + delete labels[modelId]; + } else { + labels[modelId] = displayName; + } + } + if (Object.keys(labels).length === 0) delete provider.modelDisplayNames; + } +} + +/** Refresh the user cost-overlay registry from `config` and return it unchanged. */ +export function withRefreshedCostOverlays(config: OcxConfig): OcxConfig { + refreshUserCostOverlays(config); + return config; +} diff --git a/src/config/mutation-lock.ts b/src/config/mutation-lock.ts new file mode 100644 index 0000000000..827b5c2b17 --- /dev/null +++ b/src/config/mutation-lock.ts @@ -0,0 +1,244 @@ +import { Database } from "bun:sqlite"; +import { chmodSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "./paths"; +import { hardenSecretDir, windowsSecretAclApplies } from "../lib/windows-secret-acl"; +import { recordOwnedConfigPath } from "../lib/config-ownership"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { + bumpConfigGenerationAtPath, + bumpCurrentConfigGeneration, + initializeConfigGeneration, + observeConfigGenerationAtPath, + readConfigGenerationAtPath, + readConfigGenerationInTransaction, + type ConfigGenerationObservation, +} from "../codex/generation"; +import type { + BumpConfigGeneration, + ConfigGeneration, + ReadConfigGeneration, + WithExpectedConfigGenerationSync, +} from "../codex/convergence-types"; + +const CONFIG_MUTATION_DB_FILENAME = "config-mutation.sqlite"; +const CONFIG_MUTATION_DB_SIDECARS = ["-journal", "-wal", "-shm"] as const; +let warnedConfigMutationDirectoryAcl = false; + +export class ConfigMutationLockError extends Error { + readonly code = "CONFIG_MUTATION_LOCK_UNAVAILABLE"; + + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "ConfigMutationLockError"; + } +} + +function configMutationDatabasePath(): string { + const dir = getConfigDir(); + // First statement on purpose: a rejected mutation must leave nothing behind, not a + // freshly created/chmod'd directory or database. See src/lib/test-home-guard.ts. + assertNotRealHomeUnderTest(dir); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } else { + try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ } + } + if (windowsSecretAclApplies()) { + try { + // Distinct timeout memo from management-token directory harden: a required + // management-dir timeout must not poison config mutation on the same home + // (windows-latest server-management-auth cases). + hardenSecretDir(dir, { required: true, timeoutMemoKey: `${dir}::config-mutation` }); + } catch (error) { + if (!warnedConfigMutationDirectoryAcl) { + warnedConfigMutationDirectoryAcl = true; + const diagnostics = error instanceof Error ? error.message : "ACL hardening failed"; + console.warn( + `[opencodex] Config mutation coordination directory ACL hardening did not complete; continuing without it. ${diagnostics}`, + ); + } + } + } + const path = join(dir, CONFIG_MUTATION_DB_FILENAME); + recordOwnedConfigPath(dir, path); + for (const suffix of CONFIG_MUTATION_DB_SIDECARS) { + recordOwnedConfigPath(dir, `${path}${suffix}`); + } + return path; +} + +/** Raised when an independent config-mutation transaction is requested recursively. */ +export class NestedConfigMutationError extends Error { + constructor() { + super("prepareConfigMutationDatabasePathForWrite must not run inside withConfigMutationLockSync"); + this.name = "NestedConfigMutationError"; + } +} + +/** + * Prepare the shared config-mutation database path for an independent top-level + * SQLite transaction. Callers must not invoke this while holding + * {@link withConfigMutationLockSync}; a second `BEGIN IMMEDIATE` deliberately + * fails busy instead of joining an uncommitted transaction. + * + * @throws {NestedConfigMutationError} If a config mutation lock is already held. + */ +export function prepareConfigMutationDatabasePathForWrite(): string { + if (configMutationLockDepth > 0) { + throw new NestedConfigMutationError(); + } + return configMutationDatabasePath(); +} + +let configMutationLockDepth = 0; +let configMutationDatabase: Database | null = null; + +/** + * Serialize synchronous config and Codex credential-generation commits across processes with an + * OS-backed SQLite write transaction. `busy_timeout=0` is deliberate: runtime request paths must + * fail immediately under contention rather than freeze the Bun event loop. Process exit releases + * SQLite locks without stale-owner deletion or lease recovery races. + * + * Reentrancy is limited to the current synchronous call stack; never return a Promise from `fn`. + */ +export function withConfigMutationLockSync(fn: () => T): T { + if (configMutationLockDepth > 0) { + configMutationLockDepth += 1; + try { + return fn(); + } finally { + configMutationLockDepth -= 1; + } + } + const path = configMutationDatabasePath(); + let database: Database | undefined; + let transactionOpen = false; + try { + database = new Database(path, { create: true }); + try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ } + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; + initializeConfigGeneration(database); + } catch (cause) { + if (transactionOpen) { + try { database?.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ } + } + try { database?.close(); } catch { /* acquisition already failed */ } + const code = cause && typeof cause === "object" && "code" in cause + ? String((cause as { code?: unknown }).code) + : ""; + throw new ConfigMutationLockError( + code === "SQLITE_BUSY" ? "Config mutation already in progress" : "Could not acquire config mutation transaction", + { cause }, + ); + } + + configMutationLockDepth = 1; + configMutationDatabase = database; + try { + const value = fn(); + database.exec("COMMIT"); + transactionOpen = false; + return value; + } catch (error) { + if (transactionOpen) { + try { database.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ } + transactionOpen = false; + } + throw error; + } finally { + configMutationLockDepth = 0; + configMutationDatabase = null; + try { database.close(); } catch { /* the OS lock is released with the handle */ } + } +} + +export function bumpGenerationForCooperatingConfigWrite(): void { + if (!configMutationDatabase) { + throw new Error("A cooperating config write requires the config mutation transaction."); + } + bumpCurrentConfigGeneration(configMutationDatabase); +} + +export const readConfigGeneration: ReadConfigGeneration = () => { + try { + return readConfigGenerationAtPath(configMutationDatabasePath()); + } catch { + return { kind: "unavailable", reason: "database" }; + } +}; + +export function observeConfigGeneration(): ConfigGenerationObservation { + return observeConfigGenerationAtPath(join(getConfigDir(), CONFIG_MUTATION_DB_FILENAME)); +} + +/** + * Read the generation from the transaction that is open RIGHT NOW. + * + * The observer cannot do this job. On the very first acquisition the + * `BEGIN IMMEDIATE` that creates the table has not committed yet, so a separate + * read-only connection cannot read a generation from it — measured, not + * assumed. A caller that compared a pre-lock observation against an observer + * re-read would therefore refuse every first write as stale. + * + * Throwing when no transaction is open is deliberate. Being called outside the + * lock is broken plumbing, and returning a typed "unavailable" would let that + * bug arrive disguised as an environmental failure — retried forever, on a + * machine where nothing is wrong. + */ +export function readConfigGenerationInCurrentMutationTransaction(): ConfigGeneration { + if (configMutationLockDepth < 1 || !configMutationDatabase) { + throw new Error( + "readConfigGenerationInCurrentMutationTransaction requires an open config mutation transaction.", + ); + } + return readConfigGenerationInTransaction(configMutationDatabase); +} + +export const bumpConfigGeneration: BumpConfigGeneration = expected => { + try { + return bumpConfigGenerationAtPath(configMutationDatabasePath(), expected); + } catch { + return { kind: "unavailable", reason: "database" }; + } +}; + +function configGenerationFailureReason(error: unknown): "busy" | "database" { + const cause = error instanceof ConfigMutationLockError ? error.cause : error; + const code = cause && typeof cause === "object" && "code" in cause + ? String((cause as { code?: unknown }).code) + : ""; + const message = cause instanceof Error ? cause.message : ""; + return code === "SQLITE_BUSY" + || code === "SQLITE_LOCKED" + || /database (?:is|table is) locked/i.test(message) + ? "busy" + : "database"; +} + +export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync = ( + expected, + commit, +) => { + let callbackThrew = false; + let callbackError: unknown; + try { + return withConfigMutationLockSync(() => { + const database = configMutationDatabase; + if (!database) throw new Error("Config mutation transaction database is unavailable."); + const current = readConfigGenerationInTransaction(database); + if (current.value !== expected.value) return { kind: "conflict", current }; + try { + return { kind: "matched", generation: current, value: commit() }; + } catch (error) { + callbackThrew = true; + callbackError = error; + throw error; + } + }); + } catch (error) { + if (callbackThrew && error === callbackError) throw error; + return { kind: "unavailable", reason: configGenerationFailureReason(error) }; + } +}; diff --git a/src/config/openai-tier-backup.ts b/src/config/openai-tier-backup.ts new file mode 100644 index 0000000000..e4eee7f26c --- /dev/null +++ b/src/config/openai-tier-backup.ts @@ -0,0 +1,268 @@ +import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { getConfigPath } from "./paths"; +import { isMissingPathError, nextAtomicTempSequence } from "./atomic-write"; +import { forgetEphemeralSecretPath, hardenSecretPath } from "../lib/windows-secret-acl"; + +export class OpenAiTierBackupCleanupError extends Error { + constructor() { super("OpenAI tier backup temporary cleanup failed"); this.name = "OpenAiTierBackupCleanupError"; } +} + +export class OpenAiTierBackupRollbackError extends Error { + constructor() { super("OpenAI tier backup rollback failed"); this.name = "OpenAiTierBackupRollbackError"; } +} + +export class OpenAiTierBackupCollisionError extends Error { + readonly configPath?: string; + constructor(configPath?: string) { + super("Existing OpenAI tier backup differs from the current config"); + this.name = "OpenAiTierBackupCollisionError"; + this.configPath = configPath; + } +} + +export class OpenAiTierRollbackPreserveError extends Error { + readonly code?: "missing" | "not-rollback" | "mismatch" | "exhausted"; + constructor(message: string, options?: ErrorOptions & { code?: OpenAiTierRollbackPreserveError["code"] }) { + super(message, options); + this.name = "OpenAiTierRollbackPreserveError"; + this.code = options?.code; + } +} + +export class OpenAiTierBackupSecretResidualError extends Error { + constructor(readonly tempPath: string, options?: ErrorOptions) { + super("OpenAI tier backup could not scrub or remove a secret-bearing temporary file", options); + this.name = "OpenAiTierBackupSecretResidualError"; + } +} + +export interface OpenAiTierBackupIO { + exists(path: string): boolean; + read(path: string): Uint8Array; + createExclusive(path: string): void; + write(path: string, bytes: Uint8Array): void; + harden(path: string): void; + publishNoReplace(temp: string, backup: string): void; + truncate(path: string): void; + unlink(path: string): void; +} + +function sameBytes(left: Uint8Array, right: Uint8Array): boolean { + return left.byteLength === right.byteLength && left.every((value, index) => value === right[index]); +} + +function isAlreadyExistsError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST"; +} + +/** + * Classify an existing `.pre-openai-tiers-v2.bak` snapshot. + * + * - `"stale"`: unparseable JSON (not written by us / truncated) or already a + * post-migration (tier v2) snapshot — safe to delete or replace. + * - `"rollback"`: parses as a valid pre-migration (v1) config — a + * user-intentional rollback point that must never be silently destroyed. + * + * Shared by the startup migration backup path and `ocx init` cleanup so both + * apply the same preservation policy (issue #257 / sol review 260722). + */ +export function classifyOpenAiTierBackup(backupBytes: Uint8Array): "stale" | "rollback" { + try { + // Use Buffer.from to ensure proper UTF-8 decoding from Uint8Array/Buffer. + const parsed = JSON.parse(Buffer.from(backupBytes).toString("utf8")) as Record; + return parsed.openaiProviderTierVersion === 2 ? "stale" : "rollback"; + } catch { + // Unparseable: not a config file we created, treat as stale. + return "stale"; + } +} + +export function backupConfigBeforeOpenAiTierMigration( + configPath = getConfigPath(), + io: OpenAiTierBackupIO = { + exists: existsSync, + read: target => readFileSync(target), + createExclusive: target => { writeFileSync(target, new Uint8Array(), { flag: "wx", mode: 0o600 }); }, + write: (target, bytes) => writeFileSync(target, bytes), + harden: target => { + try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } + // Soft-fail: a wedged/failed icacls on CI temp volumes must not abort + // startServer mid-suite (timeout + EBUSY cascade on shared TEST_DIR). + // chmod above still applies; live credential writes keep required:true. + if (process.platform === "win32") hardenSecretPath(target, { required: false }); + }, + publishNoReplace: (temp, backup) => linkSync(temp, backup), + truncate: target => truncateSync(target, 0), + unlink: unlinkSync, + }, +): "absent" | "created" | "reused" { + const source = configPath; + if (!io.exists(source)) return "absent"; + const original = io.read(source); + // v2 snapshot path. The historical `.pre-openai-tiers-v1.bak` is read only by restore + // docs/fixtures and is never reused or overwritten as the v2 snapshot. + const backup = `${source}.pre-openai-tiers-v2.bak`; + if (io.exists(backup)) { + if (!sameBytes(original, io.read(backup))) { + // The backup differs from the current config. Only treat it as stale when it is + // clearly not a user-intentional rollback point: + // - unparseable JSON: written by a different tool or truncated + // - already at tier version 2: the backup is from a post-migration config (e.g. + // ocx init wrote a fresh v2 config, making the old backup obsolete) + // A backup that parses as a valid pre-migration (v1) config is kept as-is and + // we throw a collision error, because silently replacing a user-created rollback + // point would be surprising and potentially destructive. + const backupBytes = io.read(backup); + if (classifyOpenAiTierBackup(backupBytes) === "rollback") { + throw new OpenAiTierBackupCollisionError(source); + } + console.warn("[openai-provider-migration] Replacing stale pre-migration backup (post-migration config was rewritten since last migration)."); + io.unlink(backup); + } else { + return "reused"; + } + } + const temp = `${backup}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; + let published = false; + let cleanupAttempted = false; + + const scrubUnpublishedTemp = (): void => { + cleanupAttempted = true; + let scrubbed = false; + try { + io.truncate(temp); + scrubbed = true; + } catch (error) { + if (isMissingPathError(error)) scrubbed = true; + else { + try { io.write(temp, new Uint8Array()); scrubbed = true; } catch { /* removal may still succeed */ } + } + } + let removed = false; + try { + io.unlink(temp); + removed = true; + } catch (error) { + if (isMissingPathError(error)) { + removed = true; + } + else { + try { io.unlink(temp); removed = true; } + catch (retryError) { + if (isMissingPathError(retryError)) { + removed = true; + } + } + } + } + if (removed) forgetEphemeralSecretPath(temp); + if (!removed && !scrubbed) throw new OpenAiTierBackupSecretResidualError(temp); + if (!removed) throw new OpenAiTierBackupCleanupError(); + }; + + try { + io.createExclusive(temp); + io.write(temp, original); + io.harden(temp); + try { + io.publishNoReplace(temp, backup); + } catch (cause) { + if (!isAlreadyExistsError(cause)) throw cause; + const winner = io.read(backup); + if (!sameBytes(original, winner)) throw new OpenAiTierBackupCollisionError(source); + scrubUnpublishedTemp(); + return "reused"; + } + published = true; + try { + io.unlink(temp); + forgetEphemeralSecretPath(temp); + } catch (firstError) { + if (isMissingPathError(firstError)) { + forgetEphemeralSecretPath(temp); + } else try { + io.unlink(temp); + forgetEphemeralSecretPath(temp); + } catch (secondError) { + if (isMissingPathError(secondError)) { + forgetEphemeralSecretPath(temp); + return "created"; + } + // temp and backup are hard links to the same inode. Roll back the backup + // link before any truncation so the downgrade snapshot is never zeroed. + try { io.unlink(backup); } catch { throw new OpenAiTierBackupRollbackError(); } + published = false; + scrubUnpublishedTemp(); + throw new OpenAiTierBackupCleanupError(); + } + } + return "created"; + } catch (cause) { + if (!published && !cleanupAttempted) { + scrubUnpublishedTemp(); + } + throw cause; + } +} + +export interface OpenAiTierRollbackPreserveIO { + exists(path: string): boolean; + read(path: string): Uint8Array; + copyExclusive(source: string, destination: string): void; + unlink(path: string): void; +} + +const DEFAULT_ROLLBACK_PRESERVE_IO: OpenAiTierRollbackPreserveIO = { + exists: existsSync, + read: target => readFileSync(target), + copyExclusive: (source, destination) => { + copyFileSync(source, destination, fsConstants.COPYFILE_EXCL); + }, + unlink: unlinkSync, +}; + +const OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS = 16; + +/** + * Copy a rollback-classified `.pre-openai-tiers-v2.bak` to a unique + * `.pre-openai-tiers-v1-rollback.[suffix].bak` path, then unlink the + * blocking v2 name. The original bytes are copied with no-replace publication; + * the v2 path is removed only after the copy is verified. Shared by startup + * migration recovery and `ocx init` cleanup so the two paths cannot drift. + */ +export function preserveOpenAiTierRollbackSnapshot( + configPath = getConfigPath(), + io: OpenAiTierRollbackPreserveIO = DEFAULT_ROLLBACK_PRESERVE_IO, +): string { + const backup = `${configPath}.pre-openai-tiers-v2.bak`; + if (!io.exists(backup)) { + throw new OpenAiTierRollbackPreserveError("OpenAI tier rollback backup is missing", { code: "missing" }); + } + const original = io.read(backup); + if (classifyOpenAiTierBackup(original) !== "rollback") { + throw new OpenAiTierRollbackPreserveError("OpenAI tier backup is not a rollback snapshot", { code: "not-rollback" }); + } + for (let attempt = 0; attempt < OPENAI_TIER_ROLLBACK_PRESERVE_ATTEMPTS; attempt++) { + const preserved = `${configPath}.pre-openai-tiers-v1-rollback.${Date.now()}${attempt ? `-${attempt}` : ""}.bak`; + try { + io.copyExclusive(backup, preserved); + } catch (error) { + if (isAlreadyExistsError(error)) continue; + throw error; + } + let copied: Uint8Array; + try { + copied = io.read(preserved); + } catch (error) { + throw new OpenAiTierRollbackPreserveError("Failed to read preserved rollback snapshot", { cause: error, code: "mismatch" }); + } + if (!sameBytes(original, copied)) { + try { io.unlink(preserved); } catch { /* keep the original backup; incomplete copy is best-effort */ } + throw new OpenAiTierRollbackPreserveError("Preserved rollback snapshot does not match source bytes", { code: "mismatch" }); + } + io.unlink(backup); + return preserved; + } + throw new OpenAiTierRollbackPreserveError("Unable to find a unique rollback snapshot path", { code: "exhausted" }); +} + diff --git a/src/config/persist-unlocked.ts b/src/config/persist-unlocked.ts new file mode 100644 index 0000000000..7b2c05f10c --- /dev/null +++ b/src/config/persist-unlocked.ts @@ -0,0 +1,92 @@ +import { existsSync, readFileSync } from "node:fs"; +import { configReasoningPinsConfigError } from "./provider-validation"; +import type { OcxConfig } from "../types"; +import { refreshUserCostOverlays, withPreservedDiskOnlyProviders } from "../usage/user-cost-overlays"; +import { atomicWriteFile, isMissingPathError } from "./atomic-write"; +import { getConfigPath } from "./paths"; +import { configRebaseDeletionKeys, projectConfigRebaseProvenance } from "./rebase-provenance"; +import { clientConnectionSchema } from "./schema/leaf-validators"; + +/** The literal file, with no schema merge or default injection. */ +export function readRawConfigJson(): Record | undefined { + try { + const configPath = getConfigPath(); + if (!existsSync(configPath)) return undefined; + const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + return parsed as Record; + } catch { + // Unreadable or corrupt: behave exactly as before. Never fail a save over protection. + return undefined; + } +} + +function failClosedClientPersistenceError( + raw: Record | undefined, + candidate: OcxConfig, +): string | null { + if (!raw) return null; + const rawHasClient = Object.hasOwn(raw, "client") && raw.client !== undefined; + const rawRole = raw.runtimeRole; + const rawRoleValid = rawRole === undefined + || rawRole === "standalone" + || rawRole === "hub" + || rawRole === "client"; + const rawClientValid = !rawHasClient || clientConnectionSchema.safeParse(raw.client).success; + const rawPairValid = rawRoleValid + && ((rawRole === "client" && rawHasClient && rawClientValid) + || (rawRole !== "client" && !rawHasClient)); + if (rawPairValid) return null; + + const candidateValid = candidate.runtimeRole === "client" + && clientConnectionSchema.safeParse(candidate.client).success; + const deletions = configRebaseDeletionKeys(candidate); + const explicitClear = deletions.has("client") && deletions.has("runtimeRole"); + if (candidateValid || explicitClear) return null; + return "config write refused: malformed or mismatched remote client state must be repaired or explicitly cleared"; +} + +/** + * Atomic config.json write WITHOUT the mutation lock; callers must hold + * `withConfigMutationLockSync`. Returns true when bytes changed. Refreshes the + * cost-overlay registry from the persisted config so runtime estimates follow + * every save path. + */ +export function persistConfigUnlocked(config: OcxConfig): boolean { + const pinError = configReasoningPinsConfigError(config); + if (pinError) throw new Error(pinError); + const configPath = getConfigPath(); + const rawBeforeWrite = readRawConfigJson(); + const clientPersistenceError = failClosedClientPersistenceError(rawBeforeWrite, config); + if (clientPersistenceError) throw new Error(clientPersistenceError); + // External editors can add provider rows the live config deliberately does + // not route with yet; merge them at the serialization boundary so an + // unrelated in-process save cannot erase the provider or its overlay. + // Provider preservation reads symbol-keyed live-owner state, which structuredClone + // intentionally drops. Resolve that ownership before projecting JSON provenance. + const provenanceProjection = projectConfigRebaseProvenance(config); + const persisted = withPreservedDiskOnlyProviders(config); + if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance; + else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance; + const bytes = JSON.stringify(persisted, null, 2) + "\n"; + let unchanged = false; + try { + unchanged = readFileSync(configPath, "utf8") === bytes; + } catch (error) { + if (!isMissingPathError(error)) throw error; + } + // Keep the runtime overlay registry in sync with EVERY persist path, + // including byte-identical saves: a cooperating CLI process may have written + // the same bytes (e.g. before a proxy notification), and Logs/Usage must + // adopt the overlay without waiting for a changed save or restart. + if (unchanged) { + refreshUserCostOverlays(persisted); + return false; + } + atomicWriteFile(configPath, bytes); + // For changed saves, refresh only AFTER the write succeeded so a failed + // write cannot leave estimates reflecting configuration never persisted. + refreshUserCostOverlays(persisted); + return true; +} diff --git a/src/config/proxy-env.ts b/src/config/proxy-env.ts new file mode 100644 index 0000000000..649c8b6f1f --- /dev/null +++ b/src/config/proxy-env.ts @@ -0,0 +1,188 @@ +import { join } from "node:path"; +import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "./subagent-models"; +import { MULTI_AGENT_SURFACE_ADVISORY_VERSION } from "./multi-agent-surface"; +import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES } from "../lib/app-owned-memory"; +import { describeProxyForLog, readWindowsSystemProxy, type WindowsProxyRegistryReader } from "../lib/windows-system-proxy"; +import { OPENAI_PROVIDER_TIER_VERSION, type OcxConfig } from "../types"; +import type { OcxRuntimeRole } from "../types/config"; + +export function codexAutoStartEnabled(config: Pick): boolean { + return config.codexAutoStart !== false; +} + +export const CODEX_SHIM_AUTO_RESTORE_ENV = "OPENCODEX_CODEX_SHIM_AUTO_RESTORE"; + +export function codexShimAutoRestoreEnabled( + config: Pick, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return config.codexShimAutoRestore !== false && env[CODEX_SHIM_AUTO_RESTORE_ENV] !== "0"; +} + +export function multiAgentGuidanceEnabled( + config: Pick, +): boolean { + return config.multiAgentGuidanceEnabled !== false; +} + +export function runtimeRole(config: Pick): OcxRuntimeRole { + return config.runtimeRole ?? "standalone"; +} + +export function getDefaultConfig(): OcxConfig { + // Fresh-install default: works out of the box with Codex's ChatGPT OAuth (no API key). + // gpt-* requests forward the caller's incoming OAuth headers to the ChatGPT backend. + // Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice. + return { + port: 10100, + emptyCompletionRetry: false, + dropCodexSafetyBuffering: false, + fastRows: true, + managementUsageMaxReadBytes: 64 * 1024 * 1024, + appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024), + // Fresh/re-initialized configs are already written in the current three-tier + // OpenAI shape. Mark them as such so startup does not mistake them for a + // legacy config and collide with an immutable backup from an earlier setup. + openaiProviderTierVersion: OPENAI_PROVIDER_TIER_VERSION, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "pool", + }, + }, + defaultProvider: "openai", + subagentModels: [...DEFAULT_SUBAGENT_MODELS], + subagentModelsVersion: SUBAGENT_MODELS_VERSION, + // v1 is the shipped surface while a v2 native-to-routed task is undeliverable + // ciphertext. Written explicitly rather than left absent, because an absent key + // means base everywhere else. A fresh install starts already acknowledged: there is + // nothing to advise an operator who is on the recommended surface. + multiAgentMode: "v1", + multiAgentSurfaceAdvisoryVersion: MULTI_AGENT_SURFACE_ADVISORY_VERSION, + multiAgentGuidanceEnabled: true, + websockets: false, + codexAutoStart: true, + codexShimAutoRestore: true, + }; +} + +export function resolveEnvValue(value: string | undefined): string | undefined { + if (!value) return undefined; + const match = value.match(/^\$\{(\w+)\}$/); + if (match) return process.env[match[1]]; + if (value.startsWith("$")) return process.env[value.slice(1)]; + return value; +} + +const warnedProxyConfigDiscards = new Set<"proxy" | "noProxy" | "noProxyElements">(); + +function warnProxyConfigDiscardOnce(kind: "proxy" | "noProxy" | "noProxyElements"): void { + if (warnedProxyConfigDiscards.has(kind)) return; + warnedProxyConfigDiscards.add(kind); + if (kind === "proxy") { + console.warn( + "⚠️ config.json proxy was discarded because it is not a non-empty resolved string — configured proxy routing is disabled; existing proxy environment variables remain authoritative, otherwise outbound requests use direct egress", + ); + } else if (kind === "noProxy") { + console.warn( + "⚠️ config.json noProxy was discarded because it is not a string, string array, or resolved environment reference — existing NO_PROXY and loopback bypasses remain", + ); + } else { + console.warn( + "⚠️ config.json noProxy contains invalid elements — invalid elements were ignored; valid entries, existing NO_PROXY, and loopback bypasses remain", + ); + } +} + +/** + * Mirror `config.proxy` into HTTP(S)_PROXY env vars. Bun fetch consumes them natively; transports + * such as the ChatGPT upstream WebSocket select the same environment explicitly. User-set HTTP(S)_PROXY + * variables win; config fills missing scheme proxies, which take precedence over ALL_PROXY for WS. + * localhost/127.0.0.1 are appended to NO_PROXY so the CLI's own health checks and + * running-proxy API calls stay direct. Call once per process entry that makes outbound provider + * requests (server start, catalog sync). + */ +export function applyProxyEnv(config: OcxConfig): void { + applyProxyEnvWith(config); +} + +/** Test seam for `proxy: "auto"`: the registry reader and platform are injectable. */ +export function applyProxyEnvWith( + config: OcxConfig, + auto: { reader?: WindowsProxyRegistryReader; platform?: NodeJS.Platform } = {}, +): void { + // `proxy` and `noProxy` are not declared in the top-level schema, which ends in + // `.passthrough()`, so whatever is on disk arrives here verbatim. A non-string value + // reached string-only methods and threw out of this function, and it runs once per + // process entry point — the failure was a startup crash, not a degraded proxy. Ignore + // malformed values with a privacy-safe warning instead: they cannot express a routing + // intent, and refusing to start is a worse answer than starting without them. + const rawProxy = config.proxy; + let proxy = typeof rawProxy === "string" ? resolveEnvValue(rawProxy) : undefined; + if (!proxy) { + if (rawProxy !== undefined) warnProxyConfigDiscardOnce("proxy"); + return; + } + if (proxy.trim().toLowerCase() === "auto") { + // #1525 slice 1: one startup read of the Windows static proxy. Never copy the literal + // "auto" into HTTP_PROXY; every non-proxy outcome leaves outbound routing as it was. + if (process.env.HTTP_PROXY?.trim() || process.env.http_proxy?.trim() + || process.env.HTTPS_PROXY?.trim() || process.env.https_proxy?.trim()) { + console.log("[opencodex] proxy \"auto\": existing HTTP_PROXY/HTTPS_PROXY environment wins; system proxy not consulted"); + proxy = undefined; + } else { + const found = readWindowsSystemProxy(auto.reader, auto.platform); + if (found.kind === "proxy") { + console.log(`[opencodex] proxy "auto": using Windows system proxy ${describeProxyForLog(found.url)}`); + proxy = found.url; + } else { + const reason = found.kind === "unsupported" + ? "only Windows system proxy discovery is supported; using direct egress on this OS" + : found.kind === "disabled" + ? "Windows system proxy is disabled; using direct egress" + : found.kind === "socks-only" + ? "Windows system proxy is SOCKS-only, which HTTP_PROXY cannot express; using direct egress" + : "Windows proxy settings could not be read; using direct egress"; + console.log(`[opencodex] proxy "auto": ${reason}`); + proxy = undefined; + } + } + } + if (proxy) { + if (!process.env.HTTP_PROXY?.trim() && !process.env.http_proxy?.trim()) process.env.HTTP_PROXY = proxy; + if (!process.env.HTTPS_PROXY?.trim() && !process.env.https_proxy?.trim()) process.env.HTTPS_PROXY = proxy; + } + const existing = process.env.NO_PROXY ?? process.env.no_proxy ?? ""; + const entries = existing.split(",").map(s => s.trim()).filter(Boolean); + const seen = new Set(entries.map(e => e.toLowerCase())); + // Configured entries first, then loopback: loopback is unconditional, so appending it last + // keeps it present even when the operator lists a loopback host themselves. + const raw = config.noProxy; + let configuredEntries: string[]; + if (Array.isArray(raw)) { + // One unusable element must not discard the operator's other entries. + if (raw.some(entry => typeof entry !== "string")) warnProxyConfigDiscardOnce("noProxyElements"); + configuredEntries = raw.filter((entry): entry is string => typeof entry === "string"); + } else if (typeof raw === "string") { + const resolved = resolveEnvValue(raw); + if (raw && resolved === undefined) warnProxyConfigDiscardOnce("noProxy"); + configuredEntries = (resolved ?? "").split(","); + } else { + if (raw !== undefined) warnProxyConfigDiscardOnce("noProxy"); + configuredEntries = []; + } + const configured = configuredEntries + .map(entry => entry.trim()) + .filter(Boolean); + for (const host of [...configured, "localhost", "127.0.0.1", "::1", "[::1]"]) { + const key = host.toLowerCase(); + if (!seen.has(key)) { + entries.push(host); + seen.add(key); + } + } + process.env.NO_PROXY = entries.join(","); +} + diff --git a/src/config/salvage.ts b/src/config/salvage.ts new file mode 100644 index 0000000000..254e4e19f6 --- /dev/null +++ b/src/config/salvage.ts @@ -0,0 +1,244 @@ +import { chmodSync, copyFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import * as z from "zod/v4"; +import { CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR } from "../codex/account-namespace-match"; +import { redactSecretString } from "../lib/redact"; +import { hasWarnedConfigFallback, markWarnedConfigFallback } from "./warn-memo"; +import { configSchema } from "./schema/config-schema"; +import type { OcxConfig } from "../types"; + +export function warnConfigRepaired(configPath: string, error: z.ZodError): void { + if (hasWarnedConfigFallback(configPath)) return; + markWarnedConfigFallback(configPath); + const fields = error.issues.map(i => i.path.join(".") || "config").join(", "); + console.error(`opencodex config at ${configPath}: repaired missing field(s) [${fields}] with defaults. Your providers and accounts are preserved.`); +} + +/** + * Sections whose entries are independent of one another, so one bad entry is + * safe to drop without changing what the rest mean. + * + * Both are validated entry-by-entry in the `superRefine` above, which raises + * every finding as a *document*-level issue. That is what made a single routing + * candidate naming a disabled provider discard the operator's whole config — + * all eleven providers, every API key, and the entire `modelCosts` table — + * while the proxy carried on serving from built-in defaults and reporting + * healthy. + */ +const SALVAGEABLE_CONFIG_SECTIONS = ["routingProfiles", "combos"] as const; + +/** Optional nested fields that can be dropped whole without changing the rest of the document. */ +const SALVAGEABLE_OPTIONAL_FIELDS: ReadonlyArray = [ + ["claudeCode", "desktopProfile"], +]; + +function isSalvageableConfigPath(section: string, id: string): boolean { + if ((SALVAGEABLE_CONFIG_SECTIONS as readonly string[]).includes(section)) return true; + return SALVAGEABLE_OPTIONAL_FIELDS.some(path => path[0] === section && path[1] === id); +} + +/** + * Drop just the named entries a parse failure blamed, so the rest of the + * document survives. + * + * Returns `null` when the failure was not confined to those sections — the + * caller then keeps its existing behaviour rather than guessing. + * + * The whole entry goes, not the individual offending candidate. A routing + * profile that quietly loses one candidate still routes, just not where the + * operator said it should, and a policy that silently changed shape is a worse + * outcome than one that is plainly absent. Absent is also the loud option: a + * dry-run against it answers `unknown_profile`, which — paired with the warning + * this emits — points at the real mistake. + */ +function dropInvalidConfigSections( + parsed: unknown, + error: z.ZodError, +): { candidate: Record; dropped: string[] } | null { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + + const doomed = new Map>(); + for (const issue of error.issues) { + if (isUnsalvageableIssue(issue)) return null; + const [section, id] = issue.path; + if (typeof section !== "string" || typeof id !== "string") return null; + if (!isSalvageableConfigPath(section, id)) return null; + // A complaint about the container itself ("combos must be an object") is + // not about one entry, so there is nothing selective to drop. + if (issue.path.length < 2) return null; + let ids = doomed.get(section); + if (!ids) doomed.set(section, ids = new Set()); + ids.add(id); + } + if (doomed.size === 0) return null; + + const candidate: Record = { ...(parsed as Record) }; + const dropped: string[] = []; + for (const [section, ids] of doomed) { + const current = candidate[section]; + if (!current || typeof current !== "object" || Array.isArray(current)) return null; + const kept: Record = {}; + for (const [key, value] of Object.entries(current as Record)) { + if (ids.has(key)) dropped.push(`${section}.${key}`); + else kept[key] = value; + } + candidate[section] = kept; + } + return dropped.length > 0 ? { candidate, dropped } : null; +} + +/** + * Salvage until the document parses, not just once. + * + * One pass is not enough because the sections depend on each other: routing + * profiles are validated against the combo map, so dropping an invalid combo can + * expose a profile that referenced it. A single-pass salvage sees that second + * failure and gives up, discarding the whole config -- the exact outcome this + * code exists to prevent. + * + * `rawDocument` is the operator's document before defaults were merged in. When + * supplied, the same entries are deleted from it too, so a diagnostics caller can + * still tell an absent optional setting from one we injected. + */ + +/** + * Findings that must never be salvaged away. + * + * Salvage removes the entry a finding blamed, which is right for an ordinary + * validation mistake and wrong for a namespace collision: the collision is a + * *relationship* between a combo/profile and a Codex account selector, and it is + * reported on the combo. Dropping that combo makes the document parse and quietly + * admits the account selector the schema just refused, turning a hard admission + * boundary into a config that loads. Refuse the whole document instead. + */ +const UNSALVAGEABLE_ISSUE_MESSAGES: readonly string[] = [ + CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, +]; + +function isUnsalvageableIssue(issue: z.ZodIssue): boolean { + return UNSALVAGEABLE_ISSUE_MESSAGES.some(message => issue.message.includes(message)); +} +export function salvageConfigCandidate( + merged: unknown, + initialError: z.ZodError, + rawDocument?: unknown, +): { + candidate: Record; + rawCandidate: unknown; + parsed: OcxConfig; + dropped: string[]; + issues: z.ZodIssue[]; +} | null { + let candidate: unknown = merged; + let rawCandidate: unknown = rawDocument; + let error = initialError; + const dropped: string[] = []; + const issues: z.ZodIssue[] = []; + // Bounded by construction: every pass must remove at least one entry, and there + // are only so many entries to remove. + const budget = countSalvageableEntries(merged) + 1; + for (let pass = 0; pass < budget; pass++) { + const step = dropInvalidConfigSections(candidate, error); + if (!step || step.dropped.length === 0) return null; + dropped.push(...step.dropped); + issues.push(...error.issues); + candidate = step.candidate; + rawCandidate = deleteEntryPaths(rawCandidate, step.dropped); + const result = configSchema.safeParse(candidate); + if (result.success) { + return { candidate: step.candidate, rawCandidate, parsed: result.data as OcxConfig, dropped, issues }; + } + error = result.error; + } + return null; +} + +function countSalvageableEntries(document: unknown): number { + if (!document || typeof document !== "object" || Array.isArray(document)) return 0; + let total = 0; + for (const section of SALVAGEABLE_CONFIG_SECTIONS) { + const value = (document as Record)[section]; + if (value && typeof value === "object" && !Array.isArray(value)) { + total += Object.keys(value as Record).length; + } + } + for (const [section, id] of SALVAGEABLE_OPTIONAL_FIELDS) { + const container = (document as Record)[section]; + if (container && typeof container === "object" && !Array.isArray(container) + && Object.hasOwn(container as Record, id)) { + total += 1; + } + } + return total; +} + +/** Delete `section.id` entries from a copy of the raw document. */ +function deleteEntryPaths(document: unknown, entryPaths: readonly string[]): unknown { + if (!document || typeof document !== "object" || Array.isArray(document)) return document; + const next: Record = { ...(document as Record) }; + for (const entryPath of entryPaths) { + const separator = entryPath.indexOf("."); + if (separator <= 0) continue; + const section = entryPath.slice(0, separator); + const id = entryPath.slice(separator + 1); + const container = next[section]; + if (!container || typeof container !== "object" || Array.isArray(container)) continue; + const kept: Record = { ...(container as Record) }; + delete kept[id]; + next[section] = kept; + } + return next; +} + +/** + * Entry ids are operator-chosen and can be token-shaped, so nothing dynamic reaches + * the log unredacted. Static section names stay readable -- they are the part that + * tells the operator where to look. + */ +function redactEntryPath(entryPath: string): string { + const separator = entryPath.indexOf("."); + if (separator <= 0) return redactSecretString(entryPath); + return entryPath.slice(0, separator) + "." + redactSecretString(entryPath.slice(separator + 1)); +} + +function redactIssuePath(path: readonly PropertyKey[]): string { + return path + .map((segment, index) => (index === 0 && typeof segment === "string" ? segment : redactSecretString(String(segment)))) + .join("."); +} + +export function warnDroppedConfigSections(configPath: string, dropped: string[], issues: readonly z.ZodIssue[]): void { + if (hasWarnedConfigFallback(configPath)) return; + markWarnedConfigFallback(configPath); + const reasons = issues + .map(issue => `${redactIssuePath(issue.path)}: ${redactSecretString(issue.message)}`) + .join("; "); + console.error( + `opencodex config at ${configPath}: dropped [${dropped.map(redactEntryPath).join(", ")}] and loaded the rest — ${reasons}. ` + + "Everything else in your config, including providers and modelCosts, is preserved.", + ); +} + +export function warnAndBackupInvalidConfig(configPath: string, error: unknown): void { + if (hasWarnedConfigFallback(configPath)) return; + markWarnedConfigFallback(configPath); + + const backupPath = backupInvalidConfig(configPath); + const reason = error instanceof z.ZodError + ? error.issues.map(issue => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ") + : error instanceof Error ? error.message : String(error); + const backupNote = backupPath ? ` A backup was written to ${backupPath}.` : ""; + console.error(`Could not load opencodex config at ${configPath}: ${reason}. Using default config.${backupNote}`); +} + +export function backupInvalidConfig(configPath: string): string | null { + if (!existsSync(configPath)) return null; + const backupPath = `${configPath}.invalid-${new Date().toISOString().replace(/[:.]/g, "-")}`; + try { + copyFileSync(configPath, backupPath); + try { chmodSync(backupPath, 0o600); } catch { /* best-effort */ } + return backupPath; + } catch { + return null; + } +} diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts new file mode 100644 index 0000000000..da959ce60d --- /dev/null +++ b/src/config/schema/config-schema.ts @@ -0,0 +1,640 @@ +import * as z from "zod/v4"; +import { + agentTaskRecoverySchema, + catalogAutoRefreshSchema, + clientConnectionSchema, + CODEX_ACCOUNT_PIN_PATTERN, + codexAccountPrioritiesSchema, + codexPoolSchema, + codexQuotaAutoRefreshSchema, + credentialGroupsSchema, + hubConfigSchema, + providerConfigSchema, + quotaResetNotifySchema, + remoteGuiConfigSchema, + runtimeRoleSchema, + configuredCodexPoolAccountIds, + apiKeyEntrySchema, + asideProfileSyncSchema, + clientIntegrationsSchema, + CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR, + codexAccountNamespacesSchema, + modelPinnedEffortsSchema, + modelPreferHostedToolsConfigError, + providerModelCostsConfigError, + providerRelativeSendPathConfigError, +} from "./leaf-validators"; +import { isValidProviderName, hasOwnProvider } from "../provider-name"; +import { + apiKeyTransportConfigError, + booleanRecordConfigError, + modelAdapterRecordConfigError, + modelDisplayNamesConfigError, + nonBlankStringArrayConfigError, + positiveIntegerConfigError, + positiveIntegerRecordConfigError, + providerBaseUrlConfigError, + providerHeadersConfigError, + reasoningSummaryDeliveryRecordConfigError, +} from "../provider-validation"; +import { + CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + codexAccountNamespaceForModel, + codexProviderNamespaceKey, + MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET, +} from "../../codex/account-namespace-match"; +import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "../../codex/upstream-host-health"; +import { COMBO_NAMESPACE, comboConfigIssues } from "../../combos/types"; +import { routingProfileIssues } from "../../routing/profile"; +import { POLICY_NAMESPACE } from "../../routing/profile-namespace"; +import { providerDestinationConfigError } from "../../lib/destination-policy"; +import { redactSecretString } from "../../lib/redact"; +import { openRouterRoutingConfigError } from "../../providers/openrouter-routing"; +import { vercelGatewayRoutingConfigError } from "../../providers/vercel-gateway-routing"; +import { type OcxApiKeyEntry, type OcxProviderConfig } from "../../types"; +import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; +import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget"; +import { hasFastWireCapabilityConflict } from "../../providers/fastwire"; +import { parseDesktopProfile } from "../../claude/desktop-profile"; +import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, MAX_APP_OWNED_MEMORY_BUDGET_MB, MIN_APP_OWNED_MEMORY_BUDGET_MB } from "../../lib/app-owned-memory"; + +export const configSchema = z.object({ + port: z.number().int().min(0).max(65535).default(10100), + // A malformed hand edit must disable only remote-role behavior, not discard + // providers or data-plane keys. Live writes are rejected explicitly below. + runtimeRole: runtimeRoleSchema.optional().catch(undefined), + // Malformed optional remote blocks disable only remote GUI behavior. Live + // candidates are rejected explicitly by remoteGuiConfigError below. + hub: hubConfigSchema.optional().catch(undefined), + remoteGui: remoteGuiConfigSchema.optional().catch(undefined), + // A malformed privacy block must never be read as "unmask": .catch(undefined) drops it and + // emailMaskingEnabled then falls back to masked, which is also what an absent block means. + privacy: z.object({ maskEmails: z.boolean().optional() }).strict().optional().catch(undefined), + // A malformed present client block must remain diagnosable from raw config and + // fail closed through src/client/state.ts; unrelated provider state still loads. + client: clientConnectionSchema.optional().catch(undefined), + managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024).describe( + "Deprecated compatibility limit for bounded legacy usage readers; GET /api/usage always aggregates the complete ledger", + ), + // Invalid hand edits disable only this opt-in circuit. Live writes remain strict. + upstreamHostCircuitThreshold: z.number().int() + .min(0) + .max(UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD) + .optional() + .catch(undefined), + // Opt-in outbound body ceiling. An invalid hand edit disables only this guard, matching the + // circuit threshold above: a malformed number must not make the proxy refuse traffic. + maxUpstreamBodyBytes: z.number().int() + .min(0) + .optional() + .catch(undefined), + // Opt-in inbound body ceiling (#3573). An invalid hand edit degrades to the 256 MiB default + // rather than failing the parse, matching the outbound guard above: a malformed number must + // not change what the proxy admits. The hard ceiling is NOT enforced here — because of that + // `.catch`, and because a config object can be built without this schema at all — but in + // `resolveInboundBodyLimitBytes()`, which every reader goes through. + maxInboundBodyBytes: z.number().int() + .min(0) + .optional() + .catch(undefined), + appOwnedMemoryBudgetMb: z.number().int() + .min(MIN_APP_OWNED_MEMORY_BUDGET_MB) + .max(MAX_APP_OWNED_MEMORY_BUDGET_MB) + .default(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024)) + .catch(DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024)), + // A blank hostname degrades to undefined rather than failing the parse. `getDefaultConfig()` + // carries no `hostname` key, so the backup-and-defaults repair path below cannot merge one + // away — a hand-edited `"hostname": ""` would fail twice and reset providers/apiKeys to + // defaults, which is strictly worse than the bind bug this validation exists for. Degrading + // is safe: startServer() already falls back to 127.0.0.1 for a missing hostname. Write-time + // rejection lives in validateConfigCandidate() so bad values still surface to the caller. + hostname: z.string().trim().min(1).optional().catch(undefined), + // Discriminated on `enabled` so a disabled entry cannot be forced to carry a port (#1102). + // An enabled one MAY omit it: that is the companion form, which binds 127.0.0.1 on the proxy + // port and is legal only off a loopback/wildcard bind — a relationship between two fields, so + // it is enforced in validateConfigCandidate() and again at startup, not here (#4236). + // A malformed value degrades to undefined rather than failing the whole parse: this is an + // opt-in convenience surface, and a hand-edit typo here must never reset providers/apiKeys + // through the backup-and-defaults repair path. + unauthenticatedLoopbackListener: z.union([ + z.object({ enabled: z.literal(false) }), + z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535).optional() }), + ]).optional().catch(undefined), + providers: z.record(z.string(), providerConfigSchema), + modelPinnedEfforts: modelPinnedEffortsSchema.optional(), + defaultProvider: z.string().min(1).default("openai"), + defaultModelAliases: z.boolean().optional(), + // Malformed hand edits disable this opt-in projection without rejecting providers. + cursorEffortRows: z.boolean().optional().catch(false), + // Fast selectors default on; malformed hand edits disable them without rejecting providers. + fastRows: z.boolean().default(true).catch(false), + // Ultra Fast is opt-in for the same reason and degrades the same way: a malformed hand + // edit turns the tier off rather than rejecting the config that carries it. + ultraFastTier: z.boolean().optional().catch(false), + codexMainAccountHardLock: z.boolean().optional().catch(false), + // Future versions remain opaque through passthrough-compatible whole-config saves. + // Only version 1 grants deletion authority in the rebase path. + configRebaseProvenance: z.unknown().optional(), + // A retry can be billable, so absence and malformed hand edits both stay off. + emptyCompletionRetry: z.boolean().optional().catch(false), + // Header suppression changes what Codex sees, so absence and malformed edits stay off. + dropCodexSafetyBuffering: z.boolean().optional().catch(false), + // A malformed hand edit must not silently stop opening the browser: fall back + // to undefined, which resolves to the historical auto-open behavior. + oauthOpenBrowser: z.boolean().optional().catch(undefined), + openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(), + // Invalid hand edits must not discard an otherwise usable config. + googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined), + subagentModelsVersion: z.number().int().positive().optional().catch(undefined), + subagentModels: z.array(z.string().min(1)).optional().catch(undefined), + // A hand-edited advisory version must not cost the operator their providers; a bad + // value degrades to undefined, which simply raises the notice again. + multiAgentSurfaceAdvisoryVersion: z.number().int().nonnegative().optional().catch(undefined), + clientIntegrations: clientIntegrationsSchema.optional().catch(undefined), + // A malformed profile policy must not fall back to legacy all-profile activation. + asideProfileSync: asideProfileSyncSchema.optional().catch({ allProfiles: false }), + providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), + providerContextCapValues: z.record(z.string(), z.number().int().positive()).optional(), + contextCapValue: z.number().int().positive().optional(), + multiAgentGuidanceEnabled: z.boolean().optional(), + // Invalid optional recovery config must not discard unrelated provider/account state. + plaintextV2AgentMessages: z.boolean().optional().catch(undefined), + agentTaskRecovery: agentTaskRecoverySchema.optional().catch(undefined), + // Same rationale: a bad notify section must not cost the operator their providers. + quotaResetNotify: quotaResetNotifySchema.optional().catch(undefined), + // Same rationale: a bad auto-refresh section must not cost the operator their providers. + catalogAutoRefresh: catalogAutoRefreshSchema.optional().catch(undefined), + // These selections pre-date schema validation and used to pass through as + // unknown fields. Invalid hand edits must disable only the optional + // delegation/native-default feature, not reject the whole config and hide + // otherwise valid providers, accounts, or the configured listen port. + injectionModel: z.string().optional().catch(undefined), + injectionEffort: z.string().optional().catch(undefined), + syncCodexSubagentDefaults: z.boolean().optional().catch(undefined), + // Per-primary-model fallback chains. Values must be non-empty string arrays; + // malformed entries degrade to undefined rather than rejecting the whole config. + subagentModelFallbackByModel: z.record( + z.string(), + z.array(z.string().trim().min(1)).min(1), + ).optional().catch(undefined), + codexShimAutoRestore: z.boolean().optional(), + codexDesktopAuthless: z.boolean().optional().catch(undefined), + codexClientCompaction: z.boolean().optional().catch(undefined), + pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), + // A malformed policy degrades to "no policy" rather than failing the parse, so a hand-edited + // typo cannot trip the backup-and-defaults repair path and wipe providers or pool accounts. + // Silently ignoring it would be its own trap, so the write path rejects it and loadConfig warns. + codexPool: codexPoolSchema.optional().catch(undefined), + codexQuotaAutoRefresh: codexQuotaAutoRefreshSchema.optional().catch(undefined), + codexAccountNamespaces: codexAccountNamespacesSchema.optional(), + // Selection order is a preference, not a safety control like pause: a malformed + // map degrades to "no ordering" rather than failing the parse, so a hand-edited + // typo cannot trip the backup-and-defaults repair path and wipe providers or + // pool accounts. Warning emitted in loadConfig. + codexAccountPriorities: codexAccountPrioritiesSchema.optional().catch(undefined), + activeCodexAccountPinned: z.string().regex(CODEX_ACCOUNT_PIN_PATTERN).optional().catch(undefined), + // A malformed hand edit must degrade to false without discarding providers, accounts, + // or the exact selector map. Live writes remain strict. + codexAccountPickerEnabled: z.boolean().optional().catch(false), + resetCreditAutoRedeem: z.object({ + enabled: z.boolean().optional(), + leadTimeMinutes: z.number().int().min(1).max(60).optional(), + }).optional().catch(undefined), + // Same degrade-to-off rule as the flags above: a hand-edited typo in an opt-in pool + // feature must never cost the operator their providers. + pool: z.object({ + kernel: z.boolean().optional(), + cacheAffinity: z.boolean().optional(), + // The catch belongs on the list, not on `pool`. Left to the outer catch below, one + // malformed group failed this nested object and dropped the whole `pool` -- taking + // `kernel` and `cacheAffinity` with it, which is a live routing change the operator + // never made. Scoped here, a malformed or ambiguous group costs only the declared + // grouping: loadConfig warns, and the write path rejects it outright. + credentialGroups: credentialGroupsSchema.optional().catch(undefined), + }).optional().catch(undefined), + // Model ids excluded from the Grok Build managed block (dashboard switches). + grokExcludedModels: z.array(z.string()).optional(), + // Invalid values degrade to undefined ("auto") instead of failing the whole + // parse: a hand-edited typo must never trip the backup-and-defaults repair + // path below and wipe providers/pool accounts. Warning emitted in loadConfig. + streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined), + blockedModelRedirects: z.record(z.string(), z.string()).optional().catch(undefined), + // Same degrade-don't-reject rationale as the fields above: a hand-edited + // non-string must not trip the backup-and-defaults repair path. Unset then + // takes the canonical sideband path (src/server/live.ts normalizeSidebandRoot). + experimentalRealtimeWsBaseUrl: z.string().optional().catch(undefined), + // Salvage element by element, and never fail the parse. Two spellings were + // measured on this zod version and both lose data: + // `z.array(entry).catch(undefined)` -> one bad entry discards EVERY key + // `z.array(z.unknown())` -> a non-array value still raises + // invalid_type, reaching the + // backup-and-defaults repair path + // Starting from `unknown` is what makes both survivable. A key the user still + // has deployed must not be collateral damage for one bad neighbour, and on a + // remote bind an emptied array is worse than cosmetic: assertServerAuthConfig + // refuses to start without a data credential. + apiKeys: z.unknown().optional().transform(value => { + if (value === undefined) return undefined; + if (!Array.isArray(value)) return undefined; + return value + .filter(row => apiKeyEntrySchema.safeParse(row).success) + .map(row => apiKeyEntrySchema.parse(row) as OcxApiKeyEntry); + }), +}).passthrough().superRefine((config, ctx) => { + const claudeCode = (config as { claudeCode?: unknown }).claudeCode; + if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) { + ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" }); + } else if (claudeCode) { + const claude = claudeCode as { desktopProfile?: unknown }; + if (claude.desktopProfile !== undefined) { + try { + parseDesktopProfile(claude.desktopProfile); + } catch (error) { + ctx.addIssue({ + code: "custom", + path: ["claudeCode", "desktopProfile"], + message: error instanceof Error ? error.message : String(error), + }); + } + } + } + + const accountNamespaces = config.codexAccountNamespaces; + if (accountNamespaces) { + const configuredAccountIds = configuredCodexPoolAccountIds(config.codexAccounts); + const configuredProviderNamespaces = new Set([ + COMBO_NAMESPACE, + OPENAI_CODEX_PROVIDER_ID, + POLICY_NAMESPACE, + ...Object.keys(config.providers), + ].map(codexProviderNamespaceKey)); + const namespaceTargets = new Set( + Object.values(accountNamespaces) + .filter(accountId => accountId !== MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET), + ); + for (const namespace of Object.keys(accountNamespaces)) { + if (configuredProviderNamespaces.has(codexProviderNamespaceKey(namespace))) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces", namespace], + message: "account selectors must not collide with configured provider, combo, or routing policy namespaces", + }); + } + if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces", namespace], + message: CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR, + }); + } + } + } + for (const name of Object.keys(config.providers)) { + if (!isValidProviderName(name)) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name)], + message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys or routing namespaces (policy)", + }); + } + const provider = config.providers[name]; + if (hasFastWireCapabilityConflict(provider)) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "fastWire"], + message: "fastWire=null conflicts with supportsServiceTier=true", + }); + } + const openRouterRoutingError = openRouterRoutingConfigError(provider); + if (openRouterRoutingError) { + ctx.addIssue({ + code: "custom", + path: [ + "providers", + redactSecretString(name), + openRouterRoutingError.startsWith("modelOpenRouterRouting") + ? "modelOpenRouterRouting" + : "openRouterRouting", + ], + message: openRouterRoutingError, + }); + } + const vercelRoutingError = vercelGatewayRoutingConfigError(provider); + if (vercelRoutingError) { + ctx.addIssue({ + code: "custom", + path: [ + "providers", + redactSecretString(name), + vercelRoutingError.startsWith("modelVercelGatewayRouting") + ? "modelVercelGatewayRouting" + : "vercelGatewayRouting", + ], + message: vercelRoutingError, + }); + } + if (Object.hasOwn(provider, "virtualModels")) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "virtualModels"], + message: "virtualModels is registry-only and must not be persisted", + }); + } + const baseUrlError = providerBaseUrlConfigError(provider.baseUrl); + if (baseUrlError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "baseUrl"], + message: baseUrlError, + }); + } else { + const destinationError = providerDestinationConfigError(name, provider); + if (destinationError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "baseUrl"], + message: destinationError, + }); + } + } + for (const field of ["responsesPath", "chatCompletionsPath"] as const) { + const sendPathError = providerRelativeSendPathConfigError(field, provider[field]); + if (sendPathError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), field], + message: sendPathError, + }); + } + } + const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers); + if (headersError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "headers"], + message: headersError, + }); + } + const modelCostsError = providerModelCostsConfigError((provider as { modelCosts?: unknown }).modelCosts); + if (modelCostsError) { + ctx.addIssue({ + code: "custom", + // The provider key is caller-controlled and can be token-shaped; redact it + // before schemaDiagnosticsError serializes the path (ocx config validate/import). + path: ["providers", redactSecretString(name), "modelCosts"], + message: modelCostsError, + }); + } + const modelDisplayNamesError = modelDisplayNamesConfigError( + (provider as { modelDisplayNames?: unknown }).modelDisplayNames, + ); + if (modelDisplayNamesError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelDisplayNames"], + message: modelDisplayNamesError, + }); + } + const apiKeyTransportError = apiKeyTransportConfigError(provider as OcxProviderConfig); + if (apiKeyTransportError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "apiKeyTransport"], + message: apiKeyTransportError, + }); + } + const modelAdaptersError = modelAdapterRecordConfigError( + (provider as { modelAdapters?: unknown }).modelAdapters, + "modelAdapters", + name, + provider, + ); + if (modelAdaptersError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelAdapters"], + message: modelAdaptersError, + }); + } + const preferHostedToolsError = modelPreferHostedToolsConfigError( + (provider as { modelPreferHostedTools?: unknown }).modelPreferHostedTools, + "modelPreferHostedTools", + name, + provider, + ); + if (preferHostedToolsError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelPreferHostedTools"], + message: preferHostedToolsError, + }); + } + const maxInputError = positiveIntegerRecordConfigError( + (provider as { modelMaxInputTokens?: unknown }).modelMaxInputTokens, + "modelMaxInputTokens", + ); + if (maxInputError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelMaxInputTokens"], + message: maxInputError, + }); + } + const autoCompactError = modelAutoCompactTokenLimitsConfigError( + (provider as { modelAutoCompactTokenLimits?: unknown }).modelAutoCompactTokenLimits, + { requireNativeIds: name === OPENAI_CODEX_PROVIDER_ID }, + ); + if (autoCompactError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelAutoCompactTokenLimits"], + message: autoCompactError, + }); + } + const reasoningSummariesError = booleanRecordConfigError( + (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries, + "modelSupportsReasoningSummaries", + ); + if (reasoningSummariesError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelSupportsReasoningSummaries"], + message: reasoningSummariesError, + }); + } + const verbositySupportError = booleanRecordConfigError( + (provider as { modelSupportsVerbosity?: unknown }).modelSupportsVerbosity, + "modelSupportsVerbosity", + ); + if (verbositySupportError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelSupportsVerbosity"], + message: verbositySupportError, + }); + } + const serviceTierModelsError = booleanRecordConfigError( + (provider as { modelSupportsServiceTier?: unknown }).modelSupportsServiceTier, + "modelSupportsServiceTier", + ); + if (serviceTierModelsError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelSupportsServiceTier"], + message: serviceTierModelsError, + }); + } + const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError( + (provider as { modelReasoningSummaryDelivery?: unknown }).modelReasoningSummaryDelivery, + (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries, + ); + if (reasoningSummaryDeliveryError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelReasoningSummaryDelivery"], + message: reasoningSummaryDeliveryError, + }); + } + const defaultMaxOutputError = positiveIntegerConfigError( + (provider as { defaultMaxOutputTokens?: unknown }).defaultMaxOutputTokens, + "defaultMaxOutputTokens", + ); + if (defaultMaxOutputError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "defaultMaxOutputTokens"], + message: defaultMaxOutputError, + }); + } + const maxOutputError = positiveIntegerRecordConfigError( + (provider as { modelMaxOutputTokens?: unknown }).modelMaxOutputTokens, + "modelMaxOutputTokens", + ); + if (maxOutputError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelMaxOutputTokens"], + message: maxOutputError, + }); + } + const structuredOutputOptOutError = nonBlankStringArrayConfigError( + (provider as { noStructuredOutputModels?: unknown }).noStructuredOutputModels, + "noStructuredOutputModels", + ); + if (structuredOutputOptOutError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "noStructuredOutputModels"], + message: structuredOutputOptOutError, + }); + } + const jsonSchemaOptOutError = nonBlankStringArrayConfigError( + (provider as { noJsonSchemaModels?: unknown }).noJsonSchemaModels, + "noJsonSchemaModels", + ); + if (jsonSchemaOptOutError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "noJsonSchemaModels"], + message: jsonSchemaOptOutError, + }); + } + const retainModelsError = nonBlankStringArrayConfigError( + (provider as { retainModels?: unknown }).retainModels, + "retainModels", + ); + if (retainModelsError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "retainModels"], + message: retainModelsError, + }); + } + const toolReasoningOptOutError = nonBlankStringArrayConfigError( + (provider as { omitReasoningEffortWithToolsModels?: unknown }).omitReasoningEffortWithToolsModels, + "omitReasoningEffortWithToolsModels", + ); + if (toolReasoningOptOutError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "omitReasoningEffortWithToolsModels"], + message: toolReasoningOptOutError, + }); + } + if (Object.hasOwn(provider, "codexAccountMode") && provider.codexAccountMode !== undefined) { + // Persisted account mode is valid ONLY on the canonical built-in `openai` forward provider. + // Old openai-multi rows stay parseable (they never carry a mode) so startup can migrate them. + const canonicalOpenAiShape = name === "openai" + && provider.adapter === "openai-responses" + && (provider as { authMode?: unknown }).authMode === "forward" + && typeof provider.baseUrl === "string" + && provider.baseUrl.replace(/\/+$/, "") === "https://chatgpt.com/backend-api/codex"; + if (!canonicalOpenAiShape) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "codexAccountMode"], + message: "codexAccountMode is valid only on the canonical built-in openai provider", + }); + } + } + } + if (!hasOwnProvider(config.providers, config.defaultProvider)) { + ctx.addIssue({ + code: "custom", + path: ["defaultProvider"], + message: "defaultProvider must exist in providers", + }); + } + const combos = (config as { combos?: unknown }).combos; + if (combos !== undefined) { + if (!combos || typeof combos !== "object" || Array.isArray(combos)) { + ctx.addIssue({ code: "custom", path: ["combos"], message: "combos must be an object" }); + } else { + for (const [id, raw] of Object.entries(combos as Record)) { + const alias = raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as { alias?: unknown }).alias + : undefined; + if (typeof alias === "string" && codexAccountNamespaceForModel(accountNamespaces, alias.trim())) { + ctx.addIssue({ + code: "custom", + path: ["combos", id, "alias"], + message: CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, + }); + } + // Pass the full map so cross-combo rules (alias uniqueness) apply at load time + // too, not just via the management API; each combo is excluded from its own check. + for (const issue of comboConfigIssues(id, raw, config.providers, { + combos: combos as Record, + excludeComboId: id, + })) { + ctx.addIssue({ + code: "custom", + path: ["combos", id, ...issue.path], + message: issue.message, + }); + } + } + } + } + const routingProfiles = (config as { routingProfiles?: unknown }).routingProfiles; + if (routingProfiles !== undefined) { + if (!routingProfiles || typeof routingProfiles !== "object" || Array.isArray(routingProfiles)) { + ctx.addIssue({ code: "custom", path: ["routingProfiles"], message: "routingProfiles must be an object" }); + } else { + for (const [id, raw] of Object.entries(routingProfiles as Record)) { + for (const issue of routingProfileIssues(id, raw, { + providers: config.providers, + combos: combos as Record | undefined, + routingProfiles: routingProfiles as Record, + codexAccountNamespaces: accountNamespaces, + }, { excludeProfileId: id })) { + ctx.addIssue({ + code: "custom", + path: ["routingProfiles", id, ...issue.path], + message: issue.message, + }); + } + } + } + } +}); diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts new file mode 100644 index 0000000000..f9deab442b --- /dev/null +++ b/src/config/schema/leaf-validators.ts @@ -0,0 +1,855 @@ +import * as z from "zod/v4"; +import { join } from "node:path"; +import { isValidProviderName } from "../provider-name"; +import { + modelPinnedEffortsConfigError, + pinnedReasoningEffortConfigError, + modelDisplayNamesConfigError, + autoReviewModelOverridesConfigError, + autoReviewModelTargetConfigError, + normalizeNonBlankStringArray, + normalizeAutoReviewModelOverrides, + modelCapabilitiesConfigError, + mergeModelCapabilities, +} from "../provider-validation"; +import { isValidCodexAccountNamespaceTarget } from "../../codex/account-namespace-match"; +import { isCodexAccountPriorityKey } from "../../codex/account-priority"; +import { parseAccountPriority } from "../../codex/pool-rotation"; +import { credentialGroupIssues } from "../../routing/identity-domains"; +import { providerDestinationConfigError } from "../../lib/destination-policy"; +import { redactSecretString } from "../../lib/redact"; +import { + MODEL_ADAPTER_OVERRIDE_ALLOWED, + pinnedWireAdapter, + PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS, + UPSTREAM_HTTP_VERSION_VALUES, + type OcxProviderConfig, + type FastWire, + type ProviderCostOverlay, +} from "../../types"; +import { fastWireDeclarationError } from "../../providers/fastwire"; +import { getProviderRegistryEntry, providerMatchesRegistryTransport, providerModelWireDefault } from "../../providers/registry"; +import { resolveOpenAiVirtualModel } from "../../providers/openai-virtual-models"; +import { COST4_RATE_KEYS, isValidCost4Rate } from "../../usage/user-cost-overlays"; +import { MAX_COST4_RATE } from "../../usage/expected-prices"; +import { isHostedToolUnsupportedForModel } from "../../responses/hosted-tool-policy"; +import { getConfigDir } from "../paths"; + +/** One definition of "usable secret", shared by the schema and the warnings. */ +export function isUsableApiKeySecret(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value === value.trim(); +} + +/** + * Bounds for the opt-in same-target 429 wait-and-retry policy. Single source of truth + * shared by the config schema, the load-time sanitizer, and the management write + * boundary. Strict, so an unknown key is rejected at every validation boundary instead + * of being silently ignored (the load-time sanitizer still degrades unknown keys with a + * warning before schema validation, so hand-edited configs keep loading). + */ +export const retryOn429PolicySchema = z.object({ + enabled: z.boolean().optional(), + attempts: z.number().int().min(1).max(20).optional(), + intervalMs: z.number().int().min(100).max(600_000).optional(), + // The effective cap for a single wait is MAX_COOLDOWN_MS (10 min) in key-failover.ts; + // larger configured values would be dead config. + maxIntervalMs: z.number().int().min(100).max(600_000).optional(), + respectRetryAfter: z.boolean().optional(), +}).strict(); + +/** + * `transientRetryOn5xx` accepts only these keys. `attempts` is a TOTAL send budget shared by + * both retry layers, so the ceiling is deliberately lower than `retryOn429`'s: 10 total sends + * against an already-failing provider is already generous. + */ +const transientRetryOn5xxPolicySchema = z.object({ + enabled: z.boolean().optional(), + attempts: z.number().int().min(1).max(10).optional(), +}).strict(); + +const requestPacingRuleSchema = z.object({ + // Keep the RPM-derived timer within the same one-hour bound as minIntervalMs. + requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), + minIntervalMs: z.number().int().min(1).max(3_600_000).optional(), +}).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined, { + message: "request pacing rules need requestsPerMinute or minIntervalMs", +}); + +const requestPacingSchema = z.object({ + enabled: z.boolean(), + requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), + minIntervalMs: z.number().int().min(1).max(3_600_000).optional(), + models: z.record(z.string().trim().min(1), requestPacingRuleSchema).optional(), +}).strict().refine(value => value.enabled === false + || value.requestsPerMinute !== undefined + || value.minIntervalMs !== undefined + || (value.models !== undefined && Object.keys(value.models).length > 0), { + message: "enabled request pacing needs a provider rule or model override", +}); + +export function requestPacingConfigError(value: unknown): string | null { + if (value === undefined) return null; + const parsed = requestPacingSchema.safeParse(value); + if (parsed.success) return null; + return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides"; +} + +/** + * Bounds for the opt-in passthrough web-search bridge (`providers..webSearchBridge`, + * #3761). Strict for the same reason `retryOn429` is: a misspelled key here would silently + * leave the bridge disarmed while the operator believes they enabled it. + * + * `endpoint` names the destination that receives this provider's API key, so it gets the same + * literal destination assessment `baseUrl` gets (#4519) — see `providerWebSearchBridgeConfigError` + * below. This schema itself still only shape-checks: it is `.catch(undefined)` at the provider + * row, and a hand-edited config file never reaches the error function at all. The authorization + * boundary is therefore `resolveOllamaWebSearchEndpoint`, which runs the same assessment and is + * the only reader of this field in the tree; config validation is where an operator is told why, + * not what makes the value safe. + */ +const providerWebSearchBridgeSchema = z.object({ + enabled: z.boolean().optional(), + backend: z.enum(PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS).optional(), + maxSearches: z.number().int().min(1).max(10).optional(), + timeoutMs: z.number().int().min(1_000).max(600_000).optional(), + endpoint: z.string().min(1).optional(), +}).strict(); + +export function providerWebSearchBridgeConfigError( + value: unknown, + providerName: string, + provider: Pick, +): string | null { + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return "webSearchBridge must be a plain object"; + } + const parsed = providerWebSearchBridgeSchema.safeParse(value); + if (!parsed.success) { + return "webSearchBridge accepts only enabled (boolean), backend " + + `(${PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS.join("|")}), maxSearches (1..10), ` + + "timeoutMs (1000..600000), and endpoint (absolute http(s) URL)"; + } + const endpoint = parsed.data.endpoint; + if (endpoint !== undefined) { + let url: URL; + try { + url = new URL(endpoint); + } catch { + return "webSearchBridge.endpoint must be an absolute http(s) URL"; + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + return "webSearchBridge.endpoint must be an absolute http(s) URL"; + } + // Same classifier baseUrl uses, so a metadata address is refused outright and loopback or + // private space needs the provider's allowPrivateNetwork opt-in (or a registry entry that is + // local by definition, which is what keeps a self-hosted Ollama working). Literal-only and + // synchronous, exactly as at the baseUrl boundary: no DNS is resolved here. + const destinationError = providerDestinationConfigError(providerName, { + baseUrl: endpoint, + allowPrivateNetwork: provider.allowPrivateNetwork, + }); + if (destinationError) { + return destinationError.replace(/^baseUrl/, "webSearchBridge.endpoint"); + } + } + return null; +} + +const fastWireSchema = z.object({ + kind: z.string(), + canonicalToWire: z.record(z.string().trim(), z.string().trim()), + foreignCallerTiers: z.string(), + betas: z.array(z.string().trim()).optional(), +}).strict().superRefine((fastWire, ctx) => { + const error = fastWireDeclarationError({ fastWire }); + if (error) ctx.addIssue({ code: "custom", message: error }); +}).transform(fastWire => fastWire as FastWire); + +const modelDisplayNamesSchema = z.unknown().superRefine((value, ctx) => { + const error = modelDisplayNamesConfigError(value); + if (error) ctx.addIssue({ code: "custom", message: error }); +}).transform(value => { + const labels = Object.create(null) as Record; + for (const [modelId, displayName] of Object.entries(value as Record)) { + labels[modelId] = displayName; + } + return labels; +}); + +const pinnedReasoningEffortSchema = z.unknown().superRefine((value, ctx) => { + const error = pinnedReasoningEffortConfigError(value); + if (error) ctx.addIssue({ code: "custom", message: error }); +}).transform(value => value as string); + +export const modelPinnedEffortsSchema = z.unknown().superRefine((value, ctx) => { + const error = modelPinnedEffortsConfigError(value); + if (error) ctx.addIssue({ code: "custom", message: error }); +}).transform(value => Object.fromEntries( + Object.entries(value as Record).map(([key, effort]) => [key.trim(), effort]), +)); + +const autoReviewModelSchema = z.unknown().superRefine((value, ctx) => { + const error = autoReviewModelTargetConfigError(value, "autoReviewModel", true); + if (error) ctx.addIssue({ code: "custom", message: error }); +}).transform(value => { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +}); + +const autoReviewModelOverridesSchema = z.unknown().superRefine((value, ctx) => { + const error = autoReviewModelOverridesConfigError(value, "autoReviewModelOverrides", true); + if (error) ctx.addIssue({ code: "custom", message: error }); +}).transform(value => normalizeAutoReviewModelOverrides(value)); + +const modelCapabilitiesSchema = z.unknown().superRefine((value, ctx) => { + const error = modelCapabilitiesConfigError(value); + if (error) ctx.addIssue({ code: "custom", message: error }); +}).transform(value => mergeModelCapabilities(undefined, value)); + +/** + * Zod schema for one provider entry: known fields are validated strictly while unknown + * fields pass through (preserved for runtime extensions). + */ +export const providerConfigSchema = z.object({ + modelCapabilities: modelCapabilitiesSchema.optional(), + pinnedReasoningEffort: pinnedReasoningEffortSchema.optional(), + modelPinnedReasoningEfforts: modelPinnedEffortsSchema.optional(), + // Validated rather than left to passthrough: an unrecognized strategy would otherwise + // load silently and then be ignored at selection time, which reads as a broken feature + // rather than a rejected setting. + apiKeyPoolStrategy: z.enum(["round-robin", "fill-first", "quota"]).optional(), + autoReviewModel: autoReviewModelSchema.optional(), + autoReviewModelOverrides: autoReviewModelOverridesSchema.optional(), + adapter: z.string().min(1), + baseUrl: z.string().min(1), + alias: z.string().optional(), + modelAliases: z.record(z.string(), z.string()).optional(), + modelDisplayNames: modelDisplayNamesSchema.optional(), + defaultAliases: z.boolean().optional(), + initialModelSelection: z.object({ + version: z.literal(1), + registrationId: z.uuid(), + status: z.enum(["pending", "ready", "all-off"]), + modelCount: z.number().int().nonnegative().optional(), + }).optional().catch(undefined), + requestPacing: requestPacingSchema.optional().catch(undefined), + mcpMaxTools: z.number().int().positive().optional(), + mcpMaxSchemaBytes: z.number().int().positive().optional(), + mcpMaxResultBytes: z.number().int().positive().optional(), + apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(), + responsesPath: z.string().min(1).optional(), + chatCompletionsPath: z.string().min(1).optional(), + statelessResponses: z.boolean().optional(), + requiresAdjacentResponsesToolResults: z.boolean().optional(), + annotateEmptyToolOutputs: z.boolean().optional(), + fastWire: fastWireSchema.nullable().optional(), + supportsServiceTier: z.boolean().optional(), + modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), + preserveResponsesReasoningContent: z.boolean().optional(), + decodesNativeCompactionBlobs: z.boolean().optional(), + allowEncryptedV2AgentTasks: z.boolean().optional(), + allowPrivateNetwork: z.boolean().optional(), + // The management API accepts `null` as "clear this", so a config written before the POST + // canonicalization below can hold one on disk. Rejecting it here would send the operator + // through invalid-config recovery for a value the API told them was fine. + upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES) + .nullish() + .transform(value => value ?? undefined), + // Opt-in upstream Responses WebSocket for OpenAI-compatible providers (e.g. + // aggregators whose WebSocket ingress is measurably faster than SSE). The + // canonical ChatGPT backend WS selection is independent of this flag. + upstreamWebsocket: z.boolean().optional(), + directGeminiWireRenames: z.boolean().optional(), + noStructuredOutputModels: z.array(z.string().min(1)) + .transform(normalizeNonBlankStringArray) + .optional(), + noJsonSchemaModels: z.array(z.string().min(1)) + .transform(normalizeNonBlankStringArray) + .optional(), + retainModels: z.array(z.string().min(1)) + .transform(normalizeNonBlankStringArray) + .optional(), + omitReasoningEffortWithToolsModels: z.array(z.string().min(1)) + .transform(normalizeNonBlankStringArray) + .optional(), + retryOn429: retryOn429PolicySchema.optional(), + transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(), + codexAccountMode: z.enum(["pool", "direct"]).optional(), + // Validated rather than passed through: this schema ends in `.passthrough()`, so an + // undeclared key survives verbatim. A misspelled `codexToolMode` therefore used to be + // accepted, persisted, and then silently resolved to the `code_mode_only` default — the + // operator asked for shell mode, got code mode, and was told nothing (#2106). + codexToolMode: z.enum(["code_mode_only", "shell"]).optional(), + responsesItemIdRepair: z.object({ + message: z.array(z.string().min(1)).optional(), + reasoning: z.array(z.string().min(1)).optional(), + repairMissingTerminalIds: z.boolean().optional(), + repairInvalidIds: z.boolean().optional(), + }).strict().optional(), + responsesSnapshotRepair: z.boolean().optional(), + // Invalid blocks degrade to "absent" rather than failing the whole config load: an unusable + // bridge block must never send an operator through invalid-config recovery for an opt-in + // feature that is off by default. The management write boundary still rejects it loudly. + webSearchBridge: providerWebSearchBridgeSchema.optional().catch(undefined), + xaiResponsesXSearch: z.boolean().optional(), + xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined), + zaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined), +}).passthrough(); + + +/** + * Shared shape check for the two relative send-path overrides. `field` names the + * offending key so the message stays specific to what the user actually wrote. + */ +export function providerRelativeSendPathConfigError(field: string, value: string | undefined): string | null { + if (value === undefined) return null; + if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) || value.includes("://")) { + return `${field} must be a relative path without a URL scheme`; + } + if (!value.startsWith("/")) return `${field} must start with /`; + if (value.includes("?") || value.includes("#")) { + return `${field} must not include query strings or fragments`; + } + return null; +} + +/** + * Validate `providers..modelCosts`: a plain object keyed by exact model + * id, each value a 4-tuple of non-negative finite USD-per-1M-token rates. + * Returns null when valid/absent, else a human-readable error. + */ +export function providerModelCostsConfigError(value: unknown, field = "modelCosts"): string | null { + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return `${field} must be a plain object keyed by model id`; + } + for (const [modelId, entry] of Object.entries(value)) { + if (!modelId.trim()) return `${field} keys must be nonblank model ids`; + // Redact secret-shaped model ids and JSON-escape control characters so a + // malformed write cannot echo a pasted key/secret back through the + // management API response. + const safeModelId = JSON.stringify(redactSecretString(modelId)); + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return `${field}.${safeModelId} must be an object with input, output, cacheRead, and cacheWrite (USD per 1M tokens)`; + } + const rates = entry as Record; + for (const key of COST4_RATE_KEYS) { + const rate = rates[key]; + if (!isValidCost4Rate(rate)) { + return `${field}.${safeModelId}.${key} must be a non-negative finite number at most ${MAX_COST4_RATE} (USD per 1M tokens)`; + } + } + // Reject unknown fields: a misplaced apiKey/apiKeyPool under a cost row + // would otherwise be persisted and echoed verbatim by display paths that + // mask only top-level provider secrets. + const extraKeys = Object.keys(rates) + .filter((key) => !(COST4_RATE_KEYS as readonly string[]).includes(key)); + if (extraKeys.length > 0) { + return `${field}.${safeModelId} has unexpected fields ${JSON.stringify(extraKeys.map(redactSecretString).join(", "))} — only input, output, cacheRead, and cacheWrite are allowed (USD per 1M tokens)`; + } + } + return null; +} + +/** + * Serialize `providers..modelCosts` for display: copy ONLY the four + * numeric rate fields per model and DROP secret-shaped model ids, so a pasted + * API key in a key position cannot be echoed back by CLI/DTO display paths. + * The result uses a null prototype so "__proto__" remains an own row. + */ +export function sanitizeModelCostsForDisplay(costs: unknown): Record | undefined { + if (!costs || typeof costs !== "object" || Array.isArray(costs)) return undefined; + const out = Object.create(null) as Record; + for (const [modelId, entry] of Object.entries(costs)) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue; + const rates = entry as Record; + const input = rates.input; + const output = rates.output; + const cacheRead = rates.cacheRead; + const cacheWrite = rates.cacheWrite; + if ( + isValidCost4Rate(input) + && isValidCost4Rate(output) + && isValidCost4Rate(cacheRead) + && isValidCost4Rate(cacheWrite) + ) { + // Secret-shaped ids are DROPPED rather than mapped to "[REDACTED]" so + // distinct rows cannot collapse into one placeholder key. + if (redactSecretString(modelId) !== modelId) continue; + out[modelId] = { input, output, cacheRead, cacheWrite }; + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +const SUPPORTED_PREFERRED_HOSTED_TOOLS = new Set(["image_generation"]); + +export function modelPreferHostedToolsConfigError( + value: unknown, + field: string, + providerName: string, + provider: { adapter?: unknown; authMode?: unknown; modelAdapters?: unknown; baseUrl?: unknown }, +): string | null { + if (value === undefined) return null; + if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`; + const entries = Object.entries(value); + const registry = getProviderRegistryEntry(providerName); + // Effective transport: a `preserveCustomDestination` registry row reused under a + // different endpoint keeps its own adapter AND its own auth at runtime, because + // `routedProviderConfig()` honors `providerMatchesRegistryTransport()`. Both the + // wire check below and the forward-auth check here have to start from the same + // decision, or validation accepts a preference the adapter never applies — + // `preferConfiguredHostedTools()` runs only on the non-forward branch. + const registryTransportMatches = typeof provider.baseUrl === "string" + && providerMatchesRegistryTransport(providerName, { + baseUrl: provider.baseUrl, + adapter: provider.adapter as OcxProviderConfig["adapter"], + ...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}), + }); + const effectiveForwardAuth = registryTransportMatches + ? registry?.authKind === "forward" + : provider.authMode === "forward"; + if (entries.length > 0 && effectiveForwardAuth) { + return `${field} is not supported on forward-auth Responses providers`; + } + const requestedWireFor = (modelId: string): unknown => provider.modelAdapters + && typeof provider.modelAdapters === "object" + && !Array.isArray(provider.modelAdapters) + ? (provider.modelAdapters as Record)[modelId] + : undefined; + const resolveEffectiveWire = (modelId: string, currentWire: unknown): unknown => { + const pinned = pinnedWireAdapter(providerName, modelId); + if (pinned) return pinned; + const requestedWire = requestedWireFor(modelId); + if (typeof requestedWire === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requestedWire)) { + return requestedWire; + } + // No explicit override: fall back to the registry's per-model wire default before + // the provider-wide adapter, because that is the order `resolveModelAdapter()` + // uses at request time (src/server/adapter-resolve.ts:38-48). Skipping it rejected + // preferences the runtime would have honored — DeepSeek routes `deepseek-v4-flash` + // over native Responses for a Responses inbound while the provider-wide wire stays + // openai-chat. Hosted-tool preferences only apply to Responses traffic, so the + // inbound to ask about is "responses". + const registryDefault = typeof currentWire === "string" && typeof provider.baseUrl === "string" + ? providerModelWireDefault( + providerName, + { + baseUrl: provider.baseUrl, + adapter: currentWire, + ...(typeof provider.authMode === "string" ? { authMode: provider.authMode as OcxProviderConfig["authMode"] } : {}), + }, + modelId, + MODEL_ADAPTER_OVERRIDE_ALLOWED, + "responses", + ) + : undefined; + return registryDefault ?? currentWire; + }; + for (const [key, entry] of entries) { + if (!key.trim()) return `${field} keys must be nonblank model ids`; + if (!Array.isArray(entry)) return `${field}.${key} must be an array`; + if (entry.length === 0) return `${field}.${key} must include image_generation`; + for (const tool of entry) { + if (typeof tool !== "string" || !SUPPORTED_PREFERRED_HOSTED_TOOLS.has(tool)) { + return `${field}.${key} supports only image_generation`; + } + if (isHostedToolUnsupportedForModel(key, tool)) { + return `${field}.${key} cannot prefer ${tool}: the model does not support it`; + } + } + // Same `registryTransportMatches` decision the forward-auth check above uses: + // start from the registry adapter only when this config still points at the + // registry's documented transport. + const baseWire = registryTransportMatches ? registry?.adapter ?? provider.adapter : provider.adapter; + let effectiveWire = resolveEffectiveWire(key, baseWire); + const virtualWireModel = resolveOpenAiVirtualModel(providerName, key)?.wireModelId; + if (virtualWireModel && virtualWireModel !== key) { + effectiveWire = resolveEffectiveWire(virtualWireModel, effectiveWire); + } + if (effectiveWire !== "openai-responses") { + return `${field}.${key} requires the openai-responses wire`; + } + } + return null; +} + +const CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR = + "codexAccountNamespaces must be a plain object mapping account selectors to Codex account ids"; +const CODEX_ACCOUNT_NAMESPACE_KEY_ERROR = + "account selectors must use 1-64 letters, numbers, dots, underscores, or hyphens and cannot be reserved JavaScript object keys"; +const CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR = + "account selector targets must be @main or valid Codex pool-account ids"; +export const CODEX_ACCOUNT_NAMESPACE_ACCOUNT_ID_COLLISION_ERROR = + "account selectors must not collide with configured Codex pool-account ids or account selector targets"; + +export function configuredCodexPoolAccountIds(value: unknown): Set { + const accountIds = new Set(); + if (!Array.isArray(value)) return accountIds; + for (const account of value) { + if (!account || typeof account !== "object" || Array.isArray(account)) continue; + const { id, isMain } = account as { id?: unknown; isMain?: unknown }; + if (typeof id === "string" && isMain !== true) accountIds.add(id); + } + return accountIds; +} + +export const codexAccountNamespacesSchema = z.custom>( + (value): value is Record => !!value + && typeof value === "object" + && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null), + { error: CODEX_ACCOUNT_NAMESPACES_RECORD_ERROR }, +).superRefine((accountNamespaces, ctx) => { + // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys. + for (const [namespace, accountId] of Object.entries(accountNamespaces)) { + if (!isValidProviderName(namespace)) { + ctx.addIssue({ + code: "custom", + path: [namespace], + message: CODEX_ACCOUNT_NAMESPACE_KEY_ERROR, + }); + } + if (!isValidCodexAccountNamespaceTarget(accountId)) { + ctx.addIssue({ + code: "custom", + path: [namespace], + message: CODEX_ACCOUNT_NAMESPACE_TARGET_ERROR, + }); + } + } +}).pipe(z.record(z.string(), z.string())); + +const CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR = + "codexAccountPriorities must be a plain object mapping Codex account ids to selection-order integers"; +const CODEX_ACCOUNT_PRIORITY_KEY_ERROR = + "selection-order keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys"; +const CODEX_ACCOUNT_PRIORITY_VALUE_ERROR = + "selection order must be an integer between -100 and 100"; + +export const CODEX_ACCOUNT_PIN_PATTERN = /^[a-zA-Z0-9._-]{1,64}$/; + +export const codexAccountPrioritiesSchema = z.custom>( + (value): value is Record => !!value + && typeof value === "object" + && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null), + { error: CODEX_ACCOUNT_PRIORITIES_RECORD_ERROR }, +).superRefine((priorities, ctx) => { + // Inspect raw own entries before z.record parses them; Zod omits __proto__ record keys. + for (const [accountId, priority] of Object.entries(priorities)) { + if (!isCodexAccountPriorityKey(accountId)) { + ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_KEY_ERROR }); + } + if (parseAccountPriority(priority) === null) { + ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_ACCOUNT_PRIORITY_VALUE_ERROR }); + } + } +}).pipe(z.record(z.string(), z.number().int())); + +const codexQuotaAutoRefreshEntrySchema = z.object({ + fiveHour: z.boolean().optional(), + weekly: z.boolean().optional(), + lastFiveHourResetAt: z.number().finite().nonnegative().optional(), + lastWeeklyResetAt: z.number().finite().nonnegative().optional(), + nextFiveHourResetAt: z.number().finite().nonnegative().optional(), + nextWeeklyResetAt: z.number().finite().nonnegative().optional(), +}).strict(); +const CODEX_QUOTA_AUTO_REFRESH_KEY_ERROR = + "quota auto-refresh keys must be a Codex pool-account id or the main Codex account and cannot be reserved JavaScript object keys"; + +export const codexQuotaAutoRefreshSchema = z.custom>( + (value): value is Record => !!value + && typeof value === "object" + && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null), + { error: "codexQuotaAutoRefresh must be a plain object" }, +).superRefine((settings, ctx) => { + // Inspect own entries before z.record parses them; Zod omits __proto__ record keys. + for (const [accountId, setting] of Object.entries(settings)) { + if (!isCodexAccountPriorityKey(accountId)) { + ctx.addIssue({ code: "custom", path: [accountId], message: CODEX_QUOTA_AUTO_REFRESH_KEY_ERROR }); + } + const parsed = codexQuotaAutoRefreshEntrySchema.safeParse(setting); + if (!parsed.success) { + ctx.addIssue({ code: "custom", path: [accountId], message: "invalid quota auto-refresh setting" }); + } + } +}).pipe(z.record(z.string(), codexQuotaAutoRefreshEntrySchema)); + +/** + * Deliberately permissive. A user's config is not ours to invalidate: a strict + * entry fails the whole parse, and loadConfig's fallback then backs the file up + * and returns defaults — losing providers and pool accounts because one key name + * was too long. Length and charset rules live at the POST/PATCH boundary, where + * rejecting produces a 400 instead. `.passthrough()` keeps unknown per-key + * properties across a load -> mutate -> save round trip. + * + * Only `key` is load-bearing: admission compares that string and nothing else + * (src/server/auth-cors.ts isDataPlaneAdmissionSecret). So the secret is the one + * field that must be a usable string, and every piece of metadata around it + * degrades instead of taking the credential down with it. Dropping a working key + * because its `name` was hand-edited to a number would be a silent revocation — + * and on a remote bind, potentially a server that refuses to start. + * + * "Usable" matches admission exactly. The presented token is trimmed before the + * comparison but the stored value is not, so a key with surrounding whitespace + * can never match either form of itself. Keeping one would be worse than dropping + * it: `system-env.ts` and `cli/claude.ts` hand `apiKeys[0].key` to launched + * clients, so a junk first entry would mask a valid later one. + */ +const pendingApiKeyRotationSchema = z.object({ + id: z.string().trim().min(1).max(256), + key: z.string().refine(isUsableApiKeySecret), + createdAt: z.string().datetime({ offset: true }), + expiresAt: z.string().datetime({ offset: true }), +}).strict(); + +export const apiKeyEntrySchema = z.object({ + key: z.string().refine(isUsableApiKeySecret), + // Degrades to "" here; every schema consumer then runs `normalizeApiKeyIds`, + // which fills it deterministically so the id is stable across loads. + id: z.string().catch(""), + name: z.string().catch(""), + createdAt: z.string().catch(""), + // A damaged overlap record must never discard the still-authoritative key. + pendingRotation: pendingApiKeyRotationSchema.optional().catch(undefined), +}).passthrough(); + +/** + * Durable per-client intent. + * + * `.passthrough()` is load-bearing: a binary that only knows `codex` must not + * erase a key a later version wrote during a field-scoped mutation. And each key + * degrades on its own — a hand edit of `{"codex": "false", "future": false}` + * drops `codex` to absent (which reads as ON) and keeps `future`, rather than + * invalidating the object or, worse, the whole config. + */ +export const clientIntegrationsSchema = z.object({ + codex: z.boolean().optional().catch(undefined), + grok: z.boolean().optional().catch(undefined), + "claude-desktop": z.boolean().optional().catch(undefined), +}).passthrough(); + +export const asideProfileSyncSchema = z.object({ + allProfiles: z.boolean().optional(), + profiles: z.record( + z.string().regex(/^(0|[1-9][0-9]*)$/).refine(value => Number.isSafeInteger(Number(value))), + z.boolean(), + ).optional(), + legacyProfileId: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER).nullable().optional(), +}).passthrough(); + +export const agentTaskRecoverySchema = z.object({ + enabled: z.boolean().optional(), + model: z.string().trim().min(1).optional(), + timeoutMs: z.number().int().min(1_000).max(120_000).optional(), + cacheEntries: z.number().int().min(1).max(512).optional(), +}).strict(); + +export const runtimeRoleSchema = z.enum(["standalone", "hub", "client"]); + +function canonicalHttpOrigin(value: string): string | null { + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null; + return parsed.origin; + } catch { + return null; + } +} + +export const managementIngressSchema = z.union([ + z.object({ enabled: z.literal(false) }).strict(), + z.object({ enabled: z.literal(true), port: z.number().int().min(1).max(65535) }).strict(), +]); + +export const hubConfigSchema = z.object({ + managementPublicOrigin: z.string().transform((value, ctx) => { + const origin = canonicalHttpOrigin(value); + if (!origin) { + ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" }); + return z.NEVER; + } + return origin; + }).optional(), + // Same canonical-origin rule as managementPublicOrigin, and deliberately NOT `.catch`ed: + // a mistyped data origin must be rejected at write time, because silently dropping it + // makes `ocx hub invite` print the `http://:` fallback that the operator + // set this field precisely to replace. + dataPublicOrigin: z.string().transform((value, ctx) => { + const origin = canonicalHttpOrigin(value); + if (!origin) { + ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" }); + return z.NEVER; + } + return origin; + }).optional(), + // A malformed hand edit disables only the optional ingress. Live writes are rejected by + // managementIngressConfigError before this load-time degradation can hide the mistake. + managementIngress: managementIngressSchema.optional().catch(undefined), +}).strict(); + +const tailscaleUserSchema = z.string().trim().min(1).superRefine((value, ctx) => { + if (new TextEncoder().encode(value).byteLength > 320) { + ctx.addIssue({ code: "custom", message: "must be at most 320 UTF-8 bytes" }); + } + if (/[\x00-\x1f\x7f]/.test(value)) { + ctx.addIssue({ code: "custom", message: "must not contain ASCII control characters" }); + } +}); + +export const remoteGuiConfigSchema = z.object({ + allowedTailscaleUsers: z.array(tailscaleUserSchema).max(64).superRefine((users, ctx) => { + const seen = new Set(); + for (let index = 0; index < users.length; index++) { + const user = users[index]!; + if (seen.has(user)) { + ctx.addIssue({ code: "custom", path: [index], message: "must contain unique users after trimming" }); + } + seen.add(user); + } + }).optional(), + // Retired (see OcxRemoteGuiConfig): accepted so an existing file still loads, ignored by + // the pairing path. Removing it from a strict schema would reject the whole config. + allowInsecureHttp: z.boolean().optional(), +}).strict(); + +const connectedClientIdSchema = z.enum(["codex", "claude"]); +const clientTimestampSchema = z.string().datetime({ offset: true }); +const clientOriginSchema = z.string().transform((value, ctx) => { + const origin = canonicalHttpOrigin(value); + if (!origin) { + ctx.addIssue({ code: "custom", message: "must be a canonical http(s) origin without credentials, path, query, or fragment" }); + return z.NEVER; + } + return origin; +}); +export const clientConnectionSchema = z.object({ + serverUrl: clientOriginSchema, + managementUrl: clientOriginSchema, + managementTransport: z.enum(["direct", "relay"]), + selectedClients: z.array(connectedClientIdSchema).min(1).max(2).superRefine((clients, ctx) => { + if (new Set(clients).size !== clients.length) { + ctx.addIssue({ code: "custom", message: "must contain unique client ids" }); + } + }), + tokenEnv: z.literal("OPENCODEX_API_AUTH_TOKEN"), + apiKeyId: z.string().trim().min(1).max(256), + tokenFingerprint: z.string().regex(/^[a-f0-9]{64}$/), + protocolVersion: z.literal(1), + connectedAt: clientTimestampSchema, + catalogFingerprint: z.string().min(1).max(512).optional(), + // base64 of the pre-connect catalog, or "" for "there was none". Bounded above the + // catalog size cap so a legitimate snapshot round-trips. + priorCatalog: z.string().max(64 * 1024 * 1024).optional(), + catalogSyncedAt: clientTimestampSchema.optional(), + pendingOperation: z.object({ + kind: z.literal("rotate"), + rotationId: z.string().trim().min(1).max(256), + newKeyIssuedAt: clientTimestampSchema, + oldKeyBackupPath: z.string().min(1), + }).strict().superRefine((operation, ctx) => { + const expected = join(getConfigDir(), "service-api-token.prev"); + if (operation.oldKeyBackupPath !== expected) { + ctx.addIssue({ code: "custom", path: ["oldKeyBackupPath"], message: `must equal ${expected}` }); + } + }).optional(), +}).strict(); + +/** + * Codex pool selection policy section. + * + * `.strict()` like its neighbour: a typo in an optional feature section should surface as a + * rejected write rather than a silently ignored key that leaves the operator believing they + * excluded something. + */ +export const codexPoolSchema = z.object({ + excludedPlans: z.array(z.string().trim().min(1)).optional(), +}).strict(); + +/** + * Shape guard for the cross-element checks below. Zod runs an array-level check even + * when an element failed its own validation, and a failed element is not the shape the + * checker expects — reading `credentials.length` off it would throw out of `safeParse` + * and take the whole config load with it. Those elements already carry their own issues. + */ +export function isCredentialGroupShape(value: unknown): value is { id: string; credentials: string[] } { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const group = value as { id?: unknown; credentials?: unknown }; + return typeof group.id === "string" + && Array.isArray(group.credentials) + && group.credentials.every(member => typeof member === "string"); +} + +/** + * Operator-declared quota domains (`pool.credentialGroups`). + * + * Loose enough to hand-write, strict enough that it cannot mean two things: unique group + * ids, a non-empty member list, provider-qualified members, and each credential in at + * most one group. Those are not tidiness rules. `classifyCredential` keys a declared + * domain by group id, so a duplicate id or a credential listed twice merges two quota + * domains the operator never said were one -- after which the pool counts real capacity + * once and declines to rotate into it. A bare credential id is ambiguous for the same + * reason ids are provider-scoped in the auth store, so members carry their provider. + * {@link credentialGroupIssues} is the single definition, shared with the classifier. + */ +export const credentialGroupsSchema = z.array(z.object({ + id: z.string().trim().min(1), + credentials: z.array(z.string().trim().min(1)).min(1), + note: z.string().optional(), +})).superRefine((groups, ctx) => { + if (!Array.isArray(groups) || !groups.every(isCredentialGroupShape)) return; + for (const message of credentialGroupIssues(groups)) { + ctx.addIssue({ code: "custom", message }); + } +}); + +/** + * Quota-reset notification section. + * + * `.strict()` like its neighbour: a typo in an optional feature section should surface as a + * rejected write rather than a silently ignored key that leaves the operator believing they + * enabled something. + * + * `pollSeconds` admits 0 (passive-only, no timer) and the resolver clamps anything between 1 + * and the 60-second floor. Bounds live in the resolver rather than here so a hand-edited value + * degrades to a sane one instead of discarding the whole section. + */ +export const quotaResetNotifySchema = z.object({ + enabled: z.boolean().optional(), + kinds: z.array(z.enum(["scheduled", "surprise"])).optional(), + pollSeconds: z.number().int().min(0).optional(), + // `z.string().url()` accepts any scheme. The payload carries account identity and the hook + // URL is frequently a bearer-equivalent secret, so an http: sink puts both in cleartext. + webhookUrl: z.string().url().refine( + value => { try { return new URL(value).protocol === "https:"; } catch { return false; } }, + { message: "webhookUrl must use https" }, + ).optional(), + allowPrivateNetwork: z.boolean().optional(), + timeoutMs: z.number().int().positive().optional(), + command: z.array(z.string()).optional(), +}).strict(); + +/** + * Catalog auto-refresh section (issue #3630). + * + * `.strict()` like its neighbour: a typo in an optional feature section should surface as a + * rejected write rather than a silently ignored key that leaves the operator believing they + * enabled something. + * + * `intervalMinutes` admits 0 (configured but dormant, no timer) and the resolver clamps + * anything between 1 and the 15-minute floor. Bounds live in the resolver rather than here + * so a hand-edited value degrades to a sane one instead of discarding the whole section. + * The 1440 ceiling keeps a hand edit from scheduling the refresh further out than a day, + * which is operator error far more often than intent. + */ +export const catalogAutoRefreshSchema = z.object({ + enabled: z.boolean().optional(), + intervalMinutes: z.number().int().min(0).max(1440).optional(), +}).strict(); diff --git a/src/config/warn-memo.ts b/src/config/warn-memo.ts new file mode 100644 index 0000000000..d72d10caee --- /dev/null +++ b/src/config/warn-memo.ts @@ -0,0 +1,28 @@ +const warnedConfigFallbacks = new Set(); +const warnedInheritedFastWireConflicts = new Set(); +let lastWarningReconciledGeneration = 0; + +export function reconcileConfigWarningMemos(generation: number): number { + if (generation <= lastWarningReconciledGeneration) return 0; + const removed = warnedConfigFallbacks.size + warnedInheritedFastWireConflicts.size; + warnedConfigFallbacks.clear(); + warnedInheritedFastWireConflicts.clear(); + lastWarningReconciledGeneration = generation; + return removed; +} + +export function hasWarnedConfigFallback(configPath: string): boolean { + return warnedConfigFallbacks.has(configPath); +} + +export function markWarnedConfigFallback(configPath: string): void { + warnedConfigFallbacks.add(configPath); +} + +export function hasWarnedInheritedFastWireConflict(configPath: string): boolean { + return warnedInheritedFastWireConflicts.has(configPath); +} + +export function markWarnedInheritedFastWireConflict(configPath: string): void { + warnedInheritedFastWireConflicts.add(configPath); +} diff --git a/src/lib/workflow-budget.ts b/src/lib/workflow-budget.ts index 8ca435352c..a9eaa4c579 100644 --- a/src/lib/workflow-budget.ts +++ b/src/lib/workflow-budget.ts @@ -29,10 +29,25 @@ import { export interface WorkflowBudgetPolicy { /** Children admitted concurrently under one root. */ readonly maxConcurrentChildren: number; - /** Physical model sends charged to one root across its whole life. */ + /** Physical model sends charged to one root INSIDE {@link WorkflowBudgetPolicy.windowMs}. */ readonly maxPhysicalSends: number; - /** Distinct children one root may ever create. */ + /** Distinct children one root may have inside the same window. */ readonly maxDistinctChildren: number; + /** + * The interval both counts are measured over. + * + * These were lifetime totals, and a lifetime total is the wrong instrument. The cap was + * written against a fan-out that sends once per child seven hundred times, which is a RATE; + * a running total cannot tell that from an ordinary session spread over an afternoon and + * refuses both. Because the root id is the caller thread, for Codex that made the ceiling a + * session expiry: a session reaching it was refused for the rest of the process even after + * going idle for hours, and the only cure was restarting the proxy. + * + * Omitted means {@link WORKFLOW_DEFAULT_WINDOW_MS}. A count inside a window is never larger + * than the same count over a lifetime, so windowing can only ever admit more for identical + * traffic -- no install sees a refusal it would not have seen before. + */ + readonly windowMs?: number; /** * Concurrency slots a fan-out may never take. An interactive turn arriving into a saturated * root still gets admitted; without this a worker burst starves the conversation it serves. @@ -47,14 +62,90 @@ export interface WorkflowBudgetPolicy { readonly maxTrackedRoots: number; } +/** + * Ten minutes. Long enough that the burst this ceiling was written against -- seven hundred + * sends in a minute -- is still refused several times over, and short enough that an ordinary + * session, which averages far less than a send every two seconds, never approaches it. + */ +export const WORKFLOW_DEFAULT_WINDOW_MS = 10 * 60_000; + export const DEFAULT_WORKFLOW_BUDGET_POLICY: WorkflowBudgetPolicy = { maxConcurrentChildren: 8, maxPhysicalSends: 256, maxDistinctChildren: 64, interactiveReserve: 1, maxTrackedRoots: 512, + windowMs: WORKFLOW_DEFAULT_WINDOW_MS, }; +/** Fixed ring size. Ten minutes over twelve slots gives fifty-second granularity. */ +const WORKFLOW_WINDOW_SLOTS = 12; + +function workflowWindowMs(policy: WorkflowBudgetPolicy): number { + const declared = policy.windowMs; + return declared !== undefined && Number.isFinite(declared) && declared > 0 + ? declared + : WORKFLOW_DEFAULT_WINDOW_MS; +} + +/** + * Slot size for one root's own window. + * + * The geometry is read off the state rather than off whatever policy the current caller + * happens to hold. Two callers may legitimately pass different policies for the same root -- + * the ceiling numbers are the caller's business -- but if they also disagreed about + * `windowMs`, the slot ids one of them wrote would be on a scale the other cannot read, and + * charging with a long window while reading with a short one makes every stored slot look + * ancient and the ceiling never fire at all. + */ +function windowSlotMs(windowMs: number): number { + return Math.max(1, Math.ceil(windowMs / WORKFLOW_WINDOW_SLOTS)); +} + +/** + * Add sends to the ring, resetting a slot whose turn has come round again. + * + * A ring rather than a list of timestamps because the storage has to be bounded: a root that + * sends forever would otherwise grow forever, and this ledger exists to bound a fan-out. + */ +function recordWindowedSends(state: WorkflowState, now: number, sends: number): void { + const slotMs = windowSlotMs(state.windowMs); + const slot = Math.floor(now / slotMs); + const index = ((slot % WORKFLOW_WINDOW_SLOTS) + WORKFLOW_WINDOW_SLOTS) % WORKFLOW_WINDOW_SLOTS; + if (state.sendSlotAt[index] !== slot) { + state.sendSlotAt[index] = slot; + state.sendSlotCount[index] = 0; + } + state.sendSlotCount[index] = (state.sendSlotCount[index] ?? 0) + sends; +} + +/** Sends inside the window. A slot older than the window contributes nothing. */ +function windowedSends(state: WorkflowState, now: number): number { + const slotMs = windowSlotMs(state.windowMs); + const oldest = Math.floor(now / slotMs) - (WORKFLOW_WINDOW_SLOTS - 1); + let total = 0; + for (let index = 0; index < WORKFLOW_WINDOW_SLOTS; index += 1) { + if ((state.sendSlotAt[index] ?? Number.NEGATIVE_INFINITY) >= oldest) { + total += state.sendSlotCount[index] ?? 0; + } + } + return total; +} + +/** + * Forget children last seen before the window opened, and report how many remain. + * + * Pruning on read keeps the map bounded without a timer: every admission pays for the children + * it can still see, and a root that goes quiet is cleaned up the next time it speaks. + */ +function windowedChildren(state: WorkflowState, now: number): number { + const cutoff = now - state.windowMs; + for (const [childId, lastSeenMs] of state.children) { + if (lastSeenMs <= cutoff) state.children.delete(childId); + } + return state.children.size; +} + export type WorkflowDenial = | "workflow-concurrency-exhausted" | "workflow-sends-exhausted" @@ -113,9 +204,28 @@ export interface WorkflowSpendRequest { interface WorkflowState { active: number; + /** Lifetime total, kept for diagnostics only. The ceiling reads the window instead. */ sends: number; - children: Set; + /** Ring of per-slot send counts; sendSlotAt[i] names the slot that bucket holds. */ + sendSlotCount: number[]; + sendSlotAt: number[]; + /** Child id to the last time it was admitted, so a child that stops ages out of the count. */ + children: Map; lastSeenMs: number; + /** Window this root's ring and child map are measured over, fixed when the root appeared. */ + windowMs: number; +} + +function newWorkflowState(now: number, policy: WorkflowBudgetPolicy): WorkflowState { + return { + active: 0, + sends: 0, + sendSlotCount: new Array(WORKFLOW_WINDOW_SLOTS).fill(0), + sendSlotAt: new Array(WORKFLOW_WINDOW_SLOTS).fill(Number.NEGATIVE_INFINITY), + children: new Map(), + lastSeenMs: now, + windowMs: workflowWindowMs(policy), + }; } const roots = new Map(); @@ -127,7 +237,11 @@ const roots = new Map(); * the new root regardless, so `maxTrackedRoots` bounded nothing whenever every candidate * was active or exhausted -- which is precisely the fan-out this file exists to bound. */ -function evictOneRoot(policy: WorkflowBudgetPolicy, spendLedger?: SpendReservationLedger): boolean { +function evictOneRoot( + policy: WorkflowBudgetPolicy, + spendLedger?: SpendReservationLedger, + now: number = Date.now(), +): boolean { let oldestKey: string | undefined; let oldestAt = Number.POSITIVE_INFINITY; for (const [key, state] of roots) { @@ -136,7 +250,7 @@ function evictOneRoot(policy: WorkflowBudgetPolicy, spendLedger?: SpendReservati // EXHAUSTED-but-idle root -- count-exhausted or spend-exhausted -- because recreating it // fresh under the same id resets the very ceiling that already fired. if (state.active > 0) continue; - if (state.sends >= policy.maxPhysicalSends) continue; + if (windowedSends(state, now) >= policy.maxPhysicalSends) continue; if (spendLedger?.exhausted("root", key) === true) continue; if (state.lastSeenMs < oldestAt) { oldestAt = state.lastSeenMs; oldestKey = key; } } @@ -174,22 +288,22 @@ export function admitWorkflowTurn( const ledger = spendLedger ?? (spend ? sharedSpendLedger() : undefined); let state = roots.get(rootId); if (!state) { - if (roots.size >= policy.maxTrackedRoots && !evictOneRoot(policy, ledger)) { + if (roots.size >= policy.maxTrackedRoots && !evictOneRoot(policy, ledger, now)) { // Nothing may be forgotten, so the new root is refused instead of admitted over the // bound. The alternative -- evicting an exhausted root -- resets the ceiling that // already fired, and a caller minting fresh ids would get unlimited budget from it. return { admitted: false, reason: "workflow-tracking-exhausted", rootId }; } - state = { active: 0, sends: 0, children: new Set(), lastSeenMs: now }; + state = newWorkflowState(now, policy); roots.set(rootId, state); } state.lastSeenMs = now; - if (state.sends >= policy.maxPhysicalSends) { + if (windowedSends(state, now) >= policy.maxPhysicalSends) { return { admitted: false, reason: "workflow-sends-exhausted", rootId }; } if (childId !== undefined && !state.children.has(childId) - && state.children.size >= policy.maxDistinctChildren) { + && windowedChildren(state, now) >= policy.maxDistinctChildren) { return { admitted: false, reason: "workflow-children-exhausted", rootId }; } const ceiling = lane === "worker" @@ -229,7 +343,7 @@ export function admitWorkflowTurn( } state.active += 1; - if (childId !== undefined) state.children.add(childId); + if (childId !== undefined) state.children.set(childId, now); let released = false; return { admitted: true, @@ -244,6 +358,8 @@ export function admitWorkflowTurn( const current = roots.get(rootId); if (current) { current.active = Math.max(0, current.active - 1); + // Eviction ordering only; no ceiling reads lastSeenMs, so the wall clock is the + // right source here and a caller does not need to inject one. current.lastSeenMs = Date.now(); } // Which of the two applies depends on whether the send ever left this process. @@ -263,12 +379,20 @@ export function admitWorkflowTurn( * Charge physical sends to a root. Called from the send budget's own accounting so a retry * inside one request counts toward the workflow total, not only the request total. */ -export function chargeWorkflowSends(rootId: string | undefined, sends: number): void { +export function chargeWorkflowSends( + rootId: string | undefined, + sends: number, + now: number = Date.now(), +): void { if (!rootId || sends <= 0) return; const state = roots.get(rootId); if (!state) return; state.sends += sends; - state.lastSeenMs = Date.now(); + // Geometry comes off the root itself, so no caller can charge on one scale and read on + // another. This function does not take a policy at all any more: it has no ceiling to + // compare, and the only thing a policy could have supplied here was that scale. + recordWindowedSends(state, now, sends); + state.lastSeenMs = now; } /** @@ -315,18 +439,40 @@ export function abandonWorkflowSpend(sendId: string, spendLedger?: SpendReservat export function workflowSendCeilingReached( rootId: string | undefined, policy: WorkflowBudgetPolicy = DEFAULT_WORKFLOW_BUDGET_POLICY, + now: number = Date.now(), ): boolean { if (!rootId) return false; const state = roots.get(rootId); - return state !== undefined && state.sends >= policy.maxPhysicalSends; + return state !== undefined && windowedSends(state, now) >= policy.maxPhysicalSends; } -export function workflowBudgetSnapshot(rootId: string): { - - active: number; sends: number; children: number; +export function workflowBudgetSnapshot( + rootId: string, + policy: WorkflowBudgetPolicy = DEFAULT_WORKFLOW_BUDGET_POLICY, + now: number = Date.now(), +): { + active: number; + /** Sends inside the window. This is the number the ceiling compares. */ + sends: number; + /** Children inside the window, which is likewise what the ceiling compares. */ + children: number; + /** Everything the root has ever sent, for diagnostics; no ceiling reads it. */ + lifetimeSends: number; + windowMs: number; + maxPhysicalSends: number; + maxDistinctChildren: number; } | undefined { const state = roots.get(rootId); - return state ? { active: state.active, sends: state.sends, children: state.children.size } : undefined; + if (!state) return undefined; + return { + active: state.active, + sends: windowedSends(state, now), + children: windowedChildren(state, now), + lifetimeSends: state.sends, + windowMs: state.windowMs, + maxPhysicalSends: policy.maxPhysicalSends, + maxDistinctChildren: policy.maxDistinctChildren, + }; } /** Test seam. Production never clears a live ledger: that would reset a spent budget. */ diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 84eeb05b8e..61c08eef7b 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1,3542 +1,30 @@ -import type { CodexAccountMode, FastWire, OcxProviderConfig } from "../types"; +import type { CodexAccountMode, OcxProviderConfig } from "../types"; import { fastWireDeclarationError } from "./fastwire"; -import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "./kiro-models"; -import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_MODEL_EFFORTS, DEVIN_DEFAULT_EFFORTS } from "../adapters/devin/live-models"; -import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS, ANTIGRAVITY_MODEL_INPUT_MODALITIES } from "./antigravity-models"; -import type { ProviderBaseUrlChoice } from "./base-url-choices"; -import { - QWEN_CLOUD_BASE_URL_CHOICES, QWEN_CLOUD_TOKEN_PLAN_BASE_URL, - ALIBABA_INTL_BASE_URL_CHOICES, ALIBABA_INTL_TOKEN_PLAN_BASE_URL, - ALIBABA_CODING_BASE_URL_CHOICES, ALIBABA_CODING_INTL_BASE_URL, - MOONSHOT_BASE_URL_CHOICES, MOONSHOT_INTL_BASE_URL, -} from "./base-url-choices"; -import { - CURSOR_NO_VISION_MODELS, - CURSOR_STATIC_MODELS, - cursorModelContextWindows, - cursorModelDisplayNames, - cursorModelIds, - cursorModelInputModalities, - cursorModelReasoningEfforts, -} from "../adapters/cursor/discovery"; -import { cursorFastCapableBases } from "../adapters/cursor/catalog"; -import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "./command-code-efforts"; -import { isCanonicalOpenRouterTarget } from "./openrouter-routing"; -import { - CODEBUDDY_CN_MODELS, - CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS, - CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS, - CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS, - CODEBUDDY_CN_MODEL_REASONING_EFFORTS, - CODEBUDDY_CN_NO_VISION_MODELS, - CODEBUDDY_GLOBAL_MODELS, - CODEBUDDY_GLOBAL_MODEL_CONTEXT_WINDOWS, - CODEBUDDY_GLOBAL_MODEL_DEFAULT_REASONING_EFFORTS, - CODEBUDDY_GLOBAL_MODEL_MAX_OUTPUT_TOKENS, - CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS, - CODEBUDDY_REASONING_EFFORTS, -} from "./codebuddy-models"; -import { QODER_CN_MODELS, QODER_GLOBAL_MODELS, QODER_REASONING_EFFORTS } from "./qoder-models"; - -export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; -export type MetadataModelIdNormalize = "case-insensitive"; - -/** - * Wire protocol a client spoke when it reached the proxy. Chat and Anthropic surfaces - * translate into a Responses-shaped body and replay through `handleResponses`, so the - * original inbound has to travel with the request or the replay looks native. - */ -export type InboundWire = "responses" | "chat" | "anthropic"; - -/** - * A per-model wire default: a bare string applies to every inbound, while the object - * form may scope the default to listed inbound protocols and authentication modes. - */ -export type ModelWireDefault = string | { - wire: string; - inbound: readonly InboundWire[]; - authModes?: readonly ProviderAuthKind[]; - /** Whether this registry-selected route may relay a caller-owned service_tier. */ - forwardCallerServiceTier?: boolean; -}; - -export interface ResponsesTerminalRepairPolicy { - /** Quiet time after a structurally complete output graph before synthesizing completion. */ - graceMs: number; -} - -export type ProviderModelDiscoveryScalar = string | number | boolean; - -export type ProviderModelDiscoveryPredicate = - | { - path: readonly string[]; - equalsAny: readonly ProviderModelDiscoveryScalar[]; - caseInsensitive?: boolean; - } - | { - path: readonly string[]; - /** - * A string-valued upstream target uses substring matching; an array-valued target uses - * exact element matching. Use `equalsAny` when the string must match in full. - */ - containsAny: readonly ProviderModelDiscoveryScalar[]; - caseInsensitive?: boolean; - } - | { - path: readonly string[]; - /** Uses the same string-substring and array-element semantics as `containsAny`. */ - containsAll: readonly ProviderModelDiscoveryScalar[]; - caseInsensitive?: boolean; - }; - -export interface ProviderModelDiscoveryFilter { - /** Every predicate must match. */ - allOf?: readonly ProviderModelDiscoveryPredicate[]; - /** At least one predicate must match. */ - anyOf?: readonly ProviderModelDiscoveryPredicate[]; - /** No predicate may match. */ - noneOf?: readonly ProviderModelDiscoveryPredicate[]; -} - -interface ProviderModelDiscoverySharedSpec { - /** Query parameters applied to the resolved discovery URL. */ - query?: Readonly>; - /** Declarative eligibility rules evaluated against each untrusted model row. */ - filter?: ProviderModelDiscoveryFilter; - /** Optional lower byte ceiling; the process-wide hard ceiling still wins. */ - maxResponseBytes?: number; - /** Optional lower raw-row ceiling; the process-wide hard ceiling still wins. */ - maxModels?: number; - /** - * If a valid extracted id starts with this prefix, strip it and re-validate the remainder. - * Empty/invalid remainders skip that row only. - */ - stripIdPrefix?: string; -} - -type ProviderModelDiscoveryLocation = - | { - /** Registry-owned absolute endpoint. Mutually exclusive with `path`. */ - url: string; - path?: never; - } - | { - /** Resource path relative to baseUrl; query strings and fragments are disallowed. */ - path: string; - url?: never; - } - | { - /** Keep the adapter-derived default discovery endpoint. */ - url?: never; - path?: never; - }; - -/** - * Trusted live-model discovery policy. This metadata is registry-only: it must never be copied - * into config.json, where a same-named custom provider could otherwise redirect a stored key. - */ -export type ProviderModelDiscoverySpec = ProviderModelDiscoverySharedSpec & ProviderModelDiscoveryLocation; - -export interface ProviderRegistryEntry { - id: string; - label: string; - adapter: string; - baseUrl: string; - apiKeyTransport?: OcxProviderConfig["apiKeyTransport"]; - alias?: string; - authKind: ProviderAuthKind; - codexAccountMode?: CodexAccountMode; - /** OAuth preset may explicitly honor a persisted API-key billing mode. */ - allowKeyAuthOverride?: boolean; - allowPrivateNetworkByDefault?: boolean; - keyOptional?: boolean; - /** - * Registry-only key-login policy for public model catalogs that cannot authenticate a key. - * The dashboard flow then reports the key as unverifiable instead of a false positive. - */ - apiKeyValidation?: "unknown"; - /** - * Free-tier pricing (no paid subscription required). Distinct from `keyOptional`: - * free tiers may still require an API key (e.g. NVIDIA NIM free credits). - */ - freeTier?: boolean; - allowBaseUrlOverride?: boolean; - /** - * Do not claim an existing same-named key provider whose fixed destination differs from this - * preset. Enable for newly promoted ids so an older custom key cannot be silently retargeted. - */ - preserveCustomDestination?: boolean; - /** - * Optional endpoint picker for providers with multiple official hosts - * (e.g. Qwen Cloud token plan vs pay-as-you-go). Requires `allowBaseUrlOverride` - * so the selected URL is honored at route time. A choice without `baseUrl` is "Custom". - */ - baseUrlChoices?: readonly ProviderBaseUrlChoice[]; - /** Static headers merged into every upstream request for this provider. */ - staticHeaders?: Record; - modelSuffixBracketStrip?: boolean; - featured?: boolean; - /** - * Paid provider sponsorship under SPONSORS.md. `main` is reserved for model developers, - * `standard` for relays and gateways. The picker pins sponsor rows first (alphabetical among - * themselves) and labels them; nothing else reads this field. Routing, failover, quota, and - * defaults never consult it — that boundary is what SPONSORS.md promises users. - */ - sponsor?: { tier: "main" | "standard"; url: string }; - dashboardPreset?: boolean; - note?: string; - dashboardUrl?: string; - defaultModel?: string; - models?: string[]; - liveModels?: boolean; - /** - * Registry-only per-model wire defaults for mixed OpenAI-compatible gateways. - * These are intentionally not seeded into saved config: an explicit `modelAdapters` - * entry must remain distinguishable and must always win over a default. - * - * A bare string applies to every inbound protocol. The object form scopes the - * default to the inbound surfaces named in `inbound`, which is how a model that is - * native on two wires can serve each client on the wire it already speaks instead - * of paying a translation hop. - */ - modelWireDefaults?: Record; - /** Explicit Fast wire declaration; absence derives from the final model adapter. */ - fastWire?: FastWire | null; - /** - * Registry-only per-model override for the upstream request shape used behind a - * Codex Responses WebSocket turn. `false` keeps the client-facing WebSocket but - * asks the upstream Responses endpoint for bounded JSON, which the bridge then - * reframes as Responses events. Use only for upstreams whose streaming response - * can omit or indefinitely delay the terminal event. - */ - modelResponsesUpstreamStreaming?: Record; - /** Registry-only repair for a model whose native Responses stream may omit its terminal. */ - modelResponsesTerminalRepair?: Record; - /** - * Registry-only client-facing item-id repair policy (#938), filled onto the - * runtime provider only when the user has no explicit policy (derive.ts); - * never seeded into saved config. - */ - responsesItemIdRepair?: { - message?: string[]; - reasoning?: string[]; - repairMissingTerminalIds?: boolean; - repairInvalidIds?: boolean; - }; - /** - * Responses-API resource path for providers whose route is not `/v1/responses`. - * Unlike `modelWireDefaults` above, this IS seeded into saved config: it describes - * the provider's fixed endpoint rather than a default a user might want to override - * per model. DeepSeek documents `POST /responses` with no `/v1` segment. - */ - responsesPath?: string; - /** - * Relative send path for the `openai-chat` wire, seeded into saved config exactly like - * `responsesPath`. Needed when one upstream serves both wires under different prefixes, - * because a per-model wire override changes the adapter and not the base URL. - */ - chatCompletionsPath?: string; - /** - * Endpoints this entry used to live at, kept so a saved custom provider that still points - * at one keeps receiving this row's metadata through `registryEntryForProviderDestination`. - * Destination matching is by adapter plus normalized base URL, so moving a row's wire or - * prefix would otherwise orphan every config a user wrote against the old address. - */ - destinationAliases?: readonly { readonly baseUrl: string; readonly adapter: string }[]; - /** - * Responses upstream that stores nothing server-side. Stateful request parameters - * are dropped and `store` is pinned false, and orphaned tool results left by a - * replay miss are repaired rather than forwarded. - */ - statelessResponses?: boolean; - /** - * Responses parser requires an unambiguous call batch and its matched result batch - * to stay contiguous. This is seeded/backfilled like other fixed wire capabilities. - */ - requiresAdjacentResponsesToolResults?: boolean; - /** - * When enabled, tool results that are present but empty are annotated on the wire. - * Seeded/backfilled like other fixed wire capabilities. - */ - annotateEmptyToolOutputs?: boolean; - /** - * Registry default for the provider's `service_tier` support; see - * `OcxProviderConfig.supportsServiceTier`. Registry-only: backfilled (never - * overriding) at enrich/route time and deliberately NOT seeded into saved - * config, so an explicit user value stays distinguishable from the default - * (and the canonical openai seed comparison keeps its exact key set). - */ - supportsServiceTier?: boolean; - /** Registry default for OpenAI extended hosted web_search field support. */ - supportsOpenAiWebSearchToolFields?: boolean; - /** Registry default for native Responses custom-tool support. */ - supportsResponsesCustomTools?: boolean; - /** Registry default for exact model service-tier capability; explicit config keys win. */ - modelSupportsServiceTier?: Record; - /** - * Registry-only service-tier defaults for an OAuth preset's explicit API-key transport. - * Applied only when `allowKeyAuthOverride` is true and the captured effective auth transport - * is key-based. Explicit provider config still wins field-by-field, including `false`. - */ - keyAuthServiceTier?: { - supportsServiceTier?: boolean; - modelSupportsServiceTier?: Record; - chatServiceTier?: boolean; - }; - /** Provider-specific copy for the Codex catalog's Fast tier. */ - fastTierDescription?: string; - /** - * Registry-only destination guard for `modelSupportsServiceTier`. This scopes vendor evidence - * without changing provider ownership, routing, authentication, or config validation. - */ - modelServiceTierCapabilityBaseUrlGuard?: (baseUrl: string) => boolean; - /** Registry default for plaintext reasoning replay; see `OcxProviderConfig.preserveResponsesReasoningContent`. Registry-only like `supportsServiceTier`. */ - preserveResponsesReasoningContent?: boolean; - /** Registry defaults for per-model Codex reasoning propagation; explicit user keys win during enrichment. */ - modelSupportsReasoningSummaries?: Record; - /** Registry defaults for per-model Codex Responses verbosity support. */ - modelSupportsVerbosity?: Record; - /** - * Registry default applied to EVERY model of this provider, including ids that arrive from - * live discovery after this table was written. - * - * `modelSupportsVerbosity` only covers the ids enumerated here, so a newly discovered model - * fell through and re-advertised a control the upstream accepts and ignores. Where the opt-out - * is a property of the provider's API rather than of one model, declare it here; a per-model - * entry still wins over it. - */ - supportsVerbosity?: boolean; - modelDiscovery?: ProviderModelDiscoverySpec; - contextWindow?: number; - modelContextWindows?: Record; - /** - * Registry-supplied picker labels. Without these a routed row shows its raw slug, - * because `routedDisplayName` (codex/catalog/sync.ts) passes the slug through for every - * provider. An operator's `modelDisplayNames` still wins: derive only fills when absent. - */ - modelDisplayNames?: Record; - modelInputModalities?: Record; - defaultMaxOutputTokens?: number; - modelMaxOutputTokens?: Record; - reasoningEfforts?: string[]; - modelReasoningEfforts?: Record; - modelDefaultReasoningEfforts?: Record; - reasoningEffortMap?: Record; - modelReasoningEffortMap?: Record>; - /** - * Registry-authoritative models that send OpenAI's direct `reasoning_effort` field. - * Runtime enrichment uses this to repair stale preset metadata that still classifies a model - * as a thinking-budget/toggle model. This is registry-only and is never persisted as user config. - */ - directReasoningEffortModels?: string[]; - reasoningWireFormat?: OcxProviderConfig["reasoningWireFormat"]; - noVisionModels?: string[]; - noReasoningModels?: string[]; - noTemperatureModels?: string[]; - noTopPModels?: string[]; - noPenaltyModels?: string[]; - /** - * Registry-only seed for `OcxProviderConfig.noJsonSchemaModels`. Merged into the - * resolved provider at route time rather than persisted as user config, the same way - * `directReasoningEffortModels` above is registry-owned. - */ - noJsonSchemaModels?: string[]; - /** Opt this provider into parallel tool calls (see OcxProviderConfig.parallelToolCalls). */ - parallelToolCalls?: boolean; - /** Opt this provider into forwarding prompt_cache_key (OpenAI-specific; strict backends reject it). */ - promptCacheKey?: boolean; - /** - * Opt-in: forward `service_tier` on the `/chat/completions` wire. Same hazard as - * `promptCacheKey` — an OpenAI-specific extension that strict gateways reject. Distinct from - * `supportsServiceTier`, which governs the Responses wire. - */ - chatServiceTier?: boolean; - /** OpenAI Chat EOF policy for gateways that omit terminal frames after complete tool calls. */ - openaiChatEofTolerance?: boolean; - autoToolChoiceOnlyModels?: string[]; - preserveReasoningContentModels?: string[]; - requiresReasoningPlaceholderModels?: string[]; - /** - * Opt this provider into visible thinking summaries (see OcxProviderConfig.showThinkingSummary). - */ - showThinkingSummary?: boolean; - reasoningSplitModels?: string[]; - reasoningDetailsModels?: string[]; - thinkingToggleModels?: string[]; - thinkingBudgetModels?: string[]; - escapeBuiltinToolNames?: boolean; - oauthId?: string; - virtualModels?: Record; - modelMaxInputTokens?: Record; - jawcodeBundle?: string; - extraMetadataAliases?: string[]; - metadataModelIdNormalize?: MetadataModelIdNormalize; - googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; - project?: string; - location?: string; -} - -export type ProviderConfigSeed = Pick< - OcxProviderConfig, - "adapter" | "baseUrl" | "apiKeyTransport" | "responsesPath" | "chatCompletionsPath" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models" - | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities" - | "modelDisplayNames" - | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" - | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" - | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" - | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" | "showThinkingSummary" - | "googleMode" | "project" | "location" | "headers" ->; - -// Shared between the OAuth (Claude account) and API-key Anthropic entries so both expose the -// same static model seed. -// 260710 context refresh: Tier-2 evidence in -// devlog/_plan/260710_provider_hardening/001_research_frontier.md. -// 260902 Claude Fable 5.1 (`claude-fable-5-1`): 1M context / 128K output / adaptive thinking -// always on, per the official models overview and pricing page (platform.claude.com). -const ANTHROPIC_MODELS = ["claude-fable-5-1", "claude-fable-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"]; -const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record = { "claude-fable-5-1": 1_000_000, "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-opus-4-7": 1_000_000, "claude-opus-4-6": 1_000_000, "claude-sonnet-4-6": 1_000_000, "claude-haiku-4-5": 200_000 }; -// Every current Claude family accepts at least 64k output tokens (Haiku 4.5 / Sonnet 4.x -// through Opus 5 and Fable 5). Anthropic caps max_tokens per model server-side, so a -// larger request never over-allocates; it only stops the 8192 truncation. -const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000; -/** - * The effort rungs opencodex exposes for native Anthropic models. Without this the - * providers advertised no ladder at all, so every client that keys its effort control off - * `reasoningEfforts` — Aside and the rest of the Pi-shaped exports — wrote these models - * with no control, while the SAME Claude models routed through `cursor` or - * `google-antigravity` had one. - * - * This is an opencodex ladder, not a claim that each model takes `output_config.effort`. - * The adapter serves two wire shapes (src/adapters/anthropic.ts): adaptive families - * (fable, sonnet >= 5, opus >= 4.7) send the effort directly, while opus 4.6, sonnet 4.6 - * and haiku 4.5 take the legacy path where `reasoningBudget` TRANSLATES each rung into - * `thinking.budget_tokens`. Anthropic documents `low|medium|high|max` for the 4.6 models - * and no effort parameter at all for haiku 4.5; the budget translation is what makes five - * rungs meaningful there, and it clamps below `max_tokens` so none of them 400. - * - * Deliberately excluded, each because advertising it would offer a control that does not - * do what it says: - * - `minimal`: `adaptiveEffort` rewrites it to `low` (the adaptive wire 400s on it), so - * it is not a distinct setting. - * - `none`: only sonnet >= 5 accepts an explicit thinking disable - * (`EXPLICIT_THINKING_DISABLE_FAMILY_MINIMUMS`); Fable rejects one outright. - * - `ultra`: not an Anthropic concept, and it is degraded to `max` at the request - * boundary anyway (src/responses/parser.ts). - */ -const ANTHROPIC_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -const ANTHROPIC_MODEL_REASONING_EFFORTS: Record = Object.fromEntries( - ANTHROPIC_MODELS.map(id => [id, [...ANTHROPIC_REASONING_EFFORTS]]), -); - -// 260814 GLM-5.3 is registered pre-emptively alongside 5.2 everywhere 5.2 appears. Z.AI's -// devpack "How to Switch Models" page (docs.z.ai/devpack/latest-model) lists glm-5.3 and -// glm-5.3[1m] as Coding Plan ids on the unchanged endpoints; the capability and pricing -// tables were not published yet, so every 5.3 row mirrors its 5.2 sibling until they settle. -// The non-Z.AI providers below are speculative on purpose: they carry 5.2 today and are -// expected to pick 5.3 up on their usual lag. Providers whose live /v1/models discovery is -// enabled self-correct on the next successful fetch; static ones need a follow-up refresh. -// Every 5.3 family member, so the effort ladder, the default effort and the output -// cap are derived in ONE place. `glm-5.3-flash` was seeded into the model list and -// the context map by hand and left out of this constant, which meant it advertised -// a 1M context with a null effort ladder, no default effort and no output cap while -// its siblings carried three tiers, a `max` default and 131072 tokens. A member -// added to the list but not to the family is a model whose metadata silently -// disappears. -const ZAI_GLM_53_MODELS = ["glm-5.3", "glm-5.3[1m]", "glm-5.3-flash"]; -const ZAI_GLM_52_MODELS = ["glm-5.2", "glm-5.2[1m]"]; -const ZAI_GLM_5X_MODELS = [...ZAI_GLM_53_MODELS, ...ZAI_GLM_52_MODELS]; -/** - * The 5.x rows whose images the PROXY has to describe, which is NOT the same set as - * the 5.x rows themselves. - * - * `glm-5.3-flash` is a native VLM (docs.z.ai/guides/vlm/glm-5.3-flash), so listing it - * in `noVisionModels` sent an image through the vision sidecar and handed the model a - * text description of a picture it could have read itself - no error, worse answer, - * extra call. The correction commit fixed the Alibaba entries and left the eight - * providers that reach this constant behind. - * - * Kept separate from ZAI_GLM_5X_MODELS rather than filtered at each use site: that - * constant also drives `modelSupportsReasoningSummaries` and - * `preserveReasoningContentModels`, where flash DOES belong. - */ -const ZAI_GLM_5X_SIDECAR_VISION_MODELS = ZAI_GLM_5X_MODELS.filter(id => id !== "glm-5.3-flash"); -/** - * Positive input-modality declaration for the Chat-path GLM rows. - * - * `noVisionModels` already keeps Flash out of the vision sidecar, but that is a NEGATIVE - * statement: it stops a detour without telling the catalog what the model can read. With - * no `modelInputModalities` entry, `configuredInputModalities` returns undefined and the - * catalog falls through to the `["text"]` floor, so every client export (ZCode, Pi, OMP) - * listed a native VLM as text-only and its picker refused to attach an image. - * - * The Responses sibling row below already declares this positively, so the same model was - * described two different ways in one registry. - * - * Authoritative source: `GET https://api.z.ai/api/v1/models` returns `input_modalities: - * ["text"]` for glm-5.3 and `["text", "image"]` for glm-5.3-flash (captured in - * devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json). - * docs.z.ai/devpack/latest-model says the same in prose: "GLM-5.3 is a text-only model... - * GLM-5.3-FLASH is a multimodal model". Upstream also lists video and file for Flash; - * neither the internal vocabulary nor the export vocabulary can express them, so `image` - * is where this stops. - */ -const ZAI_GLM_5X_INPUT_MODALITIES: Record = { - ...Object.fromEntries(ZAI_GLM_5X_SIDECAR_VISION_MODELS.map(id => [id, ["text"]])), - "glm-5.3-flash": ["text", "image"], -}; -const ZAI_GLM_52_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -/** - * GLM-5.3 does NOT share 5.2's five-tier ladder. docs.z.ai/devpack/latest-model folds every - * incoming effort into three effective tiers — low/minimal/light -> low, medium/high -> high, - * xhigh/max/ultra -> max — with max as both the default and the unknown-value fallback. - * Advertising five levels would publish two picker rows that are indistinguishable on the wire, - * so only the effective tiers are exposed (same treatment Cursor and Baseten already give GLM). - */ -const ZAI_GLM_53_REASONING_EFFORTS = ["low", "high", "max"]; -/** Per-model ladders for the Coding Plan rows: 5.3 gets its three effective tiers, 5.2 keeps five. */ -const ZAI_GLM_5X_REASONING_EFFORTS: Record = { - ...Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, ZAI_GLM_53_REASONING_EFFORTS])), - ...Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, ZAI_GLM_52_REASONING_EFFORTS])), -}; -// 260710 MiniMax models and context windows: Tier-2 evidence in -// devlog/_plan/260710_provider_hardening/002_research_cn.md. -const MINIMAX_MODELS = [ - "MiniMax-M3", - "MiniMax-M2.7", "MiniMax-M2.7-highspeed", - "MiniMax-M2.5", "MiniMax-M2.5-highspeed", - "MiniMax-M2.1", "MiniMax-M2.1-highspeed", - "MiniMax-M2", -]; -const MINIMAX_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( - MINIMAX_MODELS.map(id => [id, id === "MiniMax-M3" ? 1_000_000 : 204_800]), -); -const MINIMAX_M3_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -const MINIMAX_M3_REASONING_EFFORT_MAP: Record = { - none: "disabled", - minimal: "disabled", - low: "disabled", - medium: "adaptive", - high: "adaptive", - xhigh: "adaptive", - max: "adaptive", -}; -const OPENAI_GPT56_MODELS = ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]; -const OPENAI_GPT56_PRO_MODELS = ["gpt-5.6-sol-pro", "gpt-5.6-terra-pro", "gpt-5.6-luna-pro"]; -const OPENAI_API_GPT56_CONTEXT_WINDOW = 1_050_000; -const OPENAI_API_GPT56_CONTEXT_WINDOWS: Record = { - ...Object.fromEntries([...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS].map(id => [id, OPENAI_API_GPT56_CONTEXT_WINDOW])), - "gpt-5.5": OPENAI_API_GPT56_CONTEXT_WINDOW, -}; -const OPENAI_API_GPT56_MAX_INPUT_TOKENS: Record = { - ...Object.fromEntries([...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS].map(id => [id, 922_000])), - "gpt-5.5": 922_000, -}; -const OPENAI_API_GPT56_VIRTUAL_MODELS: Record = { - "gpt-5.6-sol-pro": { wireModelId: "gpt-5.6-sol", reasoningMode: "pro" }, - "gpt-5.6-terra-pro": { wireModelId: "gpt-5.6-terra", reasoningMode: "pro" }, - "gpt-5.6-luna-pro": { wireModelId: "gpt-5.6-luna", reasoningMode: "pro" }, -}; -const OPENAI_API_GPT56_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -/* - * Meta Model API (https://api.meta.ai/v1) — published ladder, deliberately NOT the - * house set. dev.meta.ai/docs/reasoning lists "none", "minimal", "low", "medium", - * "high", "xhigh" and then excludes "none" for this family: "not supported by Muse - * Spark and returns HTTP 400". "max" and "ultra" are absent from the vendor's list - * entirely, so appending one by family resemblance would invent a wire value. - * - * Corroborated on a second surface: an unauthenticated OpenCode Zen probe of - * muse-spark-1.3-contributor-free (2026-09-03) accepted minimal..xhigh, rejected - * max/ultra with `unknown variant`, and rejected none with "does not support none - * with this model". - */ -const META_MUSE_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"]; -/* - * Identity wire map. `requestToCodexEffort` (src/reasoning-effort.ts) rewrites - * `minimal` to `low` unless a model-scoped wire map says otherwise, so without this - * the picker would advertise an effort the wire never sends — and a registry-array - * assertion would pass while the request body was wrong. Identity because Meta's - * values ARE the Codex names. - */ -const META_MUSE_REASONING_EFFORT_MAP: Record = Object.fromEntries( - META_MUSE_REASONING_EFFORTS.map(effort => [effort, effort]), -); -/** Both Muse Spark 1.3 tiers publish a 1,048,576-token window (dev.meta.ai/docs/models). */ -const META_MUSE_CONTEXT_WINDOW = 1_048_576; -const META_MUSE_MODELS = ["muse-spark-1.3", "muse-spark-1.3-contributor"]; -/** - * Daybreak program aliases. These `-latest` ids are the stable contract: OpenAI repoints - * them at newer snapshots over time (red -> gpt-5.6-cyber, blue -> gpt-5.6-sol as of - * 2026-08-11), so registering the ALIAS inherits future model swaps while a pinned - * snapshot id would silently go stale. Snapshot ids are deliberately absent here. - * Responses-only per both published endpoint tables (`v1/chat/completions` is marked - * Not supported) — never add these to a chat-completions provider. Access needs separate - * Daybreak approval and provisioning, so neither is ever a default. - * Verified 2026-08-11: developers.openai.com/api/docs/models/daybreak-red-latest.md - * and .../daybreak-blue-latest.md - */ -const OPENAI_DAYBREAK_MODELS = ["daybreak-red-latest", "daybreak-blue-latest"]; -const OPENAI_DAYBREAK_CONTEXT_WINDOWS: Record = { - "daybreak-red-latest": 400_000, - "daybreak-blue-latest": 1_050_000, -}; -const OPENAI_DAYBREAK_MAX_INPUT_TOKENS: Record = { - "daybreak-red-latest": 272_000, - "daybreak-blue-latest": 922_000, -}; -/** - * Neither Daybreak page publishes a reasoning-effort ladder. An explicit empty array means - * "expose no effort control"; OMITTING the key would instead fall back to the full routed - * ladder (`configuredReasoningEfforts` returns undefined -> `applyReasoningLevels` uses - * ROUTED_REASONING_LEVELS), which would advertise efforts the models never documented. - * `noReasoningModels` is wrong here: both pages document reasoning-token support, so these - * are reasoning models with no *selectable* ladder. - */ -const OPENAI_DAYBREAK_REASONING_EFFORTS: Record = Object.fromEntries( - OPENAI_DAYBREAK_MODELS.map(id => [id, [] as string[]]), -); -const OPENROUTER_GPT56_MODELS = OPENAI_GPT56_MODELS.map(id => `openai/${id}`); -const XAI_MODELS = [ - "grok-4.6", - "grok-4.5", - "grok-4.3", - "grok-4.20-multi-agent-0309", - "grok-4.20-0309-reasoning", - "grok-4.20-0309-non-reasoning", - "grok-build-0.1", - "grok-composer-2.5-fast", -]; -// OpenRouter's live /endpoints routes report 1,050,000; keep this separate from the -// unverified OpenAI API-key seed. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. -const OPENROUTER_GPT56_CONTEXT_WINDOW = 1_050_000; -const OPENROUTER_GPT56_CONTEXT_WINDOWS = { - "openai/gpt-5.6-sol": OPENROUTER_GPT56_CONTEXT_WINDOW, - "openai/gpt-5.6-terra": OPENROUTER_GPT56_CONTEXT_WINDOW, - "openai/gpt-5.6-luna": OPENROUTER_GPT56_CONTEXT_WINDOW, -}; - -/** - * Vendor thinking-toggle models (MiMo v2.x, GLM 5/5.1 on Zen Go): the wire knob is - * `thinking: {type: enabled|disabled}` — a binary. Advertise the full Codex picker ladder - * and map efforts onto the toggle. Zen Go - * pass-through probed live 2026-07-07 (glm-5.2 toggle verified; mimo/minimax accept shape). - */ -const THINKING_TOGGLE_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -const THINKING_TOGGLE_MAP: Record = { - none: "disabled", - minimal: "disabled", - low: "disabled", - medium: "enabled", - high: "enabled", - xhigh: "enabled", - max: "enabled", -}; -const OPENCODE_GO_THINKING_TOGGLE_MODELS = [ - "mimo-v2.5", "mimo-v2.5-pro", "glm-5", "glm-5.1", -]; -/** - * Zhipu's domestic BigModel platform. Text families first, then the vision member: modalities are - * declared per model because `noVisionModels` means the opposite of "text only" here — it routes - * images through the proxy's vision sidecar (src/codex/catalog/provider-fetch.ts), a claim nobody - * has verified for BigModel-hosted GLM. - */ -// `glm-5.3-flash` is deliberately absent: it is a native VLM -// (docs.z.ai/guides/vlm/glm-5.3-flash), unlike glm-5.3 itself. -const ZHIPU_BIGMODEL_TEXT_MODELS = ["glm-4.6", "glm-4.7", "glm-4.7-flash", "glm-5", "glm-5.1", "glm-5.2", "glm-5.3"]; -const ZHIPU_BIGMODEL_MODELS = [...ZHIPU_BIGMODEL_TEXT_MODELS, "glm-4.6v"]; -const ZHIPU_BIGMODEL_INPUT_MODALITIES: Record = { - ...Object.fromEntries(ZHIPU_BIGMODEL_TEXT_MODELS.map(id => [id, ["text"]])), - "glm-4.6v": ["text", "image"], -}; -const ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS = ["glm-4.6", "glm-4.7", "glm-5", "glm-5.1", "glm-5.2", "glm-5.3", "glm-5.3-flash"]; -const THINKING_BUDGET_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -// Qwen3.8-Max is the first Qwen3.x model with official direct `reasoning_effort` support. -// Evidence: https://qwen.ai/blog?id=qwen3.8 -const QWEN38_REASONING_EFFORTS = ["low", "medium", "xhigh"]; -const THINKING_BUDGET_MODELS = [ - "qwen3.5-397b", "qwen3.6-35b", - "qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus", -]; -const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"]; -/* - * DeepSeek moved the whole V4 name set on 2026-09-10. V4.1-Flash ships as deepseek-flash - * on the first-party API; deepseek-v4-flash and the vision preview retire as models but - * keep routing there as compatibility aliases, and deepseek-v4-pro follows from - * 2026-09-14 04:00 UTC. Evidence: https://api-docs.deepseek.com/news/news260910/. - * - * The spelling differs by who serves it, so one shared list cannot express it: the - * first-party API answers to deepseek-flash, while the Zen gateway exposes the route as - * deepseek-v4.1-flash (issue #4253, PR #4258). Vendor-hosted rosters (Volcengine plan - * snapshots, Alibaba) publish on their own schedule and keep the legacy set until they say - * otherwise - a first-party retirement notice does not end their deployment. - */ -const DEEPSEEK_V4_LEGACY_MODELS = ["deepseek-v4-flash"]; -/* - * `deepseek-v4-pro` is deliberately absent from both live sets. DeepSeek retires it from - * 2026-09-14 04:00 UTC and routes its requests to V4.1-Flash until a V4.1 Pro exists, so a - * row here would advertise a Pro context window and Pro pricing for a route that serves - * Flash. The retirement is followed through every roster in this file, including the - * vendor-hosted ones; providers that discover their models live are handled by - * `ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS` because deleting a row there removes the - * model's capabilities rather than the model. - */ -const DEEPSEEK_NATIVE_THINKING_MODELS = ["deepseek-flash", "deepseek-v4-flash"]; -const DEEPSEEK_GATEWAY_THINKING_MODELS = ["deepseek-v4.1-flash", "deepseek-v4-flash"]; -/* - * DeepSeek's legacy vision preview id (released 2026-08-21). First-party probes - * in #4436 resolve it to image-capable `deepseek-flash`; retain the existing - * declarations because gateway support is specific to each served identifier. - */ -const DEEPSEEK_VISION_PREVIEW_MODEL = "deepseek-v4-flash-vision-exp"; -/** - * CommandCode routes verified to accept image input end-to-end (#2406). - * - * Verified-negative and therefore deliberately ABSENT: deepseek/deepseek-v4-flash, - * zai-org/GLM-5.2, zai-org/GLM-5.3, xai/grok-4.6. Those - * routes accept the request and drop the image, which is worse than declining it — the - * model answers about an image it never saw. Do not add an id here on family resemblance; - * capability intersection trusts this map. - */ -const COMMAND_CODE_IMAGE_MODELS = [ - `deepseek/${DEEPSEEK_VISION_PREVIEW_MODEL}`, - "gpt-5.6-luna", - "gpt-5.6-sol", - "MiniMaxAI/MiniMax-M3", - "moonshotai/Kimi-K3", - "meta/muse-spark-1.3", - "meta/muse-spark-1.3-contributor", - "meta/muse-spark-1.2", - "meta/muse-spark-1.2-contributor", - // Native Z.AI VLM (docs.z.ai/guides/vlm/glm-5.3-flash). This exact id is already - // classified as natively vision-capable in NVIDIA_NIM_VISION_MODELS in this file; - // it is not one of the verified-negative ids the header names (those are - // deepseek/deepseek-v4-flash, zai-org/GLM-5.2, zai-org/GLM-5.3, xai/grok-4.6 — - // different ids). Adding it on the shared GLM-5.3 prefix would be the family- - // resemblance mistake the header forbids; the VLM docs are the evidence (#4505). - "z-ai/glm-5.3-flash", -] as const; -/** - * Native image stays sourced from COMMAND_CODE_IMAGE_MODELS. Text-only routes - * sit beside that list so the catalog can still advertise sidecar coverage - * without claiming the gateway itself accepts a picture. - * - * The gateway-prefixed DeepSeek V4.1 Flash route has no verified native image - * support, so declaring it image-capable would hand it a picture it drops. A - * positive text-only declaration makes it a vision-sidecar consumer - * (src/vision/eligibility.ts), so the catalog advertises image input on its - * behalf and the four-target combo in #4505 intersects to ["text","image"] - * instead of ["text"] — without claiming native vision. modelInputModalities - * is per-key filled, so this reaches an existing install even when - * noVisionModels was persisted before the id joined that list. - */ -const COMMAND_CODE_TEXT_ONLY_MODELS = [ - "deepseek/deepseek-v4.1-flash", -] as const; -const COMMAND_CODE_MODEL_INPUT_MODALITIES: Record = { - ...Object.fromEntries(COMMAND_CODE_IMAGE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])), - ...Object.fromEntries(COMMAND_CODE_TEXT_ONLY_MODELS.map(id => [id, ["text"] as ["text"]])), -}; -const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"]; -/* - * Zen free models that reject `image_url` upstream (#1043, and the reproducible - * half of #1024). - * - * Zen publishes NO modality metadata — its `/v1/models` returns only id, object, - * created, owned_by — so this list is measured, not derived. Each id was probed - * once against https://opencode.ai/zen/v1 on 2026-08-05 with a text control first - * and then a 1x1 PNG; the six below failed the image request, four of them with - * `[404] No endpoints found that support image input` and `big-pickle` with the - * exact deserialize error quoted in #1043. - * - * `mimo-v2.5-free` and `longcat-2.0-free` ACCEPT images. They remain absent - * from the blind list and are recorded separately as positive input-modality evidence, - * so capability-positive dispatch can forward images without relying on blacklist absence. - * - * Zen's roster is discovered live while this list is static, so it is a dated - * exception list, not a capability model. Re-probe before extending it. - * Evidence: devlog/_fin/260805_bug_fix_stack/002_zen_modality_probe.md - */ -const OPENCODE_ZEN_TEXT_ONLY_MODELS = [ - "big-pickle", - "nemotron-3-ultra-free", - "ling-3.0-flash-free", - "north-mini-code-free", - "laguna-s-2.1-free", - "deepseek-v4-flash-free", -]; -const OPENCODE_ZEN_IMAGE_MODELS = ["mimo-v2.5-free", "longcat-2.0-free"] as const; -/* - * DeepSeek's Codex ladder is low/high/max. With the V4 Pro GA release - * (DeepSeek-V4-Pro-0813) the official thinking-mode table is IDENTICAL for both - * V4 models (api-docs.deepseek.com/guides/thinking_mode, verified 2026-08-13): - * - * requested | v4-flash | v4-pro - * low | low | low - * medium | high | high - * high | high | high - * xhigh | high | high - * max | max | max - * - * Before GA, Pro silently upgraded low->high and mapped xhigh->max (#1057-era - * table); the page's footnote about an early-August Pro mapping update landed - * with this GA, so Pro now advertises the same three real tiers as Flash. - * - * Two standing notes (#1057): - * - * - `xhigh` is a COMPATIBILITY ALIAS, not a native tier. It stays in the wire maps - * so existing requests and saved configs keep working, but it is not advertised. - * - `medium` has no row in the vendor table — mapping it to `high` is OUR - * compatibility choice for clients that only speak the OpenAI ladder. - */ -const DEEPSEEK_FLASH_THINKING_EFFORTS = ["low", "high", "max"]; -const DEEPSEEK_PRO_THINKING_EFFORTS = ["low", "high", "max"]; -const DEEPSEEK_PRO_REASONING_MAP: Record = { - low: "low", - medium: "high", - high: "high", - xhigh: "high", - max: "max", -}; -const DEEPSEEK_FLASH_REASONING_MAP: Record = { - low: "low", - medium: "high", - high: "high", - xhigh: "high", - max: "max", -}; -/** - * Flash-versus-Pro classification for DeepSeek V4 model ids, including prefixed - * (`deepseek/deepseek-v4.1-flash`) and suffixed (`deepseek-v4-flash-free`) forms. - * `tests/providers/provider-registry-parity.test.ts` enumerates every id the registry - * actually passes here, so a future id this substring test would misread cannot - * land silently. - */ -const isDeepseekFlashModel = (modelId: string): boolean => - modelId.toLowerCase().includes("flash"); -const deepseekThinkingEffortsFor = (modelId: string): string[] => - isDeepseekFlashModel(modelId) ? DEEPSEEK_FLASH_THINKING_EFFORTS : DEEPSEEK_PRO_THINKING_EFFORTS; -const deepseekReasoningMapFor = (modelId: string): Record => - isDeepseekFlashModel(modelId) ? DEEPSEEK_FLASH_REASONING_MAP : DEEPSEEK_PRO_REASONING_MAP; -// 260719 Alibaba Token Plan Personal Edition (China/Beijing). Keep it distinct from -// Coding Plan: the products use different exact allowlists and different base URLs. -// Evidence: https://help.aliyun.com/en/model-studio/token-plan-personal-overview -// https://help.aliyun.com/en/model-studio/token-plan-quickstart -const ALIBABA_TOKEN_PLAN_MODELS = [ - "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash", - "glm-5.3", "glm-5.3-flash", "glm-5.2", -]; -const ALIBABA_TOKEN_PLAN_QWEN_MODELS = [ - "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash", -]; -const ALIBABA_TOKEN_PLAN_INPUT_MODALITIES: Record = { - "qwen3.8-max": ["text", "image"], - "qwen3.7-max": ["text", "image"], - "qwen3.7-plus": ["text", "image"], - "qwen3.6-flash": ["text", "image"], - "glm-5.3": ["text"], - "glm-5.3-flash": ["text", "image"], - "glm-5.2": ["text"], -}; - -// 260721 Alibaba Token Plan International (ap-southeast-1 / Singapore, hardened 260721). -// Multi-vendor lineup distinct from Beijing — includes DeepSeek V4 flash, Kimi K2.7, MiniMax. -// Evidence: https://www.alibabacloud.com/help/en/model-studio/token-plan-overview -// https://qwencloud.com/pricing/token-plan (qwen3.8 metadata) -const ALIBABA_INTL_TOKEN_PLAN_MODELS = [ - "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash", - "deepseek-v4-flash", "deepseek-v3.2", - "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", - "glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5", - "MiniMax-M2.5", -]; -const ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS = [ - "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash", -]; - -// 260722 Tencent Cloud Coding Plan. The plan's model set is explicitly dynamic; these are the -// current documented ids and live discovery remains enabled so successful /models responses win. -// Tencent marks every Coding Plan model as text-only input and restricts plan keys to interactive -// coding tools (not custom application backends or non-interactive batch automation). -// Evidence: https://cloud.tencent.cn/document/product/1823/130092 -const TENCENT_CODING_PLAN_MODELS = ["tc-code-latest", "glm-5", "kimi-k2.5", "minimax-m2.5"]; -// Volcengine's authenticated /api/v3/models catalog mixes chat models with embedding, -// image, video, and 3D generation resources. Keep the Codex-facing presets scoped to -// models documented for text/agent or Coding Plan use. -// -// Maintenance owner: @lidge-jun. Verified 2026-08-01 against the vendor's own docs — -// endpoints https://docs.volcengine.com/docs/82379/1528783 (Coding Plan) and -// https://docs.volcengine.com/docs/82379/2165245 (Agent Plan); Codex CLI integration -// https://www.volcengine.com/docs/82379/2556056; supported clients -// https://www.volcengine.com/docs/82379/2188957; terms https://www.volcengine.com/docs/6256/64903 -// (北京火山引擎科技有限公司). Plan quota is restricted to supported AI coding tools and misuse -// is documented as grounds for suspension — see the `note` on both Plan entries. -// Report a break by opening an issue tagging the owner; the three things that rot first are the -// static catalogs (liveModels:false cannot self-heal), the base URLs, and those Plan terms. -// Full evidence ledger: devlog/_fin/260801_pr611_volcengine_evidence/000_evidence_ledger.md -const VOLCENGINE_ARK_MODELS = [ - "doubao-seed-2-1-pro-260628", - "doubao-seed-2-1-turbo-260628", - "doubao-seed-evolving", - "deepseek-v4-flash-260425", - "deepseek-v3-2-251201", - // No glm-5-3 row: Ark pins date-stamped snapshot ids (glm-5-2-260617) that cannot be - // guessed ahead of the vendor publishing them. Add it once /api/v3/models lists one. - "glm-5-2-260617", - "glm-4-7-251222", -]; -const VOLCENGINE_DOUBAO_THINKING_MODELS = [ - "doubao-seed-2-1-pro-260628", - "doubao-seed-2-1-turbo-260628", - "doubao-seed-evolving", -]; -const VOLCENGINE_CODING_PLAN_MODELS = [ - "ark-code-latest", - "doubao-seed-2.0-code", - "deepseek-v4-flash", - "glm-5.3", - "glm-5.3-flash", - "glm-5.2", - "kimi-k2.6", - "minimax-m3", -]; -const VOLCENGINE_AGENT_PLAN_MODELS = [ - "deepseek-v4-flash", - "glm-5.3", - "glm-5.3-flash", - "glm-5.2", - "kimi-k2.6", - "minimax-m3", - "doubao-seed-2.0-pro", -]; -const VOLCENGINE_PLAN_INPUT_MODALITIES: Record = { - "kimi-k2.6": ["text", "image"], - "minimax-m3": ["text", "image"], - // Native VLM (docs.z.ai/guides/vlm/glm-5.3-flash), so it is declared here and left - // out of the text-only list below. - "glm-5.3-flash": ["text", "image"], -}; -// Every other Plan model is text-only. Declaring this explicitly keeps the vision -// sidecar from advertising image input for models that cannot accept it — the same -// treatment tencent-coding-plan gives its (entirely text-only) plan catalog. -const VOLCENGINE_PLAN_TEXT_ONLY_MODELS = [ - "ark-code-latest", - "doubao-seed-2.0-code", - "deepseek-v4-flash", - "glm-5.3", - "glm-5.2", - "doubao-seed-2.0-pro", -]; -const ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES: Record = { - "qwen3.8-max": ["text", "image"], - "qwen3.7-max": ["text", "image"], - "qwen3.7-plus": ["text", "image"], - "qwen3.6-plus": ["text", "image"], - "qwen3.6-flash": ["text", "image"], - "deepseek-v4-flash": ["text"], - "deepseek-v3.2": ["text"], - "kimi-k2.7-code": ["text", "image"], - "kimi-k2.6": ["text", "image"], - "kimi-k2.5": ["text", "image"], - "glm-5.3": ["text"], - "glm-5.3-flash": ["text", "image"], - "glm-5.2": ["text"], - "glm-5.1": ["text"], - "glm-5": ["text"], - "MiniMax-M2.5": ["text"], -}; - -// 260717 Kimi K3: the subscription endpoint uses one upstream id (`k3`) for both -// entitlement tiers. Bare `k3` advertises the Moderato 256K ceiling; the local `[1m]` -// alias advertises Allegretto's 1M ceiling and is stripped before the upstream request. -// The separately billed Moonshot API uses `kimi-k3`. -// Evidence: https://www.kimi.com/code/docs/en/kimi-code/models.html -// https://www.kimi.com/code/docs/en/kimi-code/error-reference.html -const KIMI_K3_STANDARD_CONTEXT_WINDOW = 262_144; -const KIMI_K3_1M_CONTEXT_WINDOW = 1_048_576; -const KIMI_CODING_K3_MODELS = ["k3", "k3[1m]"]; -const KIMI_LEGACY_API_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"]; -const KIMI_API_MODELS = ["kimi-k3", ...KIMI_LEGACY_API_MODELS]; -const KIMI_CODING_MODELS = [...KIMI_CODING_K3_MODELS, ...KIMI_LEGACY_API_MODELS, "kimi-for-coding"]; -const KIMI_THINKING_MODELS = KIMI_CODING_MODELS; -const KIMI_CODING_NO_REASONING_MODELS = KIMI_CODING_MODELS.filter(id => !KIMI_CODING_K3_MODELS.includes(id)); -const KIMI_API_NO_REASONING_MODELS = KIMI_API_MODELS.filter(id => id !== "kimi-k3"); -const KIMI_CODING_K3_REASONING_EFFORTS = ["low", "high", "max"]; -const KIMI_CODING_K3_REASONING_EFFORT_MAP: Record = { - none: "none", - low: "low", - medium: "high", - high: "high", - xhigh: "max", - max: "max", -}; -const KIMI_CODING_REASONING_EFFORTS = Object.fromEntries( - KIMI_CODING_MODELS.map(id => [id, KIMI_CODING_K3_MODELS.includes(id) ? KIMI_CODING_K3_REASONING_EFFORTS : []]), -); -const KIMI_CODING_DEFAULT_REASONING_EFFORTS = Object.fromEntries( - KIMI_CODING_K3_MODELS.map(id => [id, "max"]), -); -const KIMI_CODING_REASONING_EFFORT_MAPS = Object.fromEntries( - KIMI_CODING_K3_MODELS.map(id => [id, KIMI_CODING_K3_REASONING_EFFORT_MAP]), -); -const KIMI_API_REASONING_EFFORTS = Object.fromEntries( - KIMI_API_MODELS.map(id => [id, id === "kimi-k3" ? ["max"] : []]), -); -const KIMI_LOCKED_PARAMETER_MODELS = KIMI_CODING_MODELS; -const KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-for-coding"]; -const KIMI_API_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( - KIMI_API_MODELS.map(id => [id, id === "kimi-k3" ? KIMI_K3_1M_CONTEXT_WINDOW : 262_144]), -); -const KIMI_API_MODEL_INPUT_MODALITIES = { "kimi-k3": ["text", "image"] }; - -// 260715 NVIDIA NIM kimi family (issue #126): documented served ids on integrate -// chat/completions per docs.api.nvidia.com/nim/reference/llm-apis; live /v1/models -// currently lists only kimi-k2.6 but the list is dynamic, so carry the documented family. -const NVIDIA_NIM_KIMI_THINKING_MODELS = [ - "moonshotai/kimi-k2.6", "moonshotai/kimi-k2.5", "moonshotai/kimi-k2-thinking", -]; -const NVIDIA_NIM_KIMI_MODELS = [ - ...NVIDIA_NIM_KIMI_THINKING_MODELS, - "moonshotai/kimi-k2-instruct", "moonshotai/kimi-k2-instruct-0905", -]; -/** - * 260804 issue #956: NIM publishes no input-modality metadata on `/v1/models`, so the - * registry is the only source of truth for which models can see images. - * - * Two lists, both verified per-model against NVIDIA documentation on 2026-08-04 - * (build.nvidia.com model pages and docs.api.nvidia.com/nim/reference/*). Evidence and - * the per-id audit: devlog/_fin/260804_stack7_service_vision/011_nim_id_audit.md. - * - * Read `noVisionModels` carefully — it lists models that CANNOT see images, which is - * what routes them through the proxy's vision sidecar (src/vision/index.ts) and makes the - * catalog advertise image input for them. Membership is wrong in BOTH directions: - * - a text-only model missing from it keeps issue #956 (images blocked or rejected); - * - a vision model wrongly IN it gets its image silently replaced by another model's - * text description — no error, worse answers, extra cost. - * - * A new NIM id must be classified DELIBERATELY against its NVIDIA page, never assumed - * from its name: `google/gemma-4-31b-it` carries no vision marker yet accepts images, - * `-vl` also appears on embedding/reranking models, and `google/codegemma-7b` is - * text-only while `google/codegemma-1.1-7b` has no current page at all. An unclassified - * id is intentionally left alone rather than defaulted, because NIM serves non-chat - * endpoints (embeddings, rerankers, guards, OCR) that reach the same code path. - */ -const NVIDIA_NIM_VISION_MODELS = [ - "meta/llama-3.2-11b-vision-instruct", "meta/llama-3.2-90b-vision-instruct", - "nvidia/llama-3.1-nemotron-nano-vl-8b-v1", "nvidia/nemotron-nano-12b-v2-vl", - "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", "nvidia/cosmos3-nano-reasoner", - "nvidia/ising-calibration-1.5-31b", "nvidia/ising-calibration-1-35b-a3b", - "google/gemma-4-31b-it", "google/diffusiongemma-26b-a4b-it", - "minimaxai/minimax-m3", "moonshotai/kimi-k2.6", "moonshotai/kimi-k2.5", - "stepfun-ai/step-3.7-flash", "thinkingmachines/inkling", - "mistralai/mistral-medium-3.5-128b", - "z-ai/glm-5.3-flash", -]; -/** - * The catalog advertises image input only for `noVisionModels` members, so a natively - * vision-capable model would otherwise be published as text-only and the Codex app would - * block attachments before the native path ever runs. - */ -const NVIDIA_NIM_VISION_INPUT_MODALITIES: Record = Object.fromEntries( - NVIDIA_NIM_VISION_MODELS.map(id => [id, ["text", "image"]]), -); -/** - * Text-only NIM chat models — 26 ids, each carrying an explicit `Input Modalities: Text` - * (or equivalent) on its NVIDIA page. PR #964 proposed ~64; six of those are natively - * image-capable and live in NVIDIA_NIM_VISION_MODELS above, and 32 more had no current - * NVIDIA page and were dropped rather than assumed. - * - * kimi-k2-thinking and kimi-k2-instruct are text-only while k2.5/k2.6 are not — vision - * and reasoning are independent axes, so all four stay in NVIDIA_NIM_KIMI_MODELS for - * reasoning suppression regardless of which list they appear in here. - */ -const NVIDIA_NIM_NO_VISION_MODELS = [ - "deepseek-ai/deepseek-v4-flash", - "google/codegemma-7b", - "meta/llama-3.1-70b-instruct", "meta/llama-3.1-8b-instruct", - "meta/llama-3.2-1b-instruct", "meta/llama-3.2-3b-instruct", - "meta/llama-3.3-70b-instruct", "meta/llama2-70b", - "mistralai/mistral-7b-instruct-v0.3", "mistralai/mistral-nemotron", - "moonshotai/kimi-k2-thinking", "moonshotai/kimi-k2-instruct", - "nvidia/llama-3.1-nemotron-nano-8b-v1", "nvidia/llama-3.1-nemotron-ultra-253b-v1", - "nvidia/llama-3.3-nemotron-super-49b-v1", "nvidia/llama-3.3-nemotron-super-49b-v1.5", - "nvidia/nemotron-3-nano-30b-a3b", "nvidia/nemotron-3-super-120b-a12b", - "nvidia/nemotron-3-ultra-550b-a55b", "nvidia/nemotron-mini-4b-instruct", - "nvidia/nvidia-nemotron-nano-9b-v2", - "openai/gpt-oss-120b", "openai/gpt-oss-20b", - // z-ai/glm-5.3-flash belongs in NVIDIA_NIM_VISION_MODELS, not here: Z.AI documents - // it under docs.z.ai/guides/vlm/. The header above says an id must be classified - // deliberately rather than assumed from its name, and inheriting glm-5.3's - // text-only verdict because of the shared prefix is exactly that mistake. - "poolside/laguna-xs-2.1", "z-ai/glm-5.3", "z-ai/glm-5.2", -]; -const KIMI_CODING_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( - KIMI_CODING_MODELS.map(id => [id, id === "k3[1m]" ? KIMI_K3_1M_CONTEXT_WINDOW : KIMI_K3_STANDARD_CONTEXT_WINDOW]), -); -const KIMI_CODING_MODEL_INPUT_MODALITIES = Object.fromEntries( - KIMI_CODING_K3_MODELS.map(id => [id, ["text", "image"]]), -); -const NEURALWATT_REASONING_HISTORY_MODELS = [ - "glm-5.3", "glm-5.3-short", "glm-5.3-flash", - "glm-5.2", "glm-5.2-short", - "kimi-k2.6", "kimi-k2.7-code", - "qwen3.5-397b", "qwen3.6-35b", -]; - -// 260728 Baseten Model APIs: `/v1/models` owns the live lineup, while these hints -// describe only capabilities that Baseten documents per slug. Unlisted live models -// intentionally inherit the empty provider ladder instead of being advertised with -// opencodex's generic reasoning defaults. Audio is omitted because the current proxy -// request model does not carry OpenAI `audio_url` parts. -// Evidence: https://docs.baseten.co/inference/model-apis/reasoning -// https://docs.baseten.co/inference/model-apis/vision -const BASETEN_FULL_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -const BASETEN_MODEL_REASONING_EFFORTS: Record = { - "thinkingmachines/inkling": BASETEN_FULL_REASONING_EFFORTS, - "openai/gpt-oss-120b": BASETEN_FULL_REASONING_EFFORTS, - "moonshotai/Kimi-K3": ["low", "high", "max"], - // 260814: GLM-5.3 honours low/high/max upstream, unlike 5.2's high/max on Baseten. - "zai-org/GLM-5.3": ["low", "high", "max"], - "zai-org/GLM-5.3-Fast": ["low", "high", "max"], - "zai-org/GLM-5.2": ["high", "max"], - "zai-org/GLM-5.2-Fast": ["high", "max"], -}; -const BASETEN_MODEL_REASONING_EFFORT_MAP: Record> = { - "thinkingmachines/inkling": { none: "none", minimal: "minimal" }, - "openai/gpt-oss-120b": { none: "none", minimal: "minimal" }, - "moonshotai/Kimi-K3": { none: "none" }, - "zai-org/GLM-5.3": { none: "none" }, - "zai-org/GLM-5.3-Fast": { none: "none" }, - "zai-org/GLM-5.2": { none: "none" }, - "zai-org/GLM-5.2-Fast": { none: "none" }, -}; -const BASETEN_MODEL_DEFAULT_REASONING_EFFORTS: Record = { - "thinkingmachines/inkling": "high", - "openai/gpt-oss-120b": "medium", - "moonshotai/Kimi-K3": "max", -}; -const BASETEN_MODEL_INPUT_MODALITIES: Record = { - "thinkingmachines/inkling": ["text", "image"], - "moonshotai/Kimi-K2.6": ["text", "image"], - "moonshotai/Kimi-K2.7-Code": ["text", "image"], - "moonshotai/Kimi-K3": ["text", "image"], -}; - -// 260801 DigitalOcean and Scaleway expose OpenAI-shaped `/v1/models` rows with only -// id/object/created/owned_by, while their shared serverless catalogs also contain -// non-chat and endpoint-specific models. Fail closed by intersecting live discovery -// with ids that the providers' current first-party model tables establish for Chat -// Completions. A newly listed id therefore needs a docs-backed registry refresh before -// it can enter the Codex catalog. -// Evidence: https://docs.digitalocean.com/products/inference/details/models/ -// https://docs.digitalocean.com/reference/api/reference/serverless-inference/ -// https://www.scaleway.com/en/docs/generative-apis/reference-content/supported-models/ -const DIGITALOCEAN_CHAT_COMPLETION_MODELS = [ - "arcee-trinity-large-thinking", - "openai-gpt-5.6-sol", - "openai-gpt-5.6-terra", - "openai-gpt-5.6-luna", - "qwen3-coder-flash", - "qwen3.5-397b-a17b", - "deepseek-4-flash", - "deepseek-3.2", - "gemma-4-31B-it", - "minimax-m2.5", - "kimi-k3", - "kimi-k2.6", - "kimi-k2.5", - "llama3.3-70b-instruct", - "llama-4-maverick", - "mistral-3-14B", - "nemotron-3-ultra-550b", - "nvidia-nemotron-3-super-120b", - "nemotron-3-nano-omni", - "nemotron-nano-12b-v2-vl", - "mimo-v2.5-pro", - "glm-5.3", - "glm-5.3-flash", - "glm-5.2", - "glm-5.1", - "glm-5", - // The API reference uses this native slash id in its Chat Completions example. - "meta-llama/Meta-Llama-3.1-8B-Instruct", -] as const; -const SCALEWAY_SERVERLESS_CHAT_MODELS = [ - "glm-5.3", - "glm-5.3-flash", - "glm-5.2", - // gpt-oss-120b is intentionally omitted: Scaleway requires Responses API for tool calling, - // while this preset routes Codex agent tools through Chat Completions. - "qwen3.6-35b-a3b", - "qwen3.5-397b-a17b", - "qwen3-235b-a22b-instruct-2507", - "qwen3-coder-30b-a3b-instruct", - "gemma-4-26b-a4b-it", - "llama-3.3-70b-instruct", - "mistral-medium-3.5-128b", - "mistral-small-3.2-24b-instruct-2506", - "pixtral-12b-2409", -] as const; -const SCALEWAY_MODEL_INPUT_MODALITIES: Record = { - "pixtral-12b-2409": ["text", "image"], -}; -const UMANS_MODELS = [ - "umans-coder", - "umans-kimi-k2.7", - "umans-flash", - "umans-glm-5.3", - "umans-glm-5.3-flash", - "umans-glm-5.2", - "umans-glm-5.1", - "umans-qwen3.6-35b-a3b", -]; -const UMANS_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; -const UMANS_GLM_REASONING_EFFORTS = ["high", "xhigh", "max"]; -// 260814: Z.AI folds GLM-5.3 efforts into low/high/max, so `low` is a real tier here and -// `xhigh` is not distinct from `max` (docs.z.ai/devpack/latest-model). -const UMANS_GLM_53_REASONING_EFFORTS = ["low", "high", "max"]; -// `umans-glm-5.3-flash` is NOT here: Z.AI documents glm-5.3-flash under -// docs.z.ai/guides/vlm/, so it takes images natively and does not need the proxy's -// vision sidecar. The seeding pass classified it from the family name and a later -// pass corrected only some of the providers; this is one it missed. -const UMANS_TEXT_ONLY_MODELS = ["umans-glm-5.3", "umans-glm-5.2", "umans-glm-5.1"]; -const UMANS_MODEL_CONTEXT_WINDOWS: Record = { - "umans-coder": 262_144, - "umans-kimi-k2.7": 262_144, - "umans-flash": 262_144, - "umans-glm-5.3": 405_504, - // Mirrors the sibling this provider already carries. Umans has not published a - // separate window for the flash tier; asserting a different number would be a guess. - "umans-glm-5.3-flash": 405_504, - "umans-glm-5.2": 405_504, - "umans-glm-5.1": 202_752, - "umans-qwen3.6-35b-a3b": 262_144, -}; -const UMANS_MODEL_INPUT_MODALITIES: Record = Object.fromEntries( - UMANS_MODELS.map(id => [id, UMANS_TEXT_ONLY_MODELS.includes(id) ? ["text"] : ["text", "image"]]), -); -const CLINE_PASS_MODELS = [ - "cline-pass/glm-5.3", - "cline-pass/glm-5.3-flash", - "cline-pass/glm-5.2", - "cline-pass/kimi-k3", - "cline-pass/kimi-k2.7-code", - "cline-pass/kimi-k2.6", - "cline-pass/deepseek-v4-flash", - "cline-pass/mimo-v2.5", - "cline-pass/mimo-v2.5-pro", - "cline-pass/minimax-m3", - "cline-pass/qwen3.8-max", - "cline-pass/qwen3.7-max", - "cline-pass/qwen3.7-plus", -]; - -const ORCAROUTER_MODEL_DISCOVERY: ProviderModelDiscoverySpec = { - path: "models", - query: { capability: "chat" }, - maxResponseBytes: 512 * 1024, - maxModels: 512, - filter: { - anyOf: [{ - path: ["supported_endpoint_types"], - containsAny: ["openai", "openai-response", "anthropic", "gemini"], - caseInsensitive: true, - }], - noneOf: [{ - path: ["supported_endpoint_types"], - containsAny: ["image-generation", "openai-video", "jina-rerank"], - caseInsensitive: true, - }], - }, -}; -// Preserve the previously verified cold-start catalog. Live discovery remains authoritative -// when it succeeds, but a temporary catalog outage must not erase the provider's known-good -// selectors from the picker. `orcarouter/auto` is intentionally retained here even though the -// public catalog did not enumerate it at the latest verification (2026-09-07). -const ORCAROUTER_MODELS = [ - "openai/gpt-5.5", - "anthropic/claude-opus-4.8", - "google/gemini-3.5-flash", - "orcarouter/auto", -]; -const ORCAROUTER_MODEL_REASONING_EFFORTS = { - // Live /models currently exposes ids and modalities, not the accepted reasoning ladder. - "openai/gpt-5.5": ["low", "medium", "high", "xhigh"], -}; -const CLINE_PASS_MODEL_CONTEXT_WINDOWS: Record = { - "cline-pass/glm-5.3": 1_048_576, - "cline-pass/glm-5.3-flash": 1_048_576, - "cline-pass/glm-5.2": 1_048_576, - "cline-pass/kimi-k3": 1_048_576, - "cline-pass/kimi-k2.7-code": 262_144, - "cline-pass/kimi-k2.6": 262_144, - "cline-pass/deepseek-v4-flash": 1_048_576, - "cline-pass/mimo-v2.5": 1_050_000, - "cline-pass/mimo-v2.5-pro": 1_050_000, - "cline-pass/minimax-m3": 1_048_576, - "cline-pass/qwen3.7-max": 1_000_000, - "cline-pass/qwen3.7-plus": 1_000_000, -}; -const CLINE_PASS_IMAGE_MODELS = new Set([ - "cline-pass/kimi-k3", - "cline-pass/kimi-k2.7-code", - "cline-pass/kimi-k2.6", - "cline-pass/mimo-v2.5", - "cline-pass/minimax-m3", - "cline-pass/qwen3.7-plus", - // Native VLM (docs.z.ai/guides/vlm/), so its images do not go through the proxy's - // sidecar. Adding it here moves it out of CLINE_PASS_TEXT_ONLY_MODELS and flips its - // declared modalities to ["text", "image"] in one edit, because both are derived - // from this set. - "cline-pass/glm-5.3-flash", -]); -const CLINE_PASS_MODALITY_KNOWN_MODELS = CLINE_PASS_MODELS.filter(id => id !== "cline-pass/qwen3.8-max"); -const CLINE_PASS_TEXT_ONLY_MODELS = CLINE_PASS_MODALITY_KNOWN_MODELS.filter(id => !CLINE_PASS_IMAGE_MODELS.has(id)); -const CLINE_PASS_MODEL_INPUT_MODALITIES: Record = Object.fromEntries( - CLINE_PASS_MODALITY_KNOWN_MODELS.map(id => [id, CLINE_PASS_IMAGE_MODELS.has(id) ? ["text", "image"] : ["text"]]), -); +import type { + InboundWire, + ProviderRegistryEntry, + ResponsesTerminalRepairPolicy, +} from "./registry/types"; +import { PROVIDER_REGISTRY_CORE } from "./registry/entries-core"; +import { PROVIDER_REGISTRY_EXTENDED } from "./registry/entries-extended"; + +export type { + ProviderAuthKind, + MetadataModelIdNormalize, + InboundWire, + ModelWireDefault, + ResponsesTerminalRepairPolicy, + ProviderModelDiscoveryScalar, + ProviderModelDiscoveryPredicate, + ProviderModelDiscoveryFilter, + ProviderModelDiscoverySpec, + ProviderRegistryEntry, + ProviderConfigSeed, +} from "./registry/types"; export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ - { - id: "openai", - label: "OpenAI (Codex login)", - adapter: "openai-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", - authKind: "forward", - codexAccountMode: "pool", - supportsServiceTier: true, - featured: true, - note: "Codex login account pool (default) or Direct main-account mode via codexAccountMode", - }, - { - id: "cursor", - label: "Cursor (experimental)", - adapter: "cursor", - baseUrl: "https://api2.cursor.sh", - authKind: "oauth", - featured: false, - dashboardPreset: true, - note: "Experimental Cursor bridge. Live transport and live model discovery are enabled after a standalone PKCE browser login via 'ocx login cursor'; native read/write/delete/shell/fetch execution is disabled by default and request text such as Codex sandbox markers never authorizes it. Set \"nativeLocalExec\": \"on\" on providers.cursor in ~/.opencodex/config.json (dashboard: Providers → Cursor → Edit JSON) only for a trusted local experiment where every data-plane caller is trusted. \"off\" denies all, \"codex-sandbox\" is accepted for backwards compatibility but fails closed, and legacy \"unsafeAllowNativeLocalExec\": true still means explicit operator opt-in.", - models: cursorModelIds(CURSOR_STATIC_MODELS), - liveModels: true, - defaultModel: "auto", - modelContextWindows: cursorModelContextWindows(CURSOR_STATIC_MODELS), - modelDisplayNames: cursorModelDisplayNames(), - // Cursor's Fast product is a model VARIANT, not a service_tier field, so the wire kind - // is cursor-variant and the request builder consumes the decision. - fastWire: { kind: "cursor-variant", canonicalToWire: { priority: "fast" }, foreignCallerTiers: "drop" }, - // Deliberately NO provider-level supportsServiceTier: resolveFastPolicy short-circuits on - // `capability.provider === false` BEFORE consulting the per-model map, which would make - // these entries dead config. Absent leaves unlisted bases "unclassified", and a - // non-service-tier adapter cannot forward a caller tier, so they still publish no toggle. - modelSupportsServiceTier: Object.fromEntries(cursorFastCapableBases().map(id => [id, true])), - fastTierDescription: "Cursor Fast variant", - modelInputModalities: cursorModelInputModalities(CURSOR_STATIC_MODELS), - modelReasoningEfforts: cursorModelReasoningEfforts(CURSOR_STATIC_MODELS), - // Kimi K3 documents `max` as its API default, and its Cursor ladder has no `medium` - // rung — so applyReasoningLevels' medium->high->first fallback would settle the catalog - // default on `high`, the picker would send `high` explicitly, and the request builder's - // no-effort fallback to `kimi-k3-max` would never be reached. Mirrors the other K3 - // routes (kimi, kimi-code, opencode-go). - modelDefaultReasoningEfforts: { "kimi-k3": "max" }, - // Blind Cursor models (Auto routers, Composer, GLM-5.2, GLM-5.3) go through the vision sidecar; - // multimodal hosts (Claude/Gemini/GPT/Kimi/Grok) take native SelectedImage. The catalog - // still advertises image for noVision members so Codex can attach (sidecar option B). - noVisionModels: [...CURSOR_NO_VISION_MODELS], - }, - { - // The canonical Cognition account provider, after absorbing `devin-cli` - // (devlog/_plan/260913_devin_provider_merge). The two ids were the same - // `devin` adapter, the same server.codeium.com api-server, and the same - // `devin-session-token$` credential — only the account source - // differed: this entry did an Auth0 browser sign-in while `devin-cli` - // imported the token the installed CLI's own PKCE login had already - // written to credentials.toml. The merged login is import-first with a - // browser fallback: the CLI credential is taken when present (no browser - // opens), and the Auth0 flow remains because it is the only path for - // users without the CLI. `devin-cli` survives only as a deprecated - // alias; a startup migration rewrites saved provider rows, cross-config - // references, and auth.json slots to `devin`. - // - // `oauth` classifies the ACCOUNT, not the transport. This is not a local - // runtime: unlike Ollama or LM Studio it cannot answer at all until a - // vendor account is signed in, and `local` grouped it with things that - // have no account. It is also the only classification that reaches the - // dashboard Accounts tab, which is built from OAUTH_PROVIDERS. - id: "devin", - label: "Cognition (Devin/Windsurf)", - adapter: "devin", - baseUrl: "https://server.codeium.com", - authKind: "oauth", - featured: false, - // Off: `deriveProviderPresets` keys the preset catalog off this flag, so a - // true row would draw the provider twice — an Accounts login row and a - // preset tile. - dashboardPreset: false, - note: "Experimental unofficial Cognition/Devin bridge. ocx login devin first imports the credential an installed Devin CLI already holds (no browser); without one it opens Auth0 browser sign-in and exchanges the token via Cognition's RegisterUser for a long-lived API key.", - // Union seed of the two merged rosters: the newer devin-cli lineup first - // (it is the current catalog, so its default ordering wins), then the ids - // only the old devin entry carried. Degraded-mode seed only either way — - // `liveModels` discovers the account's real roster. - models: ["swe-2", "swe-1-7", "gpt-5-6-sol", "gpt-6-astra", "claude-opus-5", "claude-fable-5-1", "claude-sonnet-5", "glm-5-3", "kimi-k3", "gemini-3-8-flash", "grok-4-6", "swe-1-7-lightning", "gpt-5-6-luna", "gpt-5-6-terra", "claude-opus-4-8", "glm-5-2", "kimi-k2-7", "grok-4-5"], - liveModels: true, - defaultModel: "swe-2", - modelContextWindows: DEVIN_MODEL_CONTEXT_WINDOWS, - // Degraded-mode ladders only. Once a credential is present the account - // catalog supplies each base model its measured rungs; these two fields are - // what a signed-out picker and the Pi-shaped client exports fall back to. - modelReasoningEfforts: DEVIN_MODEL_EFFORTS, - reasoningEfforts: DEVIN_DEFAULT_EFFORTS, - }, - { - id: "xai", - label: "xAI Grok", - adapter: "openai-chat", - baseUrl: "https://api.x.ai/v1", - authKind: "oauth", - allowKeyAuthOverride: true, - // Priority Processing is documented for xAI's public API-key Chat Completions and - // Responses endpoints. The OAuth lane is classified per-model below, not here: - // do not turn this into a provider-wide supportsServiceTier declaration. - keyAuthServiceTier: { - supportsServiceTier: true, - chatServiceTier: true, - }, - // OAuth (Grok subscription gateway) service-tier capability, classified by live probe - // on 2026-09-13 (devlog/_fin/260913_xai_oauth_fast/020_probe-evidence.md): each listed - // model accepted service_tier "priority" over grok-oauth and echoed priority upstream. - // Key-auth already declares provider-wide support above, so this map only newly opens - // the OAuth lane. grok-4.20-multi-agent-0309 is deliberately absent: the gateway accepts - // the field but answers service_tier "default" — a live downgrade, not a fast tier. - // Unlisted and future-discovered ids stay unclassified. - modelSupportsServiceTier: { - "grok-4.6": true, - "grok-4.5": true, - "grok-4.3": true, - "grok-4.20-0309-reasoning": true, - "grok-4.20-0309-non-reasoning": true, - "grok-build-0.1": true, - "grok-composer-2.5-fast": true, - }, - // Lets a caller-sent service_tier forward on the Chat wire (fastwire forwardCallerTier - // chain). Provider-wide by construction: unclassified chat-wire models then preserve a - // caller tier verbatim, the same contract other unclassified Responses routes already - // follow; --fast publication and proxy-owned fast injection stay capability-scoped by - // the map above. Key-auth declared the same value via keyAuthServiceTier, so the key - // lane is unchanged. - chatServiceTier: true, - // Shared across key and OAuth catalog rows. OAuth subscription has no - // per-token price, so the 2x claim is scoped to key auth. - fastTierDescription: "Priority processing; tier pricing applies on key auth only", - featured: true, - oauthId: "xai", - jawcodeBundle: "xai", - supportsOpenAiWebSearchToolFields: false, - // Live A/B on 2026-08-20: xAI rejects native custom/custom_tool_call shapes while accepting - // the otherwise-identical request after the custom tool is lowered to a function. - supportsResponsesCustomTools: false, - note: "Log in with your Grok account", - // Parallel tool calls: officially supported and default-on per docs.x.ai function-calling - // (verified 260709, devlog/_plan/260709_parallel_tool_calls). Streamed calls arrive whole - // per chunk, so the buffered parser assembles them losslessly. - parallelToolCalls: true, - // Live /v1/models discovery is the authoritative lineup (verified 260709: returns grok-4.5); - // the static list below is the logged-out fallback seed. - liveModels: true, - // 260709 refresh: lineup + metadata from official docs.x.ai (grok-4.5 announced 07-08); - // grok-composer-2.5-fast kept as account-verified (absent from public docs). Evidence: - // devlog/model_update/260709_model_refresh/001_xai_lineup.md. - // 260823: grok-4.20-multi-agent-0309 still returns 400 on Chat Completions, but works - // on Responses. The server reports this dated id for both it and the floating - // grok-4.20-multi-agent-beta-latest alias, so expose only the dated deployment id. - // 260813: grok-4.6 added per docs.x.ai/developers/grok-4-6. Context/vision still match - // grok-4.5; the reasoning ladder does not — 4.6 adds the documented xhigh rung. - models: XAI_MODELS, - // Measured only on grok-4.6 against cli-chat-proxy.grok.com: even an invalid - // `text.verbosity` value is accepted and low/high/omitted output length is non-monotonic. - // Apply the resulting opt-out to the whole xAI lineup because `text.verbosity` is an OpenAI - // Responses parameter absent from xAI's documented API, not because every model was probed. - // Keep this separate from reasoning-summary support: that bit gates Codex's - // entire Responses reasoning object, including reasoning.effort. - modelSupportsVerbosity: Object.fromEntries(XAI_MODELS.map(id => [id, false])), - // Provider-wide, not merely per-model: `text.verbosity` is an OpenAI Responses parameter - // absent from xAI's documented API, so a model discovered later has no more support for it - // than the seeded ones do. - supportsVerbosity: false, - defaultModel: "grok-4.5", - // Grok 4.6/4.5 subscription Responses callers use the native wire with the existing - // namespace/web-search/replay normalization. Chat remains an explicit modelAdapters - // opt-in. Multi-agent has no Chat wire and uses Responses under both auth modes. - // grok-4.6/4.5 are classified OAuth fast-tier models (modelSupportsServiceTier above), - // so a caller-sent service_tier:"priority" forwards on this lane — the Codex fast-toggle - // path. Multi-agent keeps its pin: probed 2026-09-13, the gateway downgrades its tier to - // "default", so forwarding a caller tier would advertise a tier it does not get. - modelWireDefaults: { - "grok-4.6": { - wire: "openai-responses", - inbound: ["responses"], - authModes: ["oauth"], - }, - "grok-4.5": { - wire: "openai-responses", - inbound: ["responses"], - authModes: ["oauth"], - }, - "grok-4.20-multi-agent-0309": { - // Even at high effort it emits no reasoning-summary deltas or encrypted replay - // material. Do not encode that as modelSupportsReasoningSummaries:false: through - // Codex #1100 that suppresses the entire reasoning object, including the effort - // that controls this model's agent count. An empty summary pane is harmless. - // Chat Completions returns 400 for this model, so every inbound uses Responses — - // `anthropic` included. Omitting it left providerModelWireDefault returning undefined - // for the Claude Messages lane, so resolveWireProtocolOverride kept xAI's provider-wide - // openai-chat adapter and sent this model to the wire it 400s on. - wire: "openai-responses", - inbound: ["responses", "chat", "anthropic"], - forwardCallerServiceTier: false, - }, - }, - // Vision lineup per docs.x.ai model-capabilities/images/understanding: the grok-4.x chat - // models accept image input (JPEG/PNG, URL or base64). Without this the catalog leaves - // inputModalities undefined, and deriveComboCatalogModel defaults an undefined member to - // ["text"] — so any combo containing an xAI target is advertised to Codex as text-only and - // the app blocks attachments client-side. grok-build-0.1 / grok-composer-2.5-fast stay out - // (they are already listed in noVisionModels below). - modelInputModalities: { - "grok-4.6": ["text", "image"], - "grok-4.5": ["text", "image"], - "grok-4.3": ["text", "image"], - "grok-4.20-multi-agent-0309": ["text", "image"], - "grok-4.20-0309-reasoning": ["text", "image"], - "grok-4.20-0309-non-reasoning": ["text", "image"], - }, - noReasoningModels: ["grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"], - // Replay assistant reasoning_content for grok reasoning models: xAI documents dropped - // reasoning_content as the top cause of prompt-cache misses on multi-turn conversations - // (docs.x.ai prompt-caching/multi-turn, verified 2026-07-13 — devlog/_plan/260713_grok_caching). - // Models that never emit reasoning simply have no thinking parts to replay (no-op). - preserveReasoningContentModels: ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"], - // grok-4.5 reasoning is always-on with low/medium/high (no off tier, no xhigh). - // grok-4.6 adds xhigh per docs.x.ai/developers/model-capabilities/text/reasoning; - // multi-agent accepts the same four wire values to select 4 or 16 collaborators. xAI - // documents high as the 4.6 default but no multi-agent default, so do not invent one. - modelReasoningEfforts: { - "grok-4.6": ["low", "medium", "high", "xhigh"], - "grok-4.5": ["low", "medium", "high"], - "grok-4.20-multi-agent-0309": ["low", "medium", "high", "xhigh"], - }, - modelDefaultReasoningEfforts: { "grok-4.6": "high" }, - modelContextWindows: { - "grok-4.6": 500_000, - "grok-4.5": 500_000, - "grok-4.3": 1_000_000, - "grok-4.20-multi-agent-0309": 1_000_000, - "grok-4.20-0309-reasoning": 1_000_000, - "grok-4.20-0309-non-reasoning": 1_000_000, - "grok-build-0.1": 256_000, - }, - noVisionModels: ["grok-build-0.1", "grok-composer-2.5-fast"], - }, - { - id: "command-code", - label: "Command Code - Auth", - adapter: "command-code", - baseUrl: "https://api.commandcode.ai", - authKind: "oauth", - oauthId: "command-code", - featured: true, - note: "Log in with your Command Code account", - // OAuth needs one initial selection, but the exposed catalog is always discovered from the - // signed-in account. Do not add a static model list here. - defaultModel: "deepseek/deepseek-v4-flash", - liveModels: true, - modelDiscovery: { - url: "https://api.commandcode.ai/provider/v1/models", - maxResponseBytes: 262_144, - maxModels: 256, - }, - // These are capability facts from official Command Code model profiles, not seeded models. - // Unknown/new live models deliberately do not advertise a reasoning picker. - reasoningEfforts: [], - modelReasoningEfforts: COMMAND_CODE_MODEL_REASONING_EFFORTS, - // The DeepSeek vision preview id is preemptive metadata — it is expected to - // merge into deepseek-v4-flash later. - modelContextWindows: { - [`deepseek/${DEEPSEEK_VISION_PREVIEW_MODEL}`]: 1_048_576, - }, - modelInputModalities: COMMAND_CODE_MODEL_INPUT_MODALITIES, - defaultMaxOutputTokens: 64_000, - // The proprietary generate wire has no verified per-request serialization flag. - parallelToolCalls: false, - }, - { - id: "orcarouter-oauth", - label: "OrcaRouter - Auth", - adapter: "openai-chat", - baseUrl: "https://api.orcarouter.ai/v1", - authKind: "oauth", - oauthId: "orcarouter-oauth", - featured: true, - allowBaseUrlOverride: true, - defaultModel: "openai/gpt-5.5", - models: ORCAROUTER_MODELS, - liveModels: true, - modelDiscovery: ORCAROUTER_MODEL_DISCOVERY, - modelReasoningEfforts: ORCAROUTER_MODEL_REASONING_EFFORTS, - note: "Connect your OrcaRouter account with OAuth 2.0 + PKCE; the issued API key is stored in OpenCodex's existing credential store.", - }, - { - id: "anthropic", - label: "Anthropic Claude", - adapter: "anthropic", - baseUrl: "https://api.anthropic.com", - authKind: "oauth", - allowBaseUrlOverride: true, - featured: true, - oauthId: "anthropic", - jawcodeBundle: "anthropic", - note: "Log in with your Claude account", - models: [...ANTHROPIC_MODELS], - modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, - modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, - // Codex omits max_output_tokens; without a provider budget the Anthropic adapter - // falls back to 8192, which truncates long answers with stop_reason=max_tokens. - defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, - defaultModel: "claude-sonnet-5", - }, - { - id: "anthropic-apikey", - label: "Anthropic (API key)", - adapter: "anthropic", - baseUrl: "https://api.anthropic.com", - authKind: "key", - featured: true, - dashboardUrl: "https://console.anthropic.com/settings/keys", - jawcodeBundle: "anthropic", - extraMetadataAliases: ["anthropic-key"], - note: "Direct Anthropic API billing — no Claude subscription", - models: [...ANTHROPIC_MODELS], - liveModels: true, - modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, - modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, - defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, - defaultModel: "claude-sonnet-5", - }, - { - id: "kimi", - label: "Kimi", - adapter: "openai-chat", - baseUrl: "https://api.kimi.com/coding/v1", - authKind: "oauth", - modelSuffixBracketStrip: true, - // Kimi Code Plan documents a stable session/task prompt_cache_key as required to improve - // cache hit rates. - // The chat adapter only forwards a key already on the internal request (Codex's session key, - // or the one the Claude /v1/messages inbound derives); the adapter itself never invents one. - // Evidence: https://platform.kimi.com/docs/api/chat - promptCacheKey: true, - featured: true, - oauthId: "kimi", - jawcodeBundle: "moonshot", - note: "Log in with your Kimi account", - models: KIMI_CODING_MODELS, - defaultModel: "kimi-k2.7-code", - modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS, - modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES, - // K3 accepts low/high/max; Codex aliases are normalized by the model-scoped wire map. - noReasoningModels: KIMI_CODING_NO_REASONING_MODELS, - modelReasoningEfforts: KIMI_CODING_REASONING_EFFORTS, - modelDefaultReasoningEfforts: KIMI_CODING_DEFAULT_REASONING_EFFORTS, - modelReasoningEffortMap: KIMI_CODING_REASONING_EFFORT_MAPS, - noTemperatureModels: KIMI_LOCKED_PARAMETER_MODELS, - noTopPModels: KIMI_LOCKED_PARAMETER_MODELS, - noPenaltyModels: KIMI_LOCKED_PARAMETER_MODELS, - autoToolChoiceOnlyModels: KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS, - preserveReasoningContentModels: KIMI_THINKING_MODELS, - }, - { - id: "kiro", - label: "Kiro (AWS CodeWhisperer)", - adapter: "kiro", - baseUrl: "https://runtime.us-east-1.kiro.dev", - authKind: "oauth", - oauthId: "kiro", - note: "Import-first: reuses your installed and signed-in Kiro CLI session (requires `kiro-cli login`). Add account logs `kiro-cli` out, switches it through a fresh browser login, stores the account by profile ARN, and restores the previous CLI session on cancellation or failure. Experimental third-party harness — see Kiro ToS.", - models: KIRO_MODELS, - defaultModel: "kiro-auto", - // Kiro speaks CodeWhisperer wire, not OpenAI-style GET /models. Keep the static - // catalog authoritative so a spurious 2xx from runtime.../models cannot drop seeded ids - // (e.g. newly listed GPT-5.6 tiers) via live-discovery reconciliation. - liveModels: false, - // Per-model context metadata is maintained next to the Kiro model list. - modelContextWindows: KIRO_MODEL_CONTEXT_WINDOWS, - modelReasoningEfforts: KIRO_MODEL_REASONING_EFFORTS, - modelSupportsVerbosity: Object.fromEntries(KIRO_MODELS.map(id => [id, false])), - }, - { - // Nous Portal — Nous Research subscription gateway (same backend Hermes Agent - // uses). OAuth is a device grant (src/oauth/nous.ts): the access token IS the - // per-request inference JWT (scope inference:invoke), refresh tokens are - // single-use and rotated on every refresh. Catalog is a mix of paid models - // (billed against the Portal subscription) and `:free` slugs (e.g. - // tencent/hy3:free, stepfun/step-3.7-flash:free, inclusionai/ling-3.0-flash:free); - // free-tier gating is decided live by the Portal per account, so discovery - // from the signed-in account is authoritative; the static seed below is the - // logged-out fallback and only lists free models verified on a real account - // (2026-08-10): the Portal free list is authoritative and currently has - // exactly 4 :free models: tencent/hy3:free, poolside/laguna-s-2.1:free, - // stepfun/step-3.7-flash:free, poolside/laguna-xs-2.1:free. - // inclusionai/ling-3.0-flash:free was removed from the Portal free list - // (404 on the inference API since 2026-08-07) and must not be seeded. - id: "nous", - label: "Nous Portal", - adapter: "openai-chat", - baseUrl: "https://inference-api.nousresearch.com/v1", - authKind: "oauth", - oauthId: "nous", - featured: true, - // Mixed free + paid provider: the free tier is per-model (the `:free` - // slugs), not a property of the whole provider, so freeTier stays false to - // avoid implying every model is free. - freeTier: false, - dashboardUrl: "https://portal.nousresearch.com", - defaultModel: "tencent/hy3:free", - liveModels: true, - models: ["tencent/hy3:free", "poolside/laguna-s-2.1:free", "stepfun/step-3.7-flash:free", "poolside/laguna-xs-2.1:free"], - modelDiscovery: { - // Resolves against effectiveBaseUrl (registry baseUrl .../v1) to the same - // canonical endpoint https://inference-api.nousresearch.com/v1/models. - // Nous returns a mixed paid/free catalog whose JSON can exceed 256 KiB; - // keep the provider-specific limit below the process-wide 4 MiB ceiling. - path: "models", - maxResponseBytes: 1_048_576, - maxModels: 512, - }, - note: "Nous Research subscription gateway. OAuth device login with your own Portal account; mixed paid + :free models discovered live (fallback seed 2026-08-10: tencent/hy3:free, poolside/laguna-s-2.1:free, stepfun/step-3.7-flash:free, poolside/laguna-xs-2.1:free).", - }, - { - id: "openai-apikey", - label: "OpenAI API", - adapter: "openai-responses", - baseUrl: "https://api.openai.com/v1", - authKind: "key", - supportsServiceTier: true, - featured: true, - dashboardUrl: "https://platform.openai.com/api-keys", - defaultModel: "gpt-5.5", - models: ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, ...OPENAI_DAYBREAK_MODELS, "gpt-6-astra"], - liveModels: true, - modelContextWindows: { ...OPENAI_API_GPT56_CONTEXT_WINDOWS, ...OPENAI_DAYBREAK_CONTEXT_WINDOWS, "gpt-6-astra": 1_050_000 }, - modelMaxInputTokens: { ...OPENAI_API_GPT56_MAX_INPUT_TOKENS, ...OPENAI_DAYBREAK_MAX_INPUT_TOKENS, "gpt-6-astra": 922_000 }, - modelMaxOutputTokens: { "gpt-6-astra": 128_000 }, - modelInputModalities: Object.fromEntries( - ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, ...OPENAI_DAYBREAK_MODELS, "gpt-6-astra"] - .map(id => [id, ["text", "image"]]), - ), - modelReasoningEfforts: { - ...Object.fromEntries( - [...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS].map(id => [id, OPENAI_API_GPT56_REASONING_EFFORTS]), - ), - ...OPENAI_DAYBREAK_REASONING_EFFORTS, - "gpt-6-astra": ["low", "medium", "high", "xhigh", "max"], - }, - virtualModels: OPENAI_API_GPT56_VIRTUAL_MODELS, - }, - /* [Decision Log] - - 목적과 의도: Reach Meta's Muse Spark models directly on Meta's own Model API, instead of only through the Command Code and OpenCode Zen resellers already in this registry. - - 기존 구현 및 제약 조건: Meta publishes both POST /v1/responses and POST /v1/chat/completions at https://api.meta.ai/v1, and no API key was issued for this change — every value here comes from the published spec (devlog/_plan/260903_muse_spark_plan_oauth/001). - - 검토한 주요 대안: register as openai-chat; use provider id "meta"; enable live discovery; wire the Muse Code subscription credential as OAuth. - - 선택한 방식: an openai-responses key provider under the id "meta-model", with a static two-model roster and no OAuth. - - 다른 대안 대신 이 방식을 선택한 이유: Meta calls Responses "the recommended default for new work ... OpenAI-compatible and exposes the full feature set", carrying reasoning replay and native input_image that Chat would forfeit. The id is "meta-model" because "meta" would capture the LIVE Command Code selector meta/muse-spark-1.3 at router.ts's provider-prefix branch, and would derive META_API_KEY — the Muse Code CLI's variable, not this API's MODEL_API_KEY. - - 장점, 단점 및 영향: users reach Muse Spark without a reseller; discovery stays off until an authenticated /v1/models payload is actually observed, so an unseen roster (Meta also serves image and voice families here) cannot leak into the picker. - */ - { - id: "meta-model", - label: "Meta Model API", - adapter: "openai-responses", - baseUrl: "https://api.meta.ai/v1", - authKind: "key", - dashboardUrl: "https://dev.meta.ai/docs/authentication", - defaultModel: "muse-spark-1.3", - models: META_MUSE_MODELS, - // Static roster: no authenticated /v1/models payload was ever observed (the only - // contact was an unauthenticated GET returning 401 invalid_api_key), and Meta serves - // non-agent families on this same base URL. Turning discovery on would publish an - // unseen roster into the picker. - liveModels: false, - // A user may already own a custom provider named "meta-model" pointing elsewhere; - // without this, registry transport canonicalization would retarget it and send their - // saved key to Meta. - preserveCustomDestination: true, - modelContextWindows: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_CONTEXT_WINDOW])), - // text+image only. Meta also documents video, audio (degraded on 1.3), and PDF, but - // the catalog modality enum is text/image and over-advertising poisons the exported - // client config (see tests/codex-integration/catalog-input-modality-enum.test.ts). - modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])), - modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])), - modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])), - // No defaultMaxOutputTokens: Meta publishes none. The only number in its docs - // (131072) appears inside a third-party config sample, and the protocol pages call - // the real limit "model-dependent". - // Meta names its variable MODEL_API_KEY, but the env var opencodex reads is derived - // from the provider id (META_MODEL_API_KEY). Saying only Meta's name would send a - // user to export a variable this proxy never reads. - note: "Pay-as-you-go Meta Model API. Get a key at https://dev.meta.ai (Meta calls it MODEL_API_KEY; export it here as META_MODEL_API_KEY) — a Meta developer account needs a payment method before it can serve requests, and every call is metered per token. A Muse Code subscription does NOT work here: Meta scopes that credential to the Muse Code CLI and bills any other key pay-as-you-go (dev.meta.ai/docs/muse-code/subscriptions). The Contributor tier (muse-spark-1.3-contributor) is cheap because Meta trains on your prompts — about 92% off input, 95% off output, 99% off cached input; do not send confidential material through it. Muse Spark is also reachable through resellers: command-code carries both tiers, opencode-go serves only muse-spark-1.3-contributor.", - }, - /* [Decision Log] - - 목적과 의도: Let an operator who already signed the Muse Code CLI in reach Muse Spark with that credential, instead of provisioning a second key. - - 기존 구현 및 제약 조건: The CLI stores a pointer at ~/.config/muse/auth.json and the secret in the macOS Keychain (ai.meta.dev.credentials/meta). Measured: the OAuth access_token 401s on /v1/models while the sibling api_key returns 200, so the usable artifact is a static key, not a refreshable token. - - 검토한 주요 대안: spawn `muse login` and poll; reimplement Meta's device grant; treat it as a second key preset; ship nothing. - - 선택한 방식: an OAuth provider that imports the existing credential on macOS and accepts a pasted key elsewhere, validates either once, and never spawns or reimplements anything. - - 다른 대안 대신 이 방식을 선택한 이유: `muse login` has no non-interactive mode, so a spawned child could outlive cancellation, and polling for the pointer file is satisfied instantly by the one already on disk — reimporting the OLD account on a force-login. Reimplementing the grant would mean guessing a client id the vendor does not publish. - - 장점, 단점 및 영향: no new credential to provision, and the id is distinct from meta-model so neither pool contaminates the other. Meta scopes this credential to its own CLI, so the provider carries a HIGH_RISK ToS warning, a CLI-side warning before any read, and a note that says plainly what is unsupported. - */ - { - id: "meta-muse", - label: "Meta Muse Code (CLI credential)", - adapter: "openai-responses", - baseUrl: "https://api.meta.ai/v1", - // Meta own client sends this on every Muse Code call. We never have, so a future - // server-side requirement would break every Muse request with no local signal. - // Declared here rather than in a transport hook so it also covers model discovery - // (src/oauth/index.ts:1176) and still yields to a user-set header - // (mergeRegistryStaticHeaders, src/providers/registry.ts:3494). - staticHeaders: { "x-api-version": "1.0.0" }, - authKind: "oauth", - oauthId: "meta-muse", - dashboardUrl: "https://dev.meta.ai", - defaultModel: "muse-spark-1.3", - models: META_MUSE_MODELS, - // Same reason as meta-model: the authenticated roster carries muse-image-1.0 and - // muse-voice-transcribe-1.0, which this Responses-agent provider cannot drive. - liveModels: false, - modelContextWindows: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_CONTEXT_WINDOW])), - modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])), - modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])), - modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])), - note: "Signs in to Meta with a browser device code on any platform, then mints the Muse Code subscription key. That grant is reimplemented from the one the Muse Code CLI performs and has NOT been exercised against Meta from OpenCodex, so treat the first login as unverified. If the Muse Code CLI is already signed in on macOS, the existing key is imported instead of starting a new grant. A pasted key from https://dev.meta.ai still works as a fallback when a device login cannot complete, and faces the same format check and live validation. A device login authenticates as Meta own Muse Code client, which is a stronger claim than reusing a key the CLI already minted. Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The key, imported or pasted, is copied into OpenCodex's auth store. For an account signed in with the device login, OpenCodex refreshes Meta's subscription windows on demand from the same key endpoint the login uses, at most once every five minutes. For an imported or pasted key there is no endpoint to query them on demand, so OpenCodex reads them from streaming responses and shows the last observed value with its age; refreshing one then requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", - }, - { - id: "umans", - label: "Umans AI Coding Plan", - adapter: "anthropic", - baseUrl: "https://api.code.umans.ai", - authKind: "key", - featured: true, - dashboardUrl: "https://app.umans.ai/billing", - defaultModel: "umans-coder", - models: UMANS_MODELS, - modelContextWindows: UMANS_MODEL_CONTEXT_WINDOWS, - modelInputModalities: UMANS_MODEL_INPUT_MODALITIES, - note: "Coding plan via Anthropic Messages", - modelReasoningEfforts: { - "umans-coder": UMANS_REASONING_EFFORTS, - "umans-kimi-k2.7": UMANS_REASONING_EFFORTS, - "umans-flash": UMANS_REASONING_EFFORTS, - "umans-glm-5.3": UMANS_GLM_53_REASONING_EFFORTS, - "umans-glm-5.3-flash": UMANS_GLM_53_REASONING_EFFORTS, - "umans-glm-5.2": UMANS_GLM_REASONING_EFFORTS, - "umans-glm-5.1": UMANS_GLM_REASONING_EFFORTS, - "umans-qwen3.6-35b-a3b": UMANS_REASONING_EFFORTS, - }, - noVisionModels: UMANS_TEXT_ONLY_MODELS, - escapeBuiltinToolNames: true, - }, - { - id: "opencode-go", label: "opencode go", adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", - authKind: "key", featured: true, dashboardUrl: "https://opencode.ai/auth", defaultModel: "kimi-k2.7-code", - jawcodeBundle: "opencode-go", note: "GLM, DeepSeek, Kimi, Qwen, MiMo…", - // Zen Go can close a Chat stream after a fully assembled function call without sending - // finish_reason or [DONE] (#2260). The adapter still rejects incomplete argument JSON. - openaiChatEofTolerance: true, - // Go rejects reasoning.encrypted_content with previous_response_id (#3838). - // Use explicit replay history and the existing stateless Responses policy. - statelessResponses: true, - /* [Decision Log] - - 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, Grok 4.6, and Muse Spark Contributor (#2617). - - 기존 구현 및 제약 조건: The provider is mixed-wire but its provider-wide `openai-chat` adapter sent Luna to `/chat/completions`; explicit user `modelAdapters` entries must remain authoritative. - - 검토한 주요 대안: Change the whole provider to Responses; infer the wire from model-family names; add one registry-only exact-model default. - - 선택한 방식: Declare only the named models as `openai-responses` through the existing registry default mechanism; the map stays an exact-model allowlist rather than a family or provider-wide rule. - - 다른 대안 대신 이 방식을 선택한 이유: OpenCode Go documents sibling models on Chat or Anthropic endpoints, and an exact registry default preserves both those routes and explicit opt-out precedence. - - 장점, 단점 및 영향: Each listed model reaches `/responses` from every inbound surface without changing siblings; a future upstream endpoint change requires an evidence-backed registry update. - */ - modelWireDefaults: { - "gpt-5.6-luna": "openai-responses", - "grok-4.6": "openai-responses", - "muse-spark-1.3-contributor": "openai-responses", - "muse-spark-1.2-contributor": "openai-responses", - }, - modelContextWindows: { - "kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW, - // Zen Go discovers only the gateway id, so carry DeepSeek's official 1M V4.1 - // window here or Codex falls back to its conservative 128k routed-model default. - "deepseek-v4.1-flash": 1_048_576, - // The DeepSeek vision preview id is metadata-only here: the Go roster is - // discovered live, so it applies the moment the gateway serves the id. - [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576, - // Muse Spark Contributor serves a 1,048,576-token (1M) context window over - // /responses on Zen Go, matching its 1.1 sibling (Meta developer docs, verified 2026-08-28). - // Without this declaration the catalog falls back to 128k, capping real usable context. - // 1.3 ships the same window as 1.2 and is served from the same Zen Go roster. - "muse-spark-1.3-contributor": 1_048_576, - "muse-spark-1.2-contributor": 1_048_576, - }, - modelInputModalities: { - "kimi-k3": ["text", "image"], - // glm-5.3-flash is a native VLM (docs.z.ai/guides/vlm/glm-5.3-flash). It is - // deliberately absent from this preset's noVisionModels, which is the - // correct NEGATIVE half, but with no positive modelInputModalities entry - // configuredInputModalities returns undefined and the catalog falls through - // to the ["text"] floor. The same model is already declared ["text","image"] - // on the zai and zhipu-bigmodel-coding presets, so the registry described - // one model two ways (#4505). - "glm-5.3-flash": ["text", "image"], - // Experimental DeepSeek vision preview — expected to merge into deepseek-v4-flash later. - [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], - // This route is text-only upstream — it is already listed in this preset's - // noVisionModels, which routes images through the proxy's vision sidecar and - // makes the catalog advertise image input on its behalf. The positive - // text-only declaration is what reaches an EXISTING install: derive.ts fills - // noVisionModels all-or-nothing, so a config persisted before this id joined - // the list keeps a stale list, the sidecar predicate never matches, the row - // carries no modality at all, and any combo containing it collapses to - // ["text"] (#4505). modelInputModalities IS per-key filled, so this - // declaration lands on old configs. It states the route's real upstream - // capability and keeps the sidecar explicitly distinct from native vision. - "deepseek-v4.1-flash": ["text"], - // Muse Spark Contributor is natively multimodal on Zen Go: it accepts input_image - // parts over /responses (probed 2026-08-26). Without this declaration the catalog - // advertises it text-only and the Codex app blocks image attachments client-side with - // "This model does not support image inputs" before the request ever reaches the proxy. - // 1.3 is the same-shaped successor and Command Code documents it as multimodal. - "muse-spark-1.3-contributor": ["text", "image"], - "muse-spark-1.2-contributor": ["text", "image"], - }, - modelReasoningEfforts: { - "gpt-5.6-luna": OPENAI_API_GPT56_REASONING_EFFORTS, - "grok-4.6": ["low", "medium", "high", "xhigh"], - "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, - "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, - "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, - "qwen3.8-max": QWEN38_REASONING_EFFORTS, - "kimi-k3": KIMI_CODING_K3_REASONING_EFFORTS, - "kimi-k2.7-code": [], - "kimi-k2.7-code-highspeed": [], - ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS])), - ...Object.fromEntries(OPENCODE_GO_THINKING_BUDGET_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])), - ...Object.fromEntries(DEEPSEEK_GATEWAY_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), - }, - modelDefaultReasoningEfforts: { "grok-4.6": "high", "kimi-k3": "max" }, - // glm-5.2 uses identity labels now that `max` is a native Codex level (no alias map); - // the thinking-toggle map is a REAL wire alias (effort -> enabled/disabled) and stays. - modelReasoningEffortMap: { - "kimi-k3": KIMI_CODING_K3_REASONING_EFFORT_MAP, - ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])), - ...Object.fromEntries(DEEPSEEK_GATEWAY_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), - }, - modelSupportsReasoningSummaries: { - "glm-5.3": true, - "glm-5.3-flash": true, - "glm-5.2": true, - "glm-5.1": true, - "glm-5": true, - ...Object.fromEntries(DEEPSEEK_GATEWAY_THINKING_MODELS.map(id => [id, true])), - }, - thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS, - /* - * The Go-specific list, not the shared one. The shared `THINKING_BUDGET_MODELS` also - * carries Neuralwatt-only ids (`qwen3.5-397b`, `qwen3.6-35b`) that this preset never - * gives a ladder to, so a live roster serving one of them armed the thinking-budget - * wire path with nothing to advertise: the catalog showed no effort control while the - * adapter still translated effort into `thinking_budget`. - */ - thinkingBudgetModels: OPENCODE_GO_THINKING_BUDGET_MODELS, - noReasoningModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], - // Text-only Zen Go models (jawcode metadata) — the vision sidecar describes images for - // every model listed here (and the catalog advertises image input on their behalf). - // Kimi K2.7 Code accepts text+image+video: do NOT list it here. - noVisionModels: [ - "glm-5.3", "glm-5.2", "glm-5", "glm-5.1", - "deepseek-v4.1-flash", "deepseek-v4-flash", - "mimo-v2-pro", "mimo-v2.5-pro", - "minimax-m2.5", "minimax-m2.7", - "qwen3.7-max", - ], - noTemperatureModels: ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"], - noTopPModels: ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"], - noPenaltyModels: ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"], - autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], - // Issue #78: DeepSeek V4 thinking mode requires reasoning_content replay on tool-call turns. - preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", ...DEEPSEEK_GATEWAY_THINKING_MODELS], - /* - * Issues #1338 / #1415: this gateway answers a `response_format` of type - * `json_schema` with HTTP 400 `This response_format type is unavailable now` - * (quoted from the upstream body as `Error from provider (Console Go)`), which - * breaks every Codex auto-review turn on a DeepSeek route. #1424 shipped the - * operator-side opt-out; operators have been applying it by hand ever since. - * The reported rejection is type-specific, so this narrower list downgrades the - * request to `json_object` instead of claiming the whole field is unavailable. - */ - noJsonSchemaModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS], - }, - { - id: "neuralwatt", - label: "Neuralwatt Cloud", - adapter: "openai-chat", - baseUrl: "https://api.neuralwatt.com/v1", - authKind: "key", - dashboardUrl: "https://portal.neuralwatt.com", - defaultModel: "glm-5.3", - // 2026-07-10 live /v1/models: K2.5 rows were removed and GLM-5.2 short variants added. - // 260814: the glm-5.3 quartet is speculative; live discovery is authoritative and drops - // any id Neuralwatt has not published yet. - // Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md and https://api.neuralwatt.com/v1/models. - models: [ - "glm-5.3", "glm-5.3-fast", "glm-5.3-short", "glm-5.3-short-fast", - "glm-5.3-flash", - "glm-5.2", "glm-5.2-fast", "glm-5.2-short", "glm-5.2-short-fast", - "kimi-k2.6", "kimi-k2.6-fast", - "kimi-k2.7-code", - "qwen3.5-397b", "qwen3.5-397b-fast", "qwen3.6-35b", "qwen3.6-35b-fast", - ], - // Neuralwatt's /v1/models metadata is authoritative; these static hints are the offline fallback. - modelReasoningEfforts: { - "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, - "glm-5.3-fast": [], - "glm-5.3-short": ZAI_GLM_53_REASONING_EFFORTS, - "glm-5.3-short-fast": [], - // No `-fast`/`-short` variants are asserted for the flash tier: those suffixes - // encode routing Neuralwatt documents per model, and this seed has no source for them. - "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, - "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, - "glm-5.2-fast": [], - "glm-5.2-short": ZAI_GLM_52_REASONING_EFFORTS, - "glm-5.2-short-fast": [], - "kimi-k2.6": [], - "kimi-k2.6-fast": [], - "kimi-k2.7-code": [], - // Qwen3.x uses thinking_budget, NOT graded reasoning_effort; the adapter maps the five - // Codex picker levels onto budget fractions. - "qwen3.5-397b": THINKING_BUDGET_EFFORTS, - "qwen3.5-397b-fast": [], - "qwen3.6-35b": THINKING_BUDGET_EFFORTS, - "qwen3.6-35b-fast": [], - }, - thinkingBudgetModels: THINKING_BUDGET_MODELS, - noReasoningModels: ["glm-5.3-fast", "glm-5.3-short-fast", "glm-5.2-fast", "glm-5.2-short-fast", "kimi-k2.6-fast", "qwen3.5-397b-fast", "qwen3.6-35b-fast"], - noVisionModels: ["glm-5.3", "glm-5.3-fast", "glm-5.3-short", "glm-5.3-short-fast", "glm-5.2", "glm-5.2-fast", "glm-5.2-short", "glm-5.2-short-fast", "qwen3.5-397b", "qwen3.5-397b-fast"], - noTemperatureModels: ["kimi-k2.7-code"], - noTopPModels: ["kimi-k2.7-code"], - noPenaltyModels: ["kimi-k2.7-code"], - autoToolChoiceOnlyModels: ["kimi-k2.7-code"], - preserveReasoningContentModels: NEURALWATT_REASONING_HISTORY_MODELS, - }, - { - id: "openrouter", - label: "OpenRouter", - adapter: "openai-chat", - baseUrl: "https://openrouter.ai/api/v1", - authKind: "key", - featured: true, - dashboardUrl: "https://openrouter.ai/keys", - jawcodeBundle: "openrouter", - models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], - modelContextWindows: { - "anthropic/claude-sonnet-5": 1_000_000, - ...OPENROUTER_GPT56_CONTEXT_WINDOWS, - }, - // OpenRouter documents priority support for OpenAI endpoints, but not Anthropic. Keep the - // provider unclassified and opt in only the exact OpenAI-backed slugs we ship. These facts - // belong only to the canonical destination; a same-named custom gateway is unknown to us. - modelServiceTierCapabilityBaseUrlGuard: isCanonicalOpenRouterTarget, - modelSupportsServiceTier: { - "openai/gpt-5.6-sol": true, - "openai/gpt-5.6-terra": true, - "openai/gpt-5.6-luna": true, - }, - // Deliberately no OpenRouter route pin: it bills the endpoint actually used and reports the - // actual service_tier. B0 confirmation therefore owns downgrade safety. Forcing `only` plus - // `allow_fallbacks:false` would turn a graceful priority-capacity fallback into a hard failure. - }, - { - // Primary sources checked 2026-08-02: - // - docs.cline.bot/getting-started/clinepass publishes this exact catalog and explicitly - // authorizes using the full slugs through Cline's external API. - // - docs.cline.bot/api/chat-completions and /api/errors define the endpoint, reasoning delta, - // and choice-scoped mid-stream error contract. - // - Cline's official catalog source resolves per-model capabilities through OpenRouter data; - // the static context/modality snapshot below was cross-checked against that catalog. - // - cline.bot/tos identifies Cline Bot Inc. as the operator. Maintenance owner: @lidge-jun. - id: "cline-pass", - label: "ClinePass", - adapter: "openai-chat", - baseUrl: "https://api.cline.bot/api/v1", - authKind: "key", - dashboardUrl: "https://app.cline.bot", - defaultModel: "cline-pass/kimi-k3", - models: CLINE_PASS_MODELS, - modelContextWindows: CLINE_PASS_MODEL_CONTEXT_WINDOWS, - modelInputModalities: CLINE_PASS_MODEL_INPUT_MODALITIES, - noVisionModels: CLINE_PASS_TEXT_ONLY_MODELS, - // Live-probed 2026-08-13 across every static ClinePass model: the gateway accepts and - // validates low/medium/high/xhigh/max, and rejects an invalid sentinel. Preserve the - // caller's requested tier and let ClinePass own any backend-specific normalization. - reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], - reasoningWireFormat: "gateway-object", - preserveCustomDestination: true, - note: "ClinePass subscription API. Uses a Cline API key and the full cline-pass/ upstream slug; quota is shared across the account's rolling 5-hour, weekly, and monthly limits.", - }, - // Cline API (usage-billing): OpenAI-compatible Chat Completions. Model IDs follow the - // OpenRouter-style `provider/model` convention. Live /models discovery is key-gated (401 - // without auth), so the static seed is the cold-start fallback. Evidence: docs.cline.bot/api/*. - { - id: "cline", - label: "Cline", - adapter: "openai-chat", - baseUrl: "https://api.cline.bot/api/v1", - authKind: "key", - dashboardUrl: "https://app.cline.bot", - liveModels: true, - defaultModel: "anthropic/claude-sonnet-4-6", - models: [ - "anthropic/claude-sonnet-4-6", - "openai/gpt-4o", - "google/gemini-2.5-pro", - "deepseek/deepseek-chat", - "minimax/minimax-m2.5", - ], - preserveCustomDestination: true, - note: "Cline usage-billing API: one key, 100+ models, OpenRouter-style ids. Promotional free models are IDE/CLI-only per Cline docs; minimax/minimax-m2.5 is the documented API free experimentation model.", - }, - { - // OrcaRouter: OpenAI-compatible adaptive router (api.orcarouter.ai). The public live - // catalog is authoritative; model ids and input modalities are never maintained here. - id: "orcarouter", label: "OrcaRouter - API", adapter: "openai-chat", baseUrl: "https://api.orcarouter.ai/v1", - authKind: "key", dashboardUrl: "https://www.orcarouter.ai/console", - // The catalog is public, so a successful /models probe cannot validate a submitted key. - apiKeyValidation: "unknown", - // Standard sponsor under SPONSORS.md (agreement signed 2026-09-07). Pins the row in the - // picker and adds the chip; nothing about routing or defaults changes. - sponsor: { tier: "standard", url: "https://www.orcarouter.ai/?utm_source=opencodex&utm_medium=readme" }, - defaultModel: "openai/gpt-5.5", - models: ORCAROUTER_MODELS, - liveModels: true, - modelDiscovery: ORCAROUTER_MODEL_DISCOVERY, - // Catalog discovery owns WHICH models exist. These entries only retain verified - // request-shaping facts that the upstream catalog does not currently publish. - modelReasoningEfforts: ORCAROUTER_MODEL_REASONING_EFFORTS, - note: "OpenAI-compatible adaptive router. Models and multimodal capabilities are discovered live from the public chat catalog. Use the OrcaRouter account entry for PKCE login.", - }, - { - // PackyCode: API relay (packyapi.com) for Claude Code, Codex, Gemini and more. Codex traffic - // uses the OpenAI-compatible host from their Codex/Kimi Code guides (docs.packyapi.com): - // https://cf.api.fan/v1 — GET /v1/models answers 401 without a key, so the host is live and - // discovery narrows to what the key's token group allows. Model ids are bare OpenAI-style - // ids (the Codex token group lists gpt-5.5 / gpt-5.1-codex). - // Standard sponsor under SPONSORS.md; the dashboardUrl carries their affiliate code. - id: "packycode", label: "PackyCode", adapter: "openai-chat", baseUrl: "https://cf.api.fan/v1", - authKind: "key", dashboardUrl: "https://www.packyapi.com/register?aff=k5KT", - sponsor: { tier: "standard", url: "https://www.packyapi.com/register?aff=k5KT" }, - defaultModel: "gpt-5.5", - models: ["gpt-5.5", "gpt-5.1-codex"], - liveModels: true, - // New key preset: opt into collision preservation so a row named `packycode` that a user - // points at a different PackyCode host keeps its own destination instead of being pulled - // back onto the Codex endpoint below. - preserveCustomDestination: true, - note: "API relay for Claude Code, Codex, Gemini and more. Create a Codex-group token at packyapi.com; live discovery lists what the token group allows.", - }, - { - // BizRouter: Korean enterprise LLM gateway (api.bizrouter.ai). Model ids are - // vendor-namespaced (`/`) and pass through to the upstream as-is. - // Live-verified 2026-07-24: /v1/chat/completions accepts the `tools` field and - // streams, and GET /v1/models returns the per-API-key allowed catalog in the - // OpenAI list shape, so live model discovery narrows to what the key can use. - id: "bizrouter", label: "BizRouter", adapter: "openai-chat", baseUrl: "https://api.bizrouter.ai/v1", - authKind: "key", dashboardUrl: "https://bizrouter.ai/settings/keys", - defaultModel: "openai/gpt-5.6-sol", - models: ["openai/gpt-5.6-sol", "anthropic/claude-sonnet-5", "google/gemini-3.5-flash"], - note: "Korean enterprise LLM gateway. Per-key allowed models are discovered live from /v1/models. Full catalog: https://bizrouter.ai/models", - }, - { id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys" }, - // 2026-07-10 Gemini API refresh: Tier-2 ai.google.dev evidence recorded in - // devlog/_plan/260710_provider_hardening/001_research_frontier.md. - { - id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true, - dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3.5-flash", models: ["gemini-3.8-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview", "gemini-3.7-flash"], - modelContextWindows: { "gemini-3.8-flash": 1_048_576, "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_000_000, "gemini-3.5-flash-lite": 1_048_576, "gemini-3.7-flash": 1_048_576 }, - modelInputModalities: { "gemini-3.8-flash": ["text", "image"], "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"], "gemini-3.7-flash": ["text", "image"] }, - modelReasoningEfforts: { - // 3.7 and 3.8 omit `minimal`: Google documents it as a validation error on both model - // pages, so advertising it hands the user a rung the API rejects. 3.5/3.6 keep theirs — - // their pages still list it, and this unit has no evidence to change them. - "gemini-3.8-flash": ["low", "medium", "high"], - "gemini-3.7-flash": ["low", "medium", "high"], - "gemini-3.6-flash": ["minimal", "low", "medium", "high"], - "gemini-3.5-flash": ["minimal", "low", "medium", "high"], - "gemini-3.1-pro-preview": ["low", "medium", "high"], - }, - jawcodeBundle: "google", extraMetadataAliases: ["gemini"], - }, - // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API - // evidence from ai.google.dev does not establish Vertex publisher availability. - { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] }, - // Antigravity discovers models with a POST to the CCA `:fetchAvailableModels` RPC, which - // `buildModelsRequest` already built by hand. Declaring it here changes no request URL — the - // relative path resolves to the same destination — but it lets `isRegistryModelDiscoveryUrl` - // prove that URL, which is what admits a Clash/Surge/Mihomo TUN fake-IP answer (#4261). The - // path must stay RELATIVE: this row sets `allowBaseUrlOverride`, and an absolute `url` would - // retarget a user's custom base back to Google. A leading `./` is required because a bare - // `v1internal:` reads as a URL scheme and `providerModelDiscoverySpecError` rejects it. - { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", showThinkingSummary: true, jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, - { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, - { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, - { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, - { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" }, - { - id: "deepseek", - label: "DeepSeek", - baseUrl: "https://api.deepseek.com", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://platform.deepseek.com/api_keys", - // Route DeepSeek's own catalog bundle so routed rebuilds restore the official - // context window from the vendored model-metadata bundle instead of falling - // back to the 128k strict-fields default (scripts/model-metadata.source.json, - // verified 2026-08-08). - jawcodeBundle: "deepseek", - // deepseek-chat/deepseek-reasoner were deprecated upstream on 2026-07-24 15:59 UTC; - // the current official identifier is deepseek-flash. They stay in - // the list only as compatibility aliases so existing saved configs and requests - // keep validating and routing (they previously mapped to v4-flash; devlog - // _fin/260710_provider_hardening/002_research_cn.md). The current offerings are - // V4.1-Flash — defaultModel and the model-specific wiring below use its live id. - // Keep the legacy vision-preview alias; see DEEPSEEK_VISION_PREVIEW_MODEL. - models: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_NATIVE_THINKING_MODELS, DEEPSEEK_VISION_PREVIEW_MODEL], - // V4.1-Flash is the current first-party offering; `deepseek-v4-flash` now routes there - // as a compatibility alias, so a new install should ask for the live id by name. - defaultModel: "deepseek-flash", - // Official DeepSeek Codex setup (codex-deepseek-setup.sh) advertises 1,048,576 - // for both V4 models; the older 1,000,000 figure was a rounded approximation. - modelContextWindows: { "deepseek-flash": 1_048_576, "deepseek-v4-flash": 1_048_576, [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576 }, - modelInputModalities: { - "deepseek-flash": ["text", "image"], - [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], - }, - // DeepSeek documents both V4 models as native Responses API models adapted for Codex - // (model table marks Responses API ✓ for flash and pro; the /responses reference lists - // both ids as accepted `model` values — verified 2026-08-13 with the V4 Pro GA, - // version label DeepSeek-V4-Pro-0813). - modelWireDefaults: { - // Codex speaks Responses natively and DeepSeek ships a Codex-compatible - // apply_patch tool on that wire, so a Responses inbound goes straight out with - // no translation. Claude Code and OpenAI-compatible clients keep the - // provider-wide Chat wire: DeepSeek serves Chat Completions natively too, so - // translating them into Responses would add a hop onto our newest upstream path - // for no gain. - "deepseek-v4-flash": { wire: "openai-responses", inbound: ["responses"] }, - // Same Responses contract as the V4 ids it succeeds; without this row the new - // default would fall back to the provider-wide Chat wire. - "deepseek-flash": { wire: "openai-responses", inbound: ["responses"] }, - }, - // The #875-era bounded-JSON force (`modelResponsesUpstreamStreaming`) is retired - // for this entry: the official guide documents a `response.completed` / - // `response.incomplete` / `response.failed` terminal with NO `data: [DONE]` - // sentinel, and live probes (2026-08-07, including the tool-result replay shape - // that originally stalled) close on the terminal. The relay's terminal boundary - // (src/server/relay.ts) already cuts the stream at that event and synthesizes - // `[DONE]`, so forcing stream:false only delayed every byte until generation - // finished (28-46 s of silence on long turns). The registry knob itself remains - // for providers that need it — re-adding one line here restores the old policy. - // Evidence: https://api-docs.deepseek.com/guides/responses_api/ + - // devlog/_fin/260807_deepseek_responses_streaming/000_plan.md. - // Current official streams normally carry a real terminal; retain a narrow grace - // repair for the historical shape that closes after a complete graph without one. - modelResponsesTerminalRepair: { "deepseek-flash": { graceMs: 5_000 }, "deepseek-v4-flash": { graceMs: 5_000 } }, - // DeepSeek's Responses route emits bare UUID item ids, which leave Codex - // clients stuck on an uncommitted turn (#938). Client-facing only — raw - // continuation snapshots keep the upstream ids. - responsesItemIdRepair: { repairInvalidIds: true, repairMissingTerminalIds: true }, - // DeepSeek's Responses route is `POST /responses` with no `/v1` segment. Without - // this the passthrough adapter falls back to its legacy `/v1/responses` - // construction and the wire above can never route. - // Evidence: https://api-docs.deepseek.com/api/create-response/ - responsesPath: "/responses", - // DeepSeek's Responses reference does not list `service_tier`; unsupported - // parameters are documented as silently ignored, but the fail-closed policy - // strips the field rather than forwarding a knob the upstream never asked for. - supportsServiceTier: false, - // DeepSeek's Responses compatibility guide accepts plaintext reasoning items and - // merges them into the adjacent assistant message, so replayed reasoning must - // not be blanked the way the ChatGPT backend requires. (Whether the Responses - // route REQUIRES replay on tool-call continuations is an inference from the - // Chat Thinking-Mode docs, not a confirmed Responses contract.) - preserveResponsesReasoningContent: true, - // "The API is stateless: responses and conversations are not stored on the - // server." https://api-docs.deepseek.com/api/create-response/ - statelessResponses: true, - // DeepSeek rejects a valid Codex continuation when hook-provided developer - // context splits a call from its result (#1292); parallel calls remain one - // reasoning-bearing assistant batch rather than being split per pair (#1477). - requiresAdjacentResponsesToolResults: true, - // DeepSeek exec tool results can be present-but-empty (a script that ran without - // calling text(...)); annotate them so routed models do not silently accept an - // empty result or re-issue the same call. - annotateEmptyToolOutputs: true, - /* [Decision Log] - - 목적: DeepSeek V4 thinking mode multi-turn/tool-call requests must replay prior assistant reasoning_content. - - 대안 분석: Globally preserve reasoning_content for all OpenAI-compatible models; preserve it for legacy deepseek-reasoner too; mark only V4 thinking models in registry metadata. - - 선택 근거: DeepSeek V4 thinking mode requires history replay, while older DeepSeek reasoner has different compatibility rules. A model-scoped registry flag fixes built-in and stale saved configs without broad provider regressions. - */ - modelReasoningEfforts: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), - modelReasoningEffortMap: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), - modelSupportsReasoningSummaries: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, true])), - preserveReasoningContentModels: DEEPSEEK_NATIVE_THINKING_MODELS, - // #4436: first-party deepseek-flash accepts native images on Chat and Responses. - // Keep unprobed compatibility aliases on the #88 sidecar path. This must be fixed - // here: router enrichment unions this list with saved config, so config cannot remove it. - noVisionModels: ["deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash"], - }, - // llama-3.3-70b was deprecated by Cerebras on 2026-02-16. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. - { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b" }, - { - // Primary sources checked 2026-08-08: - // - https://chutes.ai/pricing documents the shared llm.chutes.ai/v1 OpenAI-compatible - // gateway, Bearer API keys, and chat completions. Its public - // https://llm.chutes.ai/v1/models response supplies supported_features for filtering. - // - https://chutes.ai/terms identifies Chutes Global Corp as the platform operator, applies - // to API consumers, and directs production/high-volume automated inference to PAYGO. - // Maintainer: @olddonkey; no affiliation with Chutes. - id: "chutes", - label: "Chutes", - baseUrl: "https://llm.chutes.ai/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://chutes.ai/auth/start", - liveModels: true, - preserveCustomDestination: true, - // The public model catalog cannot prove that a supplied Bearer key is valid. - apiKeyValidation: "unknown", - // Chutes documents tool calling, but not a provider-wide parallel tool-call contract. - parallelToolCalls: false, - // The live catalog reports reasoning support, but not a stable effort ladder. - reasoningEfforts: [], - modelDiscovery: { - path: "models", - maxResponseBytes: 256 * 1024, - maxModels: 128, - filter: { - // The shared LLM catalog also contains rows without native tool support. Codex needs a - // complete agent loop, so admit only rows whose live metadata advertises tools. - allOf: [{ path: ["supported_features"], containsAny: ["tools"] }], - }, - }, - note: "Shared OpenAI-compatible LLM gateway only; live discovery exposes tool-capable rows. User-deployed custom Chute endpoints and non-LLM APIs require a custom provider.", - }, - { - id: "deepinfra", - label: "DeepInfra", - baseUrl: "https://api.deepinfra.com/v1/openai", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://deepinfra.com/dash/api_keys", - liveModels: true, - preserveCustomDestination: true, - modelDiscovery: { - // DeepInfra documents the OpenAI model catalog outside the chat-compatible `/v1/openai` - // namespace, so keep this destination registry-owned instead of deriving it from baseUrl. - url: "https://api.deepinfra.com/v1/models", - maxResponseBytes: 512 * 1024, - maxModels: 512, - filter: { - allOf: [{ path: ["metadata", "tags"], containsAny: ["chat"] }], - }, - }, - note: "OpenAI-compatible chat models only; live discovery excludes non-chat rows from DeepInfra's mixed model catalog.", - }, - { - id: "hyperbolic", - label: "Hyperbolic", - baseUrl: "https://api.hyperbolic.xyz/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://app.hyperbolic.ai", - liveModels: true, - preserveCustomDestination: true, - modelDiscovery: { - path: "models", - maxResponseBytes: 256 * 1024, - maxModels: 256, - }, - note: "Serverless text and vision-language chat models only; Hyperbolic's separate image, audio, and GPU endpoints are out of scope.", - }, - { - // Primary sources checked 2026-08-03: - // - docs.nscale.com documents the production OpenAI-compatible endpoint, bearer service - // tokens, /v1/models, and a tool-calling request using this exact Llama model id. - // - nscale.com/policies/terms-conditions identifies Nscale AS as the service operator and - // covers customers using its public-cloud inference offering. Maintainer: @olddonkey; - // no affiliation with Nscale. - id: "nscale", - label: "Nscale Serverless Inference", - baseUrl: "https://inference.api.nscale.com/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://console.nscale.com", - defaultModel: "meta-llama/Llama-3.1-8B-Instruct", - models: ["meta-llama/Llama-3.1-8B-Instruct"], - liveModels: true, - preserveCustomDestination: true, - // Nscale documents tools but not parallel tool calls. Keep requests serialized. - parallelToolCalls: false, - // The API schema accepts reasoning_effort, but does not publish per-model tiers. - reasoningEfforts: [], - modelDiscovery: { - path: "models", - maxResponseBytes: 256 * 1024, - maxModels: 256, - filter: { - // Nscale's catalog mixes chat, image, and embedding rows without a modality field. - // Admit only the exact model used in its official tool-calling API example. - allOf: [{ path: ["id"], equalsAny: ["meta-llama/Llama-3.1-8B-Instruct"] }], - }, - }, - note: "Serverless OpenAI-compatible inference. Live discovery admits only the tool-capable model established by Nscale's official API example; other mixed-catalog rows remain hidden pending equivalent evidence.", - }, - { - // Primary sources checked 2026-08-03: - // - docs.vultr.com documents the fixed OpenAI-compatible base URL, per-subscription bearer - // key, /v1/models, and states that tool calling is currently limited to kimi-k2-instruct. - // - Vultr's official properties identify VULTR as a The Constant Company, LLC trademark and - // document customer API integrations. Maintainer: @olddonkey; no affiliation with Vultr. - id: "vultr", - label: "Vultr Serverless Inference", - baseUrl: "https://api.vultrinference.com/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://my.vultr.com", - defaultModel: "kimi-k2-instruct", - models: ["kimi-k2-instruct"], - liveModels: true, - preserveCustomDestination: true, - parallelToolCalls: false, - reasoningEfforts: [], - modelDiscovery: { - path: "models", - maxResponseBytes: 256 * 1024, - maxModels: 256, - filter: { - // Vultr explicitly limits tool calling to this model. A coding agent must not select - // another chat model that cannot complete its tool loop. - allOf: [{ path: ["id"], equalsAny: ["kimi-k2-instruct"] }], - }, - }, - note: "Serverless Inference subscription API. Live discovery exposes only kimi-k2-instruct because Vultr documents it as the sole tool-calling model.", - }, - { - id: "baseten", - label: "Baseten Model APIs", - baseUrl: "https://inference.baseten.co/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://app.baseten.co/settings/api_keys", - liveModels: true, - preserveCustomDestination: true, - // Baseten's Chat Completions contract documents parallel_tool_calls as default-on. - parallelToolCalls: true, - // Baseten says models outside its reasoning table do not support reasoning. Keep - // unknown/new live slugs conservative until an official-docs registry refresh proves it. - reasoningEfforts: [], - modelReasoningEfforts: BASETEN_MODEL_REASONING_EFFORTS, - modelReasoningEffortMap: BASETEN_MODEL_REASONING_EFFORT_MAP, - modelDefaultReasoningEfforts: BASETEN_MODEL_DEFAULT_REASONING_EFFORTS, - modelInputModalities: BASETEN_MODEL_INPUT_MODALITIES, - modelDiscovery: { - path: "models", - maxResponseBytes: 1_048_576, - maxModels: 256, - }, - note: "Shared Model APIs only (personal API key, or team key with Call Model APIs access); dedicated Truss predict endpoints are outside this preset.", - }, - { - id: "commandcode", - label: "Command Code - API", - adapter: "openai-chat", - baseUrl: "https://api.commandcode.ai/provider/v1", - authKind: "key", - dashboardUrl: "https://commandcode.ai/studio/", - liveModels: true, - preserveCustomDestination: true, - defaultModel: "deepseek/deepseek-v4-flash", - promptCacheKey: true, - // The default is also the cold-start seed: live discovery failure must not empty the catalog - // for a freshly configured provider with no stale cache (issue #308 pattern). - models: ["deepseek/deepseek-v4-flash"], - // The public model catalog is unauthenticated, so a Bearer probe cannot prove key validity. - apiKeyValidation: "unknown", - // The public catalog reports ids/context windows only; no trustworthy reasoning contract. - reasoningEfforts: [], - // Official Command Code model-profile reasoning facts (shared with the OAuth - // `command-code` entry). Without them the API-key preset never advertises a - // reasoning picker, and the router's known-ids decode source misses the native - // slash ids — so a Codex-facing slug like `commandcode/deepseek-deepseek-v4-flash` - // is sent upstream verbatim and rejected with `unsupported_model`. - modelReasoningEfforts: COMMAND_CODE_MODEL_REASONING_EFFORTS, - // The DeepSeek vision preview id is preemptive for when the catalog serves it - // (merges into v4-flash later). - modelContextWindows: { - [`deepseek/${DEEPSEEK_VISION_PREVIEW_MODEL}`]: 1_048_576, - }, - modelInputModalities: COMMAND_CODE_MODEL_INPUT_MODALITIES, - modelDiscovery: { - path: "models", - maxResponseBytes: 256 * 1024, - maxModels: 256, - }, - // Verified 2026-08-03: public /provider/v1/models returns 51 rows; /chat/completions returns - // 401 UNAUTHORIZED without a Bearer key. Primary source: https://commandcode.ai/docs/provider. - note: "Command Code Provider API (OpenAI-compatible); API access requires the Provider plan. Use `ocx login command-code` for OAuth account login (imports an existing local Command Code CLI credential when present). Docs: https://commandcode.ai/docs/provider.", - }, - { - id: "sambanova", - label: "SambaNova Cloud", - baseUrl: "https://api.sambanova.ai/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://cloud.sambanova.ai/apis", - liveModels: true, - preserveCustomDestination: true, - apiKeyValidation: "unknown", - // SambaNova documents this request field but does not yet support parallel function calls. - parallelToolCalls: false, - // The public catalog does not report a trustworthy per-model reasoning contract. - reasoningEfforts: [], - modelDiscovery: { - path: "models", - maxResponseBytes: 128 * 1024, - maxModels: 128, - }, - note: "SambaNova Cloud text-generation models only; private SambaStudio deployment endpoints are outside this preset.", - }, - { - id: "nebius", - label: "Nebius Token Factory", - baseUrl: "https://api.tokenfactory.nebius.com/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://tokenfactory.nebius.com", - liveModels: true, - preserveCustomDestination: true, - // The public tools guide documents single function selection, not parallel tool calls. - parallelToolCalls: false, - // Missing reasoning metadata must not promote a model to Codex's full fallback ladder. - reasoningEfforts: [], - modelDiscovery: { - path: "models", - query: { verbose: "true" }, - maxResponseBytes: 512 * 1024, - maxModels: 512, - filter: { - // Keep rows whose reported architecture output includes text (for example, - // text->text or text+image->text); embedding and image-generation rows are excluded. - allOf: [{ path: ["architecture", "modality"], containsAny: ["->text"] }], - }, - }, - note: "Shared Token Factory text-output inference only; live discovery excludes embedding and image-generation rows.", - }, - { - id: "digitalocean", - label: "DigitalOcean Serverless Inference", - baseUrl: "https://inference.do-ai.run/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://cloud.digitalocean.com/model-studio/manage-keys", - liveModels: true, - preserveCustomDestination: true, - // The Chat Completions contract documents function calls but not universal parallel support. - parallelToolCalls: false, - // Unknown catalog rows must not inherit Codex's full fallback reasoning ladder. - reasoningEfforts: [], - modelDiscovery: { - path: "models", - maxResponseBytes: 256 * 1024, - maxModels: 256, - filter: { - allOf: [{ path: ["id"], equalsAny: DIGITALOCEAN_CHAT_COMPLETION_MODELS }], - }, - }, - note: "Shared Serverless Inference Chat Completions only; agent-specific, dedicated, Responses-only, embedding, and media-generation models are outside this preset.", - }, - { - id: "scaleway", - label: "Scaleway Generative APIs", - baseUrl: "https://api.scaleway.ai/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://console.scaleway.com/generative-api", - liveModels: true, - freeTier: true, - preserveCustomDestination: true, - // Parallel support varies by model; avoid advertising it as a provider-wide capability. - parallelToolCalls: false, - // The generic `/models` rows carry no trustworthy reasoning metadata. - reasoningEfforts: [], - modelInputModalities: SCALEWAY_MODEL_INPUT_MODALITIES, - modelDiscovery: { - path: "models", - maxResponseBytes: 128 * 1024, - maxModels: 128, - filter: { - allOf: [{ path: ["id"], equalsAny: SCALEWAY_SERVERLESS_CHAT_MODELS }], - }, - }, - note: "Shared Generative APIs Serverless Chat Completions only; project-qualified and dedicated deployment hosts require a custom provider.", - }, - { - // Primary sources checked 2026-08-08: - // - https://featherless.ai/docs/api-overview-and-common-options documents the fixed - // OpenAI-compatible base URL, Bearer keys, and Chat Completions. - // - https://featherless.ai/docs/api-reference-models documents authenticated plan filtering, - // chat capability filtering, popularity sorting, pagination, and per-row tool metadata. - // - https://featherless.ai/legal/terms-of-service identifies Featherless as a Delaware LLC, - // covers developers building on its APIs, and reserves arbitrary applications for Scale - // plans. Maintainer: @olddonkey; no affiliation with Featherless. - id: "featherless", - label: "Featherless AI", - baseUrl: "https://api.featherless.ai/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://featherless.ai/account/api-keys", - liveModels: true, - preserveCustomDestination: true, - // /v1/models is documented as callable authenticated or unauthenticated, so a 2xx catalog - // response cannot prove that the supplied Bearer key is valid. - apiKeyValidation: "unknown", - // Featherless documents tool calling, but not a provider-wide parallel tool-call contract. - parallelToolCalls: false, - // Reasoning controls use model-specific chat_template_kwargs, not OpenAI reasoning_effort. - reasoningEfforts: [], - modelDiscovery: { - path: "models", - query: { - available_on_current_plan: "true", - capabilities: "chat", - page: "1", - per_page: "100", - sort: "-popularity", - }, - maxResponseBytes: 128 * 1024, - maxModels: 100, - filter: { - // Treat server-side filters as a size optimization, not an authority boundary. A row must - // independently prove plan availability, no separate Hugging Face gate, and tool support. - allOf: [ - { path: ["available_on_current_plan"], equalsAny: [true] }, - { path: ["is_gated"], equalsAny: [false] }, - { path: ["features", "tool_use"], equalsAny: [true] }, - ], - }, - }, - note: "Authenticated first page of popular chat models only; live discovery admits at most 100 plan-available, ungated rows whose metadata explicitly reports tool use.", - }, - { - // Primary sources checked 2026-08-08: - // - https://novita.ai/docs/api-reference/model-apis-llm-create-chat-completion and - // https://novita.ai/docs/api-reference/model-apis-llm-list-models document the fixed - // OpenAI-compatible Chat Completions and model-list endpoints. - // - https://novita.ai/docs/api-reference/basic-authentication documents Bearer API keys. - // - https://novita.ai/legal/terms-of-service (updated 2026-08-05) expressly covers AI - // inference APIs, third-party Model Providers, and customer Input/Output processing. - // - https://huggingface.co/docs/inference-providers/main/providers/novita lists Novita as an - // Inference Providers partner for chat/VLM traffic, independently supporting routing use. - // - https://tsdr.uspto.gov/statusview/sn99255805 is the official use-in-commerce record - // connecting the NOVITA AI mark to Hivemind Labs, Inc., a Delaware corporation. The mark - // application is now abandoned; it is cited only as the public operator-identity record. - // Maintainer: @olddonkey; no affiliation with Novita AI or Hivemind Labs, Inc. - id: "novita", - label: "Novita AI", - baseUrl: "https://api.novita.ai/openai/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://novita.ai/settings/key-management", - liveModels: true, - preserveCustomDestination: true, - // The live catalog is public even though the reference shows an Authorization header, so a - // successful model fetch cannot prove that a supplied key is valid. - apiKeyValidation: "unknown", - // The request reference documents tools but not a provider-wide parallel-tool contract. - parallelToolCalls: false, - // Novita exposes model-specific thinking flags, not an OpenAI reasoning_effort contract. - reasoningEfforts: [], - modelDiscovery: { - path: "models", - maxResponseBytes: 512 * 1024, - maxModels: 256, - filter: { - // Require both Novita's chat classification and the exact configured wire endpoint. - allOf: [ - { path: ["model_type"], equalsAny: ["chat"] }, - { path: ["endpoints"], containsAny: ["chat/completions"] }, - ], - }, - }, - note: "Public live catalog filtered to rows that explicitly report chat type and Chat Completions support; key validity remains unknown until an authenticated inference request.", - }, - // FREEZE 2026-07-10: exact serverless ids remain auth-gated/unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. - { id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" }, - { id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" }, - { - id: "firepass", label: "Fire Pass (Fireworks Kimi)", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", - dashboardUrl: "https://fireworks.ai/account/api-keys", - note: "Model data frozen pending Tier-2 entitlement proof", - }, - { - id: "moonshot", label: "Moonshot (Kimi API)", baseUrl: MOONSHOT_INTL_BASE_URL, adapter: "openai-chat", authKind: "key", - allowBaseUrlOverride: true, - baseUrlChoices: MOONSHOT_BASE_URL_CHOICES, - dashboardUrl: "https://platform.moonshot.ai/console/api-keys", defaultModel: "kimi-k2.7-code", jawcodeBundle: "moonshot", - models: KIMI_API_MODELS, - modelContextWindows: KIMI_API_MODEL_CONTEXT_WINDOWS, - modelInputModalities: KIMI_API_MODEL_INPUT_MODALITIES, - noReasoningModels: KIMI_API_NO_REASONING_MODELS, - modelReasoningEfforts: KIMI_API_REASONING_EFFORTS, - noTemperatureModels: KIMI_API_MODELS, - noTopPModels: KIMI_API_MODELS, - noPenaltyModels: KIMI_API_MODELS, - autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], - preserveReasoningContentModels: KIMI_API_MODELS, - note: "International default (api.moonshot.ai). China accounts: choose China (.cn) or Custom for api.moonshot.cn.", - }, - { id: "huggingface", label: "Hugging Face", baseUrl: "https://router.huggingface.co/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://huggingface.co/settings/tokens" }, - // 260715 NIM hardening (issue #126, devlog/_plan/260715_issue126_nim_kimi): - // - NIM kimi rejects `parallel_tool_calls: true` with 400 "This model only supports single - // tool-calls at once!" (openclaw#37048). NVIDIA's own function-calling docs default the - // Boolean to false, so provider-wide `false` is the documented-safe wire value. - // - `reasoning_effort` is not portable on NIM (models use chat_template_kwargs); the kimi - // family is live-discovered with no capability metadata, so Codex would otherwise send - // reasoning_effort=medium. Exact-id lists per modelInList semantics; gpt-oss on NIM keeps - // its working reasoning_effort. Future kimi ids must be appended individually. - { - id: "nvidia", label: "NVIDIA NIM", baseUrl: "https://integrate.api.nvidia.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://build.nvidia.com", - // Free pricing, but an API key is still required (free key from build.nvidia.com). - freeTier: true, - parallelToolCalls: false, - // 260804 issue #956: NIM exposes no input modalities, so vision capability is - // classified here. Both lists are verified per-model; unlisted ids stay unclassified - // by design (see the comment on NVIDIA_NIM_VISION_MODELS). - noVisionModels: NVIDIA_NIM_NO_VISION_MODELS, - modelInputModalities: NVIDIA_NIM_VISION_INPUT_MODALITIES, - noReasoningModels: NVIDIA_NIM_KIMI_MODELS, - modelReasoningEfforts: Object.fromEntries(NVIDIA_NIM_KIMI_MODELS.map(id => [id, []])), - preserveReasoningContentModels: NVIDIA_NIM_KIMI_THINKING_MODELS, - note: "Free tier on NVIDIA NIM — API key still required (get a free key at build.nvidia.com).", - }, - { id: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://venice.ai/settings/api" }, - // 260710 GLM-5.2 context and path-specific ids: Tier-2 evidence in - // devlog/_plan/260710_provider_hardening/002_research_cn.md. - // 260814: glm-5.3 / glm-5.3[1m] added per docs.z.ai/devpack/latest-model, which lists them as - // Coding Plan ids on this same endpoint. - // 260815: docs.z.ai/guides/llm/glm-5.3 now publishes the capability table (thinking, streaming, - // function calling, caching, structured output) and a 128K output budget, recorded here as the - // exact 131_072 every other source in this repo uses for that model. Coding Plan pricing stays - // unpublished, so no cost entry is asserted. - { - id: "zai", label: "Z.AI — GLM Coding Plan", baseUrl: "https://api.z.ai", adapter: "openai-responses", authKind: "key", - // One subscription and one key, three protocols. docs.z.ai/guides/llm/glm-5.3 lists them: - // Chat Completions at /api/coding/paas/v4, Responses at /api/v1, Anthropic Messages at - // /api/anthropic. docs.z.ai/devpack/latest-model points Codex-family clients at /api/v1, - // and the Chat path is the one that misbehaves in practice. - // - // Responses is the default and Chat stays reachable per model through `modelAdapters`. - // The two wires sit under different prefixes, and a wire override swaps the adapter - // without touching baseUrl, so each wire carries its own relative send path. - // - // Measured 2026-09-12 against a live key: every roster id answers 200 on - // /api/v1/responses, and every one also answers 200 on the Chat prefix, so no model - // needs a `modelWireDefaults` pin. /api/v1/chat/completions returns 403 - // model_access_denied, which is why the Chat path cannot simply hang off the new base. - responsesPath: "/api/v1/responses", - chatCompletionsPath: "/api/coding/paas/v4/chat/completions", - // The address this row occupied before the move. A saved custom provider still pointing - // at the Chat endpoint keeps receiving this row's metadata (#1100). - destinationAliases: [{ baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat" }], - dashboardUrl: "https://z.ai/manage-apikey/apikey-list", defaultModel: "glm-5.3", - note: "GLM-5.3 coding subscription", - models: ["glm-5.3", "glm-5.3[1m]", "glm-5.3-flash", "glm-5.2", "glm-5.2[1m]", "glm-5.1", "glm-5", "glm-4.6"], - // The upstream catalog reports 1_048_576 for the 5.3 family, which is what the domestic - // Responses row already carries. Both are documented as "1M"; this is that number. - modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3[1m]": 1_048_576, "glm-5.3-flash": 1_048_576, "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 }, - // Z.AI returns 400 for bracketed model ids on both wires; the aliases are local. - modelSuffixBracketStrip: true, - noVisionModels: ZAI_GLM_5X_SIDECAR_VISION_MODELS, - modelInputModalities: ZAI_GLM_5X_INPUT_MODALITIES, - modelReasoningEfforts: ZAI_GLM_5X_REASONING_EFFORTS, - modelDefaultReasoningEfforts: Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, "max"])), - modelMaxOutputTokens: Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, 131_072])), - modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_5X_MODELS.map(id => [id, true])), - preserveReasoningContentModels: ZAI_GLM_5X_MODELS, - // Responses replay uses this provider-level flag; the model list above still covers a - // caller who opts back into Chat. - preserveResponsesReasoningContent: true, - }, - // Zhipu's domestic BigModel platform: OpenAI-compatible pay-as-you-go on open.bigmodel.cn — a - // different host and billing product from the `zai` coding-plan subscription above. - // The id is deliberately NOT `glm` or `glm-cn`: both are already bound in FREE_PROVIDER_DIRECTORY - // (to api.z.ai and to the BigModel *coding* path), and routedProviderConfig() canonicalizes a - // saved provider onto the registry baseUrl — reusing either id would silently retarget an - // existing config's endpoint and send its API key to another host. - // Evidence: docs.bigmodel.cn/api-reference (OpenAI-compatible chat completions), - // docs.bigmodel.cn/cn/guide/models/text/glm-4.6 (thinking: {type: enabled|disabled}). - // Originally proposed in #536 by @Lucinegogo. - { - id: "zhipu-bigmodel", - label: "Zhipu AI — BigModel", - baseUrl: "https://open.bigmodel.cn/api/paas/v4", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys", - defaultModel: "glm-4.6", - models: ZHIPU_BIGMODEL_MODELS, - // The GLM families here are the same ones the `zai` metadata bundle already describes, so the - // bundle owns context windows and modalities for the whole list instead of a hand-copied table. - jawcodeBundle: "zai", - // Declared explicitly for the default model so its window survives a bundle-lookup miss: - // without it, catalog normalization falls back to a generic 128k and compacts ~76,800 early. - modelContextWindows: { "glm-4.6": 204_800 }, - modelInputModalities: ZHIPU_BIGMODEL_INPUT_MODALITIES, - // GLM exposes a binary thinking knob, not an effort ladder: the adapter emits - // `thinking: {type}` for these ids and would otherwise send a rejected reasoning_effort. - thinkingToggleModels: ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS, - modelReasoningEfforts: Object.fromEntries( - ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS]), - ), - modelReasoningEffortMap: Object.fromEntries( - ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP]), - ), - modelSupportsReasoningSummaries: Object.fromEntries( - ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, true]), - ), - preserveReasoningContentModels: ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS, - // GLM thinking is a binary toggle (low maps to disabled), so a legitimate - // tool round can carry no reasoning at all; never fabricate a placeholder - // for it, only replay real recorded text (P2 on #1205). - requiresReasoningPlaceholderModels: [], - // No liveModels: GET /api/paas/v4/models has not been observed to answer on this host, and a - // false live claim yields an empty picker at runtime. Flip it on once someone verifies it. - note: "Domestic BigModel pay-as-you-go endpoint (open.bigmodel.cn)", - }, - // BigModel's Coding Plan is a SEPARATE endpoint from the pay-as-you-go row above, and that is - // the whole reason this one exists. #1100 was reported against - // `https://open.bigmodel.cn/api/coding/paas/v4`; the row above covers only `/api/paas/v4`, so - // destination enrichment matched nothing, `modelSupportsReasoningSummaries` stayed unset, and - // Codex kept dropping the inbound reasoning object — effort displayed as `-`. - // - // A prefix or fuzzy endpoint match would have been the shortcut. It is also how a config - // pointed at one vendor route silently inherits another route's metadata, so endpoints stay - // exact and each one gets its own row. - // - // The id is NOT `glm-cn`, which the free-provider directory already binds to this same coding - // path: registering it here would let routedProviderConfig() canonicalize a saved `glm-cn` - // config onto this baseUrl. Same reasoning as `zhipu-bigmodel` above. - // - // Models follow Z.AI's coding-plan list rather than the pay-as-you-go one. This endpoint is - // the subscription product, and the reporter's `glm-5.2` is only on that side. - { - id: "zhipu-bigmodel-coding", - label: "Zhipu AI — BigModel Coding Plan", - baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys", - defaultModel: "glm-5.3", - models: ["glm-5.3", "glm-5.3[1m]", "glm-5.3-flash", "glm-5.2", "glm-5.2[1m]", "glm-5.1", "glm-5", "glm-4.6"], - jawcodeBundle: "zai", - modelContextWindows: { "glm-5.3": 1_000_000, "glm-5.3[1m]": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 }, - modelSuffixBracketStrip: true, - noVisionModels: ZAI_GLM_5X_SIDECAR_VISION_MODELS, - modelInputModalities: ZAI_GLM_5X_INPUT_MODALITIES, - modelReasoningEfforts: ZAI_GLM_5X_REASONING_EFFORTS, - modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_5X_MODELS.map(id => [id, true])), - preserveReasoningContentModels: ZAI_GLM_5X_MODELS, - // No liveModels: the same reasoning as the pay-as-you-go row — an unverified live claim - // yields an empty picker at runtime. - note: "Domestic BigModel Coding Plan endpoint (open.bigmodel.cn)", - }, - // Narrowed carry of #3641: the official Codex example declares a local static catalog, - // not an HTTP /models contract. Keep Responses separate from the Chat endpoint above. - // Source: https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md (checked 2026-09-07). - // - // #4201 completes the roster. The `models.json` example on that Codex page is a *starter - // catalog*, not the set of models the endpoint serves, and reading it as the latter is what - // left Flash off a subscription that sells it. Three upstream pages say so directly, all - // checked 2026-09-11: - // - coding-plan/latest-model.md pins Codex to THIS baseUrl - // (`Codex:https://open.bigmodel.cn/api/v1`) and opens with GLM Coding Plan supporting - // GLM-5.3 and GLM-5.3-Flash for every tier (Max & Pro & Lite), then treats - // `glm-5.3-flash` as an already-callable id in that same tool. - // - coding-plan/overview.md: every plan supports GLM-5.3 and GLM-5.3-Flash, and calls to - // GLM-5-Turbo are auto-switched to GLM-5.3-Flash. Turbo below is therefore an alias of - // the very model this row omitted, which is the clearest statement that the endpoint - // serves Flash: it was already serving it under another name. - // - guide/models/vlm/glm-5.3-flash.md: native multimodal input, 1M context, and text - // parameters explicitly "consistent with GLM-5.3". - // No authenticated /models probe is implied by any of this, so `liveModels` and - // `apiKeyValidation` below are deliberately unchanged. - { - id: "zhipu-bigmodel-responses", - label: "Zhipu AI — BigModel Coding Plan (Responses)", - baseUrl: "https://open.bigmodel.cn/api/v1", - adapter: "openai-responses", - authKind: "key", - dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys", - defaultModel: "glm-5.3", - models: ["glm-5.3", "glm-5.3-flash", "glm-5-turbo"], - liveModels: false, - // The local Codex catalog does not establish an authenticated HTTP /models contract. - apiKeyValidation: "unknown", - jawcodeBundle: "zai", - // A pre-existing same-named custom provider must retain its destination and key boundary. - preserveCustomDestination: true, - // Flash tracks its 5.3 sibling on this row rather than the Chat row's 1_000_000. Both - // models are documented as "1M", and this preset expresses that family's 1M the way - // BigModel's own Codex declaration does. Splitting the two would leave one preset - // claiming two different sizes for one documented window. - modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3-flash": 1_048_576, "glm-5-turbo": 204_800 }, - // Flash is the only row here that can actually see an image. Its siblings are declared - // text-only and get `image` back from the vision sidecar at catalog-build time; declaring - // Flash text-only would route a native VLM's pictures through a describe-it-first detour - // and hand the model prose about an image it could have read (same defect - // ZAI_GLM_5X_SIDECAR_VISION_MODELS exists to prevent on the Chat rows). - modelInputModalities: { "glm-5.3": ["text"], "glm-5.3-flash": ["text", "image"], "glm-5-turbo": ["text"] }, - modelReasoningEfforts: { - "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, - // Same three effective tiers: upstream documents Flash's text parameters as identical - // to GLM-5.3, and the Codex effort table folds every inbound value into low/high/max. - "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, - // Explicitly empty: Turbo must not inherit the generic selectable effort ladder. - "glm-5-turbo": [], - }, - modelDefaultReasoningEfforts: { "glm-5.3": "max", "glm-5.3-flash": "max", "glm-5-turbo": "max" }, - modelSupportsReasoningSummaries: { "glm-5.3": true, "glm-5.3-flash": true, "glm-5-turbo": true }, - // Responses replay uses this provider-level flag, not the Chat-path model list. - preserveResponsesReasoningContent: true, - note: "Domestic BigModel Coding Plan Responses endpoint; static model roster", - }, - { id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api" }, - { id: "synthetic", label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://synthetic.new" }, - // SiliconFlow publishes an OpenAI-compatible chat endpoint and a dynamic model catalog. Do not - // freeze reasoning controls here: enable_thinking/thinking_budget support and limits vary by - // model, so live metadata or an explicit user override must own those capabilities. - // Evidence: https://docs.siliconflow.cn/en/api-reference/chat-completions/chat-completions - { - id: "siliconflow", - label: "SiliconFlow", - baseUrl: "https://api.siliconflow.cn/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://cloud.siliconflow.cn/account/ak", - liveModels: true, - note: "OpenAI-compatible live model catalog; reasoning controls vary by model.", - }, - // Qwen Cloud: token plan is the preset default; GUI offers pay-as-you-go + custom via baseUrlChoices. - // Formerly `qwen-portal` / portal.qwen.ai — that host is outdated. - { - id: "qwen-cloud", - label: "Qwen Cloud", - baseUrl: QWEN_CLOUD_TOKEN_PLAN_BASE_URL, - adapter: "openai-chat", - authKind: "key", - allowBaseUrlOverride: true, - baseUrlChoices: QWEN_CLOUD_BASE_URL_CHOICES, - dashboardUrl: "https://docs.qwencloud.com", - note: "Pick token plan, pay as you go, or a custom compatible-mode base URL", - }, - { - id: "tencent-coding-plan", - label: "Tencent Cloud Coding Plan", - baseUrl: "https://api.lkeap.cloud.tencent.com/coding/v3", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://console.cloud.tencent.com/tokenhub/codingplan", - defaultModel: "tc-code-latest", - models: TENCENT_CODING_PLAN_MODELS, - liveModels: true, - modelInputModalities: Object.fromEntries(TENCENT_CODING_PLAN_MODELS.map(id => [id, ["text"]])), - noVisionModels: TENCENT_CODING_PLAN_MODELS, - note: "Coding tools only. Tencent forbids general API automation, custom backends, and non-interactive batch use.", - }, - { - id: "volcengine", - label: "Volcengine Ark", - baseUrl: "https://ark.cn-beijing.volces.com/api/v3", - adapter: "openai-chat", - authKind: "key", - preserveCustomDestination: true, - dashboardUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/apikey", - defaultModel: "doubao-seed-2-1-pro-260628", - models: VOLCENGINE_ARK_MODELS, - liveModels: false, - modelReasoningEfforts: Object.fromEntries( - VOLCENGINE_DOUBAO_THINKING_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS]), - ), - modelReasoningEffortMap: Object.fromEntries( - VOLCENGINE_DOUBAO_THINKING_MODELS.map(id => [id, THINKING_TOGGLE_MAP]), - ), - thinkingToggleModels: VOLCENGINE_DOUBAO_THINKING_MODELS, - preserveReasoningContentModels: [ - "deepseek-v4-flash-260425", - "glm-5-2-260617", - "glm-4-7-251222", - ], - noVisionModels: [ - "deepseek-v4-flash-260425", - "deepseek-v3-2-251201", - "glm-5-2-260617", - "glm-4-7-251222", - ], - note: "Pay-as-you-go Ark API with a curated text/agent catalog. Calls on this endpoint do not consume Coding Plan or Agent Plan quota.", - }, - { - id: "volcengine-coding-plan", - label: "Volcengine Ark Coding Plan", - baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3", - adapter: "openai-chat", - authKind: "key", - preserveCustomDestination: true, - dashboardUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview", - defaultModel: "ark-code-latest", - models: VOLCENGINE_CODING_PLAN_MODELS, - liveModels: false, - modelInputModalities: VOLCENGINE_PLAN_INPUT_MODALITIES, - noVisionModels: VOLCENGINE_PLAN_TEXT_ONLY_MODELS, - modelReasoningEfforts: Object.fromEntries( - DEEPSEEK_V4_LEGACY_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)]), - ), - modelReasoningEffortMap: Object.fromEntries( - DEEPSEEK_V4_LEGACY_MODELS.map(id => [id, deepseekReasoningMapFor(id)]), - ), - preserveReasoningContentModels: DEEPSEEK_V4_LEGACY_MODELS, - note: "Coding tools only. Volcengine restricts Coding Plan quota to supported AI coding tools and warns that using this key for general API calls may suspend the subscription or ban the account. Use the plan key issued by the Ark console.", - }, - { - id: "volcengine-agent-plan", - label: "Volcengine Ark Agent Plan", - baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3", - responsesPath: "/responses", - adapter: "openai-responses", - authKind: "key", - // Ark's plan route does not document `service_tier`; fail closed like DeepSeek. - supportsServiceTier: false, - preserveCustomDestination: true, - dashboardUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview", - // Was `deepseek-v4-pro` until DeepSeek retired it; the plan roster's other DeepSeek - // entry takes over so a fresh install still lands on a working default. - defaultModel: "deepseek-v4-flash", - models: VOLCENGINE_AGENT_PLAN_MODELS, - liveModels: false, - modelInputModalities: VOLCENGINE_PLAN_INPUT_MODALITIES, - noVisionModels: VOLCENGINE_PLAN_TEXT_ONLY_MODELS, - note: "Coding tools only. Agent Plan is a subscription endpoint over the native Responses API with a static fallback catalog; Ark plan quota is intended for supported AI coding and agent tools, so avoid using this key as a general-purpose API key.", - }, - // 2026-07-10: docs unverified; model data frozen. Evidence: devlog/_plan/260710_provider_hardening/002_research_cn.md. - { id: "qianfan", label: "Qianfan (Baidu)", baseUrl: "https://qianfan.baidubce.com/v2", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.bce.baidu.com/iam/#/iam/apikey/list" }, - // 2026-07-10: docs unverified; model data frozen. Evidence: devlog/_plan/260710_provider_hardening/002_research_cn.md. - { id: "alibaba", label: "Alibaba Coding Plan", baseUrl: ALIBABA_CODING_INTL_BASE_URL, adapter: "openai-chat", authKind: "key", allowBaseUrlOverride: true, baseUrlChoices: ALIBABA_CODING_BASE_URL_CHOICES, dashboardUrl: "https://dashscope.console.aliyun.com/apiKey" }, - { - id: "alibaba-token-plan", - label: "Alibaba Token Plan (Beijing)", - baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://bailian.console.aliyun.com/cn-beijing?tab=plan", - defaultModel: "qwen3.8-max", - models: ALIBABA_TOKEN_PLAN_MODELS, - liveModels: false, - note: "Token Plan Personal Edition · China (Beijing)", - modelInputModalities: ALIBABA_TOKEN_PLAN_INPUT_MODALITIES, - modelContextWindows: { - "qwen3.8-max": 983_616, "qwen3.7-max": 1_000_000, "qwen3.7-plus": 1_000_000, - "qwen3.6-flash": 1_000_000, "glm-5.3": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, - }, - modelReasoningEfforts: { - ...Object.fromEntries(ALIBABA_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])), - "qwen3.8-max": QWEN38_REASONING_EFFORTS, - "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, - "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, - "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, - }, - modelDefaultReasoningEfforts: { "qwen3.8-max": "xhigh" }, - directReasoningEffortModels: ["qwen3.8-max"], - thinkingBudgetModels: ALIBABA_TOKEN_PLAN_QWEN_MODELS.filter(id => id !== "qwen3.8-max"), - preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash"], - noVisionModels: ["glm-5.3", "glm-5.2"], - }, - { - id: "alibaba-token-plan-intl", - label: "Alibaba Token Plan (International)", - baseUrl: ALIBABA_INTL_TOKEN_PLAN_BASE_URL, - adapter: "openai-chat", - authKind: "key", - allowBaseUrlOverride: true, - baseUrlChoices: ALIBABA_INTL_BASE_URL_CHOICES, - dashboardUrl: "https://modelstudio.console.alibabacloud.com/?tab=api#/api", - defaultModel: "qwen3.7-max", - models: ALIBABA_INTL_TOKEN_PLAN_MODELS, - liveModels: false, - note: "Token Plan Team Edition · Singapore (ap-southeast-1)", - metadataModelIdNormalize: "case-insensitive", - modelInputModalities: ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES, - modelContextWindows: { - "qwen3.8-max": 983_616, - "qwen3.7-max": 1_000_000, "qwen3.7-plus": 1_000_000, "qwen3.6-plus": 1_000_000, "qwen3.6-flash": 1_000_000, - "deepseek-v4-flash": 1_000_000, "deepseek-v3.2": 131_072, - "kimi-k2.7-code": 262_144, "kimi-k2.6": 262_144, "kimi-k2.5": 262_144, - "glm-5.3": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, "glm-5.1": 1_000_000, "glm-5": 1_000_000, - "MiniMax-M2.5": 204_800, - }, - modelReasoningEfforts: { - ...Object.fromEntries(ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])), - "qwen3.8-max": QWEN38_REASONING_EFFORTS, - "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, - "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, - "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, - "deepseek-v4-flash": deepseekThinkingEffortsFor("deepseek-v4-flash"), - }, - modelReasoningEffortMap: { - "deepseek-v4-flash": deepseekReasoningMapFor("deepseek-v4-flash"), - }, - directReasoningEffortModels: ["qwen3.8-max"], - thinkingBudgetModels: ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS.filter(id => id !== "qwen3.8-max"), - preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-flash", "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash"], - noVisionModels: ["deepseek-v4-flash", "deepseek-v3.2", "glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "MiniMax-M2.5"], - noReasoningModels: ["kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "deepseek-v3.2", "glm-5.1", "glm-5", "MiniMax-M2.5"], - modelDefaultReasoningEfforts: { "qwen3.8-max": "xhigh" }, - }, - // NEEDS_HUMAN 2026-07-10: kept for config compatibility, but this is a dashboard URL, - // no /models endpoint is documented, and tools are silently ignored upstream per docs.parallel.ai. - // Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. - { id: "parallel", label: "Parallel", baseUrl: "https://platform.parallel.ai", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.parallel.ai" }, - // ZenMux native ids are vendor-namespaced (`/`), verified live against - // https://zenmux.ai/api/v1/models on 2026-07-18. The static seed doubles as the - // cold-cache decode source for the Codex slug codec (src/providers/slug-codec.ts); - // live discovery still owns the full catalog. - { - id: "zenmux", label: "ZenMux", baseUrl: "https://zenmux.ai/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://zenmux.ai", - models: ["moonshotai/kimi-k3-free", "moonshotai/kimi-k3"], - }, - { - id: "litellm", label: "LiteLLM (self-hosted)", baseUrl: "http://localhost:4000/v1", adapter: "openai-chat", authKind: "key", - dashboardUrl: "https://docs.litellm.ai/docs/proxy/quick_start", - allowPrivateNetworkByDefault: true, - allowBaseUrlOverride: true, - // A self-hosted proxy may legitimately run without a master key. - keyOptional: true, - }, - { - id: "ollama-cloud", - label: "Ollama Cloud", - // The upstream /v1 spelling is deliberately unchanged: ollamaNativeChatUrl() normalizes it - // to /api/chat, and live model discovery declares its own /v1/models path against the origin, - // so the native transport needs no base-URL edit here or in the free-provider directory. - baseUrl: "https://ollama.com/v1", - // The native transport must be declared HERE, not in configuration. routedProviderConfig() - // overwrites provider.adapter with the registry adapter for every row whose transport - // matches, so a config-level adapter is silently discarded. - adapter: "ollama-native", - authKind: "key", - dashboardUrl: "https://ollama.com/settings/keys", - // Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15. - models: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"], - defaultModel: "glm-5.3", - // Owner-audited exact outage fallback: these current Ollama Cloud GLM-5.3 rows have - // 1,048,576-token context windows. Live discovery and successful /api/show enrichment keep - // their existing precedence; these values prevent a failed show from becoming generic. - modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3-flash": 1_048_576 }, - noVisionModels: [ - // glm-5.3-flash is absent on purpose: native VLM - // (docs.z.ai/guides/vlm/glm-5.3-flash), so its images skip the sidecar. - "glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "glm-4.7", - "minimax-m2.7", "minimax-m2.5", "minimax-m2.1", - "nemotron-3-ultra", "nemotron-3-super", - "deepseek-v4-flash", - "gpt-oss", "qwen3-coder:480b", - ], - // Ollama's native chat API has no `text.verbosity` equivalent and the ollama-native adapter - // never emits one, so a routed row must not inherit the Codex template's verbosity picker. - // Provider-wide rather than per-model: this catalog is discovery-authoritative, so ids that - // arrive later from live discovery must opt out too (the live-discovery gap closed by #2578). - supportsVerbosity: false, - // Live model discovery: Ollama serves the standard OpenAI-style data[] envelope at /v1/models, - // so the generic discovery pipeline needs no special-casing. The path is spelled against the - // ORIGIN (model-discovery resolves a leading-slash path against base.origin). A discovery - // spec is REQUIRED here: without one the pipeline probes https://ollama.com/models, which - // 307-redirects to /search and discovery falls back to the configured list. - modelDiscovery: { - path: "/v1/models", - }, - }, - // FREEZE 2026-07-10: codestral-latest is unconfirmed behind auth. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. - { id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest" }, - { - id: "minimax", label: "MiniMax — Coding Plan", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key", - dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, - modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS, - modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS }, - modelDefaultReasoningEfforts: { "MiniMax-M3": "medium" }, - modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, - preserveReasoningContentModels: MINIMAX_MODELS, - // MiniMax-M3 low effort maps to thinking disabled, so a legitimate tool - // round can carry no reasoning at all; only replay real recorded text, - // never a fabricated placeholder (chatgpt-codex-connector P2 on #1205). - requiresReasoningPlaceholderModels: [], - reasoningSplitModels: MINIMAX_MODELS, - // With reasoning_split the upstream returns thinking as a structured - // reasoning_details array (cumulative text snapshots per stream chunk) and - // requires that array back verbatim on the next turn — a reasoning_content - // string replay is the native-format pass-back the docs say is unsupported. - // Evidence: platform.minimax.io/docs/guides/text-m3-function-call and - // /docs/api-reference/text-openai-api (verified 2026-09-01). - reasoningDetailsModels: MINIMAX_MODELS, - thinkingToggleModels: ["MiniMax-M3"], - jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "Subscription Key or API Key", - }, - { - id: "minimax-cn", label: "MiniMax — Coding Plan (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", authKind: "key", - dashboardUrl: "https://platform.minimaxi.com", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, - modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS, - modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS }, - modelDefaultReasoningEfforts: { "MiniMax-M3": "medium" }, - modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, - preserveReasoningContentModels: MINIMAX_MODELS, - requiresReasoningPlaceholderModels: [], - reasoningSplitModels: MINIMAX_MODELS, - reasoningDetailsModels: MINIMAX_MODELS, - thinkingToggleModels: ["MiniMax-M3"], - jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "中国区 Subscription Key", - }, - { - id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key", - dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.7-code", - modelSuffixBracketStrip: true, - // API-key form of the same Kimi Code Plan transport; keep cache affinity identical to OAuth. - promptCacheKey: true, - models: KIMI_CODING_MODELS, - modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS, - modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES, - noReasoningModels: KIMI_CODING_NO_REASONING_MODELS, - modelReasoningEfforts: KIMI_CODING_REASONING_EFFORTS, - modelDefaultReasoningEfforts: KIMI_CODING_DEFAULT_REASONING_EFFORTS, - modelReasoningEffortMap: KIMI_CODING_REASONING_EFFORT_MAPS, - noTemperatureModels: KIMI_LOCKED_PARAMETER_MODELS, - noTopPModels: KIMI_LOCKED_PARAMETER_MODELS, - noPenaltyModels: KIMI_LOCKED_PARAMETER_MODELS, - autoToolChoiceOnlyModels: KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS, - preserveReasoningContentModels: KIMI_THINKING_MODELS, - }, - { - id: "opencode-zen", label: "opencode zen", baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://opencode.ai/auth", - // Same opencode.ai/zen/v1 gateway as `opencode-free` (keyed tier): DeepSeek thinking mode - // requires the assistant's original reasoning_content to be replayed on tool-call - // continuations, or the gateway answers HTTP 400 (issues #950/#994). Mirror the DeepSeek - // reasoning + thinking metadata so `opencode-zen/deepseek-v4-flash-free` — and the other - // Zen DeepSeek thinking models — never serialize a bare tool-call turn. - note: "Keyed OpenCode Zen gateway. Free models on this tier are often short-window rate-limited at roughly 15-20 requests/minute (community-measured; OpenCode does not publish RPM). Zen may return generic 429s without Retry-After / X-RateLimit headers; when Retry-After is omitted, opencodex adds a synthetic backoff hint (upstream Retry-After still wins). Distinct from the keyless opencode-free desktop quota (~200 Big Pickle/free-model requests per 5 hours). Docs: https://opencode.ai/docs/zen/. Free-model prompts may be retained for training — do not send confidential material.", - modelReasoningEfforts: Object.fromEntries( - [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekThinkingEffortsFor(id)]), - ), - modelReasoningEffortMap: Object.fromEntries( - [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekReasoningMapFor(id)]), - ), - preserveReasoningContentModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], - // Same Zen gateway as opencode-free: the DeepSeek vision preview id - // (merges into deepseek-v4-flash later). - modelContextWindows: { - [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576, - }, - modelInputModalities: { - [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], - ...Object.fromEntries(OPENCODE_ZEN_IMAGE_MODELS.map(id => [id, ["text", "image"] as string[]])), - }, - noVisionModels: [...OPENCODE_ZEN_TEXT_ONLY_MODELS, ...DEEPSEEK_GATEWAY_THINKING_MODELS], - // Same DeepSeek routes as the Go preset above, behind the same vendor, so they carry - // the same json_schema rejection (#1338 / #1415). - noJsonSchemaModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], - }, - { id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" }, - { - id: "opencode-free", - label: "OpenCode Free", - adapter: "openai-chat", - baseUrl: "https://opencode.ai/zen/v1", - authKind: "key", - keyOptional: true, - featured: true, - liveModels: true, - note: "No key needed, but OpenCode now gates this tier to its own client: Zen refuses any request that arrives without an x-opencode-session header (error type MissingSessionID, \"OpenCode's free tier can only be used in OpenCode\"). opencodex does not mint that header or claim an OpenCode client identity, because no upstream contract authorizes a third-party agent to present itself as OpenCode. Until OpenCode publishes a third-party integration path for the keyless tier, use the keyed opencode-zen provider instead (https://opencode.ai/auth). Quota figures for when the tier admitted a request: OpenCode advertises about 200 Big Pickle/free-model requests per 5 hours, and the same Zen gateway can short-window rate-limit free models at roughly 15-20 requests/minute, and may return generic 429s without Retry-After (opencodex synthesizes backoff only when that header is omitted). Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.", - dashboardUrl: "https://opencode.ai", - staticHeaders: { - // Zen answers a bare runtime User-Agent (Bun/x.y.z) more aggressively than a client - // that identifies itself, which is what the 429 in #2067 traced to. The value is - // deliberately unversioned: a pinned "opencode-cli/" is a claim about an - // install we do not have and goes stale on the vendor's schedule, not ours. - // Corroboration, not authority: OmniRoute — an independent open-source broker against - // the same Zen upstream — defaults to exactly this pair (userAgent "opencode", client - // "desktop") in open-sse/executors/opencode.ts, and got there by RETREATING from its - // own earlier "opencode-cli/1.0.0" pin. An operator can still override either value - // through the provider headers API; user headers win case-insensitively at route time. - "User-Agent": "opencode", - "x-opencode-client": "desktop", - }, - modelReasoningEfforts: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), - modelReasoningEffortMap: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), - preserveReasoningContentModels: OPENCODE_FREE_DEEPSEEK_MODELS, - // The DeepSeek vision preview id is preemptive metadata for when Zen starts - // serving it (merges into v4-flash later). - modelContextWindows: { - [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576, - }, - modelInputModalities: { - [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], - ...Object.fromEntries(OPENCODE_ZEN_IMAGE_MODELS.map(id => [id, ["text", "image"] as string[]])), - }, - // Same Zen roster behind the same base URL, so it carries the same measured - // text-only list rather than only its DeepSeek member (#1043). - noVisionModels: OPENCODE_ZEN_TEXT_ONLY_MODELS, - // Same reasoning: the free tier is the same Zen roster, so its DeepSeek members get - // the keyed tier's json_schema treatment and its reasoning contract rather than a - // narrower table that silently falls behind whenever the keyed one is updated. - noJsonSchemaModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], - }, - { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" }, - // Xiaomi's public OpenAI-compatible endpoint is a distinct transport from both the Anthropic - // preset above and the paid token-plan host below. Keep a separate fixed-destination contract - // so existing custom providers are never retargeted while the official route receives the - // strict reasoning ladder its validator enforces (#1483). - { - id: "xiaomi-mimo", - label: "Xiaomi MiMo (OpenAI Chat)", - baseUrl: "https://api.xiaomimimo.com/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://platform.xiaomimimo.com/console/balance", - defaultModel: "mimo-v2.5", - models: ["mimo-v2.5"], - reasoningEfforts: ["low", "medium", "high"], - reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" }, - preserveCustomDestination: true, - note: "Official Xiaomi MiMo OpenAI-compatible Chat endpoint. The upstream validator accepts reasoning_effort none/low/medium/high; higher Codex tiers are clamped to high.", - }, - { id: "kilo", label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://kilo.ai" }, - { - id: "mimo-free", - label: "MiMo Free", - adapter: "mimo-free", - baseUrl: "https://api.xiaomimimo.com/api/free-ai/openai/chat", - authKind: "key", - keyOptional: true, - featured: true, - liveModels: true, - dashboardUrl: "https://xiaomimimo.com", - defaultModel: "mimo-auto", - models: ["mimo-auto"], - reasoningEfforts: ["low", "medium", "high"], - reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" }, - note: "No key needed — uses Xiaomi MiMo's free public tier (limited-time offer). A JWT is bootstrapped automatically with an anonymous random client id stored locally. The endpoint contract mirrors the official MiMoCode client and is not publicly documented — Xiaomi may change or restrict it at any time. Prompts may be processed/retained by Xiaomi; do not send confidential material.", - }, - // Xiaomi MiMo paid token plan. Separate host and wire from both `xiaomi` (Anthropic) and - // `mimo-free` (free tier, bespoke adapter), so it needs its own entry rather than a variant. - // - // Pinned to openai-chat deliberately (#1158). The endpoint answers the Responses wire for - // plain turns, which is why users configuring it by hand pick `openai-responses` — MiMo - // documents Responses support. But its gateway rejects `type: "custom"` tools with - // `400 responses_feature_not_supported`, and `apply_patch` is a custom tool, so every agentic - // turn fails while chat turns succeed. The Chat path lowers custom tools to `{input: string}` - // functions and restores them as `custom_tool_call`, so the capability survives intact. - // Stripping the tools instead would stop the 400 and disable the agent loop. - { - id: "mimo", - label: "Xiaomi MiMo (token plan)", - baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", - adapter: "openai-chat", - authKind: "key", - dashboardUrl: "https://xiaomimimo.com", - defaultModel: "mimo-v2.5-pro", - models: ["mimo-v2.5-pro", "mimo-v2.5"], - // The gateway validates the ladder strictly and rejects anything above `high`. - reasoningEfforts: ["low", "medium", "high"], - reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" }, - // Live token-plan verification (#1927): the Pro route rejects image input while - // mimo-v2.5 accepts it natively. Keep this provider-scoped so a hand-rolled - // provider with the same id but another destination does not inherit the claim. - noVisionModels: ["mimo-v2.5-pro"], - // A user may already have hand-rolled a provider under this id against a different host; - // without this, routedProviderConfig() would canonicalize their base URL onto ours and send - // their key somewhere they did not choose. - preserveCustomDestination: true, - note: "Xiaomi MiMo paid token plan. Pinned to the Chat wire: the Responses endpoint rejects freeform (custom) tools such as apply_patch with 400 responses_feature_not_supported, so agentic turns fail there while plain turns succeed. Reasoning tiers above high are clamped.", - }, - { id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" }, - { - // Cloudflare Workers AI: OpenAI-compatible endpoint. The base URL contains {account_id} - // which must be resolved by the user at setup time. Model IDs use the @cf/ prefix. - // Live-verified 2026-07-21 against https://developers.cloudflare.com/workers-ai/models/ - // Official search is sibling to /ai/v1 (GET .../ai/models/search?format=openrouter). - id: "cloudflare-workers-ai", label: "Cloudflare Workers AI", - baseUrl: "https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1", - adapter: "openai-chat", authKind: "key", freeTier: true, - dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/workers-ai", - defaultModel: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", - models: [ - "@cf/meta/llama-3.3-70b-instruct-fp8-fast", - "@cf/qwen/qwq-32b", - "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", - "@cf/moonshotai/kimi-k2.7-code", - "@cf/zai-org/glm-5.3", - "@cf/zai-org/glm-5.3-flash", - "@cf/zai-org/glm-5.2", - "@cf/mistralai/mistral-small-3.1-24b-instruct", - ], - liveModels: true, - modelDiscovery: { - path: "../models/search", - query: { format: "openrouter", per_page: "1000" }, - stripIdPrefix: "workers-ai/", - maxModels: 256, - }, - note: "Workers AI · Free tier included · Account ID required in base URL", - }, - // FREEZE 2026-07-10: /models was auth-gated under key login. OAuth device-flow + copilot_internal - // exchange (issue #151) unlocks live discovery; static seed is a cold-start fallback only. - { - id: "github-copilot", - label: "GitHub Copilot", - baseUrl: "https://api.githubcopilot.com", - adapter: "openai-chat", - authKind: "oauth", - allowKeyAuthOverride: true, - featured: false, - dashboardUrl: "https://github.com/settings/copilot", - liveModels: true, - models: ["gpt-4o", "gpt-4.1", "gpt-4.1-mini", "claude-sonnet-4", "gemini-2.5-pro", "gpt-5-mini", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"], - defaultModel: "gpt-4o", - // Copilot fronts a mixed-wire catalog: these models reject /chat/completions for - // real Codex-agent traffic (function tools + reasoning), so every inbound wire - // rides Responses. Evidence: issue #748 field runs, pi.dev/models/github-copilot/* - // wire declarations, BerriAI/litellm#23332 (gpt-5.4), JetBrains LLM-29711 - // (gpt-5.6-sol). gpt-5.4-nano is deliberately absent — it has no field report; a - // user can opt it in with an explicit modelAdapters entry, which always wins. - modelWireDefaults: { - "gpt-5.3-codex": "openai-responses", - "gpt-5.4": "openai-responses", - "gpt-5.4-mini": "openai-responses", - "gpt-5.5": "openai-responses", - "gpt-5.6-luna": "openai-responses", - "gpt-5.6-sol": "openai-responses", - "gpt-5.6-terra": "openai-responses", - "gpt-6-astra": "openai-responses", - "grok-4.5": "openai-responses", - "grok-4.6": "openai-responses", - "mai-code-1.1-flash": "openai-responses", - "mai-code-1-flash-picker": "openai-responses", - }, - note: "Experimental unofficial Copilot bridge. Logs in via GitHub device flow using the public VS Code OAuth client id, then exchanges for a short-lived Copilot API token (copilot_internal). Requires an active Copilot subscription. GitHub may tighten or revoke this path; do not send confidential material you would not paste into Copilot Chat.", - }, - // FREEZE 2026-07-10: no public OpenAI-compatible endpoint is documented. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. - { id: "gitlab-duo", label: "GitLab Duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://gitlab.com/-/user_settings/personal_access_tokens" }, - { - // Official Qoder Global CLI automation surface. The canonical URL is an identity boundary; - // inference and model discovery are performed only by the installed vendor CLI. Authentication - // uses the documented PAT environment variable and never imports desktop/session credentials. - id: "qoder", - label: "Qoder (Global)", - adapter: "qoder", - baseUrl: "https://qoder.com", - authKind: "key", - apiKeyValidation: "unknown", - preserveCustomDestination: true, - dashboardUrl: "https://qoder.com/account/integrations", - defaultModel: "Qwen3.8-Max", - models: [...QODER_GLOBAL_MODELS], - liveModels: true, - reasoningEfforts: [...QODER_REASONING_EFFORTS], - noVisionModels: [...QODER_GLOBAL_MODELS], - note: "Official Qoder Global CLI using QODER_PERSONAL_ACCESS_TOKEN. Models are discovered per account with `qoder --list-models`; the documented roster is a degraded fallback. The CLI runs single-turn with tools, MCP, settings hooks, and session persistence disabled. Requires `npm install -g @qoder-ai/qodercli`.", - }, - { - // Qoder CN is a separate credential, executable, destination, entitlement cache, and health - // domain. It deliberately does not reuse the OAuth/private-protocol design from #3010. - id: "qoder-cn", - label: "Qoder CN", - adapter: "qoder", - baseUrl: "https://qoder.cn", - authKind: "key", - apiKeyValidation: "unknown", - preserveCustomDestination: true, - dashboardUrl: "https://qoder.cn/account/integrations", - defaultModel: "Qwen3.8-Max", - models: [...QODER_CN_MODELS], - liveModels: true, - reasoningEfforts: [...QODER_REASONING_EFFORTS], - noVisionModels: [...QODER_CN_MODELS], - note: "Official Qoder CN CLI using QODERCN_PERSONAL_ACCESS_TOKEN. Models are discovered per account with `qodercn --list-models`; the verified roster is a degraded fallback. The CLI runs single-turn with tools, MCP, settings hooks, and session persistence disabled. Requires `npm install -g @qodercn-ai/qoderclicn`.", - }, - { - // Official CodeBuddy Code CLI provider (Tencent Cloud), GLOBAL / `public` environment. - // Transport is the vendor-documented headless CLI automation surface - // (`codebuddy -p --output-format stream-json --tools ""`) authenticated with the official - // `CODEBUDDY_API_KEY` (https://www.codebuddy.ai/profile/keys). It does NOT read desktop - // session files, import desktop bearer tokens, impersonate the desktop client, or call the - // private console endpoint — the approach closed in #687 and left in draft in #2244. - // baseUrl is the canonical region identity: the adapter fails closed if it is overridden, so a - // global key is never sent to the CN environment (that is the separate `codebuddy-cn` entry). - // v1 runs tools-disabled so Codex keeps tool ownership; this provider is text/reasoning only - // until the control-protocol tool bridge lands (see docs). Free/trial/promotional/subscription - // credits draw from the same official API-key pool. Requires the CLI: `npm i -g @tencent-ai/codebuddy-code`. - // GOVERNANCE: whether routing this vendor automation surface behind a proxy for a third-party - // agent satisfies CodeBuddy's AUP is an open question flagged for maintainer security review. - id: "codebuddy", - label: "CodeBuddy (Global)", - adapter: "codebuddy", - baseUrl: "https://www.codebuddy.ai", - authKind: "key", - apiKeyValidation: "unknown", - preserveCustomDestination: true, - dashboardUrl: "https://www.codebuddy.ai/profile/keys", - defaultModel: "default-model", - models: CODEBUDDY_GLOBAL_MODELS, - liveModels: false, - modelContextWindows: CODEBUDDY_GLOBAL_MODEL_CONTEXT_WINDOWS, - modelMaxOutputTokens: CODEBUDDY_GLOBAL_MODEL_MAX_OUTPUT_TOKENS, - defaultMaxOutputTokens: 32_000, - reasoningEfforts: CODEBUDDY_REASONING_EFFORTS, - modelReasoningEfforts: CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS, - modelDefaultReasoningEfforts: CODEBUDDY_GLOBAL_MODEL_DEFAULT_REASONING_EFFORTS, - note: "Official CodeBuddy Code CLI (Tencent Cloud), global/public environment. Uses the documented CODEBUDDY_API_KEY + headless CLI surface; never reads desktop sessions or private console endpoints. Region-isolated from codebuddy-cn. v1 disables CLI tools (--tools \"\") so Codex retains tool ownership: text/reasoning only for now. Requires `npm i -g @tencent-ai/codebuddy-code`. AUP/routing authorization flagged for maintainer security review.", - }, - { - // Official CodeBuddy Code CLI provider, CHINA / `internal` environment. Identical adapter and - // binary as `codebuddy`; the region is fixed by the profile's CODEBUDDY_INTERNET_ENVIRONMENT - // and this canonical baseUrl. CN key: https://copilot.tencent.com/profile/keys. The CN model - // roster differs from Global (see codebuddy-models.ts) and is seeded separately (§八). - id: "codebuddy-cn", - label: "CodeBuddy (CN)", - adapter: "codebuddy", - baseUrl: "https://www.codebuddy.cn", - authKind: "key", - apiKeyValidation: "unknown", - preserveCustomDestination: true, - dashboardUrl: "https://copilot.tencent.com/profile/keys", - defaultModel: "default", - models: CODEBUDDY_CN_MODELS, - liveModels: false, - modelContextWindows: CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS, - modelMaxOutputTokens: CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS, - defaultMaxOutputTokens: 32_000, - reasoningEfforts: CODEBUDDY_REASONING_EFFORTS, - modelReasoningEfforts: CODEBUDDY_CN_MODEL_REASONING_EFFORTS, - modelDefaultReasoningEfforts: CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS, - noVisionModels: CODEBUDDY_CN_NO_VISION_MODELS, - note: "Official CodeBuddy Code CLI (Tencent Cloud), China/internal environment. Uses the documented CODEBUDDY_API_KEY + headless CLI surface; never reads desktop sessions or private console endpoints. Region-isolated from codebuddy (Global); credentials are never exchanged across regions. v1 disables CLI tools (--tools \"\"): text/reasoning only for now. Requires `npm i -g @tencent-ai/codebuddy-code`. AUP/routing authorization flagged for maintainer security review.", - }, + ...PROVIDER_REGISTRY_CORE, + ...PROVIDER_REGISTRY_EXTENDED, ]; export function providerRegistryFastWireError( diff --git a/src/providers/registry/entries-core.ts b/src/providers/registry/entries-core.ts new file mode 100644 index 0000000000..32e5cc2d95 --- /dev/null +++ b/src/providers/registry/entries-core.ts @@ -0,0 +1,1221 @@ +import { KIRO_MODELS, KIRO_MODEL_CONTEXT_WINDOWS, KIRO_MODEL_REASONING_EFFORTS } from "../kiro-models"; +import { DEVIN_MODEL_CONTEXT_WINDOWS, DEVIN_MODEL_EFFORTS, DEVIN_DEFAULT_EFFORTS } from "../../adapters/devin/live-models"; +import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, ANTIGRAVITY_MODEL_EFFORTS, ANTIGRAVITY_MODEL_INPUT_MODALITIES } from "../antigravity-models"; +import { + CURSOR_NO_VISION_MODELS, + CURSOR_STATIC_MODELS, + cursorModelContextWindows, + cursorModelDisplayNames, + cursorModelIds, + cursorModelInputModalities, + cursorModelReasoningEfforts, +} from "../../adapters/cursor/discovery"; +import { cursorFastCapableBases } from "../../adapters/cursor/catalog"; +import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "../command-code-efforts"; +import { isCanonicalOpenRouterTarget } from "../openrouter-routing"; +import type { ProviderRegistryEntry } from "./types"; +import { + ANTHROPIC_MODELS, + ANTHROPIC_MODEL_CONTEXT_WINDOWS, + ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ANTHROPIC_MODEL_REASONING_EFFORTS, + ZAI_GLM_52_REASONING_EFFORTS, + ZAI_GLM_53_REASONING_EFFORTS, + OPENAI_GPT56_MODELS, + OPENAI_GPT56_PRO_MODELS, + OPENAI_API_GPT56_CONTEXT_WINDOWS, + OPENAI_API_GPT56_MAX_INPUT_TOKENS, + OPENAI_API_GPT56_VIRTUAL_MODELS, + OPENAI_API_GPT56_REASONING_EFFORTS, + META_MUSE_REASONING_EFFORTS, + META_MUSE_REASONING_EFFORT_MAP, + META_MUSE_CONTEXT_WINDOW, + META_MUSE_MODELS, + OPENAI_DAYBREAK_MODELS, + OPENAI_DAYBREAK_CONTEXT_WINDOWS, + OPENAI_DAYBREAK_MAX_INPUT_TOKENS, + OPENAI_DAYBREAK_REASONING_EFFORTS, + OPENROUTER_GPT56_MODELS, + XAI_MODELS, + OPENROUTER_GPT56_CONTEXT_WINDOWS, + THINKING_TOGGLE_EFFORTS, + THINKING_TOGGLE_MAP, + OPENCODE_GO_THINKING_TOGGLE_MODELS, + THINKING_BUDGET_EFFORTS, + QWEN38_REASONING_EFFORTS, + THINKING_BUDGET_MODELS, + OPENCODE_GO_THINKING_BUDGET_MODELS, + DEEPSEEK_NATIVE_THINKING_MODELS, + DEEPSEEK_GATEWAY_THINKING_MODELS, + DEEPSEEK_VISION_PREVIEW_MODEL, + COMMAND_CODE_MODEL_INPUT_MODALITIES, + deepseekThinkingEffortsFor, + deepseekReasoningMapFor, + KIMI_K3_STANDARD_CONTEXT_WINDOW, + KIMI_CODING_MODELS, + KIMI_THINKING_MODELS, + KIMI_CODING_NO_REASONING_MODELS, + KIMI_CODING_K3_REASONING_EFFORTS, + KIMI_CODING_K3_REASONING_EFFORT_MAP, + KIMI_CODING_REASONING_EFFORTS, + KIMI_CODING_DEFAULT_REASONING_EFFORTS, + KIMI_CODING_REASONING_EFFORT_MAPS, + KIMI_LOCKED_PARAMETER_MODELS, + KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS, + KIMI_CODING_MODEL_CONTEXT_WINDOWS, + KIMI_CODING_MODEL_INPUT_MODALITIES, + NEURALWATT_REASONING_HISTORY_MODELS, + UMANS_MODELS, + UMANS_REASONING_EFFORTS, + UMANS_GLM_REASONING_EFFORTS, + UMANS_GLM_53_REASONING_EFFORTS, + UMANS_TEXT_ONLY_MODELS, + UMANS_MODEL_CONTEXT_WINDOWS, + UMANS_MODEL_INPUT_MODALITIES, + CLINE_PASS_MODELS, + ORCAROUTER_MODEL_DISCOVERY, + ORCAROUTER_MODELS, + ORCAROUTER_MODEL_REASONING_EFFORTS, + CLINE_PASS_MODEL_CONTEXT_WINDOWS, + CLINE_PASS_TEXT_ONLY_MODELS, + CLINE_PASS_MODEL_INPUT_MODALITIES, +} from "./model-seeds"; + +export const PROVIDER_REGISTRY_CORE: readonly ProviderRegistryEntry[] = [ + { + id: "openai", + label: "OpenAI (Codex login)", + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authKind: "forward", + codexAccountMode: "pool", + supportsServiceTier: true, + featured: true, + note: "Codex login account pool (default) or Direct main-account mode via codexAccountMode", + }, + { + id: "cursor", + label: "Cursor (experimental)", + adapter: "cursor", + baseUrl: "https://api2.cursor.sh", + authKind: "oauth", + featured: false, + dashboardPreset: true, + note: "Experimental Cursor bridge. Live transport and live model discovery are enabled after a standalone PKCE browser login via 'ocx login cursor'; native read/write/delete/shell/fetch execution is disabled by default and request text such as Codex sandbox markers never authorizes it. Set \"nativeLocalExec\": \"on\" on providers.cursor in ~/.opencodex/config.json (dashboard: Providers → Cursor → Edit JSON) only for a trusted local experiment where every data-plane caller is trusted. \"off\" denies all, \"codex-sandbox\" is accepted for backwards compatibility but fails closed, and legacy \"unsafeAllowNativeLocalExec\": true still means explicit operator opt-in.", + models: cursorModelIds(CURSOR_STATIC_MODELS), + liveModels: true, + defaultModel: "auto", + modelContextWindows: cursorModelContextWindows(CURSOR_STATIC_MODELS), + modelDisplayNames: cursorModelDisplayNames(), + // Cursor's Fast product is a model VARIANT, not a service_tier field, so the wire kind + // is cursor-variant and the request builder consumes the decision. + fastWire: { kind: "cursor-variant", canonicalToWire: { priority: "fast" }, foreignCallerTiers: "drop" }, + // Deliberately NO provider-level supportsServiceTier: resolveFastPolicy short-circuits on + // `capability.provider === false` BEFORE consulting the per-model map, which would make + // these entries dead config. Absent leaves unlisted bases "unclassified", and a + // non-service-tier adapter cannot forward a caller tier, so they still publish no toggle. + modelSupportsServiceTier: Object.fromEntries(cursorFastCapableBases().map(id => [id, true])), + fastTierDescription: "Cursor Fast variant", + modelInputModalities: cursorModelInputModalities(CURSOR_STATIC_MODELS), + modelReasoningEfforts: cursorModelReasoningEfforts(CURSOR_STATIC_MODELS), + // Kimi K3 documents `max` as its API default, and its Cursor ladder has no `medium` + // rung — so applyReasoningLevels' medium->high->first fallback would settle the catalog + // default on `high`, the picker would send `high` explicitly, and the request builder's + // no-effort fallback to `kimi-k3-max` would never be reached. Mirrors the other K3 + // routes (kimi, kimi-code, opencode-go). + modelDefaultReasoningEfforts: { "kimi-k3": "max" }, + // Blind Cursor models (Auto routers, Composer, GLM-5.2, GLM-5.3) go through the vision sidecar; + // multimodal hosts (Claude/Gemini/GPT/Kimi/Grok) take native SelectedImage. The catalog + // still advertises image for noVision members so Codex can attach (sidecar option B). + noVisionModels: [...CURSOR_NO_VISION_MODELS], + }, + { + // The canonical Cognition account provider, after absorbing `devin-cli` + // (devlog/_plan/260913_devin_provider_merge). The two ids were the same + // `devin` adapter, the same server.codeium.com api-server, and the same + // `devin-session-token$` credential — only the account source + // differed: this entry did an Auth0 browser sign-in while `devin-cli` + // imported the token the installed CLI's own PKCE login had already + // written to credentials.toml. The merged login is import-first with a + // browser fallback: the CLI credential is taken when present (no browser + // opens), and the Auth0 flow remains because it is the only path for + // users without the CLI. `devin-cli` survives only as a deprecated + // alias; a startup migration rewrites saved provider rows, cross-config + // references, and auth.json slots to `devin`. + // + // `oauth` classifies the ACCOUNT, not the transport. This is not a local + // runtime: unlike Ollama or LM Studio it cannot answer at all until a + // vendor account is signed in, and `local` grouped it with things that + // have no account. It is also the only classification that reaches the + // dashboard Accounts tab, which is built from OAUTH_PROVIDERS. + id: "devin", + label: "Cognition (Devin/Windsurf)", + adapter: "devin", + baseUrl: "https://server.codeium.com", + authKind: "oauth", + featured: false, + // Off: `deriveProviderPresets` keys the preset catalog off this flag, so a + // true row would draw the provider twice — an Accounts login row and a + // preset tile. + dashboardPreset: false, + note: "Experimental unofficial Cognition/Devin bridge. ocx login devin first imports the credential an installed Devin CLI already holds (no browser); without one it opens Auth0 browser sign-in and exchanges the token via Cognition's RegisterUser for a long-lived API key.", + // Union seed of the two merged rosters: the newer devin-cli lineup first + // (it is the current catalog, so its default ordering wins), then the ids + // only the old devin entry carried. Degraded-mode seed only either way — + // `liveModels` discovers the account's real roster. + models: ["swe-2", "swe-1-7", "gpt-5-6-sol", "gpt-6-astra", "claude-opus-5", "claude-fable-5-1", "claude-sonnet-5", "glm-5-3", "kimi-k3", "gemini-3-8-flash", "grok-4-6", "swe-1-7-lightning", "gpt-5-6-luna", "gpt-5-6-terra", "claude-opus-4-8", "glm-5-2", "kimi-k2-7", "grok-4-5"], + liveModels: true, + defaultModel: "swe-2", + modelContextWindows: DEVIN_MODEL_CONTEXT_WINDOWS, + // Degraded-mode ladders only. Once a credential is present the account + // catalog supplies each base model its measured rungs; these two fields are + // what a signed-out picker and the Pi-shaped client exports fall back to. + modelReasoningEfforts: DEVIN_MODEL_EFFORTS, + reasoningEfforts: DEVIN_DEFAULT_EFFORTS, + }, + { + id: "xai", + label: "xAI Grok", + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authKind: "oauth", + allowKeyAuthOverride: true, + // Priority Processing is documented for xAI's public API-key Chat Completions and + // Responses endpoints. The OAuth lane is classified per-model below, not here: + // do not turn this into a provider-wide supportsServiceTier declaration. + keyAuthServiceTier: { + supportsServiceTier: true, + chatServiceTier: true, + }, + // OAuth (Grok subscription gateway) service-tier capability, classified by live probe + // on 2026-09-13 (devlog/_fin/260913_xai_oauth_fast/020_probe-evidence.md): each listed + // model accepted service_tier "priority" over grok-oauth and echoed priority upstream. + // Key-auth already declares provider-wide support above, so this map only newly opens + // the OAuth lane. grok-4.20-multi-agent-0309 is deliberately absent: the gateway accepts + // the field but answers service_tier "default" — a live downgrade, not a fast tier. + // Unlisted and future-discovered ids stay unclassified. + modelSupportsServiceTier: { + "grok-4.6": true, + "grok-4.5": true, + "grok-4.3": true, + "grok-4.20-0309-reasoning": true, + "grok-4.20-0309-non-reasoning": true, + "grok-build-0.1": true, + "grok-composer-2.5-fast": true, + }, + // Lets a caller-sent service_tier forward on the Chat wire (fastwire forwardCallerTier + // chain). Provider-wide by construction: unclassified chat-wire models then preserve a + // caller tier verbatim, the same contract other unclassified Responses routes already + // follow; --fast publication and proxy-owned fast injection stay capability-scoped by + // the map above. Key-auth declared the same value via keyAuthServiceTier, so the key + // lane is unchanged. + chatServiceTier: true, + // Shared across key and OAuth catalog rows. OAuth subscription has no + // per-token price, so the 2x claim is scoped to key auth. + fastTierDescription: "Priority processing; tier pricing applies on key auth only", + featured: true, + oauthId: "xai", + jawcodeBundle: "xai", + supportsOpenAiWebSearchToolFields: false, + // Live A/B on 2026-08-20: xAI rejects native custom/custom_tool_call shapes while accepting + // the otherwise-identical request after the custom tool is lowered to a function. + supportsResponsesCustomTools: false, + note: "Log in with your Grok account", + // Parallel tool calls: officially supported and default-on per docs.x.ai function-calling + // (verified 260709, devlog/_plan/260709_parallel_tool_calls). Streamed calls arrive whole + // per chunk, so the buffered parser assembles them losslessly. + parallelToolCalls: true, + // Live /v1/models discovery is the authoritative lineup (verified 260709: returns grok-4.5); + // the static list below is the logged-out fallback seed. + liveModels: true, + // 260709 refresh: lineup + metadata from official docs.x.ai (grok-4.5 announced 07-08); + // grok-composer-2.5-fast kept as account-verified (absent from public docs). Evidence: + // devlog/model_update/260709_model_refresh/001_xai_lineup.md. + // 260823: grok-4.20-multi-agent-0309 still returns 400 on Chat Completions, but works + // on Responses. The server reports this dated id for both it and the floating + // grok-4.20-multi-agent-beta-latest alias, so expose only the dated deployment id. + // 260813: grok-4.6 added per docs.x.ai/developers/grok-4-6. Context/vision still match + // grok-4.5; the reasoning ladder does not — 4.6 adds the documented xhigh rung. + models: XAI_MODELS, + // Measured only on grok-4.6 against cli-chat-proxy.grok.com: even an invalid + // `text.verbosity` value is accepted and low/high/omitted output length is non-monotonic. + // Apply the resulting opt-out to the whole xAI lineup because `text.verbosity` is an OpenAI + // Responses parameter absent from xAI's documented API, not because every model was probed. + // Keep this separate from reasoning-summary support: that bit gates Codex's + // entire Responses reasoning object, including reasoning.effort. + modelSupportsVerbosity: Object.fromEntries(XAI_MODELS.map(id => [id, false])), + // Provider-wide, not merely per-model: `text.verbosity` is an OpenAI Responses parameter + // absent from xAI's documented API, so a model discovered later has no more support for it + // than the seeded ones do. + supportsVerbosity: false, + defaultModel: "grok-4.5", + // Grok 4.6/4.5 subscription Responses callers use the native wire with the existing + // namespace/web-search/replay normalization. Chat remains an explicit modelAdapters + // opt-in. Multi-agent has no Chat wire and uses Responses under both auth modes. + // grok-4.6/4.5 are classified OAuth fast-tier models (modelSupportsServiceTier above), + // so a caller-sent service_tier:"priority" forwards on this lane — the Codex fast-toggle + // path. Multi-agent keeps its pin: probed 2026-09-13, the gateway downgrades its tier to + // "default", so forwarding a caller tier would advertise a tier it does not get. + modelWireDefaults: { + "grok-4.6": { + wire: "openai-responses", + inbound: ["responses"], + authModes: ["oauth"], + }, + "grok-4.5": { + wire: "openai-responses", + inbound: ["responses"], + authModes: ["oauth"], + }, + "grok-4.20-multi-agent-0309": { + // Even at high effort it emits no reasoning-summary deltas or encrypted replay + // material. Do not encode that as modelSupportsReasoningSummaries:false: through + // Codex #1100 that suppresses the entire reasoning object, including the effort + // that controls this model's agent count. An empty summary pane is harmless. + // Chat Completions returns 400 for this model, so every inbound uses Responses — + // `anthropic` included. Omitting it left providerModelWireDefault returning undefined + // for the Claude Messages lane, so resolveWireProtocolOverride kept xAI's provider-wide + // openai-chat adapter and sent this model to the wire it 400s on. + wire: "openai-responses", + inbound: ["responses", "chat", "anthropic"], + forwardCallerServiceTier: false, + }, + }, + // Vision lineup per docs.x.ai model-capabilities/images/understanding: the grok-4.x chat + // models accept image input (JPEG/PNG, URL or base64). Without this the catalog leaves + // inputModalities undefined, and deriveComboCatalogModel defaults an undefined member to + // ["text"] — so any combo containing an xAI target is advertised to Codex as text-only and + // the app blocks attachments client-side. grok-build-0.1 / grok-composer-2.5-fast stay out + // (they are already listed in noVisionModels below). + modelInputModalities: { + "grok-4.6": ["text", "image"], + "grok-4.5": ["text", "image"], + "grok-4.3": ["text", "image"], + "grok-4.20-multi-agent-0309": ["text", "image"], + "grok-4.20-0309-reasoning": ["text", "image"], + "grok-4.20-0309-non-reasoning": ["text", "image"], + }, + noReasoningModels: ["grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"], + // Replay assistant reasoning_content for grok reasoning models: xAI documents dropped + // reasoning_content as the top cause of prompt-cache misses on multi-turn conversations + // (docs.x.ai prompt-caching/multi-turn, verified 2026-07-13 — devlog/_plan/260713_grok_caching). + // Models that never emit reasoning simply have no thinking parts to replay (no-op). + preserveReasoningContentModels: ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"], + // grok-4.5 reasoning is always-on with low/medium/high (no off tier, no xhigh). + // grok-4.6 adds xhigh per docs.x.ai/developers/model-capabilities/text/reasoning; + // multi-agent accepts the same four wire values to select 4 or 16 collaborators. xAI + // documents high as the 4.6 default but no multi-agent default, so do not invent one. + modelReasoningEfforts: { + "grok-4.6": ["low", "medium", "high", "xhigh"], + "grok-4.5": ["low", "medium", "high"], + "grok-4.20-multi-agent-0309": ["low", "medium", "high", "xhigh"], + }, + modelDefaultReasoningEfforts: { "grok-4.6": "high" }, + modelContextWindows: { + "grok-4.6": 500_000, + "grok-4.5": 500_000, + "grok-4.3": 1_000_000, + "grok-4.20-multi-agent-0309": 1_000_000, + "grok-4.20-0309-reasoning": 1_000_000, + "grok-4.20-0309-non-reasoning": 1_000_000, + "grok-build-0.1": 256_000, + }, + noVisionModels: ["grok-build-0.1", "grok-composer-2.5-fast"], + }, + { + id: "command-code", + label: "Command Code - Auth", + adapter: "command-code", + baseUrl: "https://api.commandcode.ai", + authKind: "oauth", + oauthId: "command-code", + featured: true, + note: "Log in with your Command Code account", + // OAuth needs one initial selection, but the exposed catalog is always discovered from the + // signed-in account. Do not add a static model list here. + defaultModel: "deepseek/deepseek-v4-flash", + liveModels: true, + modelDiscovery: { + url: "https://api.commandcode.ai/provider/v1/models", + maxResponseBytes: 262_144, + maxModels: 256, + }, + // These are capability facts from official Command Code model profiles, not seeded models. + // Unknown/new live models deliberately do not advertise a reasoning picker. + reasoningEfforts: [], + modelReasoningEfforts: COMMAND_CODE_MODEL_REASONING_EFFORTS, + // The DeepSeek vision preview id is preemptive metadata — it is expected to + // merge into deepseek-v4-flash later. + modelContextWindows: { + [`deepseek/${DEEPSEEK_VISION_PREVIEW_MODEL}`]: 1_048_576, + }, + modelInputModalities: COMMAND_CODE_MODEL_INPUT_MODALITIES, + defaultMaxOutputTokens: 64_000, + // The proprietary generate wire has no verified per-request serialization flag. + parallelToolCalls: false, + }, + { + id: "orcarouter-oauth", + label: "OrcaRouter - Auth", + adapter: "openai-chat", + baseUrl: "https://api.orcarouter.ai/v1", + authKind: "oauth", + oauthId: "orcarouter-oauth", + featured: true, + allowBaseUrlOverride: true, + defaultModel: "openai/gpt-5.5", + models: ORCAROUTER_MODELS, + liveModels: true, + modelDiscovery: ORCAROUTER_MODEL_DISCOVERY, + modelReasoningEfforts: ORCAROUTER_MODEL_REASONING_EFFORTS, + note: "Connect your OrcaRouter account with OAuth 2.0 + PKCE; the issued API key is stored in OpenCodex's existing credential store.", + }, + { + id: "anthropic", + label: "Anthropic Claude", + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authKind: "oauth", + allowBaseUrlOverride: true, + featured: true, + oauthId: "anthropic", + jawcodeBundle: "anthropic", + note: "Log in with your Claude account", + models: [...ANTHROPIC_MODELS], + modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + // Codex omits max_output_tokens; without a provider budget the Anthropic adapter + // falls back to 8192, which truncates long answers with stop_reason=max_tokens. + defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + defaultModel: "claude-sonnet-5", + }, + { + id: "anthropic-apikey", + label: "Anthropic (API key)", + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + authKind: "key", + featured: true, + dashboardUrl: "https://console.anthropic.com/settings/keys", + jawcodeBundle: "anthropic", + extraMetadataAliases: ["anthropic-key"], + note: "Direct Anthropic API billing — no Claude subscription", + models: [...ANTHROPIC_MODELS], + liveModels: true, + modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS }, + modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS }, + defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + defaultModel: "claude-sonnet-5", + }, + { + id: "kimi", + label: "Kimi", + adapter: "openai-chat", + baseUrl: "https://api.kimi.com/coding/v1", + authKind: "oauth", + modelSuffixBracketStrip: true, + // Kimi Code Plan documents a stable session/task prompt_cache_key as required to improve + // cache hit rates. + // The chat adapter only forwards a key already on the internal request (Codex's session key, + // or the one the Claude /v1/messages inbound derives); the adapter itself never invents one. + // Evidence: https://platform.kimi.com/docs/api/chat + promptCacheKey: true, + featured: true, + oauthId: "kimi", + jawcodeBundle: "moonshot", + note: "Log in with your Kimi account", + models: KIMI_CODING_MODELS, + defaultModel: "kimi-k2.7-code", + modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS, + modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES, + // K3 accepts low/high/max; Codex aliases are normalized by the model-scoped wire map. + noReasoningModels: KIMI_CODING_NO_REASONING_MODELS, + modelReasoningEfforts: KIMI_CODING_REASONING_EFFORTS, + modelDefaultReasoningEfforts: KIMI_CODING_DEFAULT_REASONING_EFFORTS, + modelReasoningEffortMap: KIMI_CODING_REASONING_EFFORT_MAPS, + noTemperatureModels: KIMI_LOCKED_PARAMETER_MODELS, + noTopPModels: KIMI_LOCKED_PARAMETER_MODELS, + noPenaltyModels: KIMI_LOCKED_PARAMETER_MODELS, + autoToolChoiceOnlyModels: KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS, + preserveReasoningContentModels: KIMI_THINKING_MODELS, + }, + { + id: "kiro", + label: "Kiro (AWS CodeWhisperer)", + adapter: "kiro", + baseUrl: "https://runtime.us-east-1.kiro.dev", + authKind: "oauth", + oauthId: "kiro", + note: "Import-first: reuses your installed and signed-in Kiro CLI session (requires `kiro-cli login`). Add account logs `kiro-cli` out, switches it through a fresh browser login, stores the account by profile ARN, and restores the previous CLI session on cancellation or failure. Experimental third-party harness — see Kiro ToS.", + models: KIRO_MODELS, + defaultModel: "kiro-auto", + // Kiro speaks CodeWhisperer wire, not OpenAI-style GET /models. Keep the static + // catalog authoritative so a spurious 2xx from runtime.../models cannot drop seeded ids + // (e.g. newly listed GPT-5.6 tiers) via live-discovery reconciliation. + liveModels: false, + // Per-model context metadata is maintained next to the Kiro model list. + modelContextWindows: KIRO_MODEL_CONTEXT_WINDOWS, + modelReasoningEfforts: KIRO_MODEL_REASONING_EFFORTS, + modelSupportsVerbosity: Object.fromEntries(KIRO_MODELS.map(id => [id, false])), + }, + { + // Nous Portal — Nous Research subscription gateway (same backend Hermes Agent + // uses). OAuth is a device grant (src/oauth/nous.ts): the access token IS the + // per-request inference JWT (scope inference:invoke), refresh tokens are + // single-use and rotated on every refresh. Catalog is a mix of paid models + // (billed against the Portal subscription) and `:free` slugs (e.g. + // tencent/hy3:free, stepfun/step-3.7-flash:free, inclusionai/ling-3.0-flash:free); + // free-tier gating is decided live by the Portal per account, so discovery + // from the signed-in account is authoritative; the static seed below is the + // logged-out fallback and only lists free models verified on a real account + // (2026-08-10): the Portal free list is authoritative and currently has + // exactly 4 :free models: tencent/hy3:free, poolside/laguna-s-2.1:free, + // stepfun/step-3.7-flash:free, poolside/laguna-xs-2.1:free. + // inclusionai/ling-3.0-flash:free was removed from the Portal free list + // (404 on the inference API since 2026-08-07) and must not be seeded. + id: "nous", + label: "Nous Portal", + adapter: "openai-chat", + baseUrl: "https://inference-api.nousresearch.com/v1", + authKind: "oauth", + oauthId: "nous", + featured: true, + // Mixed free + paid provider: the free tier is per-model (the `:free` + // slugs), not a property of the whole provider, so freeTier stays false to + // avoid implying every model is free. + freeTier: false, + dashboardUrl: "https://portal.nousresearch.com", + defaultModel: "tencent/hy3:free", + liveModels: true, + models: ["tencent/hy3:free", "poolside/laguna-s-2.1:free", "stepfun/step-3.7-flash:free", "poolside/laguna-xs-2.1:free"], + modelDiscovery: { + // Resolves against effectiveBaseUrl (registry baseUrl .../v1) to the same + // canonical endpoint https://inference-api.nousresearch.com/v1/models. + // Nous returns a mixed paid/free catalog whose JSON can exceed 256 KiB; + // keep the provider-specific limit below the process-wide 4 MiB ceiling. + path: "models", + maxResponseBytes: 1_048_576, + maxModels: 512, + }, + note: "Nous Research subscription gateway. OAuth device login with your own Portal account; mixed paid + :free models discovered live (fallback seed 2026-08-10: tencent/hy3:free, poolside/laguna-s-2.1:free, stepfun/step-3.7-flash:free, poolside/laguna-xs-2.1:free).", + }, + { + id: "openai-apikey", + label: "OpenAI API", + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authKind: "key", + supportsServiceTier: true, + featured: true, + dashboardUrl: "https://platform.openai.com/api-keys", + defaultModel: "gpt-5.5", + models: ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, ...OPENAI_DAYBREAK_MODELS, "gpt-6-astra"], + liveModels: true, + modelContextWindows: { ...OPENAI_API_GPT56_CONTEXT_WINDOWS, ...OPENAI_DAYBREAK_CONTEXT_WINDOWS, "gpt-6-astra": 1_050_000 }, + modelMaxInputTokens: { ...OPENAI_API_GPT56_MAX_INPUT_TOKENS, ...OPENAI_DAYBREAK_MAX_INPUT_TOKENS, "gpt-6-astra": 922_000 }, + modelMaxOutputTokens: { "gpt-6-astra": 128_000 }, + modelInputModalities: Object.fromEntries( + ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, ...OPENAI_DAYBREAK_MODELS, "gpt-6-astra"] + .map(id => [id, ["text", "image"]]), + ), + modelReasoningEfforts: { + ...Object.fromEntries( + [...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS].map(id => [id, OPENAI_API_GPT56_REASONING_EFFORTS]), + ), + ...OPENAI_DAYBREAK_REASONING_EFFORTS, + "gpt-6-astra": ["low", "medium", "high", "xhigh", "max"], + }, + virtualModels: OPENAI_API_GPT56_VIRTUAL_MODELS, + }, + /* [Decision Log] + - 목적과 의도: Reach Meta's Muse Spark models directly on Meta's own Model API, instead of only through the Command Code and OpenCode Zen resellers already in this registry. + - 기존 구현 및 제약 조건: Meta publishes both POST /v1/responses and POST /v1/chat/completions at https://api.meta.ai/v1, and no API key was issued for this change — every value here comes from the published spec (devlog/_plan/260903_muse_spark_plan_oauth/001). + - 검토한 주요 대안: register as openai-chat; use provider id "meta"; enable live discovery; wire the Muse Code subscription credential as OAuth. + - 선택한 방식: an openai-responses key provider under the id "meta-model", with a static two-model roster and no OAuth. + - 다른 대안 대신 이 방식을 선택한 이유: Meta calls Responses "the recommended default for new work ... OpenAI-compatible and exposes the full feature set", carrying reasoning replay and native input_image that Chat would forfeit. The id is "meta-model" because "meta" would capture the LIVE Command Code selector meta/muse-spark-1.3 at router.ts's provider-prefix branch, and would derive META_API_KEY — the Muse Code CLI's variable, not this API's MODEL_API_KEY. + - 장점, 단점 및 영향: users reach Muse Spark without a reseller; discovery stays off until an authenticated /v1/models payload is actually observed, so an unseen roster (Meta also serves image and voice families here) cannot leak into the picker. + */ + { + id: "meta-model", + label: "Meta Model API", + adapter: "openai-responses", + baseUrl: "https://api.meta.ai/v1", + authKind: "key", + dashboardUrl: "https://dev.meta.ai/docs/authentication", + defaultModel: "muse-spark-1.3", + models: META_MUSE_MODELS, + // Static roster: no authenticated /v1/models payload was ever observed (the only + // contact was an unauthenticated GET returning 401 invalid_api_key), and Meta serves + // non-agent families on this same base URL. Turning discovery on would publish an + // unseen roster into the picker. + liveModels: false, + // A user may already own a custom provider named "meta-model" pointing elsewhere; + // without this, registry transport canonicalization would retarget it and send their + // saved key to Meta. + preserveCustomDestination: true, + modelContextWindows: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_CONTEXT_WINDOW])), + // text+image only. Meta also documents video, audio (degraded on 1.3), and PDF, but + // the catalog modality enum is text/image and over-advertising poisons the exported + // client config (see tests/codex-integration/catalog-input-modality-enum.test.ts). + modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])), + modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])), + modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])), + // No defaultMaxOutputTokens: Meta publishes none. The only number in its docs + // (131072) appears inside a third-party config sample, and the protocol pages call + // the real limit "model-dependent". + // Meta names its variable MODEL_API_KEY, but the env var opencodex reads is derived + // from the provider id (META_MODEL_API_KEY). Saying only Meta's name would send a + // user to export a variable this proxy never reads. + note: "Pay-as-you-go Meta Model API. Get a key at https://dev.meta.ai (Meta calls it MODEL_API_KEY; export it here as META_MODEL_API_KEY) — a Meta developer account needs a payment method before it can serve requests, and every call is metered per token. A Muse Code subscription does NOT work here: Meta scopes that credential to the Muse Code CLI and bills any other key pay-as-you-go (dev.meta.ai/docs/muse-code/subscriptions). The Contributor tier (muse-spark-1.3-contributor) is cheap because Meta trains on your prompts — about 92% off input, 95% off output, 99% off cached input; do not send confidential material through it. Muse Spark is also reachable through resellers: command-code carries both tiers, opencode-go serves only muse-spark-1.3-contributor.", + }, + /* [Decision Log] + - 목적과 의도: Let an operator who already signed the Muse Code CLI in reach Muse Spark with that credential, instead of provisioning a second key. + - 기존 구현 및 제약 조건: The CLI stores a pointer at ~/.config/muse/auth.json and the secret in the macOS Keychain (ai.meta.dev.credentials/meta). Measured: the OAuth access_token 401s on /v1/models while the sibling api_key returns 200, so the usable artifact is a static key, not a refreshable token. + - 검토한 주요 대안: spawn `muse login` and poll; reimplement Meta's device grant; treat it as a second key preset; ship nothing. + - 선택한 방식: an OAuth provider that imports the existing credential on macOS and accepts a pasted key elsewhere, validates either once, and never spawns or reimplements anything. + - 다른 대안 대신 이 방식을 선택한 이유: `muse login` has no non-interactive mode, so a spawned child could outlive cancellation, and polling for the pointer file is satisfied instantly by the one already on disk — reimporting the OLD account on a force-login. Reimplementing the grant would mean guessing a client id the vendor does not publish. + - 장점, 단점 및 영향: no new credential to provision, and the id is distinct from meta-model so neither pool contaminates the other. Meta scopes this credential to its own CLI, so the provider carries a HIGH_RISK ToS warning, a CLI-side warning before any read, and a note that says plainly what is unsupported. + */ + { + id: "meta-muse", + label: "Meta Muse Code (CLI credential)", + adapter: "openai-responses", + baseUrl: "https://api.meta.ai/v1", + // Meta own client sends this on every Muse Code call. We never have, so a future + // server-side requirement would break every Muse request with no local signal. + // Declared here rather than in a transport hook so it also covers model discovery + // (src/oauth/index.ts:1176) and still yields to a user-set header + // (mergeRegistryStaticHeaders, src/providers/registry.ts:3494). + staticHeaders: { "x-api-version": "1.0.0" }, + authKind: "oauth", + oauthId: "meta-muse", + dashboardUrl: "https://dev.meta.ai", + defaultModel: "muse-spark-1.3", + models: META_MUSE_MODELS, + // Same reason as meta-model: the authenticated roster carries muse-image-1.0 and + // muse-voice-transcribe-1.0, which this Responses-agent provider cannot drive. + liveModels: false, + modelContextWindows: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_CONTEXT_WINDOW])), + modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])), + modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])), + modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])), + note: "Signs in to Meta with a browser device code on any platform, then mints the Muse Code subscription key. That grant is reimplemented from the one the Muse Code CLI performs and has NOT been exercised against Meta from OpenCodex, so treat the first login as unverified. If the Muse Code CLI is already signed in on macOS, the existing key is imported instead of starting a new grant. A pasted key from https://dev.meta.ai still works as a fallback when a device login cannot complete, and faces the same format check and live validation. A device login authenticates as Meta own Muse Code client, which is a stronger claim than reusing a key the CLI already minted. Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The key, imported or pasted, is copied into OpenCodex's auth store. For an account signed in with the device login, OpenCodex refreshes Meta's subscription windows on demand from the same key endpoint the login uses, at most once every five minutes. For an imported or pasted key there is no endpoint to query them on demand, so OpenCodex reads them from streaming responses and shows the last observed value with its age; refreshing one then requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).", + }, + { + id: "umans", + label: "Umans AI Coding Plan", + adapter: "anthropic", + baseUrl: "https://api.code.umans.ai", + authKind: "key", + featured: true, + dashboardUrl: "https://app.umans.ai/billing", + defaultModel: "umans-coder", + models: UMANS_MODELS, + modelContextWindows: UMANS_MODEL_CONTEXT_WINDOWS, + modelInputModalities: UMANS_MODEL_INPUT_MODALITIES, + note: "Coding plan via Anthropic Messages", + modelReasoningEfforts: { + "umans-coder": UMANS_REASONING_EFFORTS, + "umans-kimi-k2.7": UMANS_REASONING_EFFORTS, + "umans-flash": UMANS_REASONING_EFFORTS, + "umans-glm-5.3": UMANS_GLM_53_REASONING_EFFORTS, + "umans-glm-5.3-flash": UMANS_GLM_53_REASONING_EFFORTS, + "umans-glm-5.2": UMANS_GLM_REASONING_EFFORTS, + "umans-glm-5.1": UMANS_GLM_REASONING_EFFORTS, + "umans-qwen3.6-35b-a3b": UMANS_REASONING_EFFORTS, + }, + noVisionModels: UMANS_TEXT_ONLY_MODELS, + escapeBuiltinToolNames: true, + }, + { + id: "opencode-go", label: "opencode go", adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", + authKind: "key", featured: true, dashboardUrl: "https://opencode.ai/auth", defaultModel: "kimi-k2.7-code", + jawcodeBundle: "opencode-go", note: "GLM, DeepSeek, Kimi, Qwen, MiMo…", + // Zen Go can close a Chat stream after a fully assembled function call without sending + // finish_reason or [DONE] (#2260). The adapter still rejects incomplete argument JSON. + openaiChatEofTolerance: true, + // Go rejects reasoning.encrypted_content with previous_response_id (#3838). + // Use explicit replay history and the existing stateless Responses policy. + statelessResponses: true, + /* [Decision Log] + - 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, Grok 4.6, and Muse Spark Contributor (#2617). + - 기존 구현 및 제약 조건: The provider is mixed-wire but its provider-wide `openai-chat` adapter sent Luna to `/chat/completions`; explicit user `modelAdapters` entries must remain authoritative. + - 검토한 주요 대안: Change the whole provider to Responses; infer the wire from model-family names; add one registry-only exact-model default. + - 선택한 방식: Declare only the named models as `openai-responses` through the existing registry default mechanism; the map stays an exact-model allowlist rather than a family or provider-wide rule. + - 다른 대안 대신 이 방식을 선택한 이유: OpenCode Go documents sibling models on Chat or Anthropic endpoints, and an exact registry default preserves both those routes and explicit opt-out precedence. + - 장점, 단점 및 영향: Each listed model reaches `/responses` from every inbound surface without changing siblings; a future upstream endpoint change requires an evidence-backed registry update. + */ + modelWireDefaults: { + "gpt-5.6-luna": "openai-responses", + "grok-4.6": "openai-responses", + "muse-spark-1.3-contributor": "openai-responses", + "muse-spark-1.2-contributor": "openai-responses", + }, + modelContextWindows: { + "kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW, + // Zen Go discovers only the gateway id, so carry DeepSeek's official 1M V4.1 + // window here or Codex falls back to its conservative 128k routed-model default. + "deepseek-v4.1-flash": 1_048_576, + // The DeepSeek vision preview id is metadata-only here: the Go roster is + // discovered live, so it applies the moment the gateway serves the id. + [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576, + // Muse Spark Contributor serves a 1,048,576-token (1M) context window over + // /responses on Zen Go, matching its 1.1 sibling (Meta developer docs, verified 2026-08-28). + // Without this declaration the catalog falls back to 128k, capping real usable context. + // 1.3 ships the same window as 1.2 and is served from the same Zen Go roster. + "muse-spark-1.3-contributor": 1_048_576, + "muse-spark-1.2-contributor": 1_048_576, + }, + modelInputModalities: { + "kimi-k3": ["text", "image"], + // glm-5.3-flash is a native VLM (docs.z.ai/guides/vlm/glm-5.3-flash). It is + // deliberately absent from this preset's noVisionModels, which is the + // correct NEGATIVE half, but with no positive modelInputModalities entry + // configuredInputModalities returns undefined and the catalog falls through + // to the ["text"] floor. The same model is already declared ["text","image"] + // on the zai and zhipu-bigmodel-coding presets, so the registry described + // one model two ways (#4505). + "glm-5.3-flash": ["text", "image"], + // Experimental DeepSeek vision preview — expected to merge into deepseek-v4-flash later. + [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], + // This route is text-only upstream — it is already listed in this preset's + // noVisionModels, which routes images through the proxy's vision sidecar and + // makes the catalog advertise image input on its behalf. The positive + // text-only declaration is what reaches an EXISTING install: derive.ts fills + // noVisionModels all-or-nothing, so a config persisted before this id joined + // the list keeps a stale list, the sidecar predicate never matches, the row + // carries no modality at all, and any combo containing it collapses to + // ["text"] (#4505). modelInputModalities IS per-key filled, so this + // declaration lands on old configs. It states the route's real upstream + // capability and keeps the sidecar explicitly distinct from native vision. + "deepseek-v4.1-flash": ["text"], + // Muse Spark Contributor is natively multimodal on Zen Go: it accepts input_image + // parts over /responses (probed 2026-08-26). Without this declaration the catalog + // advertises it text-only and the Codex app blocks image attachments client-side with + // "This model does not support image inputs" before the request ever reaches the proxy. + // 1.3 is the same-shaped successor and Command Code documents it as multimodal. + "muse-spark-1.3-contributor": ["text", "image"], + "muse-spark-1.2-contributor": ["text", "image"], + }, + modelReasoningEfforts: { + "gpt-5.6-luna": OPENAI_API_GPT56_REASONING_EFFORTS, + "grok-4.6": ["low", "medium", "high", "xhigh"], + "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, + "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, + "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, + "qwen3.8-max": QWEN38_REASONING_EFFORTS, + "kimi-k3": KIMI_CODING_K3_REASONING_EFFORTS, + "kimi-k2.7-code": [], + "kimi-k2.7-code-highspeed": [], + ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS])), + ...Object.fromEntries(OPENCODE_GO_THINKING_BUDGET_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])), + ...Object.fromEntries(DEEPSEEK_GATEWAY_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), + }, + modelDefaultReasoningEfforts: { "grok-4.6": "high", "kimi-k3": "max" }, + // glm-5.2 uses identity labels now that `max` is a native Codex level (no alias map); + // the thinking-toggle map is a REAL wire alias (effort -> enabled/disabled) and stays. + modelReasoningEffortMap: { + "kimi-k3": KIMI_CODING_K3_REASONING_EFFORT_MAP, + ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])), + ...Object.fromEntries(DEEPSEEK_GATEWAY_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), + }, + modelSupportsReasoningSummaries: { + "glm-5.3": true, + "glm-5.3-flash": true, + "glm-5.2": true, + "glm-5.1": true, + "glm-5": true, + ...Object.fromEntries(DEEPSEEK_GATEWAY_THINKING_MODELS.map(id => [id, true])), + }, + thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS, + /* + * The Go-specific list, not the shared one. The shared `THINKING_BUDGET_MODELS` also + * carries Neuralwatt-only ids (`qwen3.5-397b`, `qwen3.6-35b`) that this preset never + * gives a ladder to, so a live roster serving one of them armed the thinking-budget + * wire path with nothing to advertise: the catalog showed no effort control while the + * adapter still translated effort into `thinking_budget`. + */ + thinkingBudgetModels: OPENCODE_GO_THINKING_BUDGET_MODELS, + noReasoningModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + // Text-only Zen Go models (jawcode metadata) — the vision sidecar describes images for + // every model listed here (and the catalog advertises image input on their behalf). + // Kimi K2.7 Code accepts text+image+video: do NOT list it here. + noVisionModels: [ + "glm-5.3", "glm-5.2", "glm-5", "glm-5.1", + "deepseek-v4.1-flash", "deepseek-v4-flash", + "mimo-v2-pro", "mimo-v2.5-pro", + "minimax-m2.5", "minimax-m2.7", + "qwen3.7-max", + ], + noTemperatureModels: ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + noTopPModels: ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + noPenaltyModels: ["kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + // Issue #78: DeepSeek V4 thinking mode requires reasoning_content replay on tool-call turns. + preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "kimi-k3", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", ...DEEPSEEK_GATEWAY_THINKING_MODELS], + /* + * Issues #1338 / #1415: this gateway answers a `response_format` of type + * `json_schema` with HTTP 400 `This response_format type is unavailable now` + * (quoted from the upstream body as `Error from provider (Console Go)`), which + * breaks every Codex auto-review turn on a DeepSeek route. #1424 shipped the + * operator-side opt-out; operators have been applying it by hand ever since. + * The reported rejection is type-specific, so this narrower list downgrades the + * request to `json_object` instead of claiming the whole field is unavailable. + */ + noJsonSchemaModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS], + }, + { + id: "neuralwatt", + label: "Neuralwatt Cloud", + adapter: "openai-chat", + baseUrl: "https://api.neuralwatt.com/v1", + authKind: "key", + dashboardUrl: "https://portal.neuralwatt.com", + defaultModel: "glm-5.3", + // 2026-07-10 live /v1/models: K2.5 rows were removed and GLM-5.2 short variants added. + // 260814: the glm-5.3 quartet is speculative; live discovery is authoritative and drops + // any id Neuralwatt has not published yet. + // Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md and https://api.neuralwatt.com/v1/models. + models: [ + "glm-5.3", "glm-5.3-fast", "glm-5.3-short", "glm-5.3-short-fast", + "glm-5.3-flash", + "glm-5.2", "glm-5.2-fast", "glm-5.2-short", "glm-5.2-short-fast", + "kimi-k2.6", "kimi-k2.6-fast", + "kimi-k2.7-code", + "qwen3.5-397b", "qwen3.5-397b-fast", "qwen3.6-35b", "qwen3.6-35b-fast", + ], + // Neuralwatt's /v1/models metadata is authoritative; these static hints are the offline fallback. + modelReasoningEfforts: { + "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, + "glm-5.3-fast": [], + "glm-5.3-short": ZAI_GLM_53_REASONING_EFFORTS, + "glm-5.3-short-fast": [], + // No `-fast`/`-short` variants are asserted for the flash tier: those suffixes + // encode routing Neuralwatt documents per model, and this seed has no source for them. + "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, + "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, + "glm-5.2-fast": [], + "glm-5.2-short": ZAI_GLM_52_REASONING_EFFORTS, + "glm-5.2-short-fast": [], + "kimi-k2.6": [], + "kimi-k2.6-fast": [], + "kimi-k2.7-code": [], + // Qwen3.x uses thinking_budget, NOT graded reasoning_effort; the adapter maps the five + // Codex picker levels onto budget fractions. + "qwen3.5-397b": THINKING_BUDGET_EFFORTS, + "qwen3.5-397b-fast": [], + "qwen3.6-35b": THINKING_BUDGET_EFFORTS, + "qwen3.6-35b-fast": [], + }, + thinkingBudgetModels: THINKING_BUDGET_MODELS, + noReasoningModels: ["glm-5.3-fast", "glm-5.3-short-fast", "glm-5.2-fast", "glm-5.2-short-fast", "kimi-k2.6-fast", "qwen3.5-397b-fast", "qwen3.6-35b-fast"], + noVisionModels: ["glm-5.3", "glm-5.3-fast", "glm-5.3-short", "glm-5.3-short-fast", "glm-5.2", "glm-5.2-fast", "glm-5.2-short", "glm-5.2-short-fast", "qwen3.5-397b", "qwen3.5-397b-fast"], + noTemperatureModels: ["kimi-k2.7-code"], + noTopPModels: ["kimi-k2.7-code"], + noPenaltyModels: ["kimi-k2.7-code"], + autoToolChoiceOnlyModels: ["kimi-k2.7-code"], + preserveReasoningContentModels: NEURALWATT_REASONING_HISTORY_MODELS, + }, + { + id: "openrouter", + label: "OpenRouter", + adapter: "openai-chat", + baseUrl: "https://openrouter.ai/api/v1", + authKind: "key", + featured: true, + dashboardUrl: "https://openrouter.ai/keys", + jawcodeBundle: "openrouter", + models: ["anthropic/claude-sonnet-5", ...OPENROUTER_GPT56_MODELS], + modelContextWindows: { + "anthropic/claude-sonnet-5": 1_000_000, + ...OPENROUTER_GPT56_CONTEXT_WINDOWS, + }, + // OpenRouter documents priority support for OpenAI endpoints, but not Anthropic. Keep the + // provider unclassified and opt in only the exact OpenAI-backed slugs we ship. These facts + // belong only to the canonical destination; a same-named custom gateway is unknown to us. + modelServiceTierCapabilityBaseUrlGuard: isCanonicalOpenRouterTarget, + modelSupportsServiceTier: { + "openai/gpt-5.6-sol": true, + "openai/gpt-5.6-terra": true, + "openai/gpt-5.6-luna": true, + }, + // Deliberately no OpenRouter route pin: it bills the endpoint actually used and reports the + // actual service_tier. B0 confirmation therefore owns downgrade safety. Forcing `only` plus + // `allow_fallbacks:false` would turn a graceful priority-capacity fallback into a hard failure. + }, + { + // Primary sources checked 2026-08-02: + // - docs.cline.bot/getting-started/clinepass publishes this exact catalog and explicitly + // authorizes using the full slugs through Cline's external API. + // - docs.cline.bot/api/chat-completions and /api/errors define the endpoint, reasoning delta, + // and choice-scoped mid-stream error contract. + // - Cline's official catalog source resolves per-model capabilities through OpenRouter data; + // the static context/modality snapshot below was cross-checked against that catalog. + // - cline.bot/tos identifies Cline Bot Inc. as the operator. Maintenance owner: @lidge-jun. + id: "cline-pass", + label: "ClinePass", + adapter: "openai-chat", + baseUrl: "https://api.cline.bot/api/v1", + authKind: "key", + dashboardUrl: "https://app.cline.bot", + defaultModel: "cline-pass/kimi-k3", + models: CLINE_PASS_MODELS, + modelContextWindows: CLINE_PASS_MODEL_CONTEXT_WINDOWS, + modelInputModalities: CLINE_PASS_MODEL_INPUT_MODALITIES, + noVisionModels: CLINE_PASS_TEXT_ONLY_MODELS, + // Live-probed 2026-08-13 across every static ClinePass model: the gateway accepts and + // validates low/medium/high/xhigh/max, and rejects an invalid sentinel. Preserve the + // caller's requested tier and let ClinePass own any backend-specific normalization. + reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], + reasoningWireFormat: "gateway-object", + preserveCustomDestination: true, + note: "ClinePass subscription API. Uses a Cline API key and the full cline-pass/ upstream slug; quota is shared across the account's rolling 5-hour, weekly, and monthly limits.", + }, + // Cline API (usage-billing): OpenAI-compatible Chat Completions. Model IDs follow the + // OpenRouter-style `provider/model` convention. Live /models discovery is key-gated (401 + // without auth), so the static seed is the cold-start fallback. Evidence: docs.cline.bot/api/*. + { + id: "cline", + label: "Cline", + adapter: "openai-chat", + baseUrl: "https://api.cline.bot/api/v1", + authKind: "key", + dashboardUrl: "https://app.cline.bot", + liveModels: true, + defaultModel: "anthropic/claude-sonnet-4-6", + models: [ + "anthropic/claude-sonnet-4-6", + "openai/gpt-4o", + "google/gemini-2.5-pro", + "deepseek/deepseek-chat", + "minimax/minimax-m2.5", + ], + preserveCustomDestination: true, + note: "Cline usage-billing API: one key, 100+ models, OpenRouter-style ids. Promotional free models are IDE/CLI-only per Cline docs; minimax/minimax-m2.5 is the documented API free experimentation model.", + }, + { + // OrcaRouter: OpenAI-compatible adaptive router (api.orcarouter.ai). The public live + // catalog is authoritative; model ids and input modalities are never maintained here. + id: "orcarouter", label: "OrcaRouter - API", adapter: "openai-chat", baseUrl: "https://api.orcarouter.ai/v1", + authKind: "key", dashboardUrl: "https://www.orcarouter.ai/console", + // The catalog is public, so a successful /models probe cannot validate a submitted key. + apiKeyValidation: "unknown", + // Standard sponsor under SPONSORS.md (agreement signed 2026-09-07). Pins the row in the + // picker and adds the chip; nothing about routing or defaults changes. + sponsor: { tier: "standard", url: "https://www.orcarouter.ai/?utm_source=opencodex&utm_medium=readme" }, + defaultModel: "openai/gpt-5.5", + models: ORCAROUTER_MODELS, + liveModels: true, + modelDiscovery: ORCAROUTER_MODEL_DISCOVERY, + // Catalog discovery owns WHICH models exist. These entries only retain verified + // request-shaping facts that the upstream catalog does not currently publish. + modelReasoningEfforts: ORCAROUTER_MODEL_REASONING_EFFORTS, + note: "OpenAI-compatible adaptive router. Models and multimodal capabilities are discovered live from the public chat catalog. Use the OrcaRouter account entry for PKCE login.", + }, + { + // PackyCode: API relay (packyapi.com) for Claude Code, Codex, Gemini and more. Codex traffic + // uses the OpenAI-compatible host from their Codex/Kimi Code guides (docs.packyapi.com): + // https://cf.api.fan/v1 — GET /v1/models answers 401 without a key, so the host is live and + // discovery narrows to what the key's token group allows. Model ids are bare OpenAI-style + // ids (the Codex token group lists gpt-5.5 / gpt-5.1-codex). + // Standard sponsor under SPONSORS.md; the dashboardUrl carries their affiliate code. + id: "packycode", label: "PackyCode", adapter: "openai-chat", baseUrl: "https://cf.api.fan/v1", + authKind: "key", dashboardUrl: "https://www.packyapi.com/register?aff=k5KT", + sponsor: { tier: "standard", url: "https://www.packyapi.com/register?aff=k5KT" }, + defaultModel: "gpt-5.5", + models: ["gpt-5.5", "gpt-5.1-codex"], + liveModels: true, + // New key preset: opt into collision preservation so a row named `packycode` that a user + // points at a different PackyCode host keeps its own destination instead of being pulled + // back onto the Codex endpoint below. + preserveCustomDestination: true, + note: "API relay for Claude Code, Codex, Gemini and more. Create a Codex-group token at packyapi.com; live discovery lists what the token group allows.", + }, + { + // BizRouter: Korean enterprise LLM gateway (api.bizrouter.ai). Model ids are + // vendor-namespaced (`/`) and pass through to the upstream as-is. + // Live-verified 2026-07-24: /v1/chat/completions accepts the `tools` field and + // streams, and GET /v1/models returns the per-API-key allowed catalog in the + // OpenAI list shape, so live model discovery narrows to what the key can use. + id: "bizrouter", label: "BizRouter", adapter: "openai-chat", baseUrl: "https://api.bizrouter.ai/v1", + authKind: "key", dashboardUrl: "https://bizrouter.ai/settings/keys", + defaultModel: "openai/gpt-5.6-sol", + models: ["openai/gpt-5.6-sol", "anthropic/claude-sonnet-5", "google/gemini-3.5-flash"], + note: "Korean enterprise LLM gateway. Per-key allowed models are discovered live from /v1/models. Full catalog: https://bizrouter.ai/models", + }, + { id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys" }, + // 2026-07-10 Gemini API refresh: Tier-2 ai.google.dev evidence recorded in + // devlog/_plan/260710_provider_hardening/001_research_frontier.md. + { + id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true, + dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3.5-flash", models: ["gemini-3.8-flash", "gemini-3.6-flash", "gemini-3.5-flash", "gemini-3.5-flash-lite", "gemini-3.1-pro-preview", "gemini-3.7-flash"], + modelContextWindows: { "gemini-3.8-flash": 1_048_576, "gemini-3.6-flash": 1_048_576, "gemini-3.5-flash": 1_000_000, "gemini-3.5-flash-lite": 1_048_576, "gemini-3.7-flash": 1_048_576 }, + modelInputModalities: { "gemini-3.8-flash": ["text", "image"], "gemini-3.6-flash": ["text", "image"], "gemini-3.5-flash-lite": ["text", "image"], "gemini-3.7-flash": ["text", "image"] }, + modelReasoningEfforts: { + // 3.7 and 3.8 omit `minimal`: Google documents it as a validation error on both model + // pages, so advertising it hands the user a rung the API rejects. 3.5/3.6 keep theirs — + // their pages still list it, and this unit has no evidence to change them. + "gemini-3.8-flash": ["low", "medium", "high"], + "gemini-3.7-flash": ["low", "medium", "high"], + "gemini-3.6-flash": ["minimal", "low", "medium", "high"], + "gemini-3.5-flash": ["minimal", "low", "medium", "high"], + "gemini-3.1-pro-preview": ["low", "medium", "high"], + }, + jawcodeBundle: "google", extraMetadataAliases: ["gemini"], + }, + // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API + // evidence from ai.google.dev does not establish Vertex publisher availability. + { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] }, + // Antigravity discovers models with a POST to the CCA `:fetchAvailableModels` RPC, which + // `buildModelsRequest` already built by hand. Declaring it here changes no request URL — the + // relative path resolves to the same destination — but it lets `isRegistryModelDiscoveryUrl` + // prove that URL, which is what admits a Clash/Surge/Mihomo TUN fake-IP answer (#4261). The + // path must stay RELATIVE: this row sets `allowBaseUrlOverride`, and an absolute `url` would + // retarget a user's custom base back to Google. A leading `./` is required because a bare + // `v1internal:` reads as a URL scheme and `providerModelDiscoverySpecError` rejects it. + { id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", showThinkingSummary: true, jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"], modelDiscovery: { path: "./v1internal:fetchAvailableModels" } }, + { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" }, + { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, + { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" }, + { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — no key needed" }, + { + id: "deepseek", + label: "DeepSeek", + baseUrl: "https://api.deepseek.com", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://platform.deepseek.com/api_keys", + // Route DeepSeek's own catalog bundle so routed rebuilds restore the official + // context window from the vendored model-metadata bundle instead of falling + // back to the 128k strict-fields default (scripts/model-metadata.source.json, + // verified 2026-08-08). + jawcodeBundle: "deepseek", + // deepseek-chat/deepseek-reasoner were deprecated upstream on 2026-07-24 15:59 UTC; + // the current official identifier is deepseek-flash. They stay in + // the list only as compatibility aliases so existing saved configs and requests + // keep validating and routing (they previously mapped to v4-flash; devlog + // _fin/260710_provider_hardening/002_research_cn.md). The current offerings are + // V4.1-Flash — defaultModel and the model-specific wiring below use its live id. + // Keep the legacy vision-preview alias; see DEEPSEEK_VISION_PREVIEW_MODEL. + models: ["deepseek-chat", "deepseek-reasoner", ...DEEPSEEK_NATIVE_THINKING_MODELS, DEEPSEEK_VISION_PREVIEW_MODEL], + // V4.1-Flash is the current first-party offering; `deepseek-v4-flash` now routes there + // as a compatibility alias, so a new install should ask for the live id by name. + defaultModel: "deepseek-flash", + // Official DeepSeek Codex setup (codex-deepseek-setup.sh) advertises 1,048,576 + // for both V4 models; the older 1,000,000 figure was a rounded approximation. + modelContextWindows: { "deepseek-flash": 1_048_576, "deepseek-v4-flash": 1_048_576, [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576 }, + modelInputModalities: { + "deepseek-flash": ["text", "image"], + [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], + }, + // DeepSeek documents both V4 models as native Responses API models adapted for Codex + // (model table marks Responses API ✓ for flash and pro; the /responses reference lists + // both ids as accepted `model` values — verified 2026-08-13 with the V4 Pro GA, + // version label DeepSeek-V4-Pro-0813). + modelWireDefaults: { + // Codex speaks Responses natively and DeepSeek ships a Codex-compatible + // apply_patch tool on that wire, so a Responses inbound goes straight out with + // no translation. Claude Code and OpenAI-compatible clients keep the + // provider-wide Chat wire: DeepSeek serves Chat Completions natively too, so + // translating them into Responses would add a hop onto our newest upstream path + // for no gain. + "deepseek-v4-flash": { wire: "openai-responses", inbound: ["responses"] }, + // Same Responses contract as the V4 ids it succeeds; without this row the new + // default would fall back to the provider-wide Chat wire. + "deepseek-flash": { wire: "openai-responses", inbound: ["responses"] }, + }, + // The #875-era bounded-JSON force (`modelResponsesUpstreamStreaming`) is retired + // for this entry: the official guide documents a `response.completed` / + // `response.incomplete` / `response.failed` terminal with NO `data: [DONE]` + // sentinel, and live probes (2026-08-07, including the tool-result replay shape + // that originally stalled) close on the terminal. The relay's terminal boundary + // (src/server/relay.ts) already cuts the stream at that event and synthesizes + // `[DONE]`, so forcing stream:false only delayed every byte until generation + // finished (28-46 s of silence on long turns). The registry knob itself remains + // for providers that need it — re-adding one line here restores the old policy. + // Evidence: https://api-docs.deepseek.com/guides/responses_api/ + + // devlog/_fin/260807_deepseek_responses_streaming/000_plan.md. + // Current official streams normally carry a real terminal; retain a narrow grace + // repair for the historical shape that closes after a complete graph without one. + modelResponsesTerminalRepair: { "deepseek-flash": { graceMs: 5_000 }, "deepseek-v4-flash": { graceMs: 5_000 } }, + // DeepSeek's Responses route emits bare UUID item ids, which leave Codex + // clients stuck on an uncommitted turn (#938). Client-facing only — raw + // continuation snapshots keep the upstream ids. + responsesItemIdRepair: { repairInvalidIds: true, repairMissingTerminalIds: true }, + // DeepSeek's Responses route is `POST /responses` with no `/v1` segment. Without + // this the passthrough adapter falls back to its legacy `/v1/responses` + // construction and the wire above can never route. + // Evidence: https://api-docs.deepseek.com/api/create-response/ + responsesPath: "/responses", + // DeepSeek's Responses reference does not list `service_tier`; unsupported + // parameters are documented as silently ignored, but the fail-closed policy + // strips the field rather than forwarding a knob the upstream never asked for. + supportsServiceTier: false, + // DeepSeek's Responses compatibility guide accepts plaintext reasoning items and + // merges them into the adjacent assistant message, so replayed reasoning must + // not be blanked the way the ChatGPT backend requires. (Whether the Responses + // route REQUIRES replay on tool-call continuations is an inference from the + // Chat Thinking-Mode docs, not a confirmed Responses contract.) + preserveResponsesReasoningContent: true, + // "The API is stateless: responses and conversations are not stored on the + // server." https://api-docs.deepseek.com/api/create-response/ + statelessResponses: true, + // DeepSeek rejects a valid Codex continuation when hook-provided developer + // context splits a call from its result (#1292); parallel calls remain one + // reasoning-bearing assistant batch rather than being split per pair (#1477). + requiresAdjacentResponsesToolResults: true, + // DeepSeek exec tool results can be present-but-empty (a script that ran without + // calling text(...)); annotate them so routed models do not silently accept an + // empty result or re-issue the same call. + annotateEmptyToolOutputs: true, + /* [Decision Log] + - 목적: DeepSeek V4 thinking mode multi-turn/tool-call requests must replay prior assistant reasoning_content. + - 대안 분석: Globally preserve reasoning_content for all OpenAI-compatible models; preserve it for legacy deepseek-reasoner too; mark only V4 thinking models in registry metadata. + - 선택 근거: DeepSeek V4 thinking mode requires history replay, while older DeepSeek reasoner has different compatibility rules. A model-scoped registry flag fixes built-in and stale saved configs without broad provider regressions. + */ + modelReasoningEfforts: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), + modelReasoningEffortMap: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), + modelSupportsReasoningSummaries: Object.fromEntries(DEEPSEEK_NATIVE_THINKING_MODELS.map(id => [id, true])), + preserveReasoningContentModels: DEEPSEEK_NATIVE_THINKING_MODELS, + // #4436: first-party deepseek-flash accepts native images on Chat and Responses. + // Keep unprobed compatibility aliases on the #88 sidecar path. This must be fixed + // here: router enrichment unions this list with saved config, so config cannot remove it. + noVisionModels: ["deepseek-chat", "deepseek-reasoner", "deepseek-v4-flash"], + }, + // llama-3.3-70b was deprecated by Cerebras on 2026-02-16. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. + { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "gpt-oss-120b" }, + { + // Primary sources checked 2026-08-08: + // - https://chutes.ai/pricing documents the shared llm.chutes.ai/v1 OpenAI-compatible + // gateway, Bearer API keys, and chat completions. Its public + // https://llm.chutes.ai/v1/models response supplies supported_features for filtering. + // - https://chutes.ai/terms identifies Chutes Global Corp as the platform operator, applies + // to API consumers, and directs production/high-volume automated inference to PAYGO. + // Maintainer: @olddonkey; no affiliation with Chutes. + id: "chutes", + label: "Chutes", + baseUrl: "https://llm.chutes.ai/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://chutes.ai/auth/start", + liveModels: true, + preserveCustomDestination: true, + // The public model catalog cannot prove that a supplied Bearer key is valid. + apiKeyValidation: "unknown", + // Chutes documents tool calling, but not a provider-wide parallel tool-call contract. + parallelToolCalls: false, + // The live catalog reports reasoning support, but not a stable effort ladder. + reasoningEfforts: [], + modelDiscovery: { + path: "models", + maxResponseBytes: 256 * 1024, + maxModels: 128, + filter: { + // The shared LLM catalog also contains rows without native tool support. Codex needs a + // complete agent loop, so admit only rows whose live metadata advertises tools. + allOf: [{ path: ["supported_features"], containsAny: ["tools"] }], + }, + }, + note: "Shared OpenAI-compatible LLM gateway only; live discovery exposes tool-capable rows. User-deployed custom Chute endpoints and non-LLM APIs require a custom provider.", + }, + { + id: "deepinfra", + label: "DeepInfra", + baseUrl: "https://api.deepinfra.com/v1/openai", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://deepinfra.com/dash/api_keys", + liveModels: true, + preserveCustomDestination: true, + modelDiscovery: { + // DeepInfra documents the OpenAI model catalog outside the chat-compatible `/v1/openai` + // namespace, so keep this destination registry-owned instead of deriving it from baseUrl. + url: "https://api.deepinfra.com/v1/models", + maxResponseBytes: 512 * 1024, + maxModels: 512, + filter: { + allOf: [{ path: ["metadata", "tags"], containsAny: ["chat"] }], + }, + }, + note: "OpenAI-compatible chat models only; live discovery excludes non-chat rows from DeepInfra's mixed model catalog.", + }, + { + id: "hyperbolic", + label: "Hyperbolic", + baseUrl: "https://api.hyperbolic.xyz/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://app.hyperbolic.ai", + liveModels: true, + preserveCustomDestination: true, + modelDiscovery: { + path: "models", + maxResponseBytes: 256 * 1024, + maxModels: 256, + }, + note: "Serverless text and vision-language chat models only; Hyperbolic's separate image, audio, and GPU endpoints are out of scope.", + }, + { + // Primary sources checked 2026-08-03: + // - docs.nscale.com documents the production OpenAI-compatible endpoint, bearer service + // tokens, /v1/models, and a tool-calling request using this exact Llama model id. + // - nscale.com/policies/terms-conditions identifies Nscale AS as the service operator and + // covers customers using its public-cloud inference offering. Maintainer: @olddonkey; + // no affiliation with Nscale. + id: "nscale", + label: "Nscale Serverless Inference", + baseUrl: "https://inference.api.nscale.com/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://console.nscale.com", + defaultModel: "meta-llama/Llama-3.1-8B-Instruct", + models: ["meta-llama/Llama-3.1-8B-Instruct"], + liveModels: true, + preserveCustomDestination: true, + // Nscale documents tools but not parallel tool calls. Keep requests serialized. + parallelToolCalls: false, + // The API schema accepts reasoning_effort, but does not publish per-model tiers. + reasoningEfforts: [], + modelDiscovery: { + path: "models", + maxResponseBytes: 256 * 1024, + maxModels: 256, + filter: { + // Nscale's catalog mixes chat, image, and embedding rows without a modality field. + // Admit only the exact model used in its official tool-calling API example. + allOf: [{ path: ["id"], equalsAny: ["meta-llama/Llama-3.1-8B-Instruct"] }], + }, + }, + note: "Serverless OpenAI-compatible inference. Live discovery admits only the tool-capable model established by Nscale's official API example; other mixed-catalog rows remain hidden pending equivalent evidence.", + }, + { + // Primary sources checked 2026-08-03: + // - docs.vultr.com documents the fixed OpenAI-compatible base URL, per-subscription bearer + // key, /v1/models, and states that tool calling is currently limited to kimi-k2-instruct. + // - Vultr's official properties identify VULTR as a The Constant Company, LLC trademark and + // document customer API integrations. Maintainer: @olddonkey; no affiliation with Vultr. + id: "vultr", + label: "Vultr Serverless Inference", + baseUrl: "https://api.vultrinference.com/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://my.vultr.com", + defaultModel: "kimi-k2-instruct", + models: ["kimi-k2-instruct"], + liveModels: true, + preserveCustomDestination: true, + parallelToolCalls: false, + reasoningEfforts: [], + modelDiscovery: { + path: "models", + maxResponseBytes: 256 * 1024, + maxModels: 256, + filter: { + // Vultr explicitly limits tool calling to this model. A coding agent must not select + // another chat model that cannot complete its tool loop. + allOf: [{ path: ["id"], equalsAny: ["kimi-k2-instruct"] }], + }, + }, + note: "Serverless Inference subscription API. Live discovery exposes only kimi-k2-instruct because Vultr documents it as the sole tool-calling model.", + }, +]; diff --git a/src/providers/registry/entries-extended.ts b/src/providers/registry/entries-extended.ts new file mode 100644 index 0000000000..2a92a9b832 --- /dev/null +++ b/src/providers/registry/entries-extended.ts @@ -0,0 +1,1204 @@ +import { + QWEN_CLOUD_BASE_URL_CHOICES, + QWEN_CLOUD_TOKEN_PLAN_BASE_URL, + ALIBABA_INTL_BASE_URL_CHOICES, + ALIBABA_INTL_TOKEN_PLAN_BASE_URL, + ALIBABA_CODING_BASE_URL_CHOICES, + ALIBABA_CODING_INTL_BASE_URL, + MOONSHOT_BASE_URL_CHOICES, + MOONSHOT_INTL_BASE_URL, +} from "../base-url-choices"; +import { COMMAND_CODE_MODEL_REASONING_EFFORTS } from "../command-code-efforts"; +import { + CODEBUDDY_CN_MODELS, + CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS, + CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS, + CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS, + CODEBUDDY_CN_MODEL_REASONING_EFFORTS, + CODEBUDDY_CN_NO_VISION_MODELS, + CODEBUDDY_GLOBAL_MODELS, + CODEBUDDY_GLOBAL_MODEL_CONTEXT_WINDOWS, + CODEBUDDY_GLOBAL_MODEL_DEFAULT_REASONING_EFFORTS, + CODEBUDDY_GLOBAL_MODEL_MAX_OUTPUT_TOKENS, + CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS, + CODEBUDDY_REASONING_EFFORTS, +} from "../codebuddy-models"; +import { QODER_CN_MODELS, QODER_GLOBAL_MODELS, QODER_REASONING_EFFORTS } from "../qoder-models"; +import type { ProviderRegistryEntry } from "./types"; +import { + ZAI_GLM_53_MODELS, + ZAI_GLM_5X_MODELS, + ZAI_GLM_5X_SIDECAR_VISION_MODELS, + ZAI_GLM_5X_INPUT_MODALITIES, + ZAI_GLM_52_REASONING_EFFORTS, + ZAI_GLM_53_REASONING_EFFORTS, + ZAI_GLM_5X_REASONING_EFFORTS, + MINIMAX_MODELS, + MINIMAX_MODEL_CONTEXT_WINDOWS, + MINIMAX_M3_REASONING_EFFORTS, + MINIMAX_M3_REASONING_EFFORT_MAP, + THINKING_TOGGLE_EFFORTS, + THINKING_TOGGLE_MAP, + ZHIPU_BIGMODEL_MODELS, + ZHIPU_BIGMODEL_INPUT_MODALITIES, + ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS, + THINKING_BUDGET_EFFORTS, + QWEN38_REASONING_EFFORTS, + DEEPSEEK_V4_LEGACY_MODELS, + DEEPSEEK_GATEWAY_THINKING_MODELS, + DEEPSEEK_VISION_PREVIEW_MODEL, + COMMAND_CODE_MODEL_INPUT_MODALITIES, + OPENCODE_FREE_DEEPSEEK_MODELS, + OPENCODE_ZEN_TEXT_ONLY_MODELS, + OPENCODE_ZEN_IMAGE_MODELS, + deepseekThinkingEffortsFor, + deepseekReasoningMapFor, + ALIBABA_TOKEN_PLAN_MODELS, + ALIBABA_TOKEN_PLAN_QWEN_MODELS, + ALIBABA_TOKEN_PLAN_INPUT_MODALITIES, + ALIBABA_INTL_TOKEN_PLAN_MODELS, + ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS, + TENCENT_CODING_PLAN_MODELS, + VOLCENGINE_ARK_MODELS, + VOLCENGINE_DOUBAO_THINKING_MODELS, + VOLCENGINE_CODING_PLAN_MODELS, + VOLCENGINE_AGENT_PLAN_MODELS, + VOLCENGINE_PLAN_INPUT_MODALITIES, + VOLCENGINE_PLAN_TEXT_ONLY_MODELS, + ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES, + KIMI_API_MODELS, + KIMI_CODING_MODELS, + KIMI_THINKING_MODELS, + KIMI_CODING_NO_REASONING_MODELS, + KIMI_API_NO_REASONING_MODELS, + KIMI_CODING_REASONING_EFFORTS, + KIMI_CODING_DEFAULT_REASONING_EFFORTS, + KIMI_CODING_REASONING_EFFORT_MAPS, + KIMI_API_REASONING_EFFORTS, + KIMI_LOCKED_PARAMETER_MODELS, + KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS, + KIMI_API_MODEL_CONTEXT_WINDOWS, + KIMI_API_MODEL_INPUT_MODALITIES, + NVIDIA_NIM_KIMI_THINKING_MODELS, + NVIDIA_NIM_KIMI_MODELS, + NVIDIA_NIM_VISION_MODELS, + NVIDIA_NIM_VISION_INPUT_MODALITIES, + NVIDIA_NIM_NO_VISION_MODELS, + KIMI_CODING_MODEL_CONTEXT_WINDOWS, + KIMI_CODING_MODEL_INPUT_MODALITIES, + BASETEN_MODEL_REASONING_EFFORTS, + BASETEN_MODEL_REASONING_EFFORT_MAP, + BASETEN_MODEL_DEFAULT_REASONING_EFFORTS, + BASETEN_MODEL_INPUT_MODALITIES, + DIGITALOCEAN_CHAT_COMPLETION_MODELS, + SCALEWAY_SERVERLESS_CHAT_MODELS, + SCALEWAY_MODEL_INPUT_MODALITIES, +} from "./model-seeds"; + +export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [ + { + id: "baseten", + label: "Baseten Model APIs", + baseUrl: "https://inference.baseten.co/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://app.baseten.co/settings/api_keys", + liveModels: true, + preserveCustomDestination: true, + // Baseten's Chat Completions contract documents parallel_tool_calls as default-on. + parallelToolCalls: true, + // Baseten says models outside its reasoning table do not support reasoning. Keep + // unknown/new live slugs conservative until an official-docs registry refresh proves it. + reasoningEfforts: [], + modelReasoningEfforts: BASETEN_MODEL_REASONING_EFFORTS, + modelReasoningEffortMap: BASETEN_MODEL_REASONING_EFFORT_MAP, + modelDefaultReasoningEfforts: BASETEN_MODEL_DEFAULT_REASONING_EFFORTS, + modelInputModalities: BASETEN_MODEL_INPUT_MODALITIES, + modelDiscovery: { + path: "models", + maxResponseBytes: 1_048_576, + maxModels: 256, + }, + note: "Shared Model APIs only (personal API key, or team key with Call Model APIs access); dedicated Truss predict endpoints are outside this preset.", + }, + { + id: "commandcode", + label: "Command Code - API", + adapter: "openai-chat", + baseUrl: "https://api.commandcode.ai/provider/v1", + authKind: "key", + dashboardUrl: "https://commandcode.ai/studio/", + liveModels: true, + preserveCustomDestination: true, + defaultModel: "deepseek/deepseek-v4-flash", + promptCacheKey: true, + // The default is also the cold-start seed: live discovery failure must not empty the catalog + // for a freshly configured provider with no stale cache (issue #308 pattern). + models: ["deepseek/deepseek-v4-flash"], + // The public model catalog is unauthenticated, so a Bearer probe cannot prove key validity. + apiKeyValidation: "unknown", + // The public catalog reports ids/context windows only; no trustworthy reasoning contract. + reasoningEfforts: [], + // Official Command Code model-profile reasoning facts (shared with the OAuth + // `command-code` entry). Without them the API-key preset never advertises a + // reasoning picker, and the router's known-ids decode source misses the native + // slash ids — so a Codex-facing slug like `commandcode/deepseek-deepseek-v4-flash` + // is sent upstream verbatim and rejected with `unsupported_model`. + modelReasoningEfforts: COMMAND_CODE_MODEL_REASONING_EFFORTS, + // The DeepSeek vision preview id is preemptive for when the catalog serves it + // (merges into v4-flash later). + modelContextWindows: { + [`deepseek/${DEEPSEEK_VISION_PREVIEW_MODEL}`]: 1_048_576, + }, + modelInputModalities: COMMAND_CODE_MODEL_INPUT_MODALITIES, + modelDiscovery: { + path: "models", + maxResponseBytes: 256 * 1024, + maxModels: 256, + }, + // Verified 2026-08-03: public /provider/v1/models returns 51 rows; /chat/completions returns + // 401 UNAUTHORIZED without a Bearer key. Primary source: https://commandcode.ai/docs/provider. + note: "Command Code Provider API (OpenAI-compatible); API access requires the Provider plan. Use `ocx login command-code` for OAuth account login (imports an existing local Command Code CLI credential when present). Docs: https://commandcode.ai/docs/provider.", + }, + { + id: "sambanova", + label: "SambaNova Cloud", + baseUrl: "https://api.sambanova.ai/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://cloud.sambanova.ai/apis", + liveModels: true, + preserveCustomDestination: true, + apiKeyValidation: "unknown", + // SambaNova documents this request field but does not yet support parallel function calls. + parallelToolCalls: false, + // The public catalog does not report a trustworthy per-model reasoning contract. + reasoningEfforts: [], + modelDiscovery: { + path: "models", + maxResponseBytes: 128 * 1024, + maxModels: 128, + }, + note: "SambaNova Cloud text-generation models only; private SambaStudio deployment endpoints are outside this preset.", + }, + { + id: "nebius", + label: "Nebius Token Factory", + baseUrl: "https://api.tokenfactory.nebius.com/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://tokenfactory.nebius.com", + liveModels: true, + preserveCustomDestination: true, + // The public tools guide documents single function selection, not parallel tool calls. + parallelToolCalls: false, + // Missing reasoning metadata must not promote a model to Codex's full fallback ladder. + reasoningEfforts: [], + modelDiscovery: { + path: "models", + query: { verbose: "true" }, + maxResponseBytes: 512 * 1024, + maxModels: 512, + filter: { + // Keep rows whose reported architecture output includes text (for example, + // text->text or text+image->text); embedding and image-generation rows are excluded. + allOf: [{ path: ["architecture", "modality"], containsAny: ["->text"] }], + }, + }, + note: "Shared Token Factory text-output inference only; live discovery excludes embedding and image-generation rows.", + }, + { + id: "digitalocean", + label: "DigitalOcean Serverless Inference", + baseUrl: "https://inference.do-ai.run/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://cloud.digitalocean.com/model-studio/manage-keys", + liveModels: true, + preserveCustomDestination: true, + // The Chat Completions contract documents function calls but not universal parallel support. + parallelToolCalls: false, + // Unknown catalog rows must not inherit Codex's full fallback reasoning ladder. + reasoningEfforts: [], + modelDiscovery: { + path: "models", + maxResponseBytes: 256 * 1024, + maxModels: 256, + filter: { + allOf: [{ path: ["id"], equalsAny: DIGITALOCEAN_CHAT_COMPLETION_MODELS }], + }, + }, + note: "Shared Serverless Inference Chat Completions only; agent-specific, dedicated, Responses-only, embedding, and media-generation models are outside this preset.", + }, + { + id: "scaleway", + label: "Scaleway Generative APIs", + baseUrl: "https://api.scaleway.ai/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://console.scaleway.com/generative-api", + liveModels: true, + freeTier: true, + preserveCustomDestination: true, + // Parallel support varies by model; avoid advertising it as a provider-wide capability. + parallelToolCalls: false, + // The generic `/models` rows carry no trustworthy reasoning metadata. + reasoningEfforts: [], + modelInputModalities: SCALEWAY_MODEL_INPUT_MODALITIES, + modelDiscovery: { + path: "models", + maxResponseBytes: 128 * 1024, + maxModels: 128, + filter: { + allOf: [{ path: ["id"], equalsAny: SCALEWAY_SERVERLESS_CHAT_MODELS }], + }, + }, + note: "Shared Generative APIs Serverless Chat Completions only; project-qualified and dedicated deployment hosts require a custom provider.", + }, + { + // Primary sources checked 2026-08-08: + // - https://featherless.ai/docs/api-overview-and-common-options documents the fixed + // OpenAI-compatible base URL, Bearer keys, and Chat Completions. + // - https://featherless.ai/docs/api-reference-models documents authenticated plan filtering, + // chat capability filtering, popularity sorting, pagination, and per-row tool metadata. + // - https://featherless.ai/legal/terms-of-service identifies Featherless as a Delaware LLC, + // covers developers building on its APIs, and reserves arbitrary applications for Scale + // plans. Maintainer: @olddonkey; no affiliation with Featherless. + id: "featherless", + label: "Featherless AI", + baseUrl: "https://api.featherless.ai/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://featherless.ai/account/api-keys", + liveModels: true, + preserveCustomDestination: true, + // /v1/models is documented as callable authenticated or unauthenticated, so a 2xx catalog + // response cannot prove that the supplied Bearer key is valid. + apiKeyValidation: "unknown", + // Featherless documents tool calling, but not a provider-wide parallel tool-call contract. + parallelToolCalls: false, + // Reasoning controls use model-specific chat_template_kwargs, not OpenAI reasoning_effort. + reasoningEfforts: [], + modelDiscovery: { + path: "models", + query: { + available_on_current_plan: "true", + capabilities: "chat", + page: "1", + per_page: "100", + sort: "-popularity", + }, + maxResponseBytes: 128 * 1024, + maxModels: 100, + filter: { + // Treat server-side filters as a size optimization, not an authority boundary. A row must + // independently prove plan availability, no separate Hugging Face gate, and tool support. + allOf: [ + { path: ["available_on_current_plan"], equalsAny: [true] }, + { path: ["is_gated"], equalsAny: [false] }, + { path: ["features", "tool_use"], equalsAny: [true] }, + ], + }, + }, + note: "Authenticated first page of popular chat models only; live discovery admits at most 100 plan-available, ungated rows whose metadata explicitly reports tool use.", + }, + { + // Primary sources checked 2026-08-08: + // - https://novita.ai/docs/api-reference/model-apis-llm-create-chat-completion and + // https://novita.ai/docs/api-reference/model-apis-llm-list-models document the fixed + // OpenAI-compatible Chat Completions and model-list endpoints. + // - https://novita.ai/docs/api-reference/basic-authentication documents Bearer API keys. + // - https://novita.ai/legal/terms-of-service (updated 2026-08-05) expressly covers AI + // inference APIs, third-party Model Providers, and customer Input/Output processing. + // - https://huggingface.co/docs/inference-providers/main/providers/novita lists Novita as an + // Inference Providers partner for chat/VLM traffic, independently supporting routing use. + // - https://tsdr.uspto.gov/statusview/sn99255805 is the official use-in-commerce record + // connecting the NOVITA AI mark to Hivemind Labs, Inc., a Delaware corporation. The mark + // application is now abandoned; it is cited only as the public operator-identity record. + // Maintainer: @olddonkey; no affiliation with Novita AI or Hivemind Labs, Inc. + id: "novita", + label: "Novita AI", + baseUrl: "https://api.novita.ai/openai/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://novita.ai/settings/key-management", + liveModels: true, + preserveCustomDestination: true, + // The live catalog is public even though the reference shows an Authorization header, so a + // successful model fetch cannot prove that a supplied key is valid. + apiKeyValidation: "unknown", + // The request reference documents tools but not a provider-wide parallel-tool contract. + parallelToolCalls: false, + // Novita exposes model-specific thinking flags, not an OpenAI reasoning_effort contract. + reasoningEfforts: [], + modelDiscovery: { + path: "models", + maxResponseBytes: 512 * 1024, + maxModels: 256, + filter: { + // Require both Novita's chat classification and the exact configured wire endpoint. + allOf: [ + { path: ["model_type"], equalsAny: ["chat"] }, + { path: ["endpoints"], containsAny: ["chat/completions"] }, + ], + }, + }, + note: "Public live catalog filtered to rows that explicitly report chat type and Chat Completions support; key validity remains unknown until an authenticated inference request.", + }, + // FREEZE 2026-07-10: exact serverless ids remain auth-gated/unverified. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. + { id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" }, + { id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" }, + { + id: "firepass", label: "Fire Pass (Fireworks Kimi)", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", + dashboardUrl: "https://fireworks.ai/account/api-keys", + note: "Model data frozen pending Tier-2 entitlement proof", + }, + { + id: "moonshot", label: "Moonshot (Kimi API)", baseUrl: MOONSHOT_INTL_BASE_URL, adapter: "openai-chat", authKind: "key", + allowBaseUrlOverride: true, + baseUrlChoices: MOONSHOT_BASE_URL_CHOICES, + dashboardUrl: "https://platform.moonshot.ai/console/api-keys", defaultModel: "kimi-k2.7-code", jawcodeBundle: "moonshot", + models: KIMI_API_MODELS, + modelContextWindows: KIMI_API_MODEL_CONTEXT_WINDOWS, + modelInputModalities: KIMI_API_MODEL_INPUT_MODALITIES, + noReasoningModels: KIMI_API_NO_REASONING_MODELS, + modelReasoningEfforts: KIMI_API_REASONING_EFFORTS, + noTemperatureModels: KIMI_API_MODELS, + noTopPModels: KIMI_API_MODELS, + noPenaltyModels: KIMI_API_MODELS, + autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"], + preserveReasoningContentModels: KIMI_API_MODELS, + note: "International default (api.moonshot.ai). China accounts: choose China (.cn) or Custom for api.moonshot.cn.", + }, + { id: "huggingface", label: "Hugging Face", baseUrl: "https://router.huggingface.co/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://huggingface.co/settings/tokens" }, + // 260715 NIM hardening (issue #126, devlog/_plan/260715_issue126_nim_kimi): + // - NIM kimi rejects `parallel_tool_calls: true` with 400 "This model only supports single + // tool-calls at once!" (openclaw#37048). NVIDIA's own function-calling docs default the + // Boolean to false, so provider-wide `false` is the documented-safe wire value. + // - `reasoning_effort` is not portable on NIM (models use chat_template_kwargs); the kimi + // family is live-discovered with no capability metadata, so Codex would otherwise send + // reasoning_effort=medium. Exact-id lists per modelInList semantics; gpt-oss on NIM keeps + // its working reasoning_effort. Future kimi ids must be appended individually. + { + id: "nvidia", label: "NVIDIA NIM", baseUrl: "https://integrate.api.nvidia.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://build.nvidia.com", + // Free pricing, but an API key is still required (free key from build.nvidia.com). + freeTier: true, + parallelToolCalls: false, + // 260804 issue #956: NIM exposes no input modalities, so vision capability is + // classified here. Both lists are verified per-model; unlisted ids stay unclassified + // by design (see the comment on NVIDIA_NIM_VISION_MODELS). + noVisionModels: NVIDIA_NIM_NO_VISION_MODELS, + modelInputModalities: NVIDIA_NIM_VISION_INPUT_MODALITIES, + noReasoningModels: NVIDIA_NIM_KIMI_MODELS, + modelReasoningEfforts: Object.fromEntries(NVIDIA_NIM_KIMI_MODELS.map(id => [id, []])), + preserveReasoningContentModels: NVIDIA_NIM_KIMI_THINKING_MODELS, + note: "Free tier on NVIDIA NIM — API key still required (get a free key at build.nvidia.com).", + }, + { id: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://venice.ai/settings/api" }, + // 260710 GLM-5.2 context and path-specific ids: Tier-2 evidence in + // devlog/_plan/260710_provider_hardening/002_research_cn.md. + // 260814: glm-5.3 / glm-5.3[1m] added per docs.z.ai/devpack/latest-model, which lists them as + // Coding Plan ids on this same endpoint. + // 260815: docs.z.ai/guides/llm/glm-5.3 now publishes the capability table (thinking, streaming, + // function calling, caching, structured output) and a 128K output budget, recorded here as the + // exact 131_072 every other source in this repo uses for that model. Coding Plan pricing stays + // unpublished, so no cost entry is asserted. + { + id: "zai", label: "Z.AI — GLM Coding Plan", baseUrl: "https://api.z.ai", adapter: "openai-responses", authKind: "key", + // One subscription and one key, three protocols. docs.z.ai/guides/llm/glm-5.3 lists them: + // Chat Completions at /api/coding/paas/v4, Responses at /api/v1, Anthropic Messages at + // /api/anthropic. docs.z.ai/devpack/latest-model points Codex-family clients at /api/v1, + // and the Chat path is the one that misbehaves in practice. + // + // Responses is the default and Chat stays reachable per model through `modelAdapters`. + // The two wires sit under different prefixes, and a wire override swaps the adapter + // without touching baseUrl, so each wire carries its own relative send path. + // + // Measured 2026-09-12 against a live key: every roster id answers 200 on + // /api/v1/responses, and every one also answers 200 on the Chat prefix, so no model + // needs a `modelWireDefaults` pin. /api/v1/chat/completions returns 403 + // model_access_denied, which is why the Chat path cannot simply hang off the new base. + responsesPath: "/api/v1/responses", + chatCompletionsPath: "/api/coding/paas/v4/chat/completions", + // The address this row occupied before the move. A saved custom provider still pointing + // at the Chat endpoint keeps receiving this row's metadata (#1100). + destinationAliases: [{ baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat" }], + dashboardUrl: "https://z.ai/manage-apikey/apikey-list", defaultModel: "glm-5.3", + note: "GLM-5.3 coding subscription", + models: ["glm-5.3", "glm-5.3[1m]", "glm-5.3-flash", "glm-5.2", "glm-5.2[1m]", "glm-5.1", "glm-5", "glm-4.6"], + // The upstream catalog reports 1_048_576 for the 5.3 family, which is what the domestic + // Responses row already carries. Both are documented as "1M"; this is that number. + modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3[1m]": 1_048_576, "glm-5.3-flash": 1_048_576, "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 }, + // Z.AI returns 400 for bracketed model ids on both wires; the aliases are local. + modelSuffixBracketStrip: true, + noVisionModels: ZAI_GLM_5X_SIDECAR_VISION_MODELS, + modelInputModalities: ZAI_GLM_5X_INPUT_MODALITIES, + modelReasoningEfforts: ZAI_GLM_5X_REASONING_EFFORTS, + modelDefaultReasoningEfforts: Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, "max"])), + modelMaxOutputTokens: Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, 131_072])), + modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_5X_MODELS.map(id => [id, true])), + preserveReasoningContentModels: ZAI_GLM_5X_MODELS, + // Responses replay uses this provider-level flag; the model list above still covers a + // caller who opts back into Chat. + preserveResponsesReasoningContent: true, + }, + // Zhipu's domestic BigModel platform: OpenAI-compatible pay-as-you-go on open.bigmodel.cn — a + // different host and billing product from the `zai` coding-plan subscription above. + // The id is deliberately NOT `glm` or `glm-cn`: both are already bound in FREE_PROVIDER_DIRECTORY + // (to api.z.ai and to the BigModel *coding* path), and routedProviderConfig() canonicalizes a + // saved provider onto the registry baseUrl — reusing either id would silently retarget an + // existing config's endpoint and send its API key to another host. + // Evidence: docs.bigmodel.cn/api-reference (OpenAI-compatible chat completions), + // docs.bigmodel.cn/cn/guide/models/text/glm-4.6 (thinking: {type: enabled|disabled}). + // Originally proposed in #536 by @Lucinegogo. + { + id: "zhipu-bigmodel", + label: "Zhipu AI — BigModel", + baseUrl: "https://open.bigmodel.cn/api/paas/v4", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys", + defaultModel: "glm-4.6", + models: ZHIPU_BIGMODEL_MODELS, + // The GLM families here are the same ones the `zai` metadata bundle already describes, so the + // bundle owns context windows and modalities for the whole list instead of a hand-copied table. + jawcodeBundle: "zai", + // Declared explicitly for the default model so its window survives a bundle-lookup miss: + // without it, catalog normalization falls back to a generic 128k and compacts ~76,800 early. + modelContextWindows: { "glm-4.6": 204_800 }, + modelInputModalities: ZHIPU_BIGMODEL_INPUT_MODALITIES, + // GLM exposes a binary thinking knob, not an effort ladder: the adapter emits + // `thinking: {type}` for these ids and would otherwise send a rejected reasoning_effort. + thinkingToggleModels: ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS, + modelReasoningEfforts: Object.fromEntries( + ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS]), + ), + modelReasoningEffortMap: Object.fromEntries( + ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP]), + ), + modelSupportsReasoningSummaries: Object.fromEntries( + ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS.map(id => [id, true]), + ), + preserveReasoningContentModels: ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS, + // GLM thinking is a binary toggle (low maps to disabled), so a legitimate + // tool round can carry no reasoning at all; never fabricate a placeholder + // for it, only replay real recorded text (P2 on #1205). + requiresReasoningPlaceholderModels: [], + // No liveModels: GET /api/paas/v4/models has not been observed to answer on this host, and a + // false live claim yields an empty picker at runtime. Flip it on once someone verifies it. + note: "Domestic BigModel pay-as-you-go endpoint (open.bigmodel.cn)", + }, + // BigModel's Coding Plan is a SEPARATE endpoint from the pay-as-you-go row above, and that is + // the whole reason this one exists. #1100 was reported against + // `https://open.bigmodel.cn/api/coding/paas/v4`; the row above covers only `/api/paas/v4`, so + // destination enrichment matched nothing, `modelSupportsReasoningSummaries` stayed unset, and + // Codex kept dropping the inbound reasoning object — effort displayed as `-`. + // + // A prefix or fuzzy endpoint match would have been the shortcut. It is also how a config + // pointed at one vendor route silently inherits another route's metadata, so endpoints stay + // exact and each one gets its own row. + // + // The id is NOT `glm-cn`, which the free-provider directory already binds to this same coding + // path: registering it here would let routedProviderConfig() canonicalize a saved `glm-cn` + // config onto this baseUrl. Same reasoning as `zhipu-bigmodel` above. + // + // Models follow Z.AI's coding-plan list rather than the pay-as-you-go one. This endpoint is + // the subscription product, and the reporter's `glm-5.2` is only on that side. + { + id: "zhipu-bigmodel-coding", + label: "Zhipu AI — BigModel Coding Plan", + baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys", + defaultModel: "glm-5.3", + models: ["glm-5.3", "glm-5.3[1m]", "glm-5.3-flash", "glm-5.2", "glm-5.2[1m]", "glm-5.1", "glm-5", "glm-4.6"], + jawcodeBundle: "zai", + modelContextWindows: { "glm-5.3": 1_000_000, "glm-5.3[1m]": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 }, + modelSuffixBracketStrip: true, + noVisionModels: ZAI_GLM_5X_SIDECAR_VISION_MODELS, + modelInputModalities: ZAI_GLM_5X_INPUT_MODALITIES, + modelReasoningEfforts: ZAI_GLM_5X_REASONING_EFFORTS, + modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_5X_MODELS.map(id => [id, true])), + preserveReasoningContentModels: ZAI_GLM_5X_MODELS, + // No liveModels: the same reasoning as the pay-as-you-go row — an unverified live claim + // yields an empty picker at runtime. + note: "Domestic BigModel Coding Plan endpoint (open.bigmodel.cn)", + }, + // Narrowed carry of #3641: the official Codex example declares a local static catalog, + // not an HTTP /models contract. Keep Responses separate from the Chat endpoint above. + // Source: https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md (checked 2026-09-07). + // + // #4201 completes the roster. The `models.json` example on that Codex page is a *starter + // catalog*, not the set of models the endpoint serves, and reading it as the latter is what + // left Flash off a subscription that sells it. Three upstream pages say so directly, all + // checked 2026-09-11: + // - coding-plan/latest-model.md pins Codex to THIS baseUrl + // (`Codex:https://open.bigmodel.cn/api/v1`) and opens with GLM Coding Plan supporting + // GLM-5.3 and GLM-5.3-Flash for every tier (Max & Pro & Lite), then treats + // `glm-5.3-flash` as an already-callable id in that same tool. + // - coding-plan/overview.md: every plan supports GLM-5.3 and GLM-5.3-Flash, and calls to + // GLM-5-Turbo are auto-switched to GLM-5.3-Flash. Turbo below is therefore an alias of + // the very model this row omitted, which is the clearest statement that the endpoint + // serves Flash: it was already serving it under another name. + // - guide/models/vlm/glm-5.3-flash.md: native multimodal input, 1M context, and text + // parameters explicitly "consistent with GLM-5.3". + // No authenticated /models probe is implied by any of this, so `liveModels` and + // `apiKeyValidation` below are deliberately unchanged. + { + id: "zhipu-bigmodel-responses", + label: "Zhipu AI — BigModel Coding Plan (Responses)", + baseUrl: "https://open.bigmodel.cn/api/v1", + adapter: "openai-responses", + authKind: "key", + dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys", + defaultModel: "glm-5.3", + models: ["glm-5.3", "glm-5.3-flash", "glm-5-turbo"], + liveModels: false, + // The local Codex catalog does not establish an authenticated HTTP /models contract. + apiKeyValidation: "unknown", + jawcodeBundle: "zai", + // A pre-existing same-named custom provider must retain its destination and key boundary. + preserveCustomDestination: true, + // Flash tracks its 5.3 sibling on this row rather than the Chat row's 1_000_000. Both + // models are documented as "1M", and this preset expresses that family's 1M the way + // BigModel's own Codex declaration does. Splitting the two would leave one preset + // claiming two different sizes for one documented window. + modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3-flash": 1_048_576, "glm-5-turbo": 204_800 }, + // Flash is the only row here that can actually see an image. Its siblings are declared + // text-only and get `image` back from the vision sidecar at catalog-build time; declaring + // Flash text-only would route a native VLM's pictures through a describe-it-first detour + // and hand the model prose about an image it could have read (same defect + // ZAI_GLM_5X_SIDECAR_VISION_MODELS exists to prevent on the Chat rows). + modelInputModalities: { "glm-5.3": ["text"], "glm-5.3-flash": ["text", "image"], "glm-5-turbo": ["text"] }, + modelReasoningEfforts: { + "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, + // Same three effective tiers: upstream documents Flash's text parameters as identical + // to GLM-5.3, and the Codex effort table folds every inbound value into low/high/max. + "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, + // Explicitly empty: Turbo must not inherit the generic selectable effort ladder. + "glm-5-turbo": [], + }, + modelDefaultReasoningEfforts: { "glm-5.3": "max", "glm-5.3-flash": "max", "glm-5-turbo": "max" }, + modelSupportsReasoningSummaries: { "glm-5.3": true, "glm-5.3-flash": true, "glm-5-turbo": true }, + // Responses replay uses this provider-level flag, not the Chat-path model list. + preserveResponsesReasoningContent: true, + note: "Domestic BigModel Coding Plan Responses endpoint; static model roster", + }, + { id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api" }, + { id: "synthetic", label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://synthetic.new" }, + // SiliconFlow publishes an OpenAI-compatible chat endpoint and a dynamic model catalog. Do not + // freeze reasoning controls here: enable_thinking/thinking_budget support and limits vary by + // model, so live metadata or an explicit user override must own those capabilities. + // Evidence: https://docs.siliconflow.cn/en/api-reference/chat-completions/chat-completions + { + id: "siliconflow", + label: "SiliconFlow", + baseUrl: "https://api.siliconflow.cn/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://cloud.siliconflow.cn/account/ak", + liveModels: true, + note: "OpenAI-compatible live model catalog; reasoning controls vary by model.", + }, + // Qwen Cloud: token plan is the preset default; GUI offers pay-as-you-go + custom via baseUrlChoices. + // Formerly `qwen-portal` / portal.qwen.ai — that host is outdated. + { + id: "qwen-cloud", + label: "Qwen Cloud", + baseUrl: QWEN_CLOUD_TOKEN_PLAN_BASE_URL, + adapter: "openai-chat", + authKind: "key", + allowBaseUrlOverride: true, + baseUrlChoices: QWEN_CLOUD_BASE_URL_CHOICES, + dashboardUrl: "https://docs.qwencloud.com", + note: "Pick token plan, pay as you go, or a custom compatible-mode base URL", + }, + { + id: "tencent-coding-plan", + label: "Tencent Cloud Coding Plan", + baseUrl: "https://api.lkeap.cloud.tencent.com/coding/v3", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://console.cloud.tencent.com/tokenhub/codingplan", + defaultModel: "tc-code-latest", + models: TENCENT_CODING_PLAN_MODELS, + liveModels: true, + modelInputModalities: Object.fromEntries(TENCENT_CODING_PLAN_MODELS.map(id => [id, ["text"]])), + noVisionModels: TENCENT_CODING_PLAN_MODELS, + note: "Coding tools only. Tencent forbids general API automation, custom backends, and non-interactive batch use.", + }, + { + id: "volcengine", + label: "Volcengine Ark", + baseUrl: "https://ark.cn-beijing.volces.com/api/v3", + adapter: "openai-chat", + authKind: "key", + preserveCustomDestination: true, + dashboardUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/apikey", + defaultModel: "doubao-seed-2-1-pro-260628", + models: VOLCENGINE_ARK_MODELS, + liveModels: false, + modelReasoningEfforts: Object.fromEntries( + VOLCENGINE_DOUBAO_THINKING_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS]), + ), + modelReasoningEffortMap: Object.fromEntries( + VOLCENGINE_DOUBAO_THINKING_MODELS.map(id => [id, THINKING_TOGGLE_MAP]), + ), + thinkingToggleModels: VOLCENGINE_DOUBAO_THINKING_MODELS, + preserveReasoningContentModels: [ + "deepseek-v4-flash-260425", + "glm-5-2-260617", + "glm-4-7-251222", + ], + noVisionModels: [ + "deepseek-v4-flash-260425", + "deepseek-v3-2-251201", + "glm-5-2-260617", + "glm-4-7-251222", + ], + note: "Pay-as-you-go Ark API with a curated text/agent catalog. Calls on this endpoint do not consume Coding Plan or Agent Plan quota.", + }, + { + id: "volcengine-coding-plan", + label: "Volcengine Ark Coding Plan", + baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3", + adapter: "openai-chat", + authKind: "key", + preserveCustomDestination: true, + dashboardUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview", + defaultModel: "ark-code-latest", + models: VOLCENGINE_CODING_PLAN_MODELS, + liveModels: false, + modelInputModalities: VOLCENGINE_PLAN_INPUT_MODALITIES, + noVisionModels: VOLCENGINE_PLAN_TEXT_ONLY_MODELS, + modelReasoningEfforts: Object.fromEntries( + DEEPSEEK_V4_LEGACY_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)]), + ), + modelReasoningEffortMap: Object.fromEntries( + DEEPSEEK_V4_LEGACY_MODELS.map(id => [id, deepseekReasoningMapFor(id)]), + ), + preserveReasoningContentModels: DEEPSEEK_V4_LEGACY_MODELS, + note: "Coding tools only. Volcengine restricts Coding Plan quota to supported AI coding tools and warns that using this key for general API calls may suspend the subscription or ban the account. Use the plan key issued by the Ark console.", + }, + { + id: "volcengine-agent-plan", + label: "Volcengine Ark Agent Plan", + baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3", + responsesPath: "/responses", + adapter: "openai-responses", + authKind: "key", + // Ark's plan route does not document `service_tier`; fail closed like DeepSeek. + supportsServiceTier: false, + preserveCustomDestination: true, + dashboardUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview", + // Was `deepseek-v4-pro` until DeepSeek retired it; the plan roster's other DeepSeek + // entry takes over so a fresh install still lands on a working default. + defaultModel: "deepseek-v4-flash", + models: VOLCENGINE_AGENT_PLAN_MODELS, + liveModels: false, + modelInputModalities: VOLCENGINE_PLAN_INPUT_MODALITIES, + noVisionModels: VOLCENGINE_PLAN_TEXT_ONLY_MODELS, + note: "Coding tools only. Agent Plan is a subscription endpoint over the native Responses API with a static fallback catalog; Ark plan quota is intended for supported AI coding and agent tools, so avoid using this key as a general-purpose API key.", + }, + // 2026-07-10: docs unverified; model data frozen. Evidence: devlog/_plan/260710_provider_hardening/002_research_cn.md. + { id: "qianfan", label: "Qianfan (Baidu)", baseUrl: "https://qianfan.baidubce.com/v2", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.bce.baidu.com/iam/#/iam/apikey/list" }, + // 2026-07-10: docs unverified; model data frozen. Evidence: devlog/_plan/260710_provider_hardening/002_research_cn.md. + { id: "alibaba", label: "Alibaba Coding Plan", baseUrl: ALIBABA_CODING_INTL_BASE_URL, adapter: "openai-chat", authKind: "key", allowBaseUrlOverride: true, baseUrlChoices: ALIBABA_CODING_BASE_URL_CHOICES, dashboardUrl: "https://dashscope.console.aliyun.com/apiKey" }, + { + id: "alibaba-token-plan", + label: "Alibaba Token Plan (Beijing)", + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://bailian.console.aliyun.com/cn-beijing?tab=plan", + defaultModel: "qwen3.8-max", + models: ALIBABA_TOKEN_PLAN_MODELS, + liveModels: false, + note: "Token Plan Personal Edition · China (Beijing)", + modelInputModalities: ALIBABA_TOKEN_PLAN_INPUT_MODALITIES, + modelContextWindows: { + "qwen3.8-max": 983_616, "qwen3.7-max": 1_000_000, "qwen3.7-plus": 1_000_000, + "qwen3.6-flash": 1_000_000, "glm-5.3": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, + }, + modelReasoningEfforts: { + ...Object.fromEntries(ALIBABA_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])), + "qwen3.8-max": QWEN38_REASONING_EFFORTS, + "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, + "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, + "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, + }, + modelDefaultReasoningEfforts: { "qwen3.8-max": "xhigh" }, + directReasoningEffortModels: ["qwen3.8-max"], + thinkingBudgetModels: ALIBABA_TOKEN_PLAN_QWEN_MODELS.filter(id => id !== "qwen3.8-max"), + preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash"], + noVisionModels: ["glm-5.3", "glm-5.2"], + }, + { + id: "alibaba-token-plan-intl", + label: "Alibaba Token Plan (International)", + baseUrl: ALIBABA_INTL_TOKEN_PLAN_BASE_URL, + adapter: "openai-chat", + authKind: "key", + allowBaseUrlOverride: true, + baseUrlChoices: ALIBABA_INTL_BASE_URL_CHOICES, + dashboardUrl: "https://modelstudio.console.alibabacloud.com/?tab=api#/api", + defaultModel: "qwen3.7-max", + models: ALIBABA_INTL_TOKEN_PLAN_MODELS, + liveModels: false, + note: "Token Plan Team Edition · Singapore (ap-southeast-1)", + metadataModelIdNormalize: "case-insensitive", + modelInputModalities: ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES, + modelContextWindows: { + "qwen3.8-max": 983_616, + "qwen3.7-max": 1_000_000, "qwen3.7-plus": 1_000_000, "qwen3.6-plus": 1_000_000, "qwen3.6-flash": 1_000_000, + "deepseek-v4-flash": 1_000_000, "deepseek-v3.2": 131_072, + "kimi-k2.7-code": 262_144, "kimi-k2.6": 262_144, "kimi-k2.5": 262_144, + "glm-5.3": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, "glm-5.1": 1_000_000, "glm-5": 1_000_000, + "MiniMax-M2.5": 204_800, + }, + modelReasoningEfforts: { + ...Object.fromEntries(ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])), + "qwen3.8-max": QWEN38_REASONING_EFFORTS, + "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, + "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, + "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, + "deepseek-v4-flash": deepseekThinkingEffortsFor("deepseek-v4-flash"), + }, + modelReasoningEffortMap: { + "deepseek-v4-flash": deepseekReasoningMapFor("deepseek-v4-flash"), + }, + directReasoningEffortModels: ["qwen3.8-max"], + thinkingBudgetModels: ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS.filter(id => id !== "qwen3.8-max"), + preserveReasoningContentModels: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "deepseek-v4-flash", "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash"], + noVisionModels: ["deepseek-v4-flash", "deepseek-v3.2", "glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "MiniMax-M2.5"], + noReasoningModels: ["kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", "deepseek-v3.2", "glm-5.1", "glm-5", "MiniMax-M2.5"], + modelDefaultReasoningEfforts: { "qwen3.8-max": "xhigh" }, + }, + // NEEDS_HUMAN 2026-07-10: kept for config compatibility, but this is a dashboard URL, + // no /models endpoint is documented, and tools are silently ignored upstream per docs.parallel.ai. + // Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. + { id: "parallel", label: "Parallel", baseUrl: "https://platform.parallel.ai", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.parallel.ai" }, + // ZenMux native ids are vendor-namespaced (`/`), verified live against + // https://zenmux.ai/api/v1/models on 2026-07-18. The static seed doubles as the + // cold-cache decode source for the Codex slug codec (src/providers/slug-codec.ts); + // live discovery still owns the full catalog. + { + id: "zenmux", label: "ZenMux", baseUrl: "https://zenmux.ai/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://zenmux.ai", + models: ["moonshotai/kimi-k3-free", "moonshotai/kimi-k3"], + }, + { + id: "litellm", label: "LiteLLM (self-hosted)", baseUrl: "http://localhost:4000/v1", adapter: "openai-chat", authKind: "key", + dashboardUrl: "https://docs.litellm.ai/docs/proxy/quick_start", + allowPrivateNetworkByDefault: true, + allowBaseUrlOverride: true, + // A self-hosted proxy may legitimately run without a master key. + keyOptional: true, + }, + { + id: "ollama-cloud", + label: "Ollama Cloud", + // The upstream /v1 spelling is deliberately unchanged: ollamaNativeChatUrl() normalizes it + // to /api/chat, and live model discovery declares its own /v1/models path against the origin, + // so the native transport needs no base-URL edit here or in the free-provider directory. + baseUrl: "https://ollama.com/v1", + // The native transport must be declared HERE, not in configuration. routedProviderConfig() + // overwrites provider.adapter with the registry adapter for every row whose transport + // matches, so a config-level adapter is silently discarded. + adapter: "ollama-native", + authKind: "key", + dashboardUrl: "https://ollama.com/settings/keys", + // Live IDs verified 2026-07-10; qwen3-coder:480b retires 2026-07-15. + models: ["glm-5.3", "glm-5.3-flash", "glm-5.2", "qwen3-coder:480b", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5:397b", "gemma4:31b"], + defaultModel: "glm-5.3", + // Owner-audited exact outage fallback: these current Ollama Cloud GLM-5.3 rows have + // 1,048,576-token context windows. Live discovery and successful /api/show enrichment keep + // their existing precedence; these values prevent a failed show from becoming generic. + modelContextWindows: { "glm-5.3": 1_048_576, "glm-5.3-flash": 1_048_576 }, + noVisionModels: [ + // glm-5.3-flash is absent on purpose: native VLM + // (docs.z.ai/guides/vlm/glm-5.3-flash), so its images skip the sidecar. + "glm-5.3", "glm-5.2", "glm-5.1", "glm-5", "glm-4.7", + "minimax-m2.7", "minimax-m2.5", "minimax-m2.1", + "nemotron-3-ultra", "nemotron-3-super", + "deepseek-v4-flash", + "gpt-oss", "qwen3-coder:480b", + ], + // Ollama's native chat API has no `text.verbosity` equivalent and the ollama-native adapter + // never emits one, so a routed row must not inherit the Codex template's verbosity picker. + // Provider-wide rather than per-model: this catalog is discovery-authoritative, so ids that + // arrive later from live discovery must opt out too (the live-discovery gap closed by #2578). + supportsVerbosity: false, + // Live model discovery: Ollama serves the standard OpenAI-style data[] envelope at /v1/models, + // so the generic discovery pipeline needs no special-casing. The path is spelled against the + // ORIGIN (model-discovery resolves a leading-slash path against base.origin). A discovery + // spec is REQUIRED here: without one the pipeline probes https://ollama.com/models, which + // 307-redirects to /search and discovery falls back to the configured list. + modelDiscovery: { + path: "/v1/models", + }, + }, + // FREEZE 2026-07-10: codestral-latest is unconfirmed behind auth. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. + { id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest" }, + { + id: "minimax", label: "MiniMax — Coding Plan", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key", + dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, + modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS, + modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS }, + modelDefaultReasoningEfforts: { "MiniMax-M3": "medium" }, + modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, + preserveReasoningContentModels: MINIMAX_MODELS, + // MiniMax-M3 low effort maps to thinking disabled, so a legitimate tool + // round can carry no reasoning at all; only replay real recorded text, + // never a fabricated placeholder (chatgpt-codex-connector P2 on #1205). + requiresReasoningPlaceholderModels: [], + reasoningSplitModels: MINIMAX_MODELS, + // With reasoning_split the upstream returns thinking as a structured + // reasoning_details array (cumulative text snapshots per stream chunk) and + // requires that array back verbatim on the next turn — a reasoning_content + // string replay is the native-format pass-back the docs say is unsupported. + // Evidence: platform.minimax.io/docs/guides/text-m3-function-call and + // /docs/api-reference/text-openai-api (verified 2026-09-01). + reasoningDetailsModels: MINIMAX_MODELS, + thinkingToggleModels: ["MiniMax-M3"], + jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "Subscription Key or API Key", + }, + { + id: "minimax-cn", label: "MiniMax — Coding Plan (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", authKind: "key", + dashboardUrl: "https://platform.minimaxi.com", defaultModel: "MiniMax-M3", models: MINIMAX_MODELS, + modelContextWindows: MINIMAX_MODEL_CONTEXT_WINDOWS, + modelReasoningEfforts: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORTS }, + modelDefaultReasoningEfforts: { "MiniMax-M3": "medium" }, + modelReasoningEffortMap: { "MiniMax-M3": MINIMAX_M3_REASONING_EFFORT_MAP }, + preserveReasoningContentModels: MINIMAX_MODELS, + requiresReasoningPlaceholderModels: [], + reasoningSplitModels: MINIMAX_MODELS, + reasoningDetailsModels: MINIMAX_MODELS, + thinkingToggleModels: ["MiniMax-M3"], + jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive", note: "中国区 Subscription Key", + }, + { + id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key", + dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.7-code", + modelSuffixBracketStrip: true, + // API-key form of the same Kimi Code Plan transport; keep cache affinity identical to OAuth. + promptCacheKey: true, + models: KIMI_CODING_MODELS, + modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS, + modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES, + noReasoningModels: KIMI_CODING_NO_REASONING_MODELS, + modelReasoningEfforts: KIMI_CODING_REASONING_EFFORTS, + modelDefaultReasoningEfforts: KIMI_CODING_DEFAULT_REASONING_EFFORTS, + modelReasoningEffortMap: KIMI_CODING_REASONING_EFFORT_MAPS, + noTemperatureModels: KIMI_LOCKED_PARAMETER_MODELS, + noTopPModels: KIMI_LOCKED_PARAMETER_MODELS, + noPenaltyModels: KIMI_LOCKED_PARAMETER_MODELS, + autoToolChoiceOnlyModels: KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS, + preserveReasoningContentModels: KIMI_THINKING_MODELS, + }, + { + id: "opencode-zen", label: "opencode zen", baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://opencode.ai/auth", + // Same opencode.ai/zen/v1 gateway as `opencode-free` (keyed tier): DeepSeek thinking mode + // requires the assistant's original reasoning_content to be replayed on tool-call + // continuations, or the gateway answers HTTP 400 (issues #950/#994). Mirror the DeepSeek + // reasoning + thinking metadata so `opencode-zen/deepseek-v4-flash-free` — and the other + // Zen DeepSeek thinking models — never serialize a bare tool-call turn. + note: "Keyed OpenCode Zen gateway. Free models on this tier are often short-window rate-limited at roughly 15-20 requests/minute (community-measured; OpenCode does not publish RPM). Zen may return generic 429s without Retry-After / X-RateLimit headers; when Retry-After is omitted, opencodex adds a synthetic backoff hint (upstream Retry-After still wins). Distinct from the keyless opencode-free desktop quota (~200 Big Pickle/free-model requests per 5 hours). Docs: https://opencode.ai/docs/zen/. Free-model prompts may be retained for training — do not send confidential material.", + modelReasoningEfforts: Object.fromEntries( + [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekThinkingEffortsFor(id)]), + ), + modelReasoningEffortMap: Object.fromEntries( + [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS].map(id => [id, deepseekReasoningMapFor(id)]), + ), + preserveReasoningContentModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], + // Same Zen gateway as opencode-free: the DeepSeek vision preview id + // (merges into deepseek-v4-flash later). + modelContextWindows: { + [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576, + }, + modelInputModalities: { + [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], + ...Object.fromEntries(OPENCODE_ZEN_IMAGE_MODELS.map(id => [id, ["text", "image"] as string[]])), + }, + noVisionModels: [...OPENCODE_ZEN_TEXT_ONLY_MODELS, ...DEEPSEEK_GATEWAY_THINKING_MODELS], + // Same DeepSeek routes as the Go preset above, behind the same vendor, so they carry + // the same json_schema rejection (#1338 / #1415). + noJsonSchemaModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], + }, + { id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" }, + { + id: "opencode-free", + label: "OpenCode Free", + adapter: "openai-chat", + baseUrl: "https://opencode.ai/zen/v1", + authKind: "key", + keyOptional: true, + featured: true, + liveModels: true, + note: "No key needed, but OpenCode now gates this tier to its own client: Zen refuses any request that arrives without an x-opencode-session header (error type MissingSessionID, \"OpenCode's free tier can only be used in OpenCode\"). opencodex does not mint that header or claim an OpenCode client identity, because no upstream contract authorizes a third-party agent to present itself as OpenCode. Until OpenCode publishes a third-party integration path for the keyless tier, use the keyed opencode-zen provider instead (https://opencode.ai/auth). Quota figures for when the tier admitted a request: OpenCode advertises about 200 Big Pickle/free-model requests per 5 hours, and the same Zen gateway can short-window rate-limit free models at roughly 15-20 requests/minute, and may return generic 429s without Retry-After (opencodex synthesizes backoff only when that header is omitted). Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.", + dashboardUrl: "https://opencode.ai", + staticHeaders: { + // Zen answers a bare runtime User-Agent (Bun/x.y.z) more aggressively than a client + // that identifies itself, which is what the 429 in #2067 traced to. The value is + // deliberately unversioned: a pinned "opencode-cli/" is a claim about an + // install we do not have and goes stale on the vendor's schedule, not ours. + // Corroboration, not authority: OmniRoute — an independent open-source broker against + // the same Zen upstream — defaults to exactly this pair (userAgent "opencode", client + // "desktop") in open-sse/executors/opencode.ts, and got there by RETREATING from its + // own earlier "opencode-cli/1.0.0" pin. An operator can still override either value + // through the provider headers API; user headers win case-insensitively at route time. + "User-Agent": "opencode", + "x-opencode-client": "desktop", + }, + modelReasoningEfforts: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])), + modelReasoningEffortMap: Object.fromEntries(OPENCODE_FREE_DEEPSEEK_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), + preserveReasoningContentModels: OPENCODE_FREE_DEEPSEEK_MODELS, + // The DeepSeek vision preview id is preemptive metadata for when Zen starts + // serving it (merges into v4-flash later). + modelContextWindows: { + [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576, + }, + modelInputModalities: { + [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"], + ...Object.fromEntries(OPENCODE_ZEN_IMAGE_MODELS.map(id => [id, ["text", "image"] as string[]])), + }, + // Same Zen roster behind the same base URL, so it carries the same measured + // text-only list rather than only its DeepSeek member (#1043). + noVisionModels: OPENCODE_ZEN_TEXT_ONLY_MODELS, + // Same reasoning: the free tier is the same Zen roster, so its DeepSeek members get + // the keyed tier's json_schema treatment and its reasoning contract rather than a + // narrower table that silently falls behind whenever the keyed one is updated. + noJsonSchemaModels: [...DEEPSEEK_GATEWAY_THINKING_MODELS, ...OPENCODE_FREE_DEEPSEEK_MODELS], + }, + { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" }, + // Xiaomi's public OpenAI-compatible endpoint is a distinct transport from both the Anthropic + // preset above and the paid token-plan host below. Keep a separate fixed-destination contract + // so existing custom providers are never retargeted while the official route receives the + // strict reasoning ladder its validator enforces (#1483). + { + id: "xiaomi-mimo", + label: "Xiaomi MiMo (OpenAI Chat)", + baseUrl: "https://api.xiaomimimo.com/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://platform.xiaomimimo.com/console/balance", + defaultModel: "mimo-v2.5", + models: ["mimo-v2.5"], + reasoningEfforts: ["low", "medium", "high"], + reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" }, + preserveCustomDestination: true, + note: "Official Xiaomi MiMo OpenAI-compatible Chat endpoint. The upstream validator accepts reasoning_effort none/low/medium/high; higher Codex tiers are clamped to high.", + }, + { id: "kilo", label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://kilo.ai" }, + { + id: "mimo-free", + label: "MiMo Free", + adapter: "mimo-free", + baseUrl: "https://api.xiaomimimo.com/api/free-ai/openai/chat", + authKind: "key", + keyOptional: true, + featured: true, + liveModels: true, + dashboardUrl: "https://xiaomimimo.com", + defaultModel: "mimo-auto", + models: ["mimo-auto"], + reasoningEfforts: ["low", "medium", "high"], + reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" }, + note: "No key needed — uses Xiaomi MiMo's free public tier (limited-time offer). A JWT is bootstrapped automatically with an anonymous random client id stored locally. The endpoint contract mirrors the official MiMoCode client and is not publicly documented — Xiaomi may change or restrict it at any time. Prompts may be processed/retained by Xiaomi; do not send confidential material.", + }, + // Xiaomi MiMo paid token plan. Separate host and wire from both `xiaomi` (Anthropic) and + // `mimo-free` (free tier, bespoke adapter), so it needs its own entry rather than a variant. + // + // Pinned to openai-chat deliberately (#1158). The endpoint answers the Responses wire for + // plain turns, which is why users configuring it by hand pick `openai-responses` — MiMo + // documents Responses support. But its gateway rejects `type: "custom"` tools with + // `400 responses_feature_not_supported`, and `apply_patch` is a custom tool, so every agentic + // turn fails while chat turns succeed. The Chat path lowers custom tools to `{input: string}` + // functions and restores them as `custom_tool_call`, so the capability survives intact. + // Stripping the tools instead would stop the 400 and disable the agent loop. + { + id: "mimo", + label: "Xiaomi MiMo (token plan)", + baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://xiaomimimo.com", + defaultModel: "mimo-v2.5-pro", + models: ["mimo-v2.5-pro", "mimo-v2.5"], + // The gateway validates the ladder strictly and rejects anything above `high`. + reasoningEfforts: ["low", "medium", "high"], + reasoningEffortMap: { xhigh: "high", max: "high", ultra: "high" }, + // Live token-plan verification (#1927): the Pro route rejects image input while + // mimo-v2.5 accepts it natively. Keep this provider-scoped so a hand-rolled + // provider with the same id but another destination does not inherit the claim. + noVisionModels: ["mimo-v2.5-pro"], + // A user may already have hand-rolled a provider under this id against a different host; + // without this, routedProviderConfig() would canonicalize their base URL onto ours and send + // their key somewhere they did not choose. + preserveCustomDestination: true, + note: "Xiaomi MiMo paid token plan. Pinned to the Chat wire: the Responses endpoint rejects freeform (custom) tools such as apply_patch with 400 responses_feature_not_supported, so agentic turns fail there while plain turns succeed. Reasoning tiers above high are clamped.", + }, + { id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" }, + { + // Cloudflare Workers AI: OpenAI-compatible endpoint. The base URL contains {account_id} + // which must be resolved by the user at setup time. Model IDs use the @cf/ prefix. + // Live-verified 2026-07-21 against https://developers.cloudflare.com/workers-ai/models/ + // Official search is sibling to /ai/v1 (GET .../ai/models/search?format=openrouter). + id: "cloudflare-workers-ai", label: "Cloudflare Workers AI", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1", + adapter: "openai-chat", authKind: "key", freeTier: true, + dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/workers-ai", + defaultModel: "@cf/meta/llama-3.3-70b-instruct-fp8-fast", + models: [ + "@cf/meta/llama-3.3-70b-instruct-fp8-fast", + "@cf/qwen/qwq-32b", + "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b", + "@cf/moonshotai/kimi-k2.7-code", + "@cf/zai-org/glm-5.3", + "@cf/zai-org/glm-5.3-flash", + "@cf/zai-org/glm-5.2", + "@cf/mistralai/mistral-small-3.1-24b-instruct", + ], + liveModels: true, + modelDiscovery: { + path: "../models/search", + query: { format: "openrouter", per_page: "1000" }, + stripIdPrefix: "workers-ai/", + maxModels: 256, + }, + note: "Workers AI · Free tier included · Account ID required in base URL", + }, + // FREEZE 2026-07-10: /models was auth-gated under key login. OAuth device-flow + copilot_internal + // exchange (issue #151) unlocks live discovery; static seed is a cold-start fallback only. + { + id: "github-copilot", + label: "GitHub Copilot", + baseUrl: "https://api.githubcopilot.com", + adapter: "openai-chat", + authKind: "oauth", + allowKeyAuthOverride: true, + featured: false, + dashboardUrl: "https://github.com/settings/copilot", + liveModels: true, + models: ["gpt-4o", "gpt-4.1", "gpt-4.1-mini", "claude-sonnet-4", "gemini-2.5-pro", "gpt-5-mini", "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", "gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"], + defaultModel: "gpt-4o", + // Copilot fronts a mixed-wire catalog: these models reject /chat/completions for + // real Codex-agent traffic (function tools + reasoning), so every inbound wire + // rides Responses. Evidence: issue #748 field runs, pi.dev/models/github-copilot/* + // wire declarations, BerriAI/litellm#23332 (gpt-5.4), JetBrains LLM-29711 + // (gpt-5.6-sol). gpt-5.4-nano is deliberately absent — it has no field report; a + // user can opt it in with an explicit modelAdapters entry, which always wins. + modelWireDefaults: { + "gpt-5.3-codex": "openai-responses", + "gpt-5.4": "openai-responses", + "gpt-5.4-mini": "openai-responses", + "gpt-5.5": "openai-responses", + "gpt-5.6-luna": "openai-responses", + "gpt-5.6-sol": "openai-responses", + "gpt-5.6-terra": "openai-responses", + "gpt-6-astra": "openai-responses", + "grok-4.5": "openai-responses", + "grok-4.6": "openai-responses", + "mai-code-1.1-flash": "openai-responses", + "mai-code-1-flash-picker": "openai-responses", + }, + note: "Experimental unofficial Copilot bridge. Logs in via GitHub device flow using the public VS Code OAuth client id, then exchanges for a short-lived Copilot API token (copilot_internal). Requires an active Copilot subscription. GitHub may tighten or revoke this path; do not send confidential material you would not paste into Copilot Chat.", + }, + // FREEZE 2026-07-10: no public OpenAI-compatible endpoint is documented. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. + { id: "gitlab-duo", label: "GitLab Duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://gitlab.com/-/user_settings/personal_access_tokens" }, + { + // Official Qoder Global CLI automation surface. The canonical URL is an identity boundary; + // inference and model discovery are performed only by the installed vendor CLI. Authentication + // uses the documented PAT environment variable and never imports desktop/session credentials. + id: "qoder", + label: "Qoder (Global)", + adapter: "qoder", + baseUrl: "https://qoder.com", + authKind: "key", + apiKeyValidation: "unknown", + preserveCustomDestination: true, + dashboardUrl: "https://qoder.com/account/integrations", + defaultModel: "Qwen3.8-Max", + models: [...QODER_GLOBAL_MODELS], + liveModels: true, + reasoningEfforts: [...QODER_REASONING_EFFORTS], + noVisionModels: [...QODER_GLOBAL_MODELS], + note: "Official Qoder Global CLI using QODER_PERSONAL_ACCESS_TOKEN. Models are discovered per account with `qoder --list-models`; the documented roster is a degraded fallback. The CLI runs single-turn with tools, MCP, settings hooks, and session persistence disabled. Requires `npm install -g @qoder-ai/qodercli`.", + }, + { + // Qoder CN is a separate credential, executable, destination, entitlement cache, and health + // domain. It deliberately does not reuse the OAuth/private-protocol design from #3010. + id: "qoder-cn", + label: "Qoder CN", + adapter: "qoder", + baseUrl: "https://qoder.cn", + authKind: "key", + apiKeyValidation: "unknown", + preserveCustomDestination: true, + dashboardUrl: "https://qoder.cn/account/integrations", + defaultModel: "Qwen3.8-Max", + models: [...QODER_CN_MODELS], + liveModels: true, + reasoningEfforts: [...QODER_REASONING_EFFORTS], + noVisionModels: [...QODER_CN_MODELS], + note: "Official Qoder CN CLI using QODERCN_PERSONAL_ACCESS_TOKEN. Models are discovered per account with `qodercn --list-models`; the verified roster is a degraded fallback. The CLI runs single-turn with tools, MCP, settings hooks, and session persistence disabled. Requires `npm install -g @qodercn-ai/qoderclicn`.", + }, + { + // Official CodeBuddy Code CLI provider (Tencent Cloud), GLOBAL / `public` environment. + // Transport is the vendor-documented headless CLI automation surface + // (`codebuddy -p --output-format stream-json --tools ""`) authenticated with the official + // `CODEBUDDY_API_KEY` (https://www.codebuddy.ai/profile/keys). It does NOT read desktop + // session files, import desktop bearer tokens, impersonate the desktop client, or call the + // private console endpoint — the approach closed in #687 and left in draft in #2244. + // baseUrl is the canonical region identity: the adapter fails closed if it is overridden, so a + // global key is never sent to the CN environment (that is the separate `codebuddy-cn` entry). + // v1 runs tools-disabled so Codex keeps tool ownership; this provider is text/reasoning only + // until the control-protocol tool bridge lands (see docs). Free/trial/promotional/subscription + // credits draw from the same official API-key pool. Requires the CLI: `npm i -g @tencent-ai/codebuddy-code`. + // GOVERNANCE: whether routing this vendor automation surface behind a proxy for a third-party + // agent satisfies CodeBuddy's AUP is an open question flagged for maintainer security review. + id: "codebuddy", + label: "CodeBuddy (Global)", + adapter: "codebuddy", + baseUrl: "https://www.codebuddy.ai", + authKind: "key", + apiKeyValidation: "unknown", + preserveCustomDestination: true, + dashboardUrl: "https://www.codebuddy.ai/profile/keys", + defaultModel: "default-model", + models: CODEBUDDY_GLOBAL_MODELS, + liveModels: false, + modelContextWindows: CODEBUDDY_GLOBAL_MODEL_CONTEXT_WINDOWS, + modelMaxOutputTokens: CODEBUDDY_GLOBAL_MODEL_MAX_OUTPUT_TOKENS, + defaultMaxOutputTokens: 32_000, + reasoningEfforts: CODEBUDDY_REASONING_EFFORTS, + modelReasoningEfforts: CODEBUDDY_GLOBAL_MODEL_REASONING_EFFORTS, + modelDefaultReasoningEfforts: CODEBUDDY_GLOBAL_MODEL_DEFAULT_REASONING_EFFORTS, + note: "Official CodeBuddy Code CLI (Tencent Cloud), global/public environment. Uses the documented CODEBUDDY_API_KEY + headless CLI surface; never reads desktop sessions or private console endpoints. Region-isolated from codebuddy-cn. v1 disables CLI tools (--tools \"\") so Codex retains tool ownership: text/reasoning only for now. Requires `npm i -g @tencent-ai/codebuddy-code`. AUP/routing authorization flagged for maintainer security review.", + }, + { + // Official CodeBuddy Code CLI provider, CHINA / `internal` environment. Identical adapter and + // binary as `codebuddy`; the region is fixed by the profile's CODEBUDDY_INTERNET_ENVIRONMENT + // and this canonical baseUrl. CN key: https://copilot.tencent.com/profile/keys. The CN model + // roster differs from Global (see codebuddy-models.ts) and is seeded separately (§八). + id: "codebuddy-cn", + label: "CodeBuddy (CN)", + adapter: "codebuddy", + baseUrl: "https://www.codebuddy.cn", + authKind: "key", + apiKeyValidation: "unknown", + preserveCustomDestination: true, + dashboardUrl: "https://copilot.tencent.com/profile/keys", + defaultModel: "default", + models: CODEBUDDY_CN_MODELS, + liveModels: false, + modelContextWindows: CODEBUDDY_CN_MODEL_CONTEXT_WINDOWS, + modelMaxOutputTokens: CODEBUDDY_CN_MODEL_MAX_OUTPUT_TOKENS, + defaultMaxOutputTokens: 32_000, + reasoningEfforts: CODEBUDDY_REASONING_EFFORTS, + modelReasoningEfforts: CODEBUDDY_CN_MODEL_REASONING_EFFORTS, + modelDefaultReasoningEfforts: CODEBUDDY_CN_MODEL_DEFAULT_REASONING_EFFORTS, + noVisionModels: CODEBUDDY_CN_NO_VISION_MODELS, + note: "Official CodeBuddy Code CLI (Tencent Cloud), China/internal environment. Uses the documented CODEBUDDY_API_KEY + headless CLI surface; never reads desktop sessions or private console endpoints. Region-isolated from codebuddy (Global); credentials are never exchanged across regions. v1 disables CLI tools (--tools \"\"): text/reasoning only for now. Requires `npm i -g @tencent-ai/codebuddy-code`. AUP/routing authorization flagged for maintainer security review.", + }, +]; diff --git a/src/providers/registry/model-seeds.ts b/src/providers/registry/model-seeds.ts new file mode 100644 index 0000000000..bcc0ae6932 --- /dev/null +++ b/src/providers/registry/model-seeds.ts @@ -0,0 +1,908 @@ +import type { ProviderModelDiscoverySpec } from "./types"; + +// Shared between the OAuth (Claude account) and API-key Anthropic entries so both expose the +// same static model seed. +// 260710 context refresh: Tier-2 evidence in +// devlog/_plan/260710_provider_hardening/001_research_frontier.md. +// 260902 Claude Fable 5.1 (`claude-fable-5-1`): 1M context / 128K output / adaptive thinking +// always on, per the official models overview and pricing page (platform.claude.com). +export const ANTHROPIC_MODELS = ["claude-fable-5-1", "claude-fable-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"]; +export const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record = { "claude-fable-5-1": 1_000_000, "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-opus-4-7": 1_000_000, "claude-opus-4-6": 1_000_000, "claude-sonnet-4-6": 1_000_000, "claude-haiku-4-5": 200_000 }; +// Every current Claude family accepts at least 64k output tokens (Haiku 4.5 / Sonnet 4.x +// through Opus 5 and Fable 5). Anthropic caps max_tokens per model server-side, so a +// larger request never over-allocates; it only stops the 8192 truncation. +export const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000; +/** + * The effort rungs opencodex exposes for native Anthropic models. Without this the + * providers advertised no ladder at all, so every client that keys its effort control off + * `reasoningEfforts` — Aside and the rest of the Pi-shaped exports — wrote these models + * with no control, while the SAME Claude models routed through `cursor` or + * `google-antigravity` had one. + * + * This is an opencodex ladder, not a claim that each model takes `output_config.effort`. + * The adapter serves two wire shapes (src/adapters/anthropic.ts): adaptive families + * (fable, sonnet >= 5, opus >= 4.7) send the effort directly, while opus 4.6, sonnet 4.6 + * and haiku 4.5 take the legacy path where `reasoningBudget` TRANSLATES each rung into + * `thinking.budget_tokens`. Anthropic documents `low|medium|high|max` for the 4.6 models + * and no effort parameter at all for haiku 4.5; the budget translation is what makes five + * rungs meaningful there, and it clamps below `max_tokens` so none of them 400. + * + * Deliberately excluded, each because advertising it would offer a control that does not + * do what it says: + * - `minimal`: `adaptiveEffort` rewrites it to `low` (the adaptive wire 400s on it), so + * it is not a distinct setting. + * - `none`: only sonnet >= 5 accepts an explicit thinking disable + * (`EXPLICIT_THINKING_DISABLE_FAMILY_MINIMUMS`); Fable rejects one outright. + * - `ultra`: not an Anthropic concept, and it is degraded to `max` at the request + * boundary anyway (src/responses/parser.ts). + */ +export const ANTHROPIC_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +export const ANTHROPIC_MODEL_REASONING_EFFORTS: Record = Object.fromEntries( + ANTHROPIC_MODELS.map(id => [id, [...ANTHROPIC_REASONING_EFFORTS]]), +); + +// 260814 GLM-5.3 is registered pre-emptively alongside 5.2 everywhere 5.2 appears. Z.AI's +// devpack "How to Switch Models" page (docs.z.ai/devpack/latest-model) lists glm-5.3 and +// glm-5.3[1m] as Coding Plan ids on the unchanged endpoints; the capability and pricing +// tables were not published yet, so every 5.3 row mirrors its 5.2 sibling until they settle. +// The non-Z.AI providers below are speculative on purpose: they carry 5.2 today and are +// expected to pick 5.3 up on their usual lag. Providers whose live /v1/models discovery is +// enabled self-correct on the next successful fetch; static ones need a follow-up refresh. +// Every 5.3 family member, so the effort ladder, the default effort and the output +// cap are derived in ONE place. `glm-5.3-flash` was seeded into the model list and +// the context map by hand and left out of this constant, which meant it advertised +// a 1M context with a null effort ladder, no default effort and no output cap while +// its siblings carried three tiers, a `max` default and 131072 tokens. A member +// added to the list but not to the family is a model whose metadata silently +// disappears. +export const ZAI_GLM_53_MODELS = ["glm-5.3", "glm-5.3[1m]", "glm-5.3-flash"]; +export const ZAI_GLM_52_MODELS = ["glm-5.2", "glm-5.2[1m]"]; +export const ZAI_GLM_5X_MODELS = [...ZAI_GLM_53_MODELS, ...ZAI_GLM_52_MODELS]; +/** + * The 5.x rows whose images the PROXY has to describe, which is NOT the same set as + * the 5.x rows themselves. + * + * `glm-5.3-flash` is a native VLM (docs.z.ai/guides/vlm/glm-5.3-flash), so listing it + * in `noVisionModels` sent an image through the vision sidecar and handed the model a + * text description of a picture it could have read itself - no error, worse answer, + * extra call. The correction commit fixed the Alibaba entries and left the eight + * providers that reach this constant behind. + * + * Kept separate from ZAI_GLM_5X_MODELS rather than filtered at each use site: that + * constant also drives `modelSupportsReasoningSummaries` and + * `preserveReasoningContentModels`, where flash DOES belong. + */ +export const ZAI_GLM_5X_SIDECAR_VISION_MODELS = ZAI_GLM_5X_MODELS.filter(id => id !== "glm-5.3-flash"); +/** + * Positive input-modality declaration for the Chat-path GLM rows. + * + * `noVisionModels` already keeps Flash out of the vision sidecar, but that is a NEGATIVE + * statement: it stops a detour without telling the catalog what the model can read. With + * no `modelInputModalities` entry, `configuredInputModalities` returns undefined and the + * catalog falls through to the `["text"]` floor, so every client export (ZCode, Pi, OMP) + * listed a native VLM as text-only and its picker refused to attach an image. + * + * The Responses sibling row below already declares this positively, so the same model was + * described two different ways in one registry. + * + * Authoritative source: `GET https://api.z.ai/api/v1/models` returns `input_modalities: + * ["text"]` for glm-5.3 and `["text", "image"]` for glm-5.3-flash (captured in + * devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json). + * docs.z.ai/devpack/latest-model says the same in prose: "GLM-5.3 is a text-only model... + * GLM-5.3-FLASH is a multimodal model". Upstream also lists video and file for Flash; + * neither the internal vocabulary nor the export vocabulary can express them, so `image` + * is where this stops. + */ +export const ZAI_GLM_5X_INPUT_MODALITIES: Record = { + ...Object.fromEntries(ZAI_GLM_5X_SIDECAR_VISION_MODELS.map(id => [id, ["text"]])), + "glm-5.3-flash": ["text", "image"], +}; +export const ZAI_GLM_52_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +/** + * GLM-5.3 does NOT share 5.2's five-tier ladder. docs.z.ai/devpack/latest-model folds every + * incoming effort into three effective tiers — low/minimal/light -> low, medium/high -> high, + * xhigh/max/ultra -> max — with max as both the default and the unknown-value fallback. + * Advertising five levels would publish two picker rows that are indistinguishable on the wire, + * so only the effective tiers are exposed (same treatment Cursor and Baseten already give GLM). + */ +export const ZAI_GLM_53_REASONING_EFFORTS = ["low", "high", "max"]; +/** Per-model ladders for the Coding Plan rows: 5.3 gets its three effective tiers, 5.2 keeps five. */ +export const ZAI_GLM_5X_REASONING_EFFORTS: Record = { + ...Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, ZAI_GLM_53_REASONING_EFFORTS])), + ...Object.fromEntries(ZAI_GLM_52_MODELS.map(id => [id, ZAI_GLM_52_REASONING_EFFORTS])), +}; +// 260710 MiniMax models and context windows: Tier-2 evidence in +// devlog/_plan/260710_provider_hardening/002_research_cn.md. +export const MINIMAX_MODELS = [ + "MiniMax-M3", + "MiniMax-M2.7", "MiniMax-M2.7-highspeed", + "MiniMax-M2.5", "MiniMax-M2.5-highspeed", + "MiniMax-M2.1", "MiniMax-M2.1-highspeed", + "MiniMax-M2", +]; +export const MINIMAX_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( + MINIMAX_MODELS.map(id => [id, id === "MiniMax-M3" ? 1_000_000 : 204_800]), +); +export const MINIMAX_M3_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +export const MINIMAX_M3_REASONING_EFFORT_MAP: Record = { + none: "disabled", + minimal: "disabled", + low: "disabled", + medium: "adaptive", + high: "adaptive", + xhigh: "adaptive", + max: "adaptive", +}; +export const OPENAI_GPT56_MODELS = ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]; +export const OPENAI_GPT56_PRO_MODELS = ["gpt-5.6-sol-pro", "gpt-5.6-terra-pro", "gpt-5.6-luna-pro"]; +export const OPENAI_API_GPT56_CONTEXT_WINDOW = 1_050_000; +export const OPENAI_API_GPT56_CONTEXT_WINDOWS: Record = { + ...Object.fromEntries([...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS].map(id => [id, OPENAI_API_GPT56_CONTEXT_WINDOW])), + "gpt-5.5": OPENAI_API_GPT56_CONTEXT_WINDOW, +}; +export const OPENAI_API_GPT56_MAX_INPUT_TOKENS: Record = { + ...Object.fromEntries([...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS].map(id => [id, 922_000])), + "gpt-5.5": 922_000, +}; +export const OPENAI_API_GPT56_VIRTUAL_MODELS: Record = { + "gpt-5.6-sol-pro": { wireModelId: "gpt-5.6-sol", reasoningMode: "pro" }, + "gpt-5.6-terra-pro": { wireModelId: "gpt-5.6-terra", reasoningMode: "pro" }, + "gpt-5.6-luna-pro": { wireModelId: "gpt-5.6-luna", reasoningMode: "pro" }, +}; +export const OPENAI_API_GPT56_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +/* + * Meta Model API (https://api.meta.ai/v1) — published ladder, deliberately NOT the + * house set. dev.meta.ai/docs/reasoning lists "none", "minimal", "low", "medium", + * "high", "xhigh" and then excludes "none" for this family: "not supported by Muse + * Spark and returns HTTP 400". "max" and "ultra" are absent from the vendor's list + * entirely, so appending one by family resemblance would invent a wire value. + * + * Corroborated on a second surface: an unauthenticated OpenCode Zen probe of + * muse-spark-1.3-contributor-free (2026-09-03) accepted minimal..xhigh, rejected + * max/ultra with `unknown variant`, and rejected none with "does not support none + * with this model". + */ +export const META_MUSE_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"]; +/* + * Identity wire map. `requestToCodexEffort` (src/reasoning-effort.ts) rewrites + * `minimal` to `low` unless a model-scoped wire map says otherwise, so without this + * the picker would advertise an effort the wire never sends — and a registry-array + * assertion would pass while the request body was wrong. Identity because Meta's + * values ARE the Codex names. + */ +export const META_MUSE_REASONING_EFFORT_MAP: Record = Object.fromEntries( + META_MUSE_REASONING_EFFORTS.map(effort => [effort, effort]), +); +/** Both Muse Spark 1.3 tiers publish a 1,048,576-token window (dev.meta.ai/docs/models). */ +export const META_MUSE_CONTEXT_WINDOW = 1_048_576; +export const META_MUSE_MODELS = ["muse-spark-1.3", "muse-spark-1.3-contributor"]; +/** + * Daybreak program aliases. These `-latest` ids are the stable contract: OpenAI repoints + * them at newer snapshots over time (red -> gpt-5.6-cyber, blue -> gpt-5.6-sol as of + * 2026-08-11), so registering the ALIAS inherits future model swaps while a pinned + * snapshot id would silently go stale. Snapshot ids are deliberately absent here. + * Responses-only per both published endpoint tables (`v1/chat/completions` is marked + * Not supported) — never add these to a chat-completions provider. Access needs separate + * Daybreak approval and provisioning, so neither is ever a default. + * Verified 2026-08-11: developers.openai.com/api/docs/models/daybreak-red-latest.md + * and .../daybreak-blue-latest.md + */ +export const OPENAI_DAYBREAK_MODELS = ["daybreak-red-latest", "daybreak-blue-latest"]; +export const OPENAI_DAYBREAK_CONTEXT_WINDOWS: Record = { + "daybreak-red-latest": 400_000, + "daybreak-blue-latest": 1_050_000, +}; +export const OPENAI_DAYBREAK_MAX_INPUT_TOKENS: Record = { + "daybreak-red-latest": 272_000, + "daybreak-blue-latest": 922_000, +}; +/** + * Neither Daybreak page publishes a reasoning-effort ladder. An explicit empty array means + * "expose no effort control"; OMITTING the key would instead fall back to the full routed + * ladder (`configuredReasoningEfforts` returns undefined -> `applyReasoningLevels` uses + * ROUTED_REASONING_LEVELS), which would advertise efforts the models never documented. + * `noReasoningModels` is wrong here: both pages document reasoning-token support, so these + * are reasoning models with no *selectable* ladder. + */ +export const OPENAI_DAYBREAK_REASONING_EFFORTS: Record = Object.fromEntries( + OPENAI_DAYBREAK_MODELS.map(id => [id, [] as string[]]), +); +export const OPENROUTER_GPT56_MODELS = OPENAI_GPT56_MODELS.map(id => `openai/${id}`); +export const XAI_MODELS = [ + "grok-4.6", + "grok-4.5", + "grok-4.3", + "grok-4.20-multi-agent-0309", + "grok-4.20-0309-reasoning", + "grok-4.20-0309-non-reasoning", + "grok-build-0.1", + "grok-composer-2.5-fast", +]; +// OpenRouter's live /endpoints routes report 1,050,000; keep this separate from the +// unverified OpenAI API-key seed. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. +export const OPENROUTER_GPT56_CONTEXT_WINDOW = 1_050_000; +export const OPENROUTER_GPT56_CONTEXT_WINDOWS = { + "openai/gpt-5.6-sol": OPENROUTER_GPT56_CONTEXT_WINDOW, + "openai/gpt-5.6-terra": OPENROUTER_GPT56_CONTEXT_WINDOW, + "openai/gpt-5.6-luna": OPENROUTER_GPT56_CONTEXT_WINDOW, +}; + +/** + * Vendor thinking-toggle models (MiMo v2.x, GLM 5/5.1 on Zen Go): the wire knob is + * `thinking: {type: enabled|disabled}` — a binary. Advertise the full Codex picker ladder + * and map efforts onto the toggle. Zen Go + * pass-through probed live 2026-07-07 (glm-5.2 toggle verified; mimo/minimax accept shape). + */ +export const THINKING_TOGGLE_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +export const THINKING_TOGGLE_MAP: Record = { + none: "disabled", + minimal: "disabled", + low: "disabled", + medium: "enabled", + high: "enabled", + xhigh: "enabled", + max: "enabled", +}; +export const OPENCODE_GO_THINKING_TOGGLE_MODELS = [ + "mimo-v2.5", "mimo-v2.5-pro", "glm-5", "glm-5.1", +]; +/** + * Zhipu's domestic BigModel platform. Text families first, then the vision member: modalities are + * declared per model because `noVisionModels` means the opposite of "text only" here — it routes + * images through the proxy's vision sidecar (src/codex/catalog/provider-fetch.ts), a claim nobody + * has verified for BigModel-hosted GLM. + */ +// `glm-5.3-flash` is deliberately absent: it is a native VLM +// (docs.z.ai/guides/vlm/glm-5.3-flash), unlike glm-5.3 itself. +export const ZHIPU_BIGMODEL_TEXT_MODELS = ["glm-4.6", "glm-4.7", "glm-4.7-flash", "glm-5", "glm-5.1", "glm-5.2", "glm-5.3"]; +export const ZHIPU_BIGMODEL_MODELS = [...ZHIPU_BIGMODEL_TEXT_MODELS, "glm-4.6v"]; +export const ZHIPU_BIGMODEL_INPUT_MODALITIES: Record = { + ...Object.fromEntries(ZHIPU_BIGMODEL_TEXT_MODELS.map(id => [id, ["text"]])), + "glm-4.6v": ["text", "image"], +}; +export const ZHIPU_BIGMODEL_THINKING_TOGGLE_MODELS = ["glm-4.6", "glm-4.7", "glm-5", "glm-5.1", "glm-5.2", "glm-5.3", "glm-5.3-flash"]; +export const THINKING_BUDGET_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +// Qwen3.8-Max is the first Qwen3.x model with official direct `reasoning_effort` support. +// Evidence: https://qwen.ai/blog?id=qwen3.8 +export const QWEN38_REASONING_EFFORTS = ["low", "medium", "xhigh"]; +export const THINKING_BUDGET_MODELS = [ + "qwen3.5-397b", "qwen3.6-35b", + "qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus", +]; +export const OPENCODE_GO_THINKING_BUDGET_MODELS = ["qwen3.5-plus", "qwen3.6-plus", "qwen3.7-max", "qwen3.7-plus"]; +/* + * DeepSeek moved the whole V4 name set on 2026-09-10. V4.1-Flash ships as deepseek-flash + * on the first-party API; deepseek-v4-flash and the vision preview retire as models but + * keep routing there as compatibility aliases, and deepseek-v4-pro follows from + * 2026-09-14 04:00 UTC. Evidence: https://api-docs.deepseek.com/news/news260910/. + * + * The spelling differs by who serves it, so one shared list cannot express it: the + * first-party API answers to deepseek-flash, while the Zen gateway exposes the route as + * deepseek-v4.1-flash (issue #4253, PR #4258). Vendor-hosted rosters (Volcengine plan + * snapshots, Alibaba) publish on their own schedule and keep the legacy set until they say + * otherwise - a first-party retirement notice does not end their deployment. + */ +export const DEEPSEEK_V4_LEGACY_MODELS = ["deepseek-v4-flash"]; +/* + * `deepseek-v4-pro` is deliberately absent from both live sets. DeepSeek retires it from + * 2026-09-14 04:00 UTC and routes its requests to V4.1-Flash until a V4.1 Pro exists, so a + * row here would advertise a Pro context window and Pro pricing for a route that serves + * Flash. The retirement is followed through every roster in this file, including the + * vendor-hosted ones; providers that discover their models live are handled by + * `ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS` because deleting a row there removes the + * model's capabilities rather than the model. + */ +export const DEEPSEEK_NATIVE_THINKING_MODELS = ["deepseek-flash", "deepseek-v4-flash"]; +export const DEEPSEEK_GATEWAY_THINKING_MODELS = ["deepseek-v4.1-flash", "deepseek-v4-flash"]; +/* + * DeepSeek's legacy vision preview id (released 2026-08-21). First-party probes + * in #4436 resolve it to image-capable `deepseek-flash`; retain the existing + * declarations because gateway support is specific to each served identifier. + */ +export const DEEPSEEK_VISION_PREVIEW_MODEL = "deepseek-v4-flash-vision-exp"; +/** + * CommandCode routes verified to accept image input end-to-end (#2406). + * + * Verified-negative and therefore deliberately ABSENT: deepseek/deepseek-v4-flash, + * zai-org/GLM-5.2, zai-org/GLM-5.3, xai/grok-4.6. Those + * routes accept the request and drop the image, which is worse than declining it — the + * model answers about an image it never saw. Do not add an id here on family resemblance; + * capability intersection trusts this map. + */ +export const COMMAND_CODE_IMAGE_MODELS = [ + `deepseek/${DEEPSEEK_VISION_PREVIEW_MODEL}`, + "gpt-5.6-luna", + "gpt-5.6-sol", + "MiniMaxAI/MiniMax-M3", + "moonshotai/Kimi-K3", + "meta/muse-spark-1.3", + "meta/muse-spark-1.3-contributor", + "meta/muse-spark-1.2", + "meta/muse-spark-1.2-contributor", + // Native Z.AI VLM (docs.z.ai/guides/vlm/glm-5.3-flash). This exact id is already + // classified as natively vision-capable in NVIDIA_NIM_VISION_MODELS in this file; + // it is not one of the verified-negative ids the header names (those are + // deepseek/deepseek-v4-flash, zai-org/GLM-5.2, zai-org/GLM-5.3, xai/grok-4.6 — + // different ids). Adding it on the shared GLM-5.3 prefix would be the family- + // resemblance mistake the header forbids; the VLM docs are the evidence (#4505). + "z-ai/glm-5.3-flash", +] as const; +/** + * Native image stays sourced from COMMAND_CODE_IMAGE_MODELS. Text-only routes + * sit beside that list so the catalog can still advertise sidecar coverage + * without claiming the gateway itself accepts a picture. + * + * The gateway-prefixed DeepSeek V4.1 Flash route has no verified native image + * support, so declaring it image-capable would hand it a picture it drops. A + * positive text-only declaration makes it a vision-sidecar consumer + * (src/vision/eligibility.ts), so the catalog advertises image input on its + * behalf and the four-target combo in #4505 intersects to ["text","image"] + * instead of ["text"] — without claiming native vision. modelInputModalities + * is per-key filled, so this reaches an existing install even when + * noVisionModels was persisted before the id joined that list. + */ +export const COMMAND_CODE_TEXT_ONLY_MODELS = [ + "deepseek/deepseek-v4.1-flash", +] as const; +export const COMMAND_CODE_MODEL_INPUT_MODALITIES: Record = { + ...Object.fromEntries(COMMAND_CODE_IMAGE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])), + ...Object.fromEntries(COMMAND_CODE_TEXT_ONLY_MODELS.map(id => [id, ["text"] as ["text"]])), +}; +export const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"]; +/* + * Zen free models that reject `image_url` upstream (#1043, and the reproducible + * half of #1024). + * + * Zen publishes NO modality metadata — its `/v1/models` returns only id, object, + * created, owned_by — so this list is measured, not derived. Each id was probed + * once against https://opencode.ai/zen/v1 on 2026-08-05 with a text control first + * and then a 1x1 PNG; the six below failed the image request, four of them with + * `[404] No endpoints found that support image input` and `big-pickle` with the + * exact deserialize error quoted in #1043. + * + * `mimo-v2.5-free` and `longcat-2.0-free` ACCEPT images. They remain absent + * from the blind list and are recorded separately as positive input-modality evidence, + * so capability-positive dispatch can forward images without relying on blacklist absence. + * + * Zen's roster is discovered live while this list is static, so it is a dated + * exception list, not a capability model. Re-probe before extending it. + * Evidence: devlog/_fin/260805_bug_fix_stack/002_zen_modality_probe.md + */ +export const OPENCODE_ZEN_TEXT_ONLY_MODELS = [ + "big-pickle", + "nemotron-3-ultra-free", + "ling-3.0-flash-free", + "north-mini-code-free", + "laguna-s-2.1-free", + "deepseek-v4-flash-free", +]; +export const OPENCODE_ZEN_IMAGE_MODELS = ["mimo-v2.5-free", "longcat-2.0-free"] as const; +/* + * DeepSeek's Codex ladder is low/high/max. With the V4 Pro GA release + * (DeepSeek-V4-Pro-0813) the official thinking-mode table is IDENTICAL for both + * V4 models (api-docs.deepseek.com/guides/thinking_mode, verified 2026-08-13): + * + * requested | v4-flash | v4-pro + * low | low | low + * medium | high | high + * high | high | high + * xhigh | high | high + * max | max | max + * + * Before GA, Pro silently upgraded low->high and mapped xhigh->max (#1057-era + * table); the page's footnote about an early-August Pro mapping update landed + * with this GA, so Pro now advertises the same three real tiers as Flash. + * + * Two standing notes (#1057): + * + * - `xhigh` is a COMPATIBILITY ALIAS, not a native tier. It stays in the wire maps + * so existing requests and saved configs keep working, but it is not advertised. + * - `medium` has no row in the vendor table — mapping it to `high` is OUR + * compatibility choice for clients that only speak the OpenAI ladder. + */ +export const DEEPSEEK_FLASH_THINKING_EFFORTS = ["low", "high", "max"]; +export const DEEPSEEK_PRO_THINKING_EFFORTS = ["low", "high", "max"]; +export const DEEPSEEK_PRO_REASONING_MAP: Record = { + low: "low", + medium: "high", + high: "high", + xhigh: "high", + max: "max", +}; +export const DEEPSEEK_FLASH_REASONING_MAP: Record = { + low: "low", + medium: "high", + high: "high", + xhigh: "high", + max: "max", +}; +/** + * Flash-versus-Pro classification for DeepSeek V4 model ids, including prefixed + * (`deepseek/deepseek-v4.1-flash`) and suffixed (`deepseek-v4-flash-free`) forms. + * `tests/providers/provider-registry-parity.test.ts` enumerates every id the registry + * actually passes here, so a future id this substring test would misread cannot + * land silently. + */ +export const isDeepseekFlashModel = (modelId: string): boolean => + modelId.toLowerCase().includes("flash"); +export const deepseekThinkingEffortsFor = (modelId: string): string[] => + isDeepseekFlashModel(modelId) ? DEEPSEEK_FLASH_THINKING_EFFORTS : DEEPSEEK_PRO_THINKING_EFFORTS; +export const deepseekReasoningMapFor = (modelId: string): Record => + isDeepseekFlashModel(modelId) ? DEEPSEEK_FLASH_REASONING_MAP : DEEPSEEK_PRO_REASONING_MAP; +// 260719 Alibaba Token Plan Personal Edition (China/Beijing). Keep it distinct from +// Coding Plan: the products use different exact allowlists and different base URLs. +// Evidence: https://help.aliyun.com/en/model-studio/token-plan-personal-overview +// https://help.aliyun.com/en/model-studio/token-plan-quickstart +export const ALIBABA_TOKEN_PLAN_MODELS = [ + "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash", + "glm-5.3", "glm-5.3-flash", "glm-5.2", +]; +export const ALIBABA_TOKEN_PLAN_QWEN_MODELS = [ + "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash", +]; +export const ALIBABA_TOKEN_PLAN_INPUT_MODALITIES: Record = { + "qwen3.8-max": ["text", "image"], + "qwen3.7-max": ["text", "image"], + "qwen3.7-plus": ["text", "image"], + "qwen3.6-flash": ["text", "image"], + "glm-5.3": ["text"], + "glm-5.3-flash": ["text", "image"], + "glm-5.2": ["text"], +}; + +// 260721 Alibaba Token Plan International (ap-southeast-1 / Singapore, hardened 260721). +// Multi-vendor lineup distinct from Beijing — includes DeepSeek V4 flash, Kimi K2.7, MiniMax. +// Evidence: https://www.alibabacloud.com/help/en/model-studio/token-plan-overview +// https://qwencloud.com/pricing/token-plan (qwen3.8 metadata) +export const ALIBABA_INTL_TOKEN_PLAN_MODELS = [ + "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash", + "deepseek-v4-flash", "deepseek-v3.2", + "kimi-k2.7-code", "kimi-k2.6", "kimi-k2.5", + "glm-5.3", "glm-5.3-flash", "glm-5.2", "glm-5.1", "glm-5", + "MiniMax-M2.5", +]; +export const ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS = [ + "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash", +]; + +// 260722 Tencent Cloud Coding Plan. The plan's model set is explicitly dynamic; these are the +// current documented ids and live discovery remains enabled so successful /models responses win. +// Tencent marks every Coding Plan model as text-only input and restricts plan keys to interactive +// coding tools (not custom application backends or non-interactive batch automation). +// Evidence: https://cloud.tencent.cn/document/product/1823/130092 +export const TENCENT_CODING_PLAN_MODELS = ["tc-code-latest", "glm-5", "kimi-k2.5", "minimax-m2.5"]; +// Volcengine's authenticated /api/v3/models catalog mixes chat models with embedding, +// image, video, and 3D generation resources. Keep the Codex-facing presets scoped to +// models documented for text/agent or Coding Plan use. +// +// Maintenance owner: @lidge-jun. Verified 2026-08-01 against the vendor's own docs — +// endpoints https://docs.volcengine.com/docs/82379/1528783 (Coding Plan) and +// https://docs.volcengine.com/docs/82379/2165245 (Agent Plan); Codex CLI integration +// https://www.volcengine.com/docs/82379/2556056; supported clients +// https://www.volcengine.com/docs/82379/2188957; terms https://www.volcengine.com/docs/6256/64903 +// (北京火山引擎科技有限公司). Plan quota is restricted to supported AI coding tools and misuse +// is documented as grounds for suspension — see the `note` on both Plan entries. +// Report a break by opening an issue tagging the owner; the three things that rot first are the +// static catalogs (liveModels:false cannot self-heal), the base URLs, and those Plan terms. +// Full evidence ledger: devlog/_fin/260801_pr611_volcengine_evidence/000_evidence_ledger.md +export const VOLCENGINE_ARK_MODELS = [ + "doubao-seed-2-1-pro-260628", + "doubao-seed-2-1-turbo-260628", + "doubao-seed-evolving", + "deepseek-v4-flash-260425", + "deepseek-v3-2-251201", + // No glm-5-3 row: Ark pins date-stamped snapshot ids (glm-5-2-260617) that cannot be + // guessed ahead of the vendor publishing them. Add it once /api/v3/models lists one. + "glm-5-2-260617", + "glm-4-7-251222", +]; +export const VOLCENGINE_DOUBAO_THINKING_MODELS = [ + "doubao-seed-2-1-pro-260628", + "doubao-seed-2-1-turbo-260628", + "doubao-seed-evolving", +]; +export const VOLCENGINE_CODING_PLAN_MODELS = [ + "ark-code-latest", + "doubao-seed-2.0-code", + "deepseek-v4-flash", + "glm-5.3", + "glm-5.3-flash", + "glm-5.2", + "kimi-k2.6", + "minimax-m3", +]; +export const VOLCENGINE_AGENT_PLAN_MODELS = [ + "deepseek-v4-flash", + "glm-5.3", + "glm-5.3-flash", + "glm-5.2", + "kimi-k2.6", + "minimax-m3", + "doubao-seed-2.0-pro", +]; +export const VOLCENGINE_PLAN_INPUT_MODALITIES: Record = { + "kimi-k2.6": ["text", "image"], + "minimax-m3": ["text", "image"], + // Native VLM (docs.z.ai/guides/vlm/glm-5.3-flash), so it is declared here and left + // out of the text-only list below. + "glm-5.3-flash": ["text", "image"], +}; +// Every other Plan model is text-only. Declaring this explicitly keeps the vision +// sidecar from advertising image input for models that cannot accept it — the same +// treatment tencent-coding-plan gives its (entirely text-only) plan catalog. +export const VOLCENGINE_PLAN_TEXT_ONLY_MODELS = [ + "ark-code-latest", + "doubao-seed-2.0-code", + "deepseek-v4-flash", + "glm-5.3", + "glm-5.2", + "doubao-seed-2.0-pro", +]; +export const ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES: Record = { + "qwen3.8-max": ["text", "image"], + "qwen3.7-max": ["text", "image"], + "qwen3.7-plus": ["text", "image"], + "qwen3.6-plus": ["text", "image"], + "qwen3.6-flash": ["text", "image"], + "deepseek-v4-flash": ["text"], + "deepseek-v3.2": ["text"], + "kimi-k2.7-code": ["text", "image"], + "kimi-k2.6": ["text", "image"], + "kimi-k2.5": ["text", "image"], + "glm-5.3": ["text"], + "glm-5.3-flash": ["text", "image"], + "glm-5.2": ["text"], + "glm-5.1": ["text"], + "glm-5": ["text"], + "MiniMax-M2.5": ["text"], +}; + +// 260717 Kimi K3: the subscription endpoint uses one upstream id (`k3`) for both +// entitlement tiers. Bare `k3` advertises the Moderato 256K ceiling; the local `[1m]` +// alias advertises Allegretto's 1M ceiling and is stripped before the upstream request. +// The separately billed Moonshot API uses `kimi-k3`. +// Evidence: https://www.kimi.com/code/docs/en/kimi-code/models.html +// https://www.kimi.com/code/docs/en/kimi-code/error-reference.html +export const KIMI_K3_STANDARD_CONTEXT_WINDOW = 262_144; +export const KIMI_K3_1M_CONTEXT_WINDOW = 1_048_576; +export const KIMI_CODING_K3_MODELS = ["k3", "k3[1m]"]; +export const KIMI_LEGACY_API_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"]; +export const KIMI_API_MODELS = ["kimi-k3", ...KIMI_LEGACY_API_MODELS]; +export const KIMI_CODING_MODELS = [...KIMI_CODING_K3_MODELS, ...KIMI_LEGACY_API_MODELS, "kimi-for-coding"]; +export const KIMI_THINKING_MODELS = KIMI_CODING_MODELS; +export const KIMI_CODING_NO_REASONING_MODELS = KIMI_CODING_MODELS.filter(id => !KIMI_CODING_K3_MODELS.includes(id)); +export const KIMI_API_NO_REASONING_MODELS = KIMI_API_MODELS.filter(id => id !== "kimi-k3"); +export const KIMI_CODING_K3_REASONING_EFFORTS = ["low", "high", "max"]; +export const KIMI_CODING_K3_REASONING_EFFORT_MAP: Record = { + none: "none", + low: "low", + medium: "high", + high: "high", + xhigh: "max", + max: "max", +}; +export const KIMI_CODING_REASONING_EFFORTS = Object.fromEntries( + KIMI_CODING_MODELS.map(id => [id, KIMI_CODING_K3_MODELS.includes(id) ? KIMI_CODING_K3_REASONING_EFFORTS : []]), +); +export const KIMI_CODING_DEFAULT_REASONING_EFFORTS = Object.fromEntries( + KIMI_CODING_K3_MODELS.map(id => [id, "max"]), +); +export const KIMI_CODING_REASONING_EFFORT_MAPS = Object.fromEntries( + KIMI_CODING_K3_MODELS.map(id => [id, KIMI_CODING_K3_REASONING_EFFORT_MAP]), +); +export const KIMI_API_REASONING_EFFORTS = Object.fromEntries( + KIMI_API_MODELS.map(id => [id, id === "kimi-k3" ? ["max"] : []]), +); +export const KIMI_LOCKED_PARAMETER_MODELS = KIMI_CODING_MODELS; +export const KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-for-coding"]; +export const KIMI_API_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( + KIMI_API_MODELS.map(id => [id, id === "kimi-k3" ? KIMI_K3_1M_CONTEXT_WINDOW : 262_144]), +); +export const KIMI_API_MODEL_INPUT_MODALITIES = { "kimi-k3": ["text", "image"] }; + +// 260715 NVIDIA NIM kimi family (issue #126): documented served ids on integrate +// chat/completions per docs.api.nvidia.com/nim/reference/llm-apis; live /v1/models +// currently lists only kimi-k2.6 but the list is dynamic, so carry the documented family. +export const NVIDIA_NIM_KIMI_THINKING_MODELS = [ + "moonshotai/kimi-k2.6", "moonshotai/kimi-k2.5", "moonshotai/kimi-k2-thinking", +]; +export const NVIDIA_NIM_KIMI_MODELS = [ + ...NVIDIA_NIM_KIMI_THINKING_MODELS, + "moonshotai/kimi-k2-instruct", "moonshotai/kimi-k2-instruct-0905", +]; +/** + * 260804 issue #956: NIM publishes no input-modality metadata on `/v1/models`, so the + * registry is the only source of truth for which models can see images. + * + * Two lists, both verified per-model against NVIDIA documentation on 2026-08-04 + * (build.nvidia.com model pages and docs.api.nvidia.com/nim/reference/*). Evidence and + * the per-id audit: devlog/_fin/260804_stack7_service_vision/011_nim_id_audit.md. + * + * Read `noVisionModels` carefully — it lists models that CANNOT see images, which is + * what routes them through the proxy's vision sidecar (src/vision/index.ts) and makes the + * catalog advertise image input for them. Membership is wrong in BOTH directions: + * - a text-only model missing from it keeps issue #956 (images blocked or rejected); + * - a vision model wrongly IN it gets its image silently replaced by another model's + * text description — no error, worse answers, extra cost. + * + * A new NIM id must be classified DELIBERATELY against its NVIDIA page, never assumed + * from its name: `google/gemma-4-31b-it` carries no vision marker yet accepts images, + * `-vl` also appears on embedding/reranking models, and `google/codegemma-7b` is + * text-only while `google/codegemma-1.1-7b` has no current page at all. An unclassified + * id is intentionally left alone rather than defaulted, because NIM serves non-chat + * endpoints (embeddings, rerankers, guards, OCR) that reach the same code path. + */ +export const NVIDIA_NIM_VISION_MODELS = [ + "meta/llama-3.2-11b-vision-instruct", "meta/llama-3.2-90b-vision-instruct", + "nvidia/llama-3.1-nemotron-nano-vl-8b-v1", "nvidia/nemotron-nano-12b-v2-vl", + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", "nvidia/cosmos3-nano-reasoner", + "nvidia/ising-calibration-1.5-31b", "nvidia/ising-calibration-1-35b-a3b", + "google/gemma-4-31b-it", "google/diffusiongemma-26b-a4b-it", + "minimaxai/minimax-m3", "moonshotai/kimi-k2.6", "moonshotai/kimi-k2.5", + "stepfun-ai/step-3.7-flash", "thinkingmachines/inkling", + "mistralai/mistral-medium-3.5-128b", + "z-ai/glm-5.3-flash", +]; +/** + * The catalog advertises image input only for `noVisionModels` members, so a natively + * vision-capable model would otherwise be published as text-only and the Codex app would + * block attachments before the native path ever runs. + */ +export const NVIDIA_NIM_VISION_INPUT_MODALITIES: Record = Object.fromEntries( + NVIDIA_NIM_VISION_MODELS.map(id => [id, ["text", "image"]]), +); +/** + * Text-only NIM chat models — 26 ids, each carrying an explicit `Input Modalities: Text` + * (or equivalent) on its NVIDIA page. PR #964 proposed ~64; six of those are natively + * image-capable and live in NVIDIA_NIM_VISION_MODELS above, and 32 more had no current + * NVIDIA page and were dropped rather than assumed. + * + * kimi-k2-thinking and kimi-k2-instruct are text-only while k2.5/k2.6 are not — vision + * and reasoning are independent axes, so all four stay in NVIDIA_NIM_KIMI_MODELS for + * reasoning suppression regardless of which list they appear in here. + */ +export const NVIDIA_NIM_NO_VISION_MODELS = [ + "deepseek-ai/deepseek-v4-flash", + "google/codegemma-7b", + "meta/llama-3.1-70b-instruct", "meta/llama-3.1-8b-instruct", + "meta/llama-3.2-1b-instruct", "meta/llama-3.2-3b-instruct", + "meta/llama-3.3-70b-instruct", "meta/llama2-70b", + "mistralai/mistral-7b-instruct-v0.3", "mistralai/mistral-nemotron", + "moonshotai/kimi-k2-thinking", "moonshotai/kimi-k2-instruct", + "nvidia/llama-3.1-nemotron-nano-8b-v1", "nvidia/llama-3.1-nemotron-ultra-253b-v1", + "nvidia/llama-3.3-nemotron-super-49b-v1", "nvidia/llama-3.3-nemotron-super-49b-v1.5", + "nvidia/nemotron-3-nano-30b-a3b", "nvidia/nemotron-3-super-120b-a12b", + "nvidia/nemotron-3-ultra-550b-a55b", "nvidia/nemotron-mini-4b-instruct", + "nvidia/nvidia-nemotron-nano-9b-v2", + "openai/gpt-oss-120b", "openai/gpt-oss-20b", + // z-ai/glm-5.3-flash belongs in NVIDIA_NIM_VISION_MODELS, not here: Z.AI documents + // it under docs.z.ai/guides/vlm/. The header above says an id must be classified + // deliberately rather than assumed from its name, and inheriting glm-5.3's + // text-only verdict because of the shared prefix is exactly that mistake. + "poolside/laguna-xs-2.1", "z-ai/glm-5.3", "z-ai/glm-5.2", +]; +export const KIMI_CODING_MODEL_CONTEXT_WINDOWS: Record = Object.fromEntries( + KIMI_CODING_MODELS.map(id => [id, id === "k3[1m]" ? KIMI_K3_1M_CONTEXT_WINDOW : KIMI_K3_STANDARD_CONTEXT_WINDOW]), +); +export const KIMI_CODING_MODEL_INPUT_MODALITIES = Object.fromEntries( + KIMI_CODING_K3_MODELS.map(id => [id, ["text", "image"]]), +); +export const NEURALWATT_REASONING_HISTORY_MODELS = [ + "glm-5.3", "glm-5.3-short", "glm-5.3-flash", + "glm-5.2", "glm-5.2-short", + "kimi-k2.6", "kimi-k2.7-code", + "qwen3.5-397b", "qwen3.6-35b", +]; + +// 260728 Baseten Model APIs: `/v1/models` owns the live lineup, while these hints +// describe only capabilities that Baseten documents per slug. Unlisted live models +// intentionally inherit the empty provider ladder instead of being advertised with +// opencodex's generic reasoning defaults. Audio is omitted because the current proxy +// request model does not carry OpenAI `audio_url` parts. +// Evidence: https://docs.baseten.co/inference/model-apis/reasoning +// https://docs.baseten.co/inference/model-apis/vision +export const BASETEN_FULL_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +export const BASETEN_MODEL_REASONING_EFFORTS: Record = { + "thinkingmachines/inkling": BASETEN_FULL_REASONING_EFFORTS, + "openai/gpt-oss-120b": BASETEN_FULL_REASONING_EFFORTS, + "moonshotai/Kimi-K3": ["low", "high", "max"], + // 260814: GLM-5.3 honours low/high/max upstream, unlike 5.2's high/max on Baseten. + "zai-org/GLM-5.3": ["low", "high", "max"], + "zai-org/GLM-5.3-Fast": ["low", "high", "max"], + "zai-org/GLM-5.2": ["high", "max"], + "zai-org/GLM-5.2-Fast": ["high", "max"], +}; +export const BASETEN_MODEL_REASONING_EFFORT_MAP: Record> = { + "thinkingmachines/inkling": { none: "none", minimal: "minimal" }, + "openai/gpt-oss-120b": { none: "none", minimal: "minimal" }, + "moonshotai/Kimi-K3": { none: "none" }, + "zai-org/GLM-5.3": { none: "none" }, + "zai-org/GLM-5.3-Fast": { none: "none" }, + "zai-org/GLM-5.2": { none: "none" }, + "zai-org/GLM-5.2-Fast": { none: "none" }, +}; +export const BASETEN_MODEL_DEFAULT_REASONING_EFFORTS: Record = { + "thinkingmachines/inkling": "high", + "openai/gpt-oss-120b": "medium", + "moonshotai/Kimi-K3": "max", +}; +export const BASETEN_MODEL_INPUT_MODALITIES: Record = { + "thinkingmachines/inkling": ["text", "image"], + "moonshotai/Kimi-K2.6": ["text", "image"], + "moonshotai/Kimi-K2.7-Code": ["text", "image"], + "moonshotai/Kimi-K3": ["text", "image"], +}; + +// 260801 DigitalOcean and Scaleway expose OpenAI-shaped `/v1/models` rows with only +// id/object/created/owned_by, while their shared serverless catalogs also contain +// non-chat and endpoint-specific models. Fail closed by intersecting live discovery +// with ids that the providers' current first-party model tables establish for Chat +// Completions. A newly listed id therefore needs a docs-backed registry refresh before +// it can enter the Codex catalog. +// Evidence: https://docs.digitalocean.com/products/inference/details/models/ +// https://docs.digitalocean.com/reference/api/reference/serverless-inference/ +// https://www.scaleway.com/en/docs/generative-apis/reference-content/supported-models/ +export const DIGITALOCEAN_CHAT_COMPLETION_MODELS = [ + "arcee-trinity-large-thinking", + "openai-gpt-5.6-sol", + "openai-gpt-5.6-terra", + "openai-gpt-5.6-luna", + "qwen3-coder-flash", + "qwen3.5-397b-a17b", + "deepseek-4-flash", + "deepseek-3.2", + "gemma-4-31B-it", + "minimax-m2.5", + "kimi-k3", + "kimi-k2.6", + "kimi-k2.5", + "llama3.3-70b-instruct", + "llama-4-maverick", + "mistral-3-14B", + "nemotron-3-ultra-550b", + "nvidia-nemotron-3-super-120b", + "nemotron-3-nano-omni", + "nemotron-nano-12b-v2-vl", + "mimo-v2.5-pro", + "glm-5.3", + "glm-5.3-flash", + "glm-5.2", + "glm-5.1", + "glm-5", + // The API reference uses this native slash id in its Chat Completions example. + "meta-llama/Meta-Llama-3.1-8B-Instruct", +] as const; +export const SCALEWAY_SERVERLESS_CHAT_MODELS = [ + "glm-5.3", + "glm-5.3-flash", + "glm-5.2", + // gpt-oss-120b is intentionally omitted: Scaleway requires Responses API for tool calling, + // while this preset routes Codex agent tools through Chat Completions. + "qwen3.6-35b-a3b", + "qwen3.5-397b-a17b", + "qwen3-235b-a22b-instruct-2507", + "qwen3-coder-30b-a3b-instruct", + "gemma-4-26b-a4b-it", + "llama-3.3-70b-instruct", + "mistral-medium-3.5-128b", + "mistral-small-3.2-24b-instruct-2506", + "pixtral-12b-2409", +] as const; +export const SCALEWAY_MODEL_INPUT_MODALITIES: Record = { + "pixtral-12b-2409": ["text", "image"], +}; +export const UMANS_MODELS = [ + "umans-coder", + "umans-kimi-k2.7", + "umans-flash", + "umans-glm-5.3", + "umans-glm-5.3-flash", + "umans-glm-5.2", + "umans-glm-5.1", + "umans-qwen3.6-35b-a3b", +]; +export const UMANS_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +export const UMANS_GLM_REASONING_EFFORTS = ["high", "xhigh", "max"]; +// 260814: Z.AI folds GLM-5.3 efforts into low/high/max, so `low` is a real tier here and +// `xhigh` is not distinct from `max` (docs.z.ai/devpack/latest-model). +export const UMANS_GLM_53_REASONING_EFFORTS = ["low", "high", "max"]; +// `umans-glm-5.3-flash` is NOT here: Z.AI documents glm-5.3-flash under +// docs.z.ai/guides/vlm/, so it takes images natively and does not need the proxy's +// vision sidecar. The seeding pass classified it from the family name and a later +// pass corrected only some of the providers; this is one it missed. +export const UMANS_TEXT_ONLY_MODELS = ["umans-glm-5.3", "umans-glm-5.2", "umans-glm-5.1"]; +export const UMANS_MODEL_CONTEXT_WINDOWS: Record = { + "umans-coder": 262_144, + "umans-kimi-k2.7": 262_144, + "umans-flash": 262_144, + "umans-glm-5.3": 405_504, + // Mirrors the sibling this provider already carries. Umans has not published a + // separate window for the flash tier; asserting a different number would be a guess. + "umans-glm-5.3-flash": 405_504, + "umans-glm-5.2": 405_504, + "umans-glm-5.1": 202_752, + "umans-qwen3.6-35b-a3b": 262_144, +}; +export const UMANS_MODEL_INPUT_MODALITIES: Record = Object.fromEntries( + UMANS_MODELS.map(id => [id, UMANS_TEXT_ONLY_MODELS.includes(id) ? ["text"] : ["text", "image"]]), +); +export const CLINE_PASS_MODELS = [ + "cline-pass/glm-5.3", + "cline-pass/glm-5.3-flash", + "cline-pass/glm-5.2", + "cline-pass/kimi-k3", + "cline-pass/kimi-k2.7-code", + "cline-pass/kimi-k2.6", + "cline-pass/deepseek-v4-flash", + "cline-pass/mimo-v2.5", + "cline-pass/mimo-v2.5-pro", + "cline-pass/minimax-m3", + "cline-pass/qwen3.8-max", + "cline-pass/qwen3.7-max", + "cline-pass/qwen3.7-plus", +]; + +export const ORCAROUTER_MODEL_DISCOVERY: ProviderModelDiscoverySpec = { + path: "models", + query: { capability: "chat" }, + maxResponseBytes: 512 * 1024, + maxModels: 512, + filter: { + anyOf: [{ + path: ["supported_endpoint_types"], + containsAny: ["openai", "openai-response", "anthropic", "gemini"], + caseInsensitive: true, + }], + noneOf: [{ + path: ["supported_endpoint_types"], + containsAny: ["image-generation", "openai-video", "jina-rerank"], + caseInsensitive: true, + }], + }, +}; +// Preserve the previously verified cold-start catalog. Live discovery remains authoritative +// when it succeeds, but a temporary catalog outage must not erase the provider's known-good +// selectors from the picker. `orcarouter/auto` is intentionally retained here even though the +// public catalog did not enumerate it at the latest verification (2026-09-07). +export const ORCAROUTER_MODELS = [ + "openai/gpt-5.5", + "anthropic/claude-opus-4.8", + "google/gemini-3.5-flash", + "orcarouter/auto", +]; +export const ORCAROUTER_MODEL_REASONING_EFFORTS = { + // Live /models currently exposes ids and modalities, not the accepted reasoning ladder. + "openai/gpt-5.5": ["low", "medium", "high", "xhigh"], +}; +export const CLINE_PASS_MODEL_CONTEXT_WINDOWS: Record = { + "cline-pass/glm-5.3": 1_048_576, + "cline-pass/glm-5.3-flash": 1_048_576, + "cline-pass/glm-5.2": 1_048_576, + "cline-pass/kimi-k3": 1_048_576, + "cline-pass/kimi-k2.7-code": 262_144, + "cline-pass/kimi-k2.6": 262_144, + "cline-pass/deepseek-v4-flash": 1_048_576, + "cline-pass/mimo-v2.5": 1_050_000, + "cline-pass/mimo-v2.5-pro": 1_050_000, + "cline-pass/minimax-m3": 1_048_576, + "cline-pass/qwen3.7-max": 1_000_000, + "cline-pass/qwen3.7-plus": 1_000_000, +}; +export const CLINE_PASS_IMAGE_MODELS = new Set([ + "cline-pass/kimi-k3", + "cline-pass/kimi-k2.7-code", + "cline-pass/kimi-k2.6", + "cline-pass/mimo-v2.5", + "cline-pass/minimax-m3", + "cline-pass/qwen3.7-plus", + // Native VLM (docs.z.ai/guides/vlm/), so its images do not go through the proxy's + // sidecar. Adding it here moves it out of CLINE_PASS_TEXT_ONLY_MODELS and flips its + // declared modalities to ["text", "image"] in one edit, because both are derived + // from this set. + "cline-pass/glm-5.3-flash", +]); +export const CLINE_PASS_MODALITY_KNOWN_MODELS = CLINE_PASS_MODELS.filter(id => id !== "cline-pass/qwen3.8-max"); +export const CLINE_PASS_TEXT_ONLY_MODELS = CLINE_PASS_MODALITY_KNOWN_MODELS.filter(id => !CLINE_PASS_IMAGE_MODELS.has(id)); +export const CLINE_PASS_MODEL_INPUT_MODALITIES: Record = Object.fromEntries( + CLINE_PASS_MODALITY_KNOWN_MODELS.map(id => [id, CLINE_PASS_IMAGE_MODELS.has(id) ? ["text", "image"] : ["text"]]), +); diff --git a/src/providers/registry/types.ts b/src/providers/registry/types.ts new file mode 100644 index 0000000000..f71c0fafe4 --- /dev/null +++ b/src/providers/registry/types.ts @@ -0,0 +1,352 @@ +import type { CodexAccountMode, FastWire, OcxProviderConfig } from "../../types"; +import type { ProviderBaseUrlChoice } from "../base-url-choices"; + +export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; +export type MetadataModelIdNormalize = "case-insensitive"; + +/** + * Wire protocol a client spoke when it reached the proxy. Chat and Anthropic surfaces + * translate into a Responses-shaped body and replay through `handleResponses`, so the + * original inbound has to travel with the request or the replay looks native. + */ +export type InboundWire = "responses" | "chat" | "anthropic"; + +/** + * A per-model wire default: a bare string applies to every inbound, while the object + * form may scope the default to listed inbound protocols and authentication modes. + */ +export type ModelWireDefault = string | { + wire: string; + inbound: readonly InboundWire[]; + authModes?: readonly ProviderAuthKind[]; + /** Whether this registry-selected route may relay a caller-owned service_tier. */ + forwardCallerServiceTier?: boolean; +}; + +export interface ResponsesTerminalRepairPolicy { + /** Quiet time after a structurally complete output graph before synthesizing completion. */ + graceMs: number; +} + +export type ProviderModelDiscoveryScalar = string | number | boolean; + +export type ProviderModelDiscoveryPredicate = + | { + path: readonly string[]; + equalsAny: readonly ProviderModelDiscoveryScalar[]; + caseInsensitive?: boolean; + } + | { + path: readonly string[]; + /** + * A string-valued upstream target uses substring matching; an array-valued target uses + * exact element matching. Use `equalsAny` when the string must match in full. + */ + containsAny: readonly ProviderModelDiscoveryScalar[]; + caseInsensitive?: boolean; + } + | { + path: readonly string[]; + /** Uses the same string-substring and array-element semantics as `containsAny`. */ + containsAll: readonly ProviderModelDiscoveryScalar[]; + caseInsensitive?: boolean; + }; + +export interface ProviderModelDiscoveryFilter { + /** Every predicate must match. */ + allOf?: readonly ProviderModelDiscoveryPredicate[]; + /** At least one predicate must match. */ + anyOf?: readonly ProviderModelDiscoveryPredicate[]; + /** No predicate may match. */ + noneOf?: readonly ProviderModelDiscoveryPredicate[]; +} + +interface ProviderModelDiscoverySharedSpec { + /** Query parameters applied to the resolved discovery URL. */ + query?: Readonly>; + /** Declarative eligibility rules evaluated against each untrusted model row. */ + filter?: ProviderModelDiscoveryFilter; + /** Optional lower byte ceiling; the process-wide hard ceiling still wins. */ + maxResponseBytes?: number; + /** Optional lower raw-row ceiling; the process-wide hard ceiling still wins. */ + maxModels?: number; + /** + * If a valid extracted id starts with this prefix, strip it and re-validate the remainder. + * Empty/invalid remainders skip that row only. + */ + stripIdPrefix?: string; +} + +type ProviderModelDiscoveryLocation = + | { + /** Registry-owned absolute endpoint. Mutually exclusive with `path`. */ + url: string; + path?: never; + } + | { + /** Resource path relative to baseUrl; query strings and fragments are disallowed. */ + path: string; + url?: never; + } + | { + /** Keep the adapter-derived default discovery endpoint. */ + url?: never; + path?: never; + }; + +/** + * Trusted live-model discovery policy. This metadata is registry-only: it must never be copied + * into config.json, where a same-named custom provider could otherwise redirect a stored key. + */ +export type ProviderModelDiscoverySpec = ProviderModelDiscoverySharedSpec & ProviderModelDiscoveryLocation; + +export interface ProviderRegistryEntry { + id: string; + label: string; + adapter: string; + baseUrl: string; + apiKeyTransport?: OcxProviderConfig["apiKeyTransport"]; + alias?: string; + authKind: ProviderAuthKind; + codexAccountMode?: CodexAccountMode; + /** OAuth preset may explicitly honor a persisted API-key billing mode. */ + allowKeyAuthOverride?: boolean; + allowPrivateNetworkByDefault?: boolean; + keyOptional?: boolean; + /** + * Registry-only key-login policy for public model catalogs that cannot authenticate a key. + * The dashboard flow then reports the key as unverifiable instead of a false positive. + */ + apiKeyValidation?: "unknown"; + /** + * Free-tier pricing (no paid subscription required). Distinct from `keyOptional`: + * free tiers may still require an API key (e.g. NVIDIA NIM free credits). + */ + freeTier?: boolean; + allowBaseUrlOverride?: boolean; + /** + * Do not claim an existing same-named key provider whose fixed destination differs from this + * preset. Enable for newly promoted ids so an older custom key cannot be silently retargeted. + */ + preserveCustomDestination?: boolean; + /** + * Optional endpoint picker for providers with multiple official hosts + * (e.g. Qwen Cloud token plan vs pay-as-you-go). Requires `allowBaseUrlOverride` + * so the selected URL is honored at route time. A choice without `baseUrl` is "Custom". + */ + baseUrlChoices?: readonly ProviderBaseUrlChoice[]; + /** Static headers merged into every upstream request for this provider. */ + staticHeaders?: Record; + modelSuffixBracketStrip?: boolean; + featured?: boolean; + /** + * Paid provider sponsorship under SPONSORS.md. `main` is reserved for model developers, + * `standard` for relays and gateways. The picker pins sponsor rows first (alphabetical among + * themselves) and labels them; nothing else reads this field. Routing, failover, quota, and + * defaults never consult it — that boundary is what SPONSORS.md promises users. + */ + sponsor?: { tier: "main" | "standard"; url: string }; + dashboardPreset?: boolean; + note?: string; + dashboardUrl?: string; + defaultModel?: string; + models?: string[]; + liveModels?: boolean; + /** + * Registry-only per-model wire defaults for mixed OpenAI-compatible gateways. + * These are intentionally not seeded into saved config: an explicit `modelAdapters` + * entry must remain distinguishable and must always win over a default. + * + * A bare string applies to every inbound protocol. The object form scopes the + * default to the inbound surfaces named in `inbound`, which is how a model that is + * native on two wires can serve each client on the wire it already speaks instead + * of paying a translation hop. + */ + modelWireDefaults?: Record; + /** Explicit Fast wire declaration; absence derives from the final model adapter. */ + fastWire?: FastWire | null; + /** + * Registry-only per-model override for the upstream request shape used behind a + * Codex Responses WebSocket turn. `false` keeps the client-facing WebSocket but + * asks the upstream Responses endpoint for bounded JSON, which the bridge then + * reframes as Responses events. Use only for upstreams whose streaming response + * can omit or indefinitely delay the terminal event. + */ + modelResponsesUpstreamStreaming?: Record; + /** Registry-only repair for a model whose native Responses stream may omit its terminal. */ + modelResponsesTerminalRepair?: Record; + /** + * Registry-only client-facing item-id repair policy (#938), filled onto the + * runtime provider only when the user has no explicit policy (derive.ts); + * never seeded into saved config. + */ + responsesItemIdRepair?: { + message?: string[]; + reasoning?: string[]; + repairMissingTerminalIds?: boolean; + repairInvalidIds?: boolean; + }; + /** + * Responses-API resource path for providers whose route is not `/v1/responses`. + * Unlike `modelWireDefaults` above, this IS seeded into saved config: it describes + * the provider's fixed endpoint rather than a default a user might want to override + * per model. DeepSeek documents `POST /responses` with no `/v1` segment. + */ + responsesPath?: string; + /** + * Relative send path for the `openai-chat` wire, seeded into saved config exactly like + * `responsesPath`. Needed when one upstream serves both wires under different prefixes, + * because a per-model wire override changes the adapter and not the base URL. + */ + chatCompletionsPath?: string; + /** + * Endpoints this entry used to live at, kept so a saved custom provider that still points + * at one keeps receiving this row's metadata through `registryEntryForProviderDestination`. + * Destination matching is by adapter plus normalized base URL, so moving a row's wire or + * prefix would otherwise orphan every config a user wrote against the old address. + */ + destinationAliases?: readonly { readonly baseUrl: string; readonly adapter: string }[]; + /** + * Responses upstream that stores nothing server-side. Stateful request parameters + * are dropped and `store` is pinned false, and orphaned tool results left by a + * replay miss are repaired rather than forwarded. + */ + statelessResponses?: boolean; + /** + * Responses parser requires an unambiguous call batch and its matched result batch + * to stay contiguous. This is seeded/backfilled like other fixed wire capabilities. + */ + requiresAdjacentResponsesToolResults?: boolean; + /** + * When enabled, tool results that are present but empty are annotated on the wire. + * Seeded/backfilled like other fixed wire capabilities. + */ + annotateEmptyToolOutputs?: boolean; + /** + * Registry default for the provider's `service_tier` support; see + * `OcxProviderConfig.supportsServiceTier`. Registry-only: backfilled (never + * overriding) at enrich/route time and deliberately NOT seeded into saved + * config, so an explicit user value stays distinguishable from the default + * (and the canonical openai seed comparison keeps its exact key set). + */ + supportsServiceTier?: boolean; + /** Registry default for OpenAI extended hosted web_search field support. */ + supportsOpenAiWebSearchToolFields?: boolean; + /** Registry default for native Responses custom-tool support. */ + supportsResponsesCustomTools?: boolean; + /** Registry default for exact model service-tier capability; explicit config keys win. */ + modelSupportsServiceTier?: Record; + /** + * Registry-only service-tier defaults for an OAuth preset's explicit API-key transport. + * Applied only when `allowKeyAuthOverride` is true and the captured effective auth transport + * is key-based. Explicit provider config still wins field-by-field, including `false`. + */ + keyAuthServiceTier?: { + supportsServiceTier?: boolean; + modelSupportsServiceTier?: Record; + chatServiceTier?: boolean; + }; + /** Provider-specific copy for the Codex catalog's Fast tier. */ + fastTierDescription?: string; + /** + * Registry-only destination guard for `modelSupportsServiceTier`. This scopes vendor evidence + * without changing provider ownership, routing, authentication, or config validation. + */ + modelServiceTierCapabilityBaseUrlGuard?: (baseUrl: string) => boolean; + /** Registry default for plaintext reasoning replay; see `OcxProviderConfig.preserveResponsesReasoningContent`. Registry-only like `supportsServiceTier`. */ + preserveResponsesReasoningContent?: boolean; + /** Registry defaults for per-model Codex reasoning propagation; explicit user keys win during enrichment. */ + modelSupportsReasoningSummaries?: Record; + /** Registry defaults for per-model Codex Responses verbosity support. */ + modelSupportsVerbosity?: Record; + /** + * Registry default applied to EVERY model of this provider, including ids that arrive from + * live discovery after this table was written. + * + * `modelSupportsVerbosity` only covers the ids enumerated here, so a newly discovered model + * fell through and re-advertised a control the upstream accepts and ignores. Where the opt-out + * is a property of the provider's API rather than of one model, declare it here; a per-model + * entry still wins over it. + */ + supportsVerbosity?: boolean; + modelDiscovery?: ProviderModelDiscoverySpec; + contextWindow?: number; + modelContextWindows?: Record; + /** + * Registry-supplied picker labels. Without these a routed row shows its raw slug, + * because `routedDisplayName` (codex/catalog/sync.ts) passes the slug through for every + * provider. An operator's `modelDisplayNames` still wins: derive only fills when absent. + */ + modelDisplayNames?: Record; + modelInputModalities?: Record; + defaultMaxOutputTokens?: number; + modelMaxOutputTokens?: Record; + reasoningEfforts?: string[]; + modelReasoningEfforts?: Record; + modelDefaultReasoningEfforts?: Record; + reasoningEffortMap?: Record; + modelReasoningEffortMap?: Record>; + /** + * Registry-authoritative models that send OpenAI's direct `reasoning_effort` field. + * Runtime enrichment uses this to repair stale preset metadata that still classifies a model + * as a thinking-budget/toggle model. This is registry-only and is never persisted as user config. + */ + directReasoningEffortModels?: string[]; + reasoningWireFormat?: OcxProviderConfig["reasoningWireFormat"]; + noVisionModels?: string[]; + noReasoningModels?: string[]; + noTemperatureModels?: string[]; + noTopPModels?: string[]; + noPenaltyModels?: string[]; + /** + * Registry-only seed for `OcxProviderConfig.noJsonSchemaModels`. Merged into the + * resolved provider at route time rather than persisted as user config, the same way + * `directReasoningEffortModels` above is registry-owned. + */ + noJsonSchemaModels?: string[]; + /** Opt this provider into parallel tool calls (see OcxProviderConfig.parallelToolCalls). */ + parallelToolCalls?: boolean; + /** Opt this provider into forwarding prompt_cache_key (OpenAI-specific; strict backends reject it). */ + promptCacheKey?: boolean; + /** + * Opt-in: forward `service_tier` on the `/chat/completions` wire. Same hazard as + * `promptCacheKey` — an OpenAI-specific extension that strict gateways reject. Distinct from + * `supportsServiceTier`, which governs the Responses wire. + */ + chatServiceTier?: boolean; + /** OpenAI Chat EOF policy for gateways that omit terminal frames after complete tool calls. */ + openaiChatEofTolerance?: boolean; + autoToolChoiceOnlyModels?: string[]; + preserveReasoningContentModels?: string[]; + requiresReasoningPlaceholderModels?: string[]; + /** + * Opt this provider into visible thinking summaries (see OcxProviderConfig.showThinkingSummary). + */ + showThinkingSummary?: boolean; + reasoningSplitModels?: string[]; + reasoningDetailsModels?: string[]; + thinkingToggleModels?: string[]; + thinkingBudgetModels?: string[]; + escapeBuiltinToolNames?: boolean; + oauthId?: string; + virtualModels?: Record; + modelMaxInputTokens?: Record; + jawcodeBundle?: string; + extraMetadataAliases?: string[]; + metadataModelIdNormalize?: MetadataModelIdNormalize; + googleMode?: "ai-studio" | "vertex" | "cloud-code-assist"; + project?: string; + location?: string; +} + +export type ProviderConfigSeed = Pick< + OcxProviderConfig, + "adapter" | "baseUrl" | "apiKeyTransport" | "responsesPath" | "chatCompletionsPath" | "authMode" | "keyOptional" | "freeTier" | "modelSuffixBracketStrip" | "defaultModel" | "models" + | "liveModels" | "contextWindow" | "modelContextWindows" | "modelInputModalities" + | "modelDisplayNames" + | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens" + | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat" + | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" + | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "reasoningDetailsModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance" | "showThinkingSummary" + | "googleMode" | "project" | "location" | "headers" +>; diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 2f77f0fe44..454958efcf 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -86,34 +86,34 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ // server/management-api { method: "POST", path: "/api/stop", module: "server/management-api", mutates: true }, // codex/auth-api - { method: "DELETE", path: "/api/codex-auth/accounts", module: "codex/auth-api", mutates: true }, - { method: "GET", path: "/api/codex-auth/accounts", module: "codex/auth-api", mutates: false }, - { method: "GET", path: "/api/codex-auth/active", module: "codex/auth-api", mutates: false }, - { method: "GET", path: "/api/codex-auth/login-status", module: "codex/auth-api", mutates: false }, - { method: "GET", path: "/api/codex-auth/quota", module: "codex/auth-api", mutates: false }, - { method: "GET", path: "/api/codex-auth/quota/history", module: "codex/auth-api", mutates: false }, - { method: "GET", path: "/api/codex-auth/reset-credits", module: "codex/auth-api", mutates: false }, - { method: "PATCH", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api", mutates: true }, - { method: "POST", path: "/api/codex-auth/accounts", module: "codex/auth-api", mutates: true }, - { method: "POST", path: "/api/codex-auth/accounts/clear-cooldown", module: "codex/auth-api", mutates: true }, - { method: "POST", path: "/api/codex-auth/accounts/refresh", module: "codex/auth-api", mutates: true }, + { method: "DELETE", path: "/api/codex-auth/accounts", module: "codex/auth-api/routes", mutates: true }, + { method: "GET", path: "/api/codex-auth/accounts", module: "codex/auth-api/routes", mutates: false }, + { method: "GET", path: "/api/codex-auth/active", module: "codex/auth-api/routes", mutates: false }, + { method: "GET", path: "/api/codex-auth/login-status", module: "codex/auth-api/routes", mutates: false }, + { method: "GET", path: "/api/codex-auth/quota", module: "codex/auth-api/routes", mutates: false }, + { method: "GET", path: "/api/codex-auth/quota/history", module: "codex/auth-api/routes", mutates: false }, + { method: "GET", path: "/api/codex-auth/reset-credits", module: "codex/auth-api/routes", mutates: false }, + { method: "PATCH", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api/routes", mutates: true }, + { method: "POST", path: "/api/codex-auth/accounts", module: "codex/auth-api/routes", mutates: true }, + { method: "POST", path: "/api/codex-auth/accounts/clear-cooldown", module: "codex/auth-api/routes", mutates: true }, + { method: "POST", path: "/api/codex-auth/accounts/refresh", module: "codex/auth-api/routes", mutates: true }, // codex/main-device-reauth-api (#3898): the native-main device reauth namespace; // /api/codex-auth/login stays pool-only and keeps rejecting __main__. { method: "POST", path: "/api/codex-auth/main/reauth-device", module: "codex/main-device-reauth-api", mutates: true }, { method: "GET", path: "/api/codex-auth/main/reauth-device", module: "codex/main-device-reauth-api", mutates: false }, { method: "DELETE", path: "/api/codex-auth/main/reauth-device", module: "codex/main-device-reauth-api", mutates: true }, - { method: "POST", path: "/api/codex-auth/login", module: "codex/auth-api", mutates: true }, - { method: "POST", path: "/api/codex-auth/login/cancel", module: "codex/auth-api", mutates: true }, - { method: "POST", path: "/api/codex-auth/login/code", module: "codex/auth-api", mutates: true }, - { method: "POST", path: "/api/codex-auth/reset-credits/consume", module: "codex/auth-api", mutates: true }, - { method: "PUT", path: "/api/codex-auth/accounts/alias", module: "codex/auth-api", mutates: true }, - { method: "PUT", path: "/api/codex-auth/accounts/pause", module: "codex/auth-api", mutates: true }, - { method: "PUT", path: "/api/codex-auth/accounts/pause-exhausted", module: "codex/auth-api", mutates: true }, - { method: "PUT", path: "/api/codex-auth/accounts/priority", module: "codex/auth-api", mutates: true }, - { method: "PUT", path: "/api/codex-auth/active", module: "codex/auth-api", mutates: true }, - { method: "PUT", path: "/api/codex-auth/auto-switch", module: "codex/auth-api", mutates: true }, - { method: "PUT", path: "/api/codex-auth/failover", module: "codex/auth-api", mutates: true }, - { method: "PUT", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api", mutates: true, exempt: { reason: "compatibility-alias", why: "Superseded by PUT /api/pool/settings, which the CLI now drives. Kept working for existing clients and pinned by exact-body goldens in tests/server/account-pool-management-api.test.ts; no CLI verb targets it any more." } }, + { method: "POST", path: "/api/codex-auth/login", module: "codex/auth-api/routes", mutates: true }, + { method: "POST", path: "/api/codex-auth/login/cancel", module: "codex/auth-api/routes", mutates: true }, + { method: "POST", path: "/api/codex-auth/login/code", module: "codex/auth-api/routes", mutates: true }, + { method: "POST", path: "/api/codex-auth/reset-credits/consume", module: "codex/auth-api/routes", mutates: true }, + { method: "PUT", path: "/api/codex-auth/accounts/alias", module: "codex/auth-api/routes", mutates: true }, + { method: "PUT", path: "/api/codex-auth/accounts/pause", module: "codex/auth-api/routes", mutates: true }, + { method: "PUT", path: "/api/codex-auth/accounts/pause-exhausted", module: "codex/auth-api/routes", mutates: true }, + { method: "PUT", path: "/api/codex-auth/accounts/priority", module: "codex/auth-api/routes", mutates: true }, + { method: "PUT", path: "/api/codex-auth/active", module: "codex/auth-api/routes", mutates: true }, + { method: "PUT", path: "/api/codex-auth/auto-switch", module: "codex/auth-api/routes", mutates: true }, + { method: "PUT", path: "/api/codex-auth/failover", module: "codex/auth-api/routes", mutates: true }, + { method: "PUT", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api/routes", mutates: true, exempt: { reason: "compatibility-alias", why: "Superseded by PUT /api/pool/settings, which the CLI now drives. Kept working for existing clients and pinned by exact-body goldens in tests/server/account-pool-management-api.test.ts; no CLI verb targets it any more." } }, // codex/native-profile-api { method: "GET", path: "/api/native-main-profiles", module: "codex/native-profile-api", mutates: false }, { method: "GET", path: "/api/native-main-profiles/doctor", module: "codex/native-profile-api", mutates: false }, diff --git a/structure/config.md b/structure/config.md index a4c0b97ead..901aa582d6 100644 --- a/structure/config.md +++ b/structure/config.md @@ -46,7 +46,7 @@ must not replace it with a local temp-and-rename shortcut. > Decision record: [ADR-0016](decisions/ADR-0016-config-surface.md) -`src/types.ts` is the shape and `src/config.ts` is the loader; neither is reproduced here. What +`src/types.ts` is the shape; the load/validate pipeline lives in the split config leaves — schema in `src/config/schema/` (`config-schema.ts`, `leaf-validators.ts`) and replace-path persistence in `src/config/persist-unlocked.ts`, with `src/config.ts` as the compatibility facade — and is not reproduced here. What matters for maintainers is which groups exist and who resolves them: | Group | Keys | Resolution rule | @@ -59,12 +59,12 @@ matters for maintainers is which groups exist and who resolves them: | Credentials | `apiKeys` | Data-plane only; never admitted to `/api/*`. | | Lifecycle | `codexAutoStart`, shim/start behavior, resume-history sync, storage cleanup | Startup safety reads these; see [`gui-and-management-api.md`](gui-and-management-api.md). | -Env values are resolved through `src/config.ts`, so a config value naming an env var never persists +Env values are resolved through `src/config/proxy-env.ts`, so a config value naming an env var never persists the secret itself. Malformed optional data-loopback and nested hub-management listener blocks are disabled in memory and reported by load-time warnings and read-only config diagnostics. Ingress warnings validate the raw ingress independently, so an invalid hub sibling does not falsely blame a valid ingress. The warning names only the field; unrelated providers and keys survive. Explicit writes remain strictly validated. -`claudeCode.desktopProfile` follows the same preserve-the-rest rule. JSON `null` (or any non-string) `appliedFingerprint` / `appliedAt` is treated as unset. A profile that is still invalid after that is dropped as a whole — `src/config.ts` salvage already does this for independent `routingProfiles` / `combos` entries — so one bad Desktop marker cannot replace the operator's providers with `getDefaultConfig()`. A `claudeCode` value that is not an object still fails the document, because there is no safe subtree to keep. +`claudeCode.desktopProfile` follows the same preserve-the-rest rule. JSON `null` (or any non-string) `appliedFingerprint` / `appliedAt` is treated as unset. A profile that is still invalid after that is dropped as a whole — `src/config/salvage.ts` already does this for independent `routingProfiles` / `combos` entries — so one bad Desktop marker cannot replace the operator's providers with `getDefaultConfig()`. A `claudeCode` value that is not an object still fails the document, because there is no safe subtree to keep. The former `showCodexSparkQuota` key is inert passthrough data when loading an old config. It is absent from the typed settings contract and cannot re-enable Spark quota through the @@ -307,4 +307,4 @@ The text-only consumer reads exact inputModalities declarations before legacy hi ## Catalog auto-refresh -`catalogAutoRefresh` on `src/types/config.ts` stores an optional `enabled` / `intervalMinutes` section that defaults off: an absent key, an explicit false, and a malformed value all leave the scheduler dormant. `src/config.ts` resolves the cadence; an explicit `intervalMinutes: 0` keeps the unref'd timer idle, and any other value is clamped up to 15 minutes because upstream `/models` caches have not moved below that and a shorter tick only multiplies rate-limit exposure. `src/codex/catalog-auto-refresh.ts` is the module-singleton interval `src/server/background-lifecycle.ts` starts beside the quota reset poller; a tick that is enabled and non-dormant drives the same catalog-only converge funnel management mutations drive. The last-outcome record lives in `src/codex/catalog-refresh-status.ts` (when the tick finished, the normalized `CatalogDisposition`, whether the served model set changed, consecutive failures) and carries no provider or account detail. +`catalogAutoRefresh` on `src/types/config.ts` stores an optional `enabled` / `intervalMinutes` section that defaults off: an absent key, an explicit false, and a malformed value all leave the scheduler dormant. `src/config/feature-flags.ts` resolves the cadence; an explicit `intervalMinutes: 0` keeps the unref'd timer idle, and any other value is clamped up to 15 minutes because upstream `/models` caches have not moved below that and a shorter tick only multiplies rate-limit exposure. `src/codex/catalog-auto-refresh.ts` is the module-singleton interval `src/server/background-lifecycle.ts` starts beside the quota reset poller; a tick that is enabled and non-dormant drives the same catalog-only converge funnel management mutations drive. The last-outcome record lives in `src/codex/catalog-refresh-status.ts` (when the tick finished, the normalized `CatalogDisposition`, whether the served model set changed, consecutive failures) and carries no provider or account detail. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 44faba2956..ed26da210c 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -105,7 +105,7 @@ be treated as implemented: `src/server/index.ts` authenticates and routes `/api/*`, then delegates to `src/server/management-api.ts`, which composes the route modules under `src/server/management/`. -Codex account routes live in `src/codex/auth-api.ts` because they own the credential store, not +Codex account routes live in `src/codex/auth-api/routes.ts` because they own the credential store, not because they are a different plane. The registered route set is larger than the areas described below; the code is the route SOT. What @@ -137,7 +137,7 @@ this document owns is which module holds which area and what invariant that area | Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply`, `GET /api/claude-desktop/status`, `GET/PUT /api/claude-code`. Apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`responses.md`](transports/responses.md)). | | Grok reset coupons | `src/server/management/grok-coupon-routes.ts` — `GET /api/grok/reset-coupons`, `POST /api/grok/reset-coupons/consume`. The dashboard owner is `gui/src/hooks/useGrokResetCoupons.ts` with `gui/src/components/provider-workspace/GrokResetCoupons.tsx`, wired into the xAI OAuth rows of `ProviderAuthPanel`. Redemption truth is the settled ledger `code`, not the HTTP status: a replayed failure returns 200 with `replayed: true`. See [`providers/xai-grok.md`](providers/xai-grok.md). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. | -| Codex accounts | `src/codex/auth-api.ts` — `GET/POST/DELETE /api/codex-auth/accounts`, `PUT /api/codex-auth/accounts/alias`, `PUT /api/codex-auth/accounts/pause`, `PUT /api/codex-auth/accounts/pause-exhausted`, `POST /api/codex-auth/accounts/clear-cooldown`, `GET/PUT /api/codex-auth/active`, `PUT /api/codex-auth/auto-switch`, `PUT /api/codex-auth/pool-strategy`, `PUT /api/codex-auth/failover`, `GET /api/codex-auth/quota`, `GET /api/codex-auth/reset-credits` with `POST /api/codex-auth/reset-credits/consume`, and the login flow `POST /api/codex-auth/login`, `POST /api/codex-auth/login/code`, `POST /api/codex-auth/login/cancel`, `GET /api/codex-auth/login-status`. Per-account quota activation uses the existing `GET/PUT /api/settings` surface and `src/codex/quota-auto-refresh.ts`, keeping scheduled spending separate from credential/authentication mutation. Account ids are opaque handles and are serialized so the GUI can address an account; emails are masked and tokens are never serialized. New-account config commits add UI-managed selector bindings in the same config save; deletion deliberately retains existing bindings for fail-closed exact routing and re-add stability. Account mutations request catalog convergence only after config durability and expose only the boolean `catalogRefreshPending` completion projection. | +| Codex accounts | `src/codex/auth-api/routes.ts` — `GET/POST/DELETE /api/codex-auth/accounts`, `PUT /api/codex-auth/accounts/alias`, `PUT /api/codex-auth/accounts/pause`, `PUT /api/codex-auth/accounts/pause-exhausted`, `POST /api/codex-auth/accounts/clear-cooldown`, `GET/PUT /api/codex-auth/active`, `PUT /api/codex-auth/auto-switch`, `PUT /api/codex-auth/pool-strategy`, `PUT /api/codex-auth/failover`, `GET /api/codex-auth/quota`, `GET /api/codex-auth/reset-credits` with `POST /api/codex-auth/reset-credits/consume`, and the login flow `POST /api/codex-auth/login`, `POST /api/codex-auth/login/code`, `POST /api/codex-auth/login/cancel`, `GET /api/codex-auth/login-status`. Per-account quota activation uses the existing `GET/PUT /api/settings` surface and `src/codex/quota-auto-refresh.ts`, keeping scheduled spending separate from credential/authentication mutation. Account ids are opaque handles and are serialized so the GUI can address an account; emails are masked and tokens are never serialized. New-account config commits add UI-managed selector bindings in the same config save; deletion deliberately retains existing bindings for fail-closed exact routing and re-add stability. Account mutations request catalog convergence only after config durability and expose only the boolean `catalogRefreshPending` completion projection. | | Sidebar | `src/server/management/sidebar-routes.ts` — `GET/POST /api/github/star` and `GET /api/update/badge`. Sidebar state is cosmetic; a failed fetch degrades silently. | | Logs | `src/server/management/logs-usage-routes.ts` — `GET /api/logs`, `GET /api/claude/inbound-debug`, and `GET /api/debug/injection-logs` join the debug streams described above. | diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 691a2fe7bf..ab7511c3b0 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -343,7 +343,7 @@ The historical v1 backup is never overwritten. Restoring the v2 backup intention shipped v1 shape; the next startup re-migrates to the same marker-2 bytes. A pre-existing snapshot that differs from the current config is classified before anything is written -(`src/config.ts` `classifyOpenAiTierBackup`): a snapshot that parses as a valid pre-migration (v1) +(`src/config/openai-tier-backup.ts` `classifyOpenAiTierBackup`, re-exported through the `src/config.ts` facade): a snapshot that parses as a valid pre-migration (v1) config is a user-intentional rollback point and is copied to a unique `config.json.pre-openai-tiers-v1-rollback..bak` path before startup retries the v2 migration backup; a snapshot that is unparseable or already tier-v2 is stale and is replaced with a @@ -470,7 +470,7 @@ Listener startup diagnostics follow [the runtime lifecycle contract](../runtime. `src/codex/routing/selection.ts` applies optional `codexPool.excludedPlans` to both candidate selection and existing active/affined accounts. An all-excluded pool returns no automatic candidate, including preview and configured-account fallback. Native main remains exempt and unknown plans remain eligible. Explicit account-qualified routes retain pause, credential and entitlement checks while bypassing only this automatic policy. -`src/codex/auth-api.ts` projects `selectionExcludedReason: "plan_excluded"` and `selectionExcludedPlan` from the routing config, even when a newer display-only WHAM plan could not be persisted. The dashboard and account CLI show the policy reason separately from credential health; renewal clears the derived fields. The automatic next-session action and badge are omitted for excluded rows. +`src/codex/auth-api/account-list.ts` projects `selectionExcludedReason: "plan_excluded"` and `selectionExcludedPlan` from the routing config, even when a newer display-only WHAM plan could not be persisted. The dashboard and account CLI show the policy reason separately from credential health; renewal clears the derived fields. The automatic next-session action and badge are omitted for excluded rows. ## Paginated history writer boundary `src/codex/history-provider.ts` refuses external writes to paginated or migration-capable history. `src/codex/inject.ts` checks affected rows and manifest-owned restore targets before and after config/profile/journal changes, including successful journal and fallback restores, and compensates detected migration. Failed config restore stops later catalog/history work and rolls back a coordinated remove transition. See the [history writer contract](../codex-home.md#paginated-history-writer-boundary) for guarantees and concurrent-writer limits. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 0ca554f4e0..ba5333ea58 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -93,7 +93,7 @@ privately to final dispatch; preliminary route selection does not inject Go-only Devin CLI credential path composition in `src/oauth/devin/cli-import.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. -Provider-scoped catalog hints remain isolated by provider in `src/providers/registry.ts`. The +Provider-scoped catalog hints remain isolated by provider in `src/providers/registry/entries-core.ts`. The OpenCode Go `deepseek-v4.1-flash` 1,048,576-token context hint does not change xAI model metadata or transport behavior. The first-party DeepSeek `deepseek-flash` native `text`/`image` declaration is likewise scoped to diff --git a/structure/runtime.md b/structure/runtime.md index 49a2fc3f2f..9d97326b34 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -28,7 +28,7 @@ When hub management ingress is enabled, `src/cli/dispatch.ts` opens the dashboar | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | | `src/server/audio-transcriptions.ts` | Standalone multipart transcription; audio-specific key admission, bounded upload/response, stored OpenAI credential resolution and lease-bound cancellation. See [audio contracts](data-planes/inbound-compat.md#standalone-file-transcription). | | `src/server/audio-live.ts`, `src/server/audio-dictation.ts` | External voice/dictation orchestration using the existing bounded socket relay, server-owned credentials, cancellation and opaque call ownership. See [streaming audio](data-planes/inbound-compat.md#streaming-audio). | -| `src/config.ts` | Persisted `~/.opencodex/config.json` schema, defaults, migrations, transactions, and compatibility re-exports for split config modules. | +| `src/config.ts` | Persisted `~/.opencodex/config.json` surface: the facade keeps the load/save/initialize entry points and re-exports, while schema lives in `src/config/schema/` (`config-schema.ts`, `leaf-validators.ts`), defaults in `src/config/proxy-env.ts`, and replace-path persistence in `src/config/persist-unlocked.ts`. | | `src/config/paths.ts` | Resolves `OPENCODEX_HOME`, `config.json`, and owner-only directory hardening. | | `src/config/atomic-write.ts` | Shared synchronous/asynchronous temp-harden-rename writer and residual-temp failure contract. | | `src/config/process-state.ts` | Owns `ocx.pid`, `runtime-port.json`, cheap liveness, full command-line identity verification, and snapshot-guarded cleanup. | @@ -171,13 +171,13 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an | Path | Responsibility | | --- | --- | -| `src/providers/registry.ts` | Canonical provider presets for CLI, dashboard, OAuth, key providers, and metadata. | +| `src/providers/registry.ts` | Compatibility facade; canonical provider presets for CLI, dashboard, OAuth, key providers, and metadata live in `src/providers/registry/entries-core.ts` and `entries-extended.ts`, with model seeds in `model-seeds.ts`. | | `src/providers/derive.ts` | Enrichment from provider presets into user config. | | `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. Kiro add-account identity prefers same-session `whoami` over a leftover SQLite state profile, and never persists the Builder ID service profile ARN as `accountId`. | | `src/combos/request.ts` | Clones each selected combo target request and applies the existing target capability ladder: adaptive unknown targets and explicit empty ladders receive no unsupported reasoning/thinking controls, while known ladders retain per-target resolution. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | -| `src/adapters/openai-chat.ts` | OpenAI-compatible Chat Completions bridge. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | +| `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `tool-call-validation.ts`, `tool-schema.ts`, `errors.ts`). Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. | | `src/adapters/google.ts` | Gemini bridge. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | @@ -206,7 +206,7 @@ before any adapter-specific transport override, so a stale configured `baseUrl` OAuth bearer token. Provider-scoped capability hints remain authoritative when discovery returns an id without -capabilities. In particular, `src/providers/registry.ts` assigns OpenCode Go's live +capabilities. In particular, `src/providers/registry/entries-core.ts` assigns OpenCode Go's live `deepseek-v4.1-flash` route the official 1,048,576-token window instead of the conservative 128k routed-model fallback. The same registry declares the first-party `deepseek-flash` model with `text` and `image` input, diff --git a/structure/subagents.md b/structure/subagents.md index b88dd48369..70c93a9ac5 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -42,10 +42,10 @@ The override is applied as a final pass in both `buildCatalogEntries` (live `/v1 ensures `normalizeRoutedCatalogEntry` (which deletes `multi_agent_version` from routed entries) does not clobber the forced value. -`getDefaultConfig()` (`src/config.ts`) writes `multiAgentMode: "v1"` explicitly, using the version +`getDefaultConfig()` (`src/config/proxy-env.ts`) writes `multiAgentMode: "v1"` explicitly, using the version constant from `src/config/multi-agent-surface.ts`, so v1 is the install default while a v2 native-to-routed child task is undeliverable ciphertext. The repair and salvage merges in -`src/config.ts` pin `multiAgentMode` and `multiAgentSurfaceAdvisoryVersion` to the stored +`src/config/diagnostics.ts` pin `multiAgentMode` and `multiAgentSurfaceAdvisoryVersion` to the stored document, because spreading the defaults underneath would repair an unrelated missing field into a surface change its operator never made. An absent key still means `"default"`, because selecting base deletes the key — absence cannot be diff --git a/tests/codex-integration/catalog-seed-window-fill.test.ts b/tests/codex-integration/catalog-seed-window-fill.test.ts index ffdd628e32..b8e12e7da6 100644 --- a/tests/codex-integration/catalog-seed-window-fill.test.ts +++ b/tests/codex-integration/catalog-seed-window-fill.test.ts @@ -20,7 +20,7 @@ function persisted(id: string, overrides: Partial = {}): OcxP return { adapter: entry.adapter, baseUrl: entry.baseUrl, ...overrides }; } -/** Mirrors detachedClone in src/codex/catalog/provider-fetch.ts. */ +/** Mirrors detachedClone in src/codex/catalog/gather-capture.ts. */ function detachedClone(value: T): T { if (Array.isArray(value)) return value.map(item => detachedClone(item)) as T; if (value && typeof value === "object") { diff --git a/tests/codex-integration/codex-auth-api.test.ts b/tests/codex-integration/codex-auth-api.test.ts index 34e29bf334..bd30621448 100644 --- a/tests/codex-integration/codex-auth-api.test.ts +++ b/tests/codex-integration/codex-auth-api.test.ts @@ -168,7 +168,7 @@ async function completeMockCodexOAuth(options: { loggedIn: true, } as ReturnType); const openSpy = spyOn(openUrlMod, "openUrl").mockImplementation(() => {}); - // Mirrors the login-status poll delay in auth-api.ts; other timers are intentionally dropped. + // Mirrors the login-status poll delay in login-flow.ts; other timers are intentionally dropped. const CODEX_OAUTH_LOGIN_POLL_INTERVAL_MS = 2_000; const timeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation((( callback: (...args: unknown[]) => void, @@ -4622,7 +4622,7 @@ describe("codex-auth API", () => { test("the device poll budget covers the 15-minute grant", async () => { // The budget is a loop bound with no observable output, so a regression to // the 5-minute browser budget would pass every behavioral test above. - const source = await Bun.file(new URL("../../src/codex/auth-api.ts", import.meta.url)).text(); + const source = await Bun.file(new URL("../../src/codex/auth-api/login-flow.ts", import.meta.url)).text(); const budget = /const pollAttempts = useDeviceFlow \? (\d+) : (\d+);/.exec(source); expect(budget).toBeTruthy(); // 900s is the grant; the extra margin covers post-grant settlement, so an @@ -5867,12 +5867,12 @@ describe("codex-auth API", () => { }); test("OAuth pool login excludes self from collision check when reauth", async () => { - const source = await Bun.file("src/codex/auth-api.ts").text(); + const source = await Bun.file("src/codex/auth-api/login-flow.ts").text(); expect(source).toContain("checkAccountIdCollision(oauthAccountId, email, plan, reauth ? accountId : undefined)"); }); test("OAuth pool reauth binds ChatGPT identity to the existing pool slot", async () => { - const source = await Bun.file("src/codex/auth-api.ts").text(); + const source = await Bun.file("src/codex/auth-api/login-flow.ts").text(); expect(source).toContain("expectedChatgptId"); expect(source).toContain("expectedEmail"); expect(source).toContain("Signed-in ChatGPT account does not match this pool account"); @@ -5880,18 +5880,18 @@ describe("codex-auth API", () => { }); test("OAuth pool login waits for the current flow to finish, not stale credentials", async () => { - const source = await Bun.file("src/codex/auth-api.ts").text(); + const source = await Bun.file("src/codex/auth-api/login-flow.ts").text(); expect(source).toContain("st.done && st.loggedIn"); expect(source).toContain("Login timed out before OAuth completed."); }); test("OAuth pool login stores a privacy log label at the account creation call site", async () => { - const source = await Bun.file("src/codex/auth-api.ts").text(); + const source = await Bun.file("src/codex/auth-api/login-flow.ts").text(); expect(source).toContain("withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts)"); }); test("GET /api/codex-auth/login-status projects transient flow-state emails at response boundaries", async () => { - const source = await Bun.file("src/codex/auth-api.ts").text(); + const source = await Bun.file("src/codex/auth-api/login-flow.ts").text(); // #3859 turned the unconditional mask into a policy projection. The guarantee is unchanged: // BOTH boundaries redact through the shared helper, and the route resolves the policy from // config rather than defaulting to reveal. diff --git a/tests/config/config-save-boundary.test.ts b/tests/config/config-save-boundary.test.ts index 5b51e8182c..c212a157f9 100644 --- a/tests/config/config-save-boundary.test.ts +++ b/tests/config/config-save-boundary.test.ts @@ -22,6 +22,7 @@ const GUARDED_FILES = [ "codex/routing.ts", // account auto-switch during a turn "codex/routing/active-account.ts", // setActiveCodexAccount moved here in the routing split "codex/auth-api.ts", // runtime account/quota persistence + "codex/auth-api/runtime-config.ts", // saveRuntimeConfig via saveConfigPreservingClaudeCode "cli/claude-desktop.ts", // CLI against a running service "server/management-api.ts", ]; diff --git a/tests/fixtures/file-size-baseline.json b/tests/fixtures/file-size-baseline.json index dff0815d02..e74693dfd4 100644 --- a/tests/fixtures/file-size-baseline.json +++ b/tests/fixtures/file-size-baseline.json @@ -17,15 +17,15 @@ ".github/scripts/issue-quality.test.cjs": 2143, "gui/src/pages/Models.tsx": 2792, "gui/src/styles.css": 2958, - "src/adapters/openai-chat.ts": 2234, + "src/adapters/openai-chat.ts": 822, "src/adapters/openai-responses.ts": 2627, "src/bridge.ts": 2206, - "src/codex/auth-api.ts": 3134, - "src/codex/catalog/provider-fetch.ts": 2944, - "src/config.ts": 4799, - "src/providers/registry.ts": 3744, + "src/codex/auth-api.ts": 43, + "src/codex/catalog/provider-fetch.ts": 54, + "src/config.ts": 460, + "src/providers/registry.ts": 232, "src/server/index.ts": 3400, - "src/server/responses/core.ts": 9360, + "src/server/responses/core.ts": 9387, "tests/ci-workflows/ci-workflows.test.ts": 5628, "tests/cli/cli-account.test.ts": 2313, "tests/codex-integration/codex-auth-api.test.ts": 6549, diff --git a/tests/lib/reasoning-replay-scope-source.test.ts b/tests/lib/reasoning-replay-scope-source.test.ts index db7f8a05da..6b772e1885 100644 --- a/tests/lib/reasoning-replay-scope-source.test.ts +++ b/tests/lib/reasoning-replay-scope-source.test.ts @@ -30,7 +30,7 @@ describe("reasoning replay scope propagation", () => { test("bridge, adapter, and cache contain no process-wide fallback", () => { const bridge = source("bridge.ts"); - const adapter = source("adapters/openai-chat.ts"); + const adapter = source("adapters/openai-chat/messages.ts"); const cache = source("responses/reasoning-replay-cache.ts"); expect(bridge.match(/const replayCacheScope = options\?\.replayCacheScope;/g)).toHaveLength(2); expect(adapter.match(/const replayCacheScope = parsed\._reasoningReplayScope;/g)).toHaveLength(1); diff --git a/tests/lib/workflow-budget.test.ts b/tests/lib/workflow-budget.test.ts index f21ecc1ef9..a8fb7ce447 100644 --- a/tests/lib/workflow-budget.test.ts +++ b/tests/lib/workflow-budget.test.ts @@ -209,3 +209,166 @@ describe("workflow spend reservation", () => { if (denied && !denied.admitted) expect(denied.reason).toBe("workflow-spend-exhausted"); }); }); + +describe("root ceilings bound a rate, not a lifetime (#4546)", () => { + const WINDOW = 60_000; + const policy: WorkflowBudgetPolicy = { + ...DEFAULT_WORKFLOW_BUDGET_POLICY, + maxPhysicalSends: 4, + maxDistinctChildren: 2, + windowMs: WINDOW, + }; + + beforeEach(() => { + resetWorkflowBudgetsForTest(); + }); + + test("a root at the send ceiling is admitted again once its window rolls", () => { + const now = 1_700_000_000_000; + const first = admitWorkflowTurn("root-a", "worker", policy, undefined, now); + expect(first?.admitted).toBe(true); + first?.lease.release(); + chargeWorkflowSends("root-a", policy.maxPhysicalSends, now); + + // Inside the window the ceiling still fires: the burst this cap was written against is + // refused exactly as before. + expect(admitWorkflowTurn("root-a", "worker", policy, undefined, now + 1)?.reason) + .toBe("workflow-sends-exhausted"); + expect(workflowSendCeilingReached("root-a", policy, now + 1)).toBe(true); + + // Past the window the same root is served, with no restart. This is the case that made a + // long-lived session unusable: work it finished hours ago kept refusing it. + const rolled = admitWorkflowTurn("root-a", "worker", policy, undefined, now + WINDOW + 1); + expect(rolled?.admitted).toBe(true); + rolled?.lease.release(); + }); + + test("distinct children age out of the count the same way sends do", () => { + const now = 1_700_000_000_000; + for (const child of ["c1", "c2"]) { + const admitted = admitWorkflowTurn("root-b", "worker", policy, child, now); + expect(admitted?.admitted).toBe(true); + admitted?.lease.release(); + } + // A third distinct child inside the window is refused at the configured ceiling. + expect(admitWorkflowTurn("root-b", "worker", policy, "c3", now + 1)?.reason) + .toBe("workflow-children-exhausted"); + + // Once c1 and c2 have aged out, c3 is a new child under an empty count rather than the + // third member of a set the root can never shrink. + const later = admitWorkflowTurn("root-b", "worker", policy, "c3", now + WINDOW + 1); + expect(later?.admitted).toBe(true); + later?.lease.release(); + }); + + test("a child that keeps working holds its slot; one that stops does not", () => { + const now = 1_700_000_000_000; + for (const at of [now, now + WINDOW / 2, now + WINDOW]) { + const busy = admitWorkflowTurn("root-c", "worker", policy, "busy", at); + expect(busy?.admitted).toBe(true); + busy?.lease.release(); + } + const quiet = admitWorkflowTurn("root-c", "worker", policy, "quiet", now); + expect(quiet?.admitted).toBe(true); + quiet?.lease.release(); + + // "busy" was seen inside the window and still counts; "quiet" was not and does not, so + // there is room for exactly one more distinct child rather than none. + const snapshot = workflowBudgetSnapshot("root-c", policy, now + WINDOW + 1); + expect(snapshot?.children).toBe(1); + }); + + test("windowing never refuses traffic the lifetime count would have admitted", () => { + // The safety argument stated as a test rather than trusted as prose: a count inside a + // window is bounded by the same count over a lifetime, so for identical traffic the + // windowed ceiling fires no earlier than the lifetime one did. + // + // The root is admitted first on purpose. An earlier version of this test charged a root + // that had never been admitted, so `chargeWorkflowSends` returned at its `!state` guard, + // the snapshot came back undefined, and every assertion sat behind `if (snapshot)`. It + // would have passed with the ring deleted. + const now = 1_700_000_000_000; + const seeded = admitWorkflowTurn("root-d", "worker", policy, undefined, now); + expect(seeded?.admitted).toBe(true); + seeded?.lease.release(); + + let lifetime = 0; + let refusals = 0; + for (let i = 0; i < policy.maxPhysicalSends * 3; i += 1) { + const at = now + i * (WINDOW / 2); + chargeWorkflowSends("root-d", 1, at); + lifetime += 1; + const snapshot = workflowBudgetSnapshot("root-d", policy, at); + expect(snapshot).toBeDefined(); + expect(snapshot?.lifetimeSends).toBe(lifetime); + expect(snapshot?.sends).toBeLessThanOrEqual(lifetime); + if (workflowSendCeilingReached("root-d", policy, at)) { + refusals += 1; + expect(lifetime).toBeGreaterThanOrEqual(policy.maxPhysicalSends); + } + } + + // Spread half a window apart, this traffic is a trickle and is never refused, while the + // lifetime count passed the same ceiling three times over. That gap is the whole change. + expect(refusals).toBe(0); + expect(lifetime).toBeGreaterThan(policy.maxPhysicalSends); + }); + + test("the window a root was created with is the one its ceiling reads", () => { + // Charging on one scale and reading on another is not hypothetical: the slot ids written + // under a long window look ancient to a short one, `windowedSends` returns zero, and the + // ceiling stops firing at all. The geometry therefore belongs to the root, not to + // whichever policy the current caller happens to be holding. + const now = 1_700_000_000_000; + const seeded = admitWorkflowTurn("root-f", "worker", policy, undefined, now); + expect(seeded?.admitted).toBe(true); + seeded?.lease.release(); + chargeWorkflowSends("root-f", policy.maxPhysicalSends, now); + + const wider: WorkflowBudgetPolicy = { ...policy, windowMs: WINDOW * 100 }; + const narrower: WorkflowBudgetPolicy = { ...policy, windowMs: 1_000 }; + expect(workflowSendCeilingReached("root-f", wider, now + 1)).toBe(true); + expect(workflowSendCeilingReached("root-f", narrower, now + 1)).toBe(true); + expect(workflowBudgetSnapshot("root-f", narrower, now + 1)?.windowMs).toBe(WINDOW); + }); + + test("the snapshot separates the window from the lifetime total", () => { + const now = 1_700_000_000_000; + const admitted = admitWorkflowTurn("root-e", "worker", policy, undefined, now); + admitted?.lease.release(); + chargeWorkflowSends("root-e", 3, now); + const inside = workflowBudgetSnapshot("root-e", policy, now); + expect(inside?.sends).toBe(3); + expect(inside?.lifetimeSends).toBe(3); + expect(inside?.windowMs).toBe(WINDOW); + + const after = workflowBudgetSnapshot("root-e", policy, now + WINDOW * 2); + // The ceiling reads the window and sees nothing; the lifetime total is still reported, so + // an operator can tell an idle root from one that never worked. + expect(after?.sends).toBe(0); + expect(after?.lifetimeSends).toBe(3); + }); +}); + + +describe("every ceiling on this path reads the caller's clock", () => { + test("no function reads Date.now() except as a parameter default", async () => { + // This defect has now appeared three times in two days: codexPoolAffinityKey, then + // chargeWorkflowSends, then workflowSendCeilingReached. Each time a caller working against + // a fixed clock wrote into one window and read from another, and each time the symptom was + // a ceiling that fired when it should not have. A function that decides admission must be + // askable about a moment, so the clock is a parameter and never an ambient read. + const source = await Bun.file( + new URL("../../src/lib/workflow-budget.ts", import.meta.url), + ).text(); + const ambient = source + .split("\n") + .map((line, index) => ({ line: line.trim(), number: index + 1 })) + .filter(entry => entry.line.includes("Date.now()")) + .filter(entry => !entry.line.startsWith("now: number = Date.now()")) + .filter(entry => !entry.line.startsWith("//")) + // lastSeenMs feeds eviction ordering, not a ceiling, and its comment says so. + .filter(entry => !entry.line.includes("lastSeenMs = Date.now()")); + expect(ambient).toEqual([]); + }); +}); diff --git a/tests/routing/routing-capability-model-matching.test.ts b/tests/routing/routing-capability-model-matching.test.ts index df5a6b22c2..3b8ba4652e 100644 --- a/tests/routing/routing-capability-model-matching.test.ts +++ b/tests/routing/routing-capability-model-matching.test.ts @@ -20,7 +20,7 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; * so it has to match the resolver. Every runtime reader of `modelContextWindows`, * `modelInputModalities` and `modelReasoningEfforts` goes through `modelRecordValue` * (`src/reasoning-effort.ts:108`, `src/server/effort-policy.ts:122`, - * `src/vision/index.ts:34`, `src/codex/catalog/provider-fetch.ts:612`), which accepts a + * `src/vision/index.ts:34`, `src/codex/catalog/model-hints.ts:165`), which accepts a * family entry for a tagged id. This file pins the evidence to that same rule. * * The window matters most: a bare lookup did not degrade to unknown there, it fell diff --git a/tests/server/management-route-registry.test.ts b/tests/server/management-route-registry.test.ts index 891ce5e791..2ca389ed81 100644 --- a/tests/server/management-route-registry.test.ts +++ b/tests/server/management-route-registry.test.ts @@ -42,7 +42,7 @@ function routeCarryingFiles(): string[] { "src/server/management-api.ts", // Mounted outside the `??` chain (management-api.ts:284, :289), which is why a scan // scoped to `src/server/management/` misses 29 route literals entirely. - "src/codex/auth-api.ts", + "src/codex/auth-api/routes.ts", "src/codex/native-profile-api.ts", ]; for (const f of readdirSync(join(repoRoot, "src/server/management")).sort()) {