From a58bee8c1fdf5abb44b3ad96c2a261e04e7098fe Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:08:15 +0900 Subject: [PATCH] fix(codex): bootstrap reasoning metadata during catalog sync The models.dev effort snapshot was only ever refreshed after a snapshot had already answered a request-time ladder read, so a missing or corrupt snapshot was never bootstrapped. Catalog sync now refreshes the snapshot for configured gated destinations before gathering the catalog, keeping the models.dev fetch off the request path. --- src/codex/sync.ts | 13 +++++++ src/providers/reasoning-metadata.ts | 5 +++ src/reasoning-effort.ts | 14 ++------ structure/catalog.md | 4 +++ structure/codex-home.md | 3 ++ structure/config.md | 3 ++ structure/gui-and-management-api.md | 2 +- structure/ops/docs-and-release.md | 4 +++ structure/providers/openai-tiers.md | 4 +++ structure/providers/xai-grok.md | 2 ++ structure/runtime.md | 4 +++ structure/subagents.md | 3 ++ structure/transports/inventory.md | 4 +++ .../codex-integration/codex-sync-api.test.ts | 36 +++++++++++++++++++ 14 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/codex/sync.ts b/src/codex/sync.ts index d9217cc0a15..47dc2a8b001 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 f22ef8363ea..4c486ca8781 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -137,6 +137,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 a29ce1888e8..1f406632099 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 }[] = [ @@ -168,18 +168,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 7b035ee99fc..ab8c2de9947 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -171,6 +171,10 @@ irreversible credential fingerprint: a credential switch observes neither the fr roster recorded under the previous credential, and a failed discovery's cooldown neither supplies the previous credential's stale roster nor suppresses the next credential's first discovery. +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. + A Devin live row spreads its measured `inputModalities` before `catalogHintsFromProviderConfig`, so exact `modelCapabilities` declarations, the legacy `modelInputModalities` record and the vision-sidecar rewrite keep precedence and the live diff --git a/structure/codex-home.md b/structure/codex-home.md index 47177b946ae..f0af7ac2b9e 100644 --- a/structure/codex-home.md +++ b/structure/codex-home.md @@ -313,6 +313,9 @@ management read degrades instead of returning an error page. `src/codex/history-provider.ts` rejects provider-history changes with `history_paginated_requires_native_writer` when a target begins with an ordinal-bearing record, later contains a paginated record after a legacy start (#4311), or declares `history_mode=paginated`. The first line alone is not sufficient: a rollout that started unnumbered and was later migrated is also refused. 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. History Worker targets resolve the canonical manifest first and an existing pre-normalization Windows filename second, so passing an explicit target cannot bypass upgrade recovery. This guard prevents the observed stable-format corruption; it does not implement native-writer integration. +`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. The preflight opens the state store read-write-free and in that order deliberately. `{ readonly: true }` is the primary open and the only one that joins a live writer's WAL shared memory, so a thread another process just migrated to paginated history is visible and refuses here. A WAL store whose last writer closed cleanly has no `-shm` to join and a read-only connection may not create one, so that open fails `SQLITE_CANTOPEN` on a perfectly healthy store and the catch-all turned it into `history_injection_preflight_unavailable` on every attempt (#4943). The immutable fallback (`immutable=1` over a `file:` URI, the same idiom as the storage scanner and the log-guard inspector) is admitted only when neither `-wal` nor `-shm` is on disk, because that is the state in which the main database is the whole store and an immutable read is exact rather than stale. Either sidecar present, or any other open failure, keeps the original error and the refusal that follows: an immutable read is a snapshot, and a refusal this preflight fails to observe is a config transition over history Codex owns. The guard covers the whole primary attempt, not only the constructor. `sqlite3_open_v2` never reads page 1, so a WAL header is not inspected until the first prepare, and on macOS that is where the absent `-shm` is raised; the first read therefore happens inside the attempt, where the error can still be classified. Bun's bundled SQLite on Linux materializes both sidecars on that same read and never fails, which is why Linux and Windows evidence could not see this gap. diff --git a/structure/config.md b/structure/config.md index 12cf9acbd10..1eb44aca414 100644 --- a/structure/config.md +++ b/structure/config.md @@ -474,6 +474,9 @@ management handler's own unknown-id answer, and it names the id and `ocx models Paginated and migration-capable history follows the [authoritative writer contract](codex-home.md#paginated-history-writer-boundary); this document adds no independent writer guarantee. +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, preserved affinity, strategy-specific threshold summaries, and shared short-observation freshness for switch warnings. diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 1fda4c2ed76..87c02dfd8d2 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -754,7 +754,7 @@ its defaults and exclusions are owned by [Responses transport](transports/respon The provider editor field policy exposes `showThinkingSummary` as a boolean provider option; it controls Responses summary defaults without a dashboard rendering change. See [Google provider](providers/google.md). -Paginated and migration-capable history follows the [authoritative writer contract](codex-home.md#paginated-history-writer-boundary); this document adds no independent writer guarantee. +Paginated and migration-capable history follows the [authoritative writer contract](codex-home.md#paginated-history-writer-boundary); this document adds no independent writer guarantee. 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, preserved affinity, strategy-specific threshold summaries, and shared short-observation freshness for switch warnings. 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 1d1000b6678..40ae3afb625 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -487,3 +487,7 @@ Native steering generation overrides, explicit public-API eligibility and the co The public server configuration reference documents the optional [compaction routing override](../transports/responses.md#compaction-routing-overrides). Its regression file is registered in both test-layout inventories. + +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 0fde5683acd..27d8d14bee9 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -731,3 +731,7 @@ often that refusal fires and can never replace it. Upstream API-key usage follows the [physical-attempt account attribution contract](../gui-and-management-api.md#upstream-key-account-attribution), independently of subscription quota observations. `src/codex/auth-api/login-flow.ts` distinguishes HTTP 429 from an attempted warmup as `codex_warmup_rate_limited` and preserves that code in OAuth status. Failed attempted warmup does not persist replacement credentials; quota-confirmed deferred registration and HTTP 401/403 handling remain separate. `src/codex/warmup.ts` retains a known 429 when bounded error-body draining times out. + +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 4dfa27723f0..5dbfe175e27 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -128,6 +128,8 @@ Devin CLI credential path composition in `src/oauth/devin/cli-import.ts` follows [Anthropic seed image metadata](../runtime.md#capability-aware-image-admission) is provider-scoped; xAI model metadata and transport behavior remain unchanged. Provider-scoped catalog hints remain isolated by provider in `src/providers/registry/entries-core.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 384b6b394e6..762bd46695c 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -478,6 +478,10 @@ Provider-scoped approval reviewer settings are projected by the [catalog owner]( 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. +`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. + Translated audio/file admission follows the [final-adapter input contract](adapters/registry.md#untranslated-input-media); native raw passthrough remains separate. ## Request-local target compatibility diff --git a/structure/subagents.md b/structure/subagents.md index cf52d556baf..932bfdea2c2 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -250,6 +250,9 @@ final catalog merge fences pending retained rows, including delete/re-add recove 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 `provider/model` ids, or exact account-qualified `/` ids. The diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index bfd34825f7d..0e1d728a0da 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -46,6 +46,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. The fixed-key Opper preset uses the shared OpenAI Chat adapter at `https://api.opper.ai/v3/compat`, discovers models through its conventional authenticated `/models` path, preserves an older same-named custom destination, and falls back to bare pool ids while passing vendor-prefixed ids through unchanged. 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 e3ebb44297b..f5fc0943cfb 100644 --- a/tests/codex-integration/codex-sync-api.test.ts +++ b/tests/codex-integration/codex-sync-api.test.ts @@ -164,6 +164,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;