From 0d42efa2845505224f9e1eff5f73f0b9bc46bf78 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:37:44 +0900 Subject: [PATCH 1/2] docs(plan): define axis five display and CLI delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record source-delta audits, manual PR delivery and final-head CI strategy. Co-authored-by: Éverton Toffanetto Co-authored-by: Zig Zag Co-authored-by: 투린 --- .../260907_axis5_display_cli/000_plan.md | 33 +++++++++++++++++ .../001_roadmap_audit.md | 11 ++++++ .../260907_axis5_display_cli/010_delivery.md | 37 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 devlog/_plan/260907_axis5_display_cli/000_plan.md create mode 100644 devlog/_plan/260907_axis5_display_cli/001_roadmap_audit.md create mode 100644 devlog/_plan/260907_axis5_display_cli/010_delivery.md diff --git a/devlog/_plan/260907_axis5_display_cli/000_plan.md b/devlog/_plan/260907_axis5_display_cli/000_plan.md new file mode 100644 index 0000000000..222ef89899 --- /dev/null +++ b/devlog/_plan/260907_axis5_display_cli/000_plan.md @@ -0,0 +1,33 @@ +# Axis 5: display names and provider automation + +Date: 2026-09-07. Class C3, scoped satisfy-spec HOTL loop requested by owner. +Goal: deliver feasible unique changes from #3627, #2716, #3780 on dev with original author trailers. +Scope: display-only catalog metadata, discovered-model editor, optional JSONL CLI output, regression coverage and their docs. No auth/default/routing changes, native stacks, releases, or edits to existing dirty work. +Resources: existing repository/GitHub credentials and Astra high leaf agents; no user-set time/token cap. Isolated checkout /tmp/ocx-axis5-01a078d6. Owner authorizes no-verify push and admin merge. All local suites are prohibited; typecheck/build/test evidence will come from final combined GitHub CI. No invented lower-layer CI successes. +Terminal: merged, proven already delivered, or evidence-backed deferred when infeasible; finish after verifying all dispositions. New product decisions are isolated and deferred rather than guessed. +Records: this unit, .tmp/axis5-evidence, and session-bound .codexclaw goalplan. + +## Roadmap + +WP0: documentation-only source-delta and delivery plan, independent plan audit and document validation. +WP1: implement three scoped source carries with individual credited commits, publish ordinary manual PR chain, audit final tree, validate final combined head, merge verified layers bottom-up, and record dev ancestry. +The three layers are a user-requested review/integration sequence, not a claimed runtime dependency: native catalog -> JSONL CLI -> discovered editor. Source PR branches are never rewritten. +Read 010_delivery.md for diff-level scope and activation scenarios. + +## Sources + +- https://github.com/lidge-jun/opencodex/pull/3627 +- https://github.com/lidge-jun/opencodex/pull/2716 +- https://github.com/lidge-jun/opencodex/pull/3780 +- Base dev: 137d6a7270e7ecfb1c791993800a17c0e30022d9 +- Existing API display-name contract from #3212 is already on dev; only missing UI is carried. + +## CI and merge + +.github/workflows/ci.yml has pull_request triggers on all bases and workflow_dispatch lane=all for complete coverage. Pushes to feature branches do not independently trigger it. Defer/cancel only this task's lower-layer expensive runs as authorized, recording cancellation as cancellation. Dispatch all on final head; only if final CI fails use lower-layer runs to isolate. Do not edit shared workflow policy or fabricate check statuses. +Use merge commits and retain parent branches so commit identity and author trailers survive bottom-up merges. Retarget a child only after its parent lands. If dev moves concurrently, integrate the new dev into the top and refresh exact combined CI before shipping the resulting changed tree. +Review-ready requirements remain visible; local suite prohibition is explicitly documented instead of ticking a false local attestation. Admin waiver applies to the requested merge, not to truthful evidence. + +CI scope refinement: the discovered editor is the final layer so the final commit and PR diff include gui/**, activating GUI lint/build/artifact jobs. ci.yml gates always run GUI tests; docs deployment is NOT dispatched because it publishes. Public docs receive static source consistency inspection here, with docs build explicitly unverified unless an existing build-only remote path is available. + +CI scheduling refinement: lower-layer head commits may use GitHub documented [skip ci] to avoid push/pull_request suite launches; this yields missing/pending evidence, NOT green. Final head has no skip marker and receives lane=all workflow_dispatch. Source: https://docs.github.com/en/actions/how-tos/manage-workflow-runs/skip-workflow-runs (opened 2026-09-07). Admin merge records this explicit owner-requested lower-layer waiver. Do not propagate skip markers into integration merge messages. diff --git a/devlog/_plan/260907_axis5_display_cli/001_roadmap_audit.md b/devlog/_plan/260907_axis5_display_cli/001_roadmap_audit.md new file mode 100644 index 0000000000..2e4b740b4d --- /dev/null +++ b/devlog/_plan/260907_axis5_display_cli/001_roadmap_audit.md @@ -0,0 +1,11 @@ +# Roadmap audit closure + +WP0 documentation implementation, 2026-09-07. +Independent Astra high reviewers: Carver (#3627), Godel (#2716), Anscombe (#3780 and integrated roadmap). + +The integrated verdict was GO-WITH-FIXES (four blockers). The plan now distinguishes lower-layer waived CI from final combined passing evidence; removes the unreachable empty-provider CLI acceptance; requires confirmed-persistence reconciliation after GUI refresh failure; and requires timeout reachability analysis against the installed bounded-fetch wrapper before adding any timeout logic. + +Source heads: native f699ec7f998d56bf205db96762b821cd8c228a35; editor 93ed44053b68a9707f8271981d5f7e4bc25e9b70; JSONL 9b873e6f7519a022dd4658db4d1cb92689bb4663. +The physical manual chain is native -> JSONL -> GUI, enabling final GUI CI jobs. It is owner-requested integration ordering, not a claimed runtime dependency. +Native external-name preservation is qualified by existing pinned Astra normalization; existing policy remains intact. +No product tests, typecheck or build ran. WP0 checks only roadmap structure, source paths, explicit acceptance and credit records. Product verification remains WP1 remote CI. diff --git a/devlog/_plan/260907_axis5_display_cli/010_delivery.md b/devlog/_plan/260907_axis5_display_cli/010_delivery.md new file mode 100644 index 0000000000..b9cc7e2c4d --- /dev/null +++ b/devlog/_plan/260907_axis5_display_cli/010_delivery.md @@ -0,0 +1,37 @@ +# WP1: reconcile, deliver, verify and merge axis 5 + +Depends on WP0 roadmap audit. Source baseline dev 137d6a727. Previous D must confirm roadmap-only completion before production patches. + +## Layer 1 — #3627 native display names + +MODIFY src/codex/catalog/sync.ts: introduce reversible native label overlay at observed-state merge, restore original label before metadata normalization, strip marker from template clones, apply configured label to supported bare native rows only. MODIFY src/codex/convergence.ts: supply the same modelDisplayNames map as retained sync. MODIFY tests/codex-integration/codex-catalog.test.ts and provider configuration docs (English, Japanese, Korean, Simplified Chinese). +Field chain: existing providers.openai.modelDisplayNames config -> both merge call sites -> nativeDisplayNames argument -> display_name plus catalog-only opencodex_native_display_name {slug,original,applied} -> JSON catalog serialization -> restoration before next normalization. Clone consumers must remove overlay markers; source inputs remain immutable. +Activation: configured label replaces native name; removing/blanking restores owned original; external Sol rename is preserved; Astra remains subject to existing pinned-metadata normalization and docs/tests state that exception; newer native metadata upgrades after reset; repeated serialized cycles stable; account-qualified/combo/pro/custom rows unchanged. Exact model IDs and capabilities unchanged. +Credit: Co-authored-by: Éverton Toffanetto . + +## Layer 2 — #2716 discovered name editor + +NEW gui/src/components/ModelDisplayNameDialog.tsx and gui/tests/models-display-name-editor.test.tsx from source PR after current API contract comparison. MODIFY gui/src/pages/Models.tsx, models-shared.ts, gui/src/styles.css, all nine locale modules, English provider configuration docs. +Field chain: existing /api/models displayNameOverride/displayNameSource -> ModelRow optional fields -> Name action/dialog -> existing display-name save/reset endpoint -> persisted provider modelDisplayNames -> reload /api/models. No new persisted field or endpoint is needed. +Activation: save/reset/unchanged cancel; blank/too long/slash/control input; one submit under double click; save failure retains dialog; reload failure remains recoverable; focus returns after close; original selector always visible and alias action remains separate. +Credit: Co-authored-by: Zig Zag . +Browser smoke: render real isolated app, open Name dialog and observe screenshot; use mocked management responses or isolated disposable home, never mutate personal config. GUI tests/build/i18n/lint and docs build are remote CI obligations; not run locally. + +## Layer 3 — #3780 provider JSONL + +MODIFY src/cli/provider.ts and src/cli/capabilities.ts to accept --jsonl, emit existing configured-array objects one per line, reject combined --json/--jsonl before reading config. MODIFY tests/cli/cli-provider.test.ts, public CLI docs and skills/ocx/references/01_management_surface.md, 02_json_shapes.md, 03_recipes.md. Regenerate or reconcile derived surface with generator source; no unrelated output. +Field chain: argv -> consumeFlag -> output choice; no config serialization changes. JSONL entries use exactly existing JSON configured fields; no credentials added. The real config loader seeds providers; a zero-provider CLI scenario is not a reachable acceptance claim. Preserve existing loader behavior. Extend source tests to compare every emitted object with --json.configured for multiple registry/custom providers, ensure empty stdout on both conflicting flag orders, and verify escaping. Update all seven translated CLI provider tables and describe consumer-side line processing without claiming producer streaming. +Activation: multiple providers including custom names -> one parseable record each; default human and --json unchanged; both flags rejected; unknown args still rejected; conflicting flags -> empty stdout before config loading. +Credit: Co-authored-by: 투린 . + +## Verification and disposition + +Static git diff --check and independent source audits throughout. Existing focused test paths are reviewed for target coverage, but ALL LOCAL SUITES NOT RUN by owner instruction. Final ci.yml workflow_dispatch lane=all on published final SHA supplies typecheck, full tests and platform results; inspect actual job conclusions and head SHA. Add missing coverage within source scope if audit identifies a contract gap. Inspect GUI workflow coverage and obtain remote GUI/build evidence if not present in final dispatch. +Source-of-truth: provider configuration and CLI docs above; update structure/03_catalog-and-subagents.md only for native overlay contract. No new enforcement layer; tests/CI are evidence, admin bypass is owner-authorized and recorded. +Before merging: fresh heads and native membership, independent review dispositions, final CI proof, original author trailers, screenshot for GUI PR. If infeasible, record concrete cause and leave only that layer unmerged. After each merge: verify mergeCommit SHA and inclusion on fetched dev. Close superseded original PR only once its delivery is on dev and preserve attribution. + +Audit amendment: native label restoration preserves an external edit only subject to existing metadata normalization, notably pinned Astra replacement. Do not change native normalization policy. Add the Astra external-edit regression and qualify the promise consistently in all four affected docs. The native feature must preserve metadata including capabilities; English/Japanese wording is explicit. Final physical branch order is native -> JSONL -> GUI to activate final GUI gates; numeric sections above identify features, not alternate dependency claims. + +GUI audit amendment: confirmed persisted save/reset must reconcile editor snapshot and draft even when reload fails. A saved:true error is distinct from an unpersisted error. Stalled requests must not lock every dialog exit indefinitely: use existing UI request cancellation/deadline conventions, and represent uncertain write outcome without claiming rollback. Add focused source tests for first-save/reset plus reload failure, saved:true errors, duplicate protection and stalled cancellation. + +Plan audit synthesis (Astra high Anscombe): GO-WITH-FIXES, four blockers folded. (1) Lower layer CI is explicitly waived/deferred, never labeled passing; fresh head/base checks plus resulting tree equivalence tie admin merges to final combined evidence. (2) Removed unreachable empty-provider CLI scenario; loader behavior preserved. (3) Confirmed-persistence vs refresh state and tests required. (4) First rederive stalled-request reachability through installed global createBoundedFetch; reuse existing bound if it already applies, add no duplicate budget. Any remaining timeout scenario must be production-reachable. From 130be8dbd2b52db21f0bcced61259940b4b9246f Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:39:43 +0900 Subject: [PATCH 2/2] feat(catalog): carry native display labels with normalization contract [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry PR #3627 from f699ec7f998d56bf205db96762b821cd8c228a35, using merge base cf9f662190c4c6770697c45c870941509cc98f9c. Keep the source runtime changes and existing Astra normalization intact. Qualify external-name restoration in all four provider docs and catalog SOT. Add Astra external-edit coverage and real retained/convergence writer coverage. Local tests, typecheck and builds NOT RUN by owner mandate. Static diff inspection and git diff --check passed; remote CI belongs to parent. Co-authored-by: Éverton Toffanetto --- .../ja/reference/configuration/providers.md | 8 + .../ko/reference/configuration/providers.md | 8 + .../docs/reference/configuration/providers.md | 10 + .../reference/configuration/providers.md | 8 + src/codex/catalog/sync.ts | 46 +++- src/codex/convergence.ts | 7 + structure/03_catalog-and-subagents.md | 12 + tests/codex-integration/codex-catalog.test.ts | 210 +++++++++++++++++- 8 files changed, 306 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index d4bfc49a6f..ddbb22d666 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -370,6 +370,14 @@ Vercel AI Gateway は、1 つのモデルを複数の基盤となる推論プロ 表示名には `modelDisplayNames` を使用します。優先順位は、運用者が設定した `modelDisplayNames`、プロバイダーカタログのメタデータ、通常の `provider/model` 表示の順です。キーはこのプロバイダー内の正確なネイティブモデル ID です。例えば `xai/grok-4.6` のキーは `grok-4.6` です。ラベルは表示専用で、正確なルーティング ID や上流モデル ID を変更しません。`config.json` の既存プロバイダー設定にこのフィールドだけを追加し、他のすべてのフィールドを残してください。`PUT /api/providers/:provider/model-display-names` に `{ "modelId": "grok-4.6", "displayName": "Grok 4.6" }` を送ると保存され、`displayName: null` を送るとその名前だけがリセットされます。 +ローカル Codex カタログでサポートされるプレフィックスなしのネイティブ GPT 行にも、 +`providers.openai.modelDisplayNames` で正確な表示名を指定できます。例えば `"gpt-6-astra": "GPT 6 Astra"` です。 +起動時の同期とローカルカタログの収束処理は、どちらもこれらの名前を再適用します。名前の設定を削除すると、行の現在の表示名が +適用済みの上書きとまだ一致する場合にのみ、元のネイティブ名が復元されます。外部で変更された表示名にも既存のネイティブメタデータ正規化が適用されます。 +例えば Astra (`gpt-6-astra`) では、固定されたネイティブ名と異なる名前は引き続きその固定名に置き換えられます。 +表示名の上書きによってモデル ID、メタデータ(機能を含む)、順序、ルーティングされたコンボのエイリアス、アカウント修飾付きの行は変更されません。 +このローカルカタログの上書きは、HTTP のモデル一覧や仮想 `*-pro` 行の表示名には適用されません。 + プレビュー GPT-5.6 フォールバック エントリは同じメカニズムを使用します。 OpenAI API キー プリセットは、ベース ID と Pro ID にコンテキスト `922000` と最大入力 `922000` をシードします。 OpenRouter は、コンテキスト `922000` を持つ `openai/gpt-5.6-sol`、`openai/gpt-5.6-terra`、および `openai/gpt-5.6-luna` をシードします。プール/ダイレクトは `922000` をアドバタイズします。同期されたカタログは、`xhigh` を区別しつつ、`max` をアドバタイズします。 ```json diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index c9c4de5ede..160d313be3 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -377,6 +377,14 @@ Vercel AI Gateway는 하나의 모델을 여러 기반 추론 공급자에 걸 표시 이름은 `modelDisplayNames`로 설정합니다. 우선순위는 운영자가 설정한 `modelDisplayNames`, 공급자 카탈로그 메타데이터, 일반 `provider/model` 표시 순서입니다. 키는 이 공급자 안의 정확한 네이티브 모델 id입니다. 예를 들어 `xai/grok-4.6`의 키는 `grok-4.6`입니다. 이름은 표시 전용이며 정확한 라우팅 id나 업스트림 모델 id를 바꾸지 않습니다. `config.json`의 기존 공급자 설정에 이 필드만 추가하고 다른 모든 필드는 유지하세요. `PUT /api/providers/:provider/model-display-names`에 `{ "modelId": "grok-4.6", "displayName": "Grok 4.6" }`를 보내 저장하고, `displayName: null`을 보내 해당 이름만 초기화합니다. +로컬 Codex 카탈로그에서 지원되는 접두사 없는 네이티브 GPT 항목에도 +`providers.openai.modelDisplayNames`로 정확한 표시 이름을 지정할 수 있습니다. 예를 들어 `"gpt-6-astra": "GPT 6 Astra"`를 사용합니다. +시작 시 동기화와 로컬 카탈로그 수렴은 모두 이 이름을 다시 적용합니다. 이름 설정을 삭제하면 항목의 현재 표시 이름이 +적용된 재정의와 여전히 일치할 때만 원래 네이티브 이름을 복원합니다. 외부에서 변경된 표시 이름도 기존 네이티브 메타데이터 정규화 규칙을 따릅니다. +예를 들어 Astra (`gpt-6-astra`)는 고정된 네이티브 이름과 다른 이름을 여전히 그 고정 이름으로 교체합니다. +표시 이름 재정의는 모델 ID, 기능을 포함한 메타데이터, 정렬 순서, 라우팅된 콤보 별칭 및 계정 선택자가 붙은 항목을 바꾸지 않습니다. +이 로컬 카탈로그 재정의는 HTTP 모델 목록이나 가상 `*-pro` 항목의 이름을 바꾸지 않습니다. + 프리뷰 GPT-5.6 폴백 항목도 같은 메커니즘을 사용합니다. OpenAI API 키 프리셋은 base와 Pro id에 컨텍스트 `922000`, 최대 입력 `922000`을 채웁니다. OpenRouter는 `openai/gpt-5.6-sol`, `openai/gpt-5.6-terra`, `openai/gpt-5.6-luna`에 컨텍스트 `922000`을 채웁니다. Pool/Direct는 `922000`을 노출하고, 동기화된 카탈로그는 `xhigh`를 구분한 채 `max`를 노출합니다. ```json diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 02d5ba4323..9bd558be44 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -230,6 +230,16 @@ all other provider settings. The example includes the surrounding required field } ``` +Supported bare native GPT rows in the local Codex catalog also accept exact labels in +`providers.openai.modelDisplayNames`, for example `"gpt-6-astra": "GPT 6 Astra"`. +Both startup synchronization and local catalog convergence reapply these labels. Removing a label +restores the original native name only when the row's display name still matches the applied +override. A newer external display name is preserved subject to existing native metadata normalization; +for example, Astra (`gpt-6-astra`) still replaces a non-pinned name with its pinned native name. +The label overlay leaves model IDs, metadata (including capabilities), ordering, +routed combo aliases, and account-qualified rows unchanged. This local catalog override does +not relabel the HTTP model listings or virtual `*-pro` rows. + The effective label order is operator `modelDisplayNames`, then provider catalog metadata, then the normal `provider/model` fallback. The routed selector remains `xai/grok-4.6`, while the upstream wire model remains `grok-4.6`. Labels are display only. They do not change authentication, adapter diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index b7339cd4c2..f121d67bc0 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -371,6 +371,14 @@ Vercel AI Gateway 可以在多个底层推理提供者之间路由一个模型 请使用 `modelDisplayNames` 设置显示名称。优先顺序是操作者设置的 `modelDisplayNames`、提供者目录元数据,然后是普通的 `provider/model` 显示。键是此提供者内精确的原生模型 id,例如 `xai/grok-4.6` 的键是 `grok-4.6`。名称只改变显示,不会改变精确路由 id 或上游模型 id。请只把此字段加入 `config.json` 中现有的提供者设置,并保留所有其他字段。向 `PUT /api/providers/:provider/model-display-names` 发送 `{ "modelId": "grok-4.6", "displayName": "Grok 4.6" }` 可保存名称,发送 `displayName: null` 只重置该名称。 +本地 Codex 目录中受支持的不带前缀的原生 GPT 条目也可以通过 +`providers.openai.modelDisplayNames` 设置精确的显示名称, 例如 `"gpt-6-astra": "GPT 6 Astra"`。 +启动时同步和本地目录收敛都会重新应用这些名称。删除名称设置时, 只有条目的当前显示名称仍与已应用的覆盖值一致, +才会恢复原始原生名称。外部更改的显示名称仍受现有原生元数据规范化规则约束。 +例如,Astra (`gpt-6-astra`) 仍会将不同于固定原生名称的名称替换为该固定名称。 +显示名称覆盖不会改变模型 ID、元数据(包括能力)、排序、路由组合别名和带账户限定的条目。 +此本地目录覆盖不会重命名 HTTP 模型列表中的条目或虚拟 `*-pro` 条目。 + 预览版 GPT-5.6 回退条目使用相同机制。OpenAI API key 预设会为基础和 Pro id 设定 `922000` 上下文和 `922000` 最大输入;OpenRouter 会为 `openai/gpt-5.6-sol`、`openai/gpt-5.6-terra` 和 `openai/gpt-5.6-luna` 设定 `922000` 上下文。Pool/Direct 会声明 `922000`;同步后的目录会声明 `max`,同时保留 `xhigh` 的独立性。 ```json diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 22d235dbcc..972b6d74c6 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -307,6 +307,11 @@ function routedDisplayName(slug: string, model?: CatalogModel, config?: Pick; + if (recoverableNativeSlug(entry) === label.slug + && typeof label.original === "string" && entry.display_name === label.applied) { + entry.display_name = label.original; + } + } + return entry; +} + /** Append missing supported native rows from trusted catalog sources only. */ export function mergeCatalogModelsWithNativeRecovery( primaryCatalogModels: readonly RawEntry[], @@ -862,6 +882,8 @@ export interface ObservedCatalogMergeInput { readonly suppressedBareNativeSlugs?: ReadonlySet; readonly policy: ObservedCatalogMergePolicy; readonly openaiContextCap?: NativeContextLimitsInput; + /** Exact display-only labels for bare native OpenAI models. */ + readonly nativeDisplayNames?: Readonly>; } /** @@ -896,12 +918,14 @@ export function mergeCatalogEntriesFromObservedState({ suppressedBareNativeSlugs = new Set(), policy, openaiContextCap, + nativeDisplayNames, }: ObservedCatalogMergeInput): RawEntry[] { // Raw catalog rows contain nested arrays/objects that normalization mutates. Detach every row at // the observed-core boundary so callers can safely retain evidence objects or repeat the merge. - const detachedCatalogModels = catalogModels.map(entry => structuredClone(entry) as RawEntry); + const detachedCatalogModels = catalogModels + .map(entry => restoreNativeDisplayName(structuredClone(entry) as RawEntry)); const detachedBaselineCatalogModels = baselineCatalogModels - .map(entry => structuredClone(entry) as RawEntry); + .map(entry => restoreNativeDisplayName(structuredClone(entry) as RawEntry)); const detachedRoutedEntries = routedEntries.map(entry => structuredClone(entry) as RawEntry); // Track this invocation's generated custom rows, not ownership markers read from disk. // Their builder already finalized exact native ladders and ordinary routed mock tiers. @@ -1256,6 +1280,17 @@ export function mergeCatalogEntriesFromObservedState({ ); applyFullModelPickerOrder(versionedEntries, modelPickerOrder); for (const entry of versionedEntries) { + // Templates and account clones must not inherit the native row's overlay marker. + delete entry.opencodex_native_display_name; + const slug = recoverableNativeSlug(entry); + if (slug !== null) { + const label = nativeDisplayNames && Object.hasOwn(nativeDisplayNames, slug) + ? nativeDisplayNames[slug]?.trim() : undefined; + if (label && label !== entry.display_name) { + entry.opencodex_native_display_name = { slug, original: entry.display_name, applied: label }; + entry.display_name = label; + } + } const kind = entry.opencodex_catalog_kind; if (trustedAccountBoundNativeCatalogSlug(entry) === undefined && kind !== CODEX_CUSTOM_MODEL_CATALOG_KIND @@ -1659,6 +1694,12 @@ export function finalizeAutoReviewModelOverride( return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels); } +/** + * Mescla o catálogo retido com os modelos visíveis e as configurações atuais, + * incluindo os nomes nativos. Tenta preservar o backup original e usa a permissão + * de escrita para publicar o resultado apenas se os bytes mudarem, retornando + * a contagem de entradas roteadas e por conta, o caminho e o estado da gravação. + */ function writeRetainedCatalogSync({ config, goModels, @@ -1880,6 +1921,7 @@ function writeRetainedCatalogSync({ accountBoundEntries, suppressedBareNativeSlugs, openaiContextCap, + nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs], diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 8b30bb9eb2..2b8a8512c6 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -226,6 +226,12 @@ function bindGatherPaths( }; } +/** + * Prepara um candidato de catálogo para convergência sem gravá-lo em disco. + * Clona a fonte e mescla as observações nativas, os modelos roteados e por conta, + * aplicando a configuração, inclusive nomes nativos, e os limites de raciocínio + * observados no runtime antes de retornar o catálogo resultante. + */ function prepareCatalog( config: Readonly, source: Extract, @@ -366,6 +372,7 @@ function prepareCatalog( accountBoundEntries, suppressedBareNativeSlugs, openaiContextCap, + nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs], diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index f9f46a0653..86aa4a81a7 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -145,6 +145,18 @@ then trusted catalog metadata such as a configured qualified provider/model alia This overlay never changes route identity or the upstream wire model, and its catalog fingerprint makes a label edit refresh Codex output. +Supported bare native GPT rows also consume `providers.openai.modelDisplayNames`. Retained sync +and convergence pass the same map to the observed-state merge. After native normalization and +ordering, the merge applies the exact nonblank trimmed label and saves +`opencodex_native_display_name: { slug, original, applied }` in the local catalog only. The next +merge detaches its inputs, removes that marker, and restores `original` only if the native slug +still matches and the current name equals `applied`. Removing or blanking the override therefore +restores the owned name before normal native metadata upgrades. Divergent external names remain +subject to those upgrades: Astra still replaces non-pinned names with its pinned native name. +Template-derived rows discard the marker. The overlay leaves model IDs, metadata (including +capabilities), ordering, routed combo aliases, custom rows and account-qualified rows unchanged; +it does not relabel HTTP model listings or virtual `*-pro` rows. + ## Native passthrough Astra has its own pinned native row: 272,000 default context, 872,000 opt-in ceiling, diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 5820edc6dd..bb3c8a880f 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync} from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { codexAccountGatedCanonicalWireModel } from "../../src/server/responses/core"; @@ -53,9 +53,17 @@ import { import { CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, mergeCatalogEntriesFromObservedState, + syncCatalogModels, type ObservedCatalogMergeInput, } from "../../src/codex/catalog/sync"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { saveConfig } from "../../src/config"; +import { SUBAGENT_MODELS_VERSION } from "../../src/config/subagent-models"; +import { captureCatalogAdmissionSnapshot } from "../../src/codex/catalog-admission"; +import { convergeCodexCatalog } from "../../src/codex/convergence"; +import { resetCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; const originalFetch = globalThis.fetch; @@ -3045,7 +3053,207 @@ function mergeObservedForTest( }); } +// Exercise both production callers: removing either caller's nativeDisplayNames argument +// must fail the persisted-label assertion, even if the pure merge tests still pass. +test.each(["retained", "convergence"] as const)("%s persists and restores native labels through the catalog writer", async writer => { + const envKeys = ["CODEX_HOME", "OPENCODEX_HOME", "CODEX_CLI_PATH"] as const; + const previousEnv = envKeys.map(key => process.env[key]); + const previousFetch = globalThis.fetch; + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-native-label-writer-"))); + const codexHome = join(root, "codex"); + const catalogPath = join(codexHome, "custom-catalog.json"); + let fetchCalls = 0; + try { + mkdirSync(codexHome); + mkdirSync(join(root, "ocx")); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = join(root, "ocx"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "custom-catalog.json"\n'); + const catalog = { models: [{ ...nativeTemplate(), slug: "gpt-5.6-sol", display_name: "Fixture Sol" }] }; + // Reuse the executable-fixture protocol from catalog-full-picker-order.test.ts so + // admission and a forced runtime refresh observe the same version and bundled rows. + const script = join(root, "fixture-codex.js"); + writeFileSync(script, [ + 'if (process.argv.includes("--version")) console.log("codex-cli 0.145.0");', + `else process.stdout.write(${JSON.stringify(JSON.stringify(catalog))});`, + ].join("\n")); + if (process.platform === "win32") { + process.env.CODEX_CLI_PATH = join(root, "fixture-codex.cmd"); + writeFileSync(process.env.CODEX_CLI_PATH, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); + } else { + process.env.CODEX_CLI_PATH = join(root, "fixture-codex"); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + writeFileSync(process.env.CODEX_CLI_PATH, `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(script)} "$@"\n`); + chmodSync(process.env.CODEX_CLI_PATH, 0o755); + } + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); + expect(loadBundledCodexCatalog()?.models?.[0]?.slug).toBe("gpt-5.6-sol"); + writeFileSync(catalogPath, JSON.stringify(catalog)); + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("native label writer fixture must not make a network request"); + }) as typeof fetch; + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", + subagentModels: [], subagentModelsVersion: SUBAGENT_MODELS_VERSION, + providers: { + openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward" }, + }, + }; + const write = async (labels?: Record) => { + if (labels) config.providers.openai!.modelDisplayNames = labels; + else delete config.providers.openai!.modelDisplayNames; + saveConfig(config); + if (writer === "convergence") { + const result = await convergeCodexCatalog(captureCatalogAdmissionSnapshot(config), { + action: "converge", scope: "catalog", reason: "management-mutation", mode: "explicit", deadlineMs: 5_000, + }); + expect(result.catalogRefresh.status).toBe("committed"); + } else { + const result = await syncCatalogModels(config); + expect(result.path).toBe(catalogPath); + expect(result.skippedReason).toBeUndefined(); + } + return (JSON.parse(readFileSync(catalogPath, "utf8")) as { models: Record[] }).models; + }; + const original = await write(); + const renamed = await write({ "gpt-5.6-sol": "Custom Sol" }); + const renamedBytes = readFileSync(catalogPath, "utf8"); + const native = renamed.find(row => row.slug === "gpt-5.6-sol")!; + expect(native.display_name).toBe("Custom Sol"); + expect(native.opencodex_native_display_name).toEqual({ + slug: "gpt-5.6-sol", original: "Fixture Sol", applied: "Custom Sol", + }); + const { opencodex_native_display_name: marker, ...withoutMarker } = native; + expect(marker).toBeDefined(); + expect({ ...withoutMarker, display_name: "Fixture Sol" }) + .toEqual(original.find(row => row.slug === "gpt-5.6-sol")!); + expect(await write({ "gpt-5.6-sol": "Custom Sol" })).toEqual(renamed); + expect(readFileSync(catalogPath, "utf8")).toBe(renamedBytes); + expect((await write({ "gpt-5.6-sol": "Changed Sol" })).find(row => row.slug === "gpt-5.6-sol")?.display_name) + .toBe("Changed Sol"); + expect(await write()).toEqual(original); + expect(fetchCalls).toBe(0); + } finally { + try { + const database = resolveCodexCatalogSerializationDatabasePath(resolveEffectiveUserIdentity(), codexHome); + for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(`${database}${suffix}`, { force: true }); + } finally { + globalThis.fetch = previousFetch; + envKeys.forEach((key, index) => { + const value = previousEnv[index]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }); + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); + removeTreeWithRetry(root); + } + } +}, 30_000); + describe("Codex catalog routed normalization", () => { + test("reapplies native display names after repeated catalog merges without changing model metadata", () => { + const input = { + catalogModels: [{ ...nativeTemplate(), slug: "gpt-5.6-sol" }], + routedEntries: [], + }; + const original = mergeObservedForTest(input); + const labels = { "gpt-5.6-sol": "GPT 5.6 Sol" }; + const renamed = mergeObservedForTest({ ...input, nativeDisplayNames: labels }); + const row = renamed.find(entry => entry.slug === "gpt-5.6-sol")!; + expect(row.display_name).toBe("GPT 5.6 Sol"); + expect({ ...row, display_name: undefined, opencodex_native_display_name: undefined }).toEqual({ + ...original.find(entry => entry.slug === "gpt-5.6-sol"), display_name: undefined, + }); + const regenerated = mergeObservedForTest({ + ...input, catalogModels: renamed, nativeDisplayNames: labels, + }); + expect(regenerated.find(entry => entry.slug === "gpt-5.6-sol")?.display_name).toBe("GPT 5.6 Sol"); + const changed = mergeObservedForTest({ + ...input, catalogModels: regenerated, + nativeDisplayNames: { "gpt-5.6-sol": " Sol 5.6 " }, + }); + expect(changed.find(entry => entry.slug === "gpt-5.6-sol")?.display_name).toBe("Sol 5.6"); + expect(JSON.stringify(regenerated)).toBe(JSON.stringify(renamed)); + for (const nativeDisplayNames of [undefined, {}, { "gpt-5.6-sol": " " }]) { + const restored = mergeObservedForTest({ ...input, catalogModels: changed, nativeDisplayNames }); + expect(restored).toEqual(original); + } + }); + + test("native display names preserve external label changes when clearing the overlay", () => { + const renamed = mergeObservedForTest({ + catalogModels: [{ ...nativeTemplate(), slug: "gpt-5.6-sol" }], routedEntries: [], + nativeDisplayNames: { "gpt-5.6-sol": "Custom Sol" }, + }); + renamed.find(entry => entry.slug === "gpt-5.6-sol")!.display_name = "Updated upstream Sol"; + const restored = mergeObservedForTest({ catalogModels: renamed, routedEntries: [] }); + const row = restored.find(entry => entry.slug === "gpt-5.6-sol")!; + expect(row.display_name).toBe("Updated upstream Sol"); + expect(row.opencodex_native_display_name).toBeUndefined(); + }); + + test("native display names preserve pinned metadata upgrades and restore pinned names", () => { + for (const slug of ["gpt-5.6-sol", "gpt-6-astra"]) { + const input = { catalogModels: [{ ...nativeTemplate(), slug, display_name: slug }], routedEntries: [] }; + const original = mergeObservedForTest(input); + const renamed = mergeObservedForTest({ ...input, nativeDisplayNames: { [slug]: "Custom name" } }); + expect(renamed.find(entry => entry.slug === slug)?.display_name).toBe("Custom name"); + expect(mergeObservedForTest({ catalogModels: renamed, routedEntries: [] })).toEqual(original); + } + }); + + test("clearing a native label keeps Astra external edits subject to pinned metadata normalization", () => { + const original = mergeObservedForTest({ + catalogModels: [{ ...nativeTemplate(), slug: "gpt-6-astra", display_name: "gpt-6-astra" }], + routedEntries: [], + }); + const renamed = mergeObservedForTest({ + catalogModels: original, routedEntries: [], + nativeDisplayNames: { "gpt-6-astra": "Custom Astra" }, + }); + const external = JSON.parse(JSON.stringify(renamed)) as Record[]; + const astra = external.find(entry => entry.slug === "gpt-6-astra")!; + astra.display_name = "External Astra name"; + astra.context_window = 123; + const restored = mergeObservedForTest({ catalogModels: external, routedEntries: [] }); + const row = restored.find(entry => entry.slug === "gpt-6-astra")!; + expect(row).toEqual(original.find(entry => entry.slug === "gpt-6-astra")!); + expect(row.display_name).not.toBe("External Astra name"); + expect(row.context_window).toBe(272_000); + expect(row.opencodex_native_display_name).toBeUndefined(); + expect(astra.display_name).toBe("External Astra name"); + expect(astra.opencodex_native_display_name).toBeDefined(); + }); + + test("native display names do not leak overlay markers through catalog templates", () => { + const template = { + ...nativeTemplate(), + opencodex_native_display_name: { slug: "gpt-5.6-sol", original: "Sol", applied: "Custom" }, + }; + const entries = buildCatalogEntries(template, ["gpt-5.5"], [{ provider: "local", id: "qwen3-coder" }]); + expect(entries.length).toBeGreaterThanOrEqual(2); + for (const entry of entries) expect(entry.opencodex_native_display_name).toBeUndefined(); + expect(template.opencodex_native_display_name).toBeDefined(); + }); + + test("native display names do not relabel a routed combo occupying a native slug", () => { + const routed = { + ...nativeTemplate(), slug: "gpt-5.6-sol", display_name: "My combo", + owned_by: "combo", description: "Routed via opencodex → combo (combo).", + opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND, + }; + const rows = mergeObservedForTest({ + catalogModels: [], routedEntries: [routed], + nativeDisplayNames: { "gpt-5.6-sol": "GPT 5.6 Sol" }, + }); + expect(rows.find(entry => entry.slug === "gpt-5.6-sol")?.display_name).toBe("My combo"); + }); + test("pending re-registration cannot recover ON rows from a degraded old catalog", () => { const old = { ...nativeTemplate(), slug: "vendor/model-0", owned_by: "vendor", opencodex_catalog_kind: CODEX_PROVIDER_MODEL_CATALOG_KIND }; const input = {