diff --git a/src/codex/sync.ts b/src/codex/sync.ts index d9217cc0a1..47dc2a8b00 100644 --- a/src/codex/sync.ts +++ b/src/codex/sync.ts @@ -14,6 +14,7 @@ import { import { admitCodexWrite, type CodexAdmission } from "./admission"; import type { CodexCatalogSyncOptions } from "./catalog/sync"; import { resetCodexAppServerCatalogStateCache } from "./app-server-processes"; +import { providerUsesReasoningMetadata, refreshReasoningMetadata } from "../providers/reasoning-metadata"; export interface CodexSyncResult { /** @@ -67,13 +68,21 @@ interface CodexSyncDeps { admitCodexWrite?: () => CodexSyncAdmission; currentExternalCodexModelProvider?: typeof currentExternalCodexModelProvider; collectCodexHomeDiagnostic?: typeof collectOrcaCodexHomeDiagnostic; + refreshReasoningMetadata?: typeof refreshReasoningMetadata; } const defaultDeps: CodexSyncDeps = { refreshCodexModelCatalog, injectCodexConfig, + refreshReasoningMetadata, }; +async function refreshReasoningMetadataForSync(config: OcxConfig, deps: CodexSyncDeps): Promise { + if (Object.values(config.providers).some(providerUsesReasoningMetadata)) { + await deps.refreshReasoningMetadata?.(); + } +} + function reportCodexHomeTarget( log: Pick | null, collectDiagnostic: typeof collectOrcaCodexHomeDiagnostic, @@ -234,6 +243,9 @@ export async function syncModelsToCodex( } applyProxyEnv(config); // `ocx ensure`/`ocx sync` fetch provider models outside the server process + // Bootstrap the optional ladder snapshot before gathering the catalog. Keeping this in the + // sync plane prevents an unrelated models.dev fetch from interleaving with a routed turn. + await refreshReasoningMetadataForSync(config, deps); let added = 0; let catalogPath: string | null = null; let catalogPathForInjection: string | null | undefined; @@ -339,6 +351,7 @@ async function refreshCatalogForSync( let refreshOutcome: "committed" | "refused" | undefined; let comboOmissions: ComboCatalogOmission[] = []; try { + await refreshReasoningMetadataForSync(config, deps); const cat = await deps.refreshCodexModelCatalog(config, undefined, catalogOptions); refreshOutcome = cat.refreshOutcome; added = cat.added; diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 25b312eced..aabdc28209 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -135,6 +135,11 @@ function metadataProviderKey(provider: OcxProviderConfig): string | undefined { return undefined; } +/** Whether catalog synchronization should fetch models.dev metadata for this destination. */ +export function providerUsesReasoningMetadata(provider: OcxProviderConfig): boolean { + return metadataProviderKey(provider) !== undefined; +} + /** * Local mirror of `modelRecordValue()` from `src/reasoning-effort.ts`, which imports this * module and so cannot be imported back. Exact id, then the `family:` prefix, then a diff --git a/src/reasoning-effort.ts b/src/reasoning-effort.ts index d97c96db99..726821d7fc 100644 --- a/src/reasoning-effort.ts +++ b/src/reasoning-effort.ts @@ -1,6 +1,6 @@ import type { OcxProviderConfig } from "./types"; import { modelInList } from "./types"; -import { dropLearnedUnsupportedReasoningEfforts, ensureReasoningMetadataSnapshot, reasoningEffortsFromMetadata } from "./providers/reasoning-metadata"; +import { dropLearnedUnsupportedReasoningEfforts, reasoningEffortsFromMetadata } from "./providers/reasoning-metadata"; // Descriptions mirror the upstream bundled models.json canonical wording (openai/codex PR #31684). export const CODEX_REASONING_LEVELS: { effort: string; description: string }[] = [ @@ -160,18 +160,10 @@ export function configuredReasoningEfforts(provider: OcxProviderConfig, modelId: } // models.dev publishes the per-model ladder that routed providers never expose on /models. // (OpenCode Zen Go answers ids only). Only consulted when nothing was configured for this - // model, so every hand-written contract stays authoritative. The snapshot refreshes itself in - // the background; no snapshot means the previous behaviour. - // The refresh is asked for only once a snapshot has already answered, which means it only ever - // refreshes a STALE snapshot. Review asked for the opposite — refresh when the snapshot is - // missing or corrupt, since that is the case this lookup cannot serve. That is declined here: - // a missing snapshot is the default state of every fresh install and every test process, so - // requesting the fetch here puts a models.dev request on the request path of the first routed - // turn to a gated destination. Refreshing a snapshot that does not exist is catalog-sync work, - // not request work. + // model, so every hand-written contract stays authoritative. Catalog sync owns snapshot + // refresh; no snapshot means the previous behaviour. const fromMetadata = reasoningEffortsFromMetadata(provider, modelId); if (fromMetadata !== undefined) { - ensureReasoningMetadataSnapshot(); return dropLearnedUnsupportedReasoningEfforts(provider, modelId, healMappedTiers(provider, modelId, fromMetadata)); } return undefined; diff --git a/structure/catalog.md b/structure/catalog.md index 8cce6268b0..9fa64ebfe8 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -95,6 +95,9 @@ Provider live-model lists are cached with a configured TTL (`src/codex/model-cac deleting, or editing a provider's shape clears that per-provider cache; a disabled-only change deliberately does not, because a disabled provider is already excluded from the catalog gather instead. Codex's own `models_cache.json` is a different cache, invalidated by catalog refresh. +Synchronizing a supported routed provider first refreshes its models.dev effort snapshot through +`src/codex/sync.ts` and `src/providers/reasoning-metadata.ts`; this keeps missing-cache network work +out of request-time ladder resolution. For `liveModels: false`, a static provider publishes the ordered union of `models` and `retainModels`. When `models` is absent or empty, its configured `defaultModel` seeds that diff --git a/structure/codex-home.md b/structure/codex-home.md index 1f1edfd7da..d94b52d138 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -250,6 +250,9 @@ Plan-based automatic exclusions leave native credential files untouched and pres `src/codex/history-provider.ts` rejects provider-history changes with `history_paginated_requires_native_writer` when a target begins with an ordinal-bearing record or declares `history_mode=paginated`. Apply, manifest-backed restore, and explicit legacy recovery preflight all selected targets before changing database rows or manifests. The append boundary checks again. Codex owns ordinal allocation and the live projection cursor; reading the last ordinal and appending N+1 is not safe concurrent coordination. Legacy unnumbered rollouts retain their existing behavior. This guard prevents the observed stable-format corruption; it does not implement native-writer integration or guarantee a concurrent legacy-to-paginated conversion is excluded. +`src/codex/sync.ts` owns startup and explicit-sync bootstrap of the optional +`src/providers/reasoning-metadata.ts` snapshot; request handling never owns that disk refresh. + Injection preflights affected history using the normalized config candidate before writing config/profile/journal, then checks again after the complete artifact write. Native restore also rechecks after successful journal restoration or fallback removal, while exact config/profile/journal preimages and any coordinated remove transaction remain available for compensation. Detected migration restores all three preimages before returning a structured refusal, including on legacy-uncoordinated homes. A failed config restore stops catalog/history work; coordinated restore rolls back its published remove transition. Legacy first-line provider patches are bound to the validated file identity before and after writing. These compensating checks do not provide a native-writer lock or authorize external ordinal allocation. The legacy external writer is now refused for affected rows in any store whose schema includes history_mode, even while their row mode is still legacy. This deliberately sacrifices automatic relabeling on migration-capable stores rather than racing native conversion. Synchronous/asynchronous restore, inline journal restore, and direct config removal preserve all artifacts on the same refusal. diff --git a/structure/config.md b/structure/config.md index f799ebd2c5..387691ea8e 100644 --- a/structure/config.md +++ b/structure/config.md @@ -269,6 +269,9 @@ described in [Responses transport](transports/responses.md), not upstream policy `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. +Catalog synchronization in `src/codex/sync.ts` also bootstraps reasoning metadata only when the +configured providers contain a destination supported by `src/providers/reasoning-metadata.ts`. + Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index c445858e6b..a1e5d4c348 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -573,7 +573,7 @@ The provider editor field policy exposes `showThinkingSummary` as a boolean prov ## 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. +`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. Management-triggered catalog synchronization uses `src/codex/sync.ts` to bootstrap the optional `src/providers/reasoning-metadata.ts` snapshot before catalog gathering for supported destinations. Codex pool settings and their consumers follow the [reset-first ordering contract](providers/openai-tiers.md#reset-first-account-ordering), including independent-quota fallback and preserved affinity. Codex account DTOs and cards expose the routing-plan exclusion separately from credential health; the [plan exclusion contract](providers/openai-tiers.md#automatic-pool-plan-exclusions) also governs CLI projection. Private pool credential metadata follows the [quota-history publication identity contract](providers/openai-tiers.md#quota-history-publication-identity); credential-only and account DTO projections omit it. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 17e54e3664..c57c2e4258 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -375,3 +375,6 @@ Exact [model input declarations](../config.md#explicit-per-model-capability-decl Provider-scoped approval reviewer settings are projected by the [catalog owner](../catalog.md#provider-scoped-approval-reviewer); this surface retains its existing routing, transport and account-selection behavior. Renamed fixed-key providers receive [missing reasoning metadata](../catalog.md#renamed-destination-reasoning-metadata) during derivation; explicit per-model entries and provider defaults retain precedence. +Startup and explicit catalog synchronization in `src/codex/sync.ts` bootstrap supported-provider +effort metadata through `src/providers/reasoning-metadata.ts`; routed requests do not trigger that +network refresh. diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 527aacba6b..56861400e4 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -518,3 +518,6 @@ The history read API reports a median effective token estimate and interval samp Live bindings obey the existing cache-affinity release policy: with `pool.cacheAffinity`, threshold crossing alone retains a healthy account. Manual preference, scoped health and shared-cursor guards remain authoritative. Independent `spark`/`reserve` quota scopes resolve reset-first to existing quota selection because shared reset timestamps do not describe those windows. The configured value stays unchanged. The Codex parser in `src/oauth/pool-kernel.ts` is reexported by the compatibility facade and used by both `/api/pool/settings` and the legacy Codex settings route. Generic and Anthropic parsers reject reset-first. The dashboard offers it only for Codex; API, CLI and translated guides preserve the same contract. +Catalog synchronization in `src/codex/sync.ts` bootstraps models.dev effort metadata through +`src/providers/reasoning-metadata.ts` only for supported routed destinations; request-time effort +mapping does not initiate that network operation. diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 6213ce1449..f6e76c73d5 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -94,6 +94,8 @@ 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 +models.dev effort snapshot is likewise destination-gated by `src/providers/reasoning-metadata.ts` +and bootstrapped from `src/codex/sync.ts`, not from a routed request. 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 a09d007d7b..78b78e0b59 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -201,6 +201,9 @@ Provider-scoped capability hints remain authoritative when discovery returns an capabilities. In particular, `src/providers/registry.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. +`src/codex/sync.ts` refreshes the `src/providers/reasoning-metadata.ts` models.dev snapshot for +supported routed destinations before catalog gathering, so missing and corrupt snapshots bootstrap +without adding network work to `src/reasoning-effort.ts` request-time ladder reads. The same registry declares the first-party `deepseek-flash` model with `text` and `image` input, so it bypasses the vision sidecar by default; explicit `noVisionModels` or text-only declarations remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` diff --git a/structure/subagents.md b/structure/subagents.md index 69047b076b..7736a412ed 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -228,6 +228,8 @@ Ordinary management discovery also completes it with Codex integration OFF. The final catalog merge fences pending retained rows, including delete/re-add recovery. Raw management rows remain visible as pending/OFF. Config listener bindings are excluded from inventory identity because live and persisted bindings may differ. +Supported routed-provider reasoning snapshots are bootstrapped by `src/codex/sync.ts` through +`src/providers/reasoning-metadata.ts`, rather than by a model request. Codex `spawn_agent` advertises only the highest-priority first five picker-visible catalog rows. Use at most five configured `subagentModels` ids; they may contain bare catalog ids, routed diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 10b5be6a6b..ad0b55b48a 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -33,6 +33,10 @@ surface is listed here so a maintainer can find the owner without grepping: | Alibaba regions | `src/providers/alibaba-region-backup.ts`, `src/providers/alibaba-region-migration.ts`, `src/providers/alibaba-region-startup.ts` | Region migration backs up before rewriting and is idempotent across restarts. | | Discovery and quota | `src/providers/model-discovery.ts`, `src/providers/quota.ts`, `src/providers/registry.ts` | Discovery rejects a response over 4 MiB or past 2,000 raw rows before caching it. Provider-scoped hints fill capabilities omitted by live rosters; OpenCode Go's `deepseek-v4.1-flash` keeps its 1,048,576-token context window. Codex quota DTOs suppress retired Spark evidence under the [OpenAI scope contract](../providers/openai-tiers.md#public-provider-contract), retaining ordinary custom windows. | +`src/codex/sync.ts` bootstraps models.dev effort ladders through +`src/providers/reasoning-metadata.ts` for supported destinations before catalog gathering, never +from the routed request transport. + The registry's first-party `deepseek-flash` row declares native `text` and `image` input, so image requests bypass the vision sidecar by default; explicit `noVisionModels` or text-only declarations remain authoritative. First-party `deepseek-chat`, `deepseek-reasoner`, and `deepseek-v4-flash` diff --git a/tests/codex-integration/codex-sync-api.test.ts b/tests/codex-integration/codex-sync-api.test.ts index cf95f322ca..89becd1812 100644 --- a/tests/codex-integration/codex-sync-api.test.ts +++ b/tests/codex-integration/codex-sync-api.test.ts @@ -150,6 +150,42 @@ describe("GUI/CLI Codex sync backend", () => { expect(errors).toEqual([]); }); + test("bootstraps reasoning metadata for gated providers before catalog gathering", async () => { + const calls: string[] = []; + const zenConfig = { + ...config, + providers: { + zen: { + ...config.providers.fixture, + baseUrl: "https://opencode.ai/zen/go/v1", + }, + }, + } as OcxConfig; + + await syncModelsToCodex(12345, zenConfig, null, { + admitCodexWrite: admittedSync, + refreshReasoningMetadata: async () => { + calls.push("reasoning"); + return { ok: true as const, reason: "refreshed", providers: 1, models: 1 }; + }, + refreshCodexModelCatalog: async () => { + calls.push("catalog"); + return { + added: 1, + path: "/tmp/opencodex-catalog.json", + catalogExists: true, + catalogWritten: true, + cacheSynced: true, + comboOmissions: [], + }; + }, + injectCodexConfig: async () => ({ success: true, message: "injected" }), + currentExternalCodexModelProvider: () => null, + }); + + expect(calls).toEqual(["reasoning", "catalog"]); + }); + test("refuses during injection preflight before catalog or cache mutation", async () => { let refreshCalls = 0; let injectCalls = 0;