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 fd369105d9..bd6555f6bf 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -68,6 +68,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | `models?` | `string[]` |シード/フォールバック モデルのリスト。 `liveModels: false` では、発見されたモデルはこれらのみです。 | | `liveModels?` | `boolean` |開始/同期時にライブ カタログをフェッチします (デフォルトは `true`)。カスタムプロバイダーは `${baseUrl}/models` を使用します。組み込みはレジストリ URL とフィルターを使用する場合があります。 | | `selectedModels?` | `string[]` |検出後のカタログ許可リスト。空でない場合は、それらの ID のみが公開されます。空または省略すると、検出されたすべてのモデルが公開されます。 | +| `modelDisplayNames?` | `Record` | このプロバイダーの正確なネイティブモデル ID をキーにした、永続的な表示専用ラベルです。大文字と小文字は区別されます。ラベルはプロバイダーカタログのメタデータより優先され、認証、アダプター、ルーティング、課金、上流リクエストには影響しません。マップは検出上限と同じ 2,000 件までです。 | | `contextWindow?` | `number` | アップストリームのメタデータが無い場合に使うプロバイダー全体のコンテキスト値。メタデータがある場合は上限として働き、より小さいライブ値をそのまま残します。Models ダッシュボードでは `providerContextCaps` とは別に設定します。 | | `modelContextWindows?` | `Record` | モデルごとのコンテキスト値および上限。`contextWindow` より優先され、ウィンドウが不明なら設定値を使い、より小さいライブメタデータがあればそちらが優先されます。 | | `modelInputModalities?` | `Record` | `["text"]` や `["text", "image"]` などのモデルごとの入力ヒント。 | @@ -338,6 +339,8 @@ Vercel AI Gateway は、1 つのモデルを複数の基盤となる推論プロ 検出を実行する必要があるが、選択した ID のみが Codex および `/v1/models` に表示される必要がある場合は、`selectedModels` を使用します。ダッシュボードには、後で許可リストを変更できるように、検出された完全なリストが保持されます。 +表示名には `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` を送るとその名前だけがリセットされます。 + プレビュー 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 7c599e9b8f..76df0bdb80 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -68,6 +68,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | `models?` | `string[]` | 시드/폴백 모델 목록입니다. `liveModels: false`이면 이 목록만 발견된 모델로 취급합니다. | | `liveModels?` | `boolean` | 시작 또는 동기화 시 라이브 카탈로그를 가져옵니다. 기본값은 `true`입니다. 사용자 지정 공급자는 `${baseUrl}/models`를 사용하고, 내장은 레지스트리 URL을 사용한 뒤 필터링할 수 있습니다. | | `selectedModels?` | `string[]` | 발견 후 카탈로그 허용 목록입니다. 값이 비어 있지 않으면 그 id만 노출하고, 비어 있거나 생략하면 발견된 모델을 모두 노출합니다. | +| `modelDisplayNames?` | `Record` | 이 공급자의 정확한 네이티브 모델 id를 키로 쓰는 영구 표시 전용 이름입니다. 키는 대소문자를 구분합니다. 이름은 공급자 카탈로그 메타데이터보다 우선하며 인증, 어댑터, 라우팅, 청구 또는 업스트림 요청을 바꾸지 않습니다. 맵은 발견 한도와 같은 최대 2,000개 항목을 가질 수 있습니다. | | `contextWindow?` | `number` | 업스트림 메타데이터가 없을 때 쓰이는 공급자 전반의 컨텍스트 값입니다. 메타데이터가 있으면 상한으로 동작해 더 작은 라이브 값을 그대로 둡니다. Models 대시보드에서 `providerContextCaps`와 별도로 설정합니다. | | `modelContextWindows?` | `Record` | 모델별 컨텍스트 값이자 상한입니다. `contextWindow`보다 우선하며, 창 크기를 알 수 없으면 설정값을 쓰고 더 작은 라이브 메타데이터가 있으면 그쪽을 따릅니다. | | `modelInputModalities?` | `Record` | `["text"]` 또는 `["text", "image"]` 같은 모델별 입력 힌트입니다. | @@ -345,6 +346,8 @@ Vercel AI Gateway는 하나의 모델을 여러 기반 추론 공급자에 걸 `selectedModels`는 발견은 계속하되, 선택된 id만 Codex와 `/v1/models`에 나타나게 하고 싶을 때 사용합니다. 대시보드는 나중에 허용 목록을 바꿀 수 있도록 발견된 전체 목록을 보관합니다. +표시 이름은 `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`을 보내 해당 이름만 초기화합니다. + 프리뷰 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 d666ca972d..30a1b40002 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -82,6 +82,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, these are the only discovered models. | | `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. | | `selectedModels?` | `string[]` | Catalog allowlist after discovery. Non-empty exposes only those ids; empty or omitted exposes all discovered models. | +| `modelDisplayNames?` | `Record` | Durable labels used only for display, keyed by this provider's exact upstream model id. Labels win over provider catalog metadata, survive discovery refreshes and provider edits, and never change authentication, adapter behavior, routing, billing, upstream request construction, the routed `provider/model` selector, or the upstream wire model. Keys are exact and case sensitive. Unknown model ids are kept so a temporarily missing model receives its label when it returns. The map accepts at most 2,000 entries, matching the discovery limit. | | `contextWindow?` | `number` | Provider-wide context fallback when upstream metadata is absent; otherwise a cap that retains smaller live metadata. The Models dashboard exposes this separately from `providerContextCaps`. | | `modelContextWindows?` | `Record` | Per-model context fallbacks/caps. These override `contextWindow`: an unknown window uses the configured value, while smaller live metadata remains authoritative. | | `modelInputModalities?` | `Record` | Per-model input hints such as `["text"]` or `["text", "image"]`. | @@ -138,6 +139,36 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `unsafeAllowNativeLocalExec?` | `boolean` | Cursor legacy boolean, equivalent to `nativeLocalExec: "on"` only when the newer field is unset. | | `nativeLocalExec?` | `"off" \| "codex-sandbox" \| "on"` | Cursor local-exec policy. `off` is default; `codex-sandbox` currently fails closed like `off`. | +### Discovered model display names + +Use `modelDisplayNames` when a provider returns machine friendly ids but the Codex model picker +needs shorter labels. The map belongs to one provider, so the same model id can have a different +label under another provider. Add the field to the existing provider row in `config.json` and keep +all other provider settings. The example includes the surrounding required fields for context: + +```json +{ + "providers": { + "xai": { + "adapter": "openai-chat", + "baseUrl": "https://api.x.ai/v1", + "modelDisplayNames": { + "grok-4.6": "Grok 4.6" + } + } + } +} +``` + +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 +behavior, routing, billing, or upstream request construction. Removing a map entry resets only its +label. A management client can set or reset one label with +`PUT /api/providers/:provider/model-display-names` and a body of +`{ "modelId": "grok-4.6", "displayName": "Grok 4.6" }`; send `displayName: null` to reset it. +Provider `PATCH` does not edit this map. Use this dedicated `PUT` endpoint to change or remove labels. + ## Codex catalog and root `config.toml` settings These settings belong in the root of `$CODEX_HOME/config.toml`, alongside diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index c013b96855..2bdd9c39e5 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -81,6 +81,7 @@ cross-route credential fallback не существует. Строки API GPT- | `models?` | `string[]` | Seed/fallback-список моделей. При `liveModels: false` это и есть единственный список обнаруженных моделей. | | `liveModels?` | `boolean` | Получать live-каталог на start/sync (по умолчанию `true`). Custom-провайдеры используют `${baseUrl}/models`; built-in могут использовать registry URL и дополнительно фильтровать результат. | | `selectedModels?` | `string[]` | Allowlist каталога после discovery. Непустой список показывает только эти id; пустой или отсутствующий показывает всё, что было обнаружено. | +| `modelDisplayNames?` | `Record` | Постоянные display-only имена с точным нативным id модели этого провайдера в качестве ключа. Ключи чувствительны к регистру. Имена имеют приоритет над metadata каталога провайдера и не меняют аутентификацию, adapter, routing, billing или upstream-запросы. Карта содержит не более 2 000 записей, как и discovery. | | `contextWindow?` | `number` | Значение контекста для всего провайдера, применяемое когда upstream не отдаёт metadata; при наличии metadata работает как cap и сохраняет более маленькое live-значение. Панель Models настраивает его отдельно от `providerContextCaps`. | | `modelContextWindows?` | `Record` | Значения и cap'ы контекста по отдельным моделям. Перекрывают `contextWindow`: если окно неизвестно, берётся заданное значение, а более маленькая live-metadata остаётся авторитетной. | | `modelInputModalities?` | `Record` | Подсказки modality по модели, например `["text"]` или `["text", "image"]`. | @@ -430,6 +431,15 @@ malformed-результаты откатываются к stale/configured fall должны появляться только избранные id. Дашборд всё равно сохраняет полный обнаруженный список для дальнейших изменений allowlist'а. +Используйте `modelDisplayNames` для отображаемых имён. Порядок приоритета: заданное оператором +`modelDisplayNames`, metadata каталога провайдера, затем обычная подпись `provider/model`. Ключом +служит точный нативный id модели внутри этого провайдера: для `xai/grok-4.6` это `grok-4.6`. +Имя влияет только на отображение и не меняет точный routing id или upstream model id. Добавляйте +это поле в существующую запись провайдера в `config.json`, сохраняя все остальные поля. Отправьте +`{ "modelId": "grok-4.6", "displayName": "Grok 4.6" }` в +`PUT /api/providers/:provider/model-display-names`, чтобы сохранить имя, или `displayName: null`, +чтобы сбросить только это имя. + Preview fallback-записи GPT-5.6 используют тот же механизм. Preset OpenAI API-key заранее засевает base- и Pro-id с context `922000` и max input `922000`; OpenRouter заранее засевает `openai/gpt-5.6-sol`, `openai/gpt-5.6-terra` и `openai/gpt-5.6-luna` с context `922000`. 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 754e77e220..b281e8368b 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 @@ -68,6 +68,7 @@ selector,而不是分配一个新名称。 | `models?` | `string[]` | 种子/回退模型列表。配合 `liveModels: false` 时,这些就是唯一发现到的模型。 | | `liveModels?` | `boolean` | 启动/同步时获取实时目录(默认 `true`)。自定义提供者使用 `${baseUrl}/models`;内置项可能使用注册表 URL 并进行过滤。 | | `selectedModels?` | `string[]` | 发现之后的目录允许列表。非空时只暴露这些 id;为空或省略时则暴露全部发现到的模型。 | +| `modelDisplayNames?` | `Record` | 持久的仅显示名称,以此提供者的精确原生模型 id 为键。键区分大小写。名称优先于提供者目录元数据,并且不会改变身份验证、适配器、路由、计费或上游请求。该映射最多可包含 2,000 个条目,与发现上限相同。 | | `contextWindow?` | `number` | 上游缺少元数据时使用的提供者级上下文数值;有元数据时作为上限,保留更小的实时数值。Models 面板中与 `providerContextCaps` 分开设置。 | | `modelContextWindows?` | `Record` | 按模型设置的上下文数值与上限。优先于 `contextWindow`:窗口未知时采用所配置的数值,而更小的实时元数据仍然优先。 | | `modelInputModalities?` | `Record` | 按模型设置的输入提示,例如 `["text"]` 或 `["text", "image"]`。 | @@ -341,6 +342,8 @@ Vercel AI Gateway 可以在多个底层推理提供者之间路由一个模型 当需要继续运行发现,但只有选定 id 应该出现在 Codex 和 `/v1/models` 中时,请使用 `selectedModels`。仪表板会保留完整的已发现列表,以便之后调整允许列表。 +请使用 `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` 只重置该名称。 + 预览版 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/docs/superpowers/plans/2026-08-26-discovered-model-display-names-core.md b/docs/superpowers/plans/2026-08-26-discovered-model-display-names-core.md new file mode 100644 index 0000000000..35ca7d61da --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-discovered-model-display-names-core.md @@ -0,0 +1,477 @@ +# Discovered Model Display Names Core Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add durable provider scoped display names for discovered models, with safe config handling, catalog propagation, and a management API, without changing routing identity. + +**Architecture:** Store operator labels in `providers..modelDisplayNames`, keyed by the exact native model ID. Apply the label at the shared provider catalog hint boundary, expose the effective name and source in management rows, and mutate one label through a provider scoped API route that persists safely and converges the Codex catalog. + +**Tech Stack:** Bun, TypeScript, Zod, Bun test, OpenCodex management API, Astro documentation. + +## Global Constraints + +- Base all work on the latest `upstream/dev` commit. +- Keep native provider IDs, model IDs, routed slugs, aliases, pricing, effort metadata, context metadata, modalities, fallbacks, and outbound requests unchanged. +- Operator display names take precedence over trusted provider metadata, which takes precedence over the existing fallback. +- Unknown or temporarily absent model IDs remain stored. +- A reset removes only the selected entry. +- Invalid hand edits degrade entry by entry and must not remove the provider. +- Management writes restore the in memory state if persistence fails. +- Catalog convergence runs exactly once after a successful persistence. +- Do not read or modify the user's live OpenCodex config, credentials, or Codex catalog. +- Write every production behavior test first and observe the expected failure. +- The core pull request and dashboard pull request remain separate. +- Do not push or open a pull request until the user sees the verified result. + +--- + +## File Map + +- `src/types/provider.ts`: declares the provider scoped display name map. +- `src/config/provider-validation.ts`: validates exact model ID keys and safe display values. +- `src/config.ts`: adds schema validation and safe load degradation. +- `src/codex/catalog/provider-fetch.ts`: resolves operator display names at the shared catalog boundary. +- `src/server/management/model-rows.ts`: exposes effective display names and their source. +- `src/server/management/model-routes.ts`: sets and resets one provider model display name. +- `tests/provider-config-validation.test.ts`: covers strict validator behavior. +- `tests/config-load-degrade.test.ts`: covers safe hand edited config loading. +- `tests/config-user-edits.test.ts`: covers persistence and concurrent unrelated map edits. +- `tests/codex-catalog.test.ts`: covers label precedence and routing invariants. +- `tests/model-display-names-management-api.test.ts`: covers read and mutation API behavior. +- `docs-site/src/content/docs/reference/configuration/providers.md`: documents the field and exact key rules. +- `structure/02_config-and-codex-home.md`: records the new persisted provider field. +- `structure/03_catalog-and-subagents.md`: records display precedence at catalog assembly. + +--- + +### Task 1: Provider Config Contract + +**Files:** +- Modify: `src/types/provider.ts` +- Modify: `src/config/provider-validation.ts` +- Modify: `src/config.ts` +- Test: `tests/provider-config-validation.test.ts` +- Test: `tests/config-load-degrade.test.ts` + +**Interfaces:** +- Produces: `OcxProviderConfig.modelDisplayNames?: Record` +- Produces: `modelDisplayNamesConfigError(value: unknown, field?: string): string | null` +- Produces: load normalization that trims valid labels and removes only invalid entries. + +- [ ] **Step 1: Add failing strict validation tests** + +Add table driven tests that call `modelDisplayNamesConfigError` directly. The valid cases are an absent map, an empty plain map, native IDs containing `/`, and a trimmed label up to 128 characters. The invalid cases are an array, a class or prototype shaped object, more than 2,000 entries, blank keys, keys longer than 1,024 characters, nonstring values, blank values, values longer than 128 characters, `/`, and control characters. + +Use literal expectations such as: + +```ts +expect(modelDisplayNamesConfigError({ "models/grok-4.6": "Grok 4.6" })).toBeNull(); +expect(modelDisplayNamesConfigError({ "grok-4.6": "Grok/4.6" })).toContain("must not contain /"); +expect(modelDisplayNamesConfigError({ "grok-4.6": "Grok\n4.6" })).toContain("control characters"); +``` + +- [ ] **Step 2: Run strict tests and confirm RED** + +Run: + +```text +bun test tests/provider-config-validation.test.ts +``` + +Expected: failure because `modelDisplayNamesConfigError` does not exist. + +- [ ] **Step 3: Implement the minimal validator and type** + +Add this field beside `modelAliases`: + +```ts +/** Display-only labels for exact native model ids discovered under this provider. */ +modelDisplayNames?: Record; +``` + +Implement one pure validator using `MODEL_DISCOVERY_MAX_MODELS` and `isValidModelDiscoveryModelId` from `src/providers/model-discovery-limits.ts`: + +```ts +export function modelDisplayNamesConfigError( + value: unknown, + field = "modelDisplayNames", +): string | null; +``` + +The validator accepts only a plain own property object, at most 2,000 entries, exact valid model IDs no longer than 1,024 characters, and string labels whose trimmed form is 1 through 128 characters with no `/` or control characters. + +- [ ] **Step 4: Run strict tests and confirm GREEN** + +Run: + +```text +bun test tests/provider-config-validation.test.ts +``` + +Expected: all tests pass. + +- [ ] **Step 5: Add failing schema and load degradation tests** + +Add tests proving: + +```ts +expect(validateConfigCandidate(validConfigWithNames).ok).toBe(true); +expect(validateConfigCandidate(configWithBlankName).ok).toBe(false); +``` + +Write a real config file fixture containing one valid and one invalid label. Assert that `loadConfig()` keeps the provider and its unrelated fields, trims the valid label, removes the invalid entry, and logs no raw value or secret shaped provider name. Also test a nonobject map and a future absent model ID. + +- [ ] **Step 6: Run config tests and confirm RED** + +Run: + +```text +bun test tests/config-load-degrade.test.ts tests/provider-config-validation.test.ts +``` + +Expected: the candidate accepts unvalidated values or the load path does not sanitize them. + +- [ ] **Step 7: Add schema refinement and safe load sanitizer** + +Declare the field in `providerConfigSchema`: + +```ts +modelDisplayNames: z.record(z.string(), z.string()).optional(), +``` + +Call `modelDisplayNamesConfigError` in the outer provider refinement and report the redacted path: + +```ts +["providers", redactSecretString(name), "modelDisplayNames"] +``` + +Add `sanitizeModelDisplayNamesForLoad(parsed)` before `configSchema.safeParse(parsed)`. It must delete a malformed whole map, remove invalid entries one at a time, trim valid values, omit an empty map, and log only redacted provider names and JSON escaped model IDs. It must never log label values. + +- [ ] **Step 8: Run config tests and confirm GREEN** + +Run: + +```text +bun test tests/config-load-degrade.test.ts tests/provider-config-validation.test.ts +``` + +Expected: all tests pass with no unexpected warnings. + +- [ ] **Step 9: Commit the config contract** + +```text +git add src/types/provider.ts src/config/provider-validation.ts src/config.ts tests/provider-config-validation.test.ts tests/config-load-degrade.test.ts +git commit -m "feat(config): add discovered model display names" +``` + +--- + +### Task 2: Catalog Display Precedence + +**Files:** +- Modify: `src/codex/catalog/provider-fetch.ts` +- Test: `tests/codex-catalog.test.ts` + +**Interfaces:** +- Consumes: `OcxProviderConfig.modelDisplayNames` +- Produces: `configuredModelDisplayName(provider, modelId): string | undefined` +- Produces: `applyProviderConfigHints` with operator first display precedence. + +- [ ] **Step 1: Add failing catalog behavior tests** + +Add focused tests that create real `CatalogModel` inputs and assert: + +```ts +const output = applyProviderConfigHints("xai", provider, discovered); +expect(output.id).toBe("grok-4.6"); +expect(catalogModelSlug(output)).toBe("xai/grok-4.6"); +expect(output.displayName).toBe("Grok 4.6"); +``` + +Cover exact case sensitive matching, same native ID under two providers, operator override over provider metadata, metadata fallback when the override is absent, reset fallback, discovery success, stale cache fallback, configured fallback after discovery failure, and repeated gathers. Compare all non display fields before and after, including cost, context, max input, compact limit, modalities, efforts, service tier, priority, alias, and fallback targets. Assert no duplicate routed slug appears. Assert custom model display names remain unchanged. + +- [ ] **Step 2: Run catalog tests and confirm RED** + +Run: + +```text +bun test tests/codex-catalog.test.ts +``` + +Expected: the discovered row keeps its old metadata or slug instead of the configured label. + +- [ ] **Step 3: Implement the exact display resolver** + +Add: + +```ts +export function configuredModelDisplayName( + provider: OcxProviderConfig, + modelId: string, +): string | undefined { + if (!provider.modelDisplayNames || !Object.hasOwn(provider.modelDisplayNames, modelId)) return undefined; + const value = provider.modelDisplayNames[modelId]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} +``` + +In `applyProviderConfigHints`, spread the configured display name after the incoming model so it overrides trusted metadata only when present. Do not call `modelRecordValue`, case fold IDs, or use the routed slug as the lookup key. + +Add `modelDisplayNames` to `providerCatalogFingerprint`, because `gatherFlightKey` decides which active gather promise may be reused before the full provider graph identity is compared. + +- [ ] **Step 4: Run catalog tests and confirm GREEN** + +Run: + +```text +bun test tests/codex-catalog.test.ts +``` + +Expected: all catalog tests pass and routing identity stays byte equivalent. + +- [ ] **Step 5: Commit catalog propagation** + +```text +git add src/codex/catalog/provider-fetch.ts tests/codex-catalog.test.ts +git commit -m "feat(catalog): apply provider model display names" +``` + +--- + +### Task 3: Management Read and Mutation API + +**Files:** +- Modify: `src/server/management/model-rows.ts` +- Modify: `src/server/management/model-routes.ts` +- Create: `tests/model-display-names-management-api.test.ts` + +**Interfaces:** +- Produces: `ManagementModelRow.displayNameSource?: "operator" | "provider" | "fallback"` +- Produces: `ManagementModelRow.displayNameOverride?: string` +- Produces: `effectiveManagementDisplayName(config, model): { displayName: string; displayNameOverride?: string; displayNameSource: "operator" | "provider" | "fallback" }` +- Produces: `PUT /api/providers/:provider/model-display-names` +- Consumes body: `{ modelId: string; displayName: string | null }` + +- [ ] **Step 1: Add failing read surface tests** + +Use `listManagementModelRows` with a real provider model fixture. Assert the row contains the effective `displayName`, stored `displayNameOverride`, and source `operator`. Test provider metadata source and fallback source separately. Assert serialized rows contain no API key, headers, account email, or unrelated provider config. + +- [ ] **Step 2: Run read tests and confirm RED** + +Run: + +```text +bun test tests/model-display-names-management-api.test.ts +``` + +Expected: `displayNameOverride` and `displayNameSource` are absent. + +- [ ] **Step 3: Add effective name metadata to management rows** + +Add one pure helper and use it for routed nonnative rows. Look up the exact provider and native ID. Return: + +```ts +displayNameOverride?: string; +displayNameSource?: "operator" | "provider" | "fallback"; +``` + +Use `operator` when the exact configured map owns the ID, `provider` when `CatalogModel.displayName` exists without an override, and `fallback` otherwise. The fallback display name is the existing routed catalog slug, so the read surface always gives the dashboard the exact visible text. Do not add these fields to native OpenAI rows in this core change. Keep custom rows on their existing custom model contract. + +- [ ] **Step 4: Run read tests and confirm GREEN** + +Run: + +```text +bun test tests/model-display-names-management-api.test.ts +``` + +Expected: read tests pass. + +- [ ] **Step 5: Add failing mutation tests** + +Call `handleModelRoutes` with real `Request` objects and an in memory config. Cover: + +- set trims and stores one label +- set works for a temporarily absent model ID +- reset removes only the target and omits an empty map +- unknown provider returns 404 +- malformed JSON, missing fields, blank model ID, blank label, slash, control character, oversized label, and nonstring value return 400 +- validation failure does not persist or converge +- successful set and reset persist once and converge once +- persistence failure restores the previous map and does not converge +- convergence failure keeps the persisted label and returns the existing catalog disposition or bounded error pattern +- two sequential updates preserve neighboring entries + +Use a persistence seam that clones the actual config snapshot. Assert final state, not only mock call counts. + +- [ ] **Step 6: Run mutation tests and confirm RED** + +Run: + +```text +bun test tests/model-display-names-management-api.test.ts +``` + +Expected: route returns `null` or 404 because it is not registered. + +- [ ] **Step 7: Implement provider scoped mutation route** + +Match: + +```ts +const displayNameMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/model-display-names$/); +``` + +Decode the provider, reject the reserved `keys` route, verify provider ownership with `hasOwnProvider`, parse the bounded JSON body, validate the exact native model ID and a one entry map through `modelDisplayNamesConfigError`, and use `null` only for reset. + +Build a detached next map and assign it only after validation. Keep a detached copy of the old map. Wrap `persistConfig(config)` in `try/catch`; on failure restore the old field exactly, including absence, then rethrow so the management boundary returns its normal bounded server error. After persistence succeeds, call `convergeCodexCatalog()` once. Read the resulting routed row through `listManagementModelRows(config)` and use `effectiveManagementDisplayName`; if the temporarily absent ID has no row, return the stored operator label or `routedSlug(name, modelId)` as the fallback. Return: + +```ts +{ + ok: true, + provider: name, + modelId, + displayName: effectiveDisplayName, + displayNameOverride: storedNameOrNull, + displayNameSource, + catalogRefresh, +} +``` + +Do not require the model ID to exist in live discovery. + +- [ ] **Step 8: Run mutation tests and confirm GREEN** + +Run: + +```text +bun test tests/model-display-names-management-api.test.ts +``` + +Expected: all API tests pass. + +- [ ] **Step 9: Add concurrent config merge regression** + +In `tests/config-user-edits.test.ts`, start with two labels. Change one label in the live config, change the other on disk, call `saveConfigPreservingClaudeCode`, and assert both changes survive. Add a second test where the live writer removes one label while the disk writer adds a different label. + +- [ ] **Step 10: Run persistence tests and confirm RED or existing support** + +Run: + +```text +bun test tests/config-user-edits.test.ts +``` + +If the first run passes, record that the existing recursive provider merge already satisfies the contract and do not add production merge code. If it fails, make the smallest generic merge correction in `src/config.ts`, then rerun until green. + +- [ ] **Step 11: Commit the management API** + +```text +git add src/server/management/model-rows.ts src/server/management/model-routes.ts tests/model-display-names-management-api.test.ts tests/config-user-edits.test.ts src/config.ts +git commit -m "feat(api): manage discovered model display names" +``` + +--- + +### Task 4: Documentation and Architecture Sync + +**Files:** +- Modify: `docs-site/src/content/docs/reference/configuration/providers.md` +- Modify: `structure/02_config-and-codex-home.md` +- Modify: `structure/03_catalog-and-subagents.md` + +**Interfaces:** +- Documents: exact native ID keys, precedence, reset behavior, and API contract. + +- [ ] **Step 1: Update the provider configuration reference** + +Add a short example: + +```json +{ + "providers": { + "xai": { + "modelDisplayNames": { + "grok-4.6": "Grok 4.6" + } + } + } +} +``` + +State that the key is the exact native model ID, not `xai/grok-4.6`, the value is display only, unknown IDs are retained, and removing an entry resets the label. + +- [ ] **Step 2: Update structure records** + +Add `modelDisplayNames` to the persisted provider field list and record the precedence `operator > trusted provider metadata > fallback`. State that catalog identity and outbound routing never consume the label. + +- [ ] **Step 3: Run documentation checks** + +Run the repository's existing docs check or Astro build command found in `docs-site/package.json`. Expected: success with no broken links or schema errors. + +- [ ] **Step 4: Commit documentation** + +```text +git add docs-site/src/content/docs/reference/configuration/providers.md structure/02_config-and-codex-home.md structure/03_catalog-and-subagents.md +git commit -m "docs: explain discovered model display names" +``` + +--- + +### Task 5: Full Verification and User Preview + +**Files:** +- Review: every file changed by Tasks 1 through 4. +- Do not modify: the user's installed OpenCodex configuration or catalog. + +**Interfaces:** +- Produces: test evidence and a disposable preview for the user. + +- [ ] **Step 1: Review the complete diff twice** + +Run: + +```text +git diff upstream/dev...HEAD --check +git diff upstream/dev...HEAD +``` + +Check exact ID matching, no route identity changes, no secret fields in DTOs, no broad config fallback, and no unrelated changes. + +- [ ] **Step 2: Run focused tests** + +```text +bun test tests/provider-config-validation.test.ts tests/config-load-degrade.test.ts tests/config-user-edits.test.ts tests/codex-catalog.test.ts tests/model-display-names-management-api.test.ts +``` + +Expected: all pass. + +- [ ] **Step 3: Run repository gates** + +```text +bun run typecheck +bun run test +bun run privacy:scan +``` + +Expected: all pass with no new warnings, failures, secrets, emails, tokens, or personal paths. + +- [ ] **Step 4: Run a disposable end to end preview** + +Create a temporary config directory outside the repository using the operating system temporary directory. Configure one local fake provider with two discovered models and one operator label. Start the branch build on an unused port, call the real management API, run catalog convergence twice, restart the disposable server, and verify: + +```text +effective display name = Grok 4.6 +routed slug = xai/grok-4.6 +native model id = grok-4.6 +second model unchanged +label survives restart and repeated sync +reset restores fallback +temporary discovery failure keeps the stored label +``` + +The fake provider must receive the unchanged native model ID in a test request. Delete only the disposable temporary directory after the preview. + +- [ ] **Step 5: Show the result before submission** + +Report the exact test counts, commands, relevant catalog JSON before and after, API request and response examples, and any limitations. Do not push the branch and do not open a pull request until the user explicitly approves the verified result. diff --git a/docs/superpowers/specs/2026-08-26-discovered-model-display-names-design.md b/docs/superpowers/specs/2026-08-26-discovered-model-display-names-design.md new file mode 100644 index 0000000000..93a35b53d6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-discovered-model-display-names-design.md @@ -0,0 +1,233 @@ +# Discovered Model Display Names Design + +## Status + +Design approved by the contributor on 2026-08-26 for issue #2201. + +Base: `dev` at `01b5da9f574956f8eb55b13e55dd48e79ab74502`. + +## Problem + +OpenCodex can assign a display name to a custom model, but a model returned by provider discovery has no operator owned display name. The generated Codex catalog therefore falls back to a namespaced routing slug such as `xai/grok-composer-2.5-fast`. + +Editing `opencodex-catalog.json` is not a durable solution because sync, startup, provider refresh, and updates regenerate that file. A Windows startup script that edits generated state introduces ordering problems with Codex Desktop and the OpenCodex proxy. + +The dashboard also has no control for naming an existing discovered model. Its current Add control creates a separate custom model row and rejects an ID that already exists in discovery. + +## Goals + +1. Let an operator assign a readable name to an existing discovered provider model. +2. Persist that name in `config.json`, not generated catalog state. +3. Reapply the name during every catalog generation path. +4. Preserve provider identity, native model ID, routed slug, routing, billing, visibility, aliases, fallback targets, and outbound wire requests. +5. Support clear and reset behavior with deterministic fallback. +6. Expose the feature through configuration and the management API first, then through the dashboard in a separate pull request. +7. Keep provider labels available while a model is temporarily absent from discovery. + +## Non goals + +1. Renaming a provider ID or native model ID. +2. Changing model routing or alias collision rules. +3. Creating a second custom model row for a discovered model. +4. Renaming native OpenAI marketing rows in the first pull request. +5. Adding automatic startup helpers or modifying Codex Desktop files. +6. Importing provider supplied marketing names from new external sources. + +## Configuration contract + +Each provider can hold an optional native model ID to display name map: + +```json +{ + "providers": { + "xai": { + "modelDisplayNames": { + "grok-4.6": "Grok 4.6", + "grok-composer-2.5-fast": "Grok Composer Fast" + } + } + } +} +``` + +The map key is the provider native model ID. It is not the routed slug. Native IDs may contain `/`, so validation must use the same model ID rules as provider discovery rather than display name rules. + +The map value is display only. It must be a trimmed, nonempty string with a bounded length. It must reject control characters and `/`, matching the existing custom model display name safety contract. A malformed map or malformed entry must degrade safely without discarding the rest of the provider configuration. Exact validation behavior will follow the repository's existing config parsing and management mutation patterns. + +Unknown or currently absent model IDs are retained. A temporary provider outage, a stale discovery response, or a model disappearing for one refresh must not delete user owned metadata. + +Deleting a map entry clears the override. An empty map may be omitted during persistence. Clearing restores the normal derived display name on the next catalog convergence. + +## Display name precedence + +For a routed discovered model, the catalog label resolves in this order: + +1. Valid operator override from `provider.modelDisplayNames[modelId]`. +2. Trusted display metadata already carried by the current catalog pipeline. +3. Existing derived name or routed slug fallback. + +The operator override changes only `CatalogModel.displayName` and the emitted Codex `display_name`. It must not feed slug construction, equality, pricing, disable checks, provider selection, effort metadata, context metadata, modality metadata, aliases, fallbacks, combos, or the outbound request model. + +## Core data flow + +```text +config.json provider.modelDisplayNames + -> defensive config validation + -> provider model discovery + -> routed catalog row construction + -> display precedence resolver + -> Codex catalog display_name +``` + +The resolver belongs at the shared catalog construction boundary so startup sync, `ocx sync`, live provider refresh, management mutations, and service restart use the same behavior. No caller should patch the generated catalog after it is written. + +## Management API + +The first pull request adds a focused mutation surface for one provider and native model ID. The exact route should follow the existing management API conventions and must: + +1. Validate provider existence, model ID, and display name. +2. Allow a model ID that is temporarily absent from live discovery. +3. Update only the targeted map entry. +4. Persist through the existing safe config writer. +5. Roll back in memory if persistence fails. +6. Trigger catalog convergence after a successful mutation. +7. Return the resulting display name and catalog refresh result. +8. Support clear or reset without accepting an ambiguous blank value. + +Read surfaces must return the effective label and whether its source is an operator override, provider metadata, or fallback. Credentials and unrelated configuration must never be exposed. + +## Dashboard follow up + +The dashboard work is a separate pull request stacked after the core contract, as requested by the maintainer in issue #2201. + +Each discovered model row receives a small rename action. The editor shows: + +1. The immutable provider and native model ID. +2. The current effective display name. +3. A text field for the operator override. +4. Save and Reset actions. +5. Clear feedback for saving, success, validation failure, network failure, and catalog refresh failure. + +The dashboard must not create a custom model to rename a discovered row. The current custom model Add flow remains unchanged. + +Saving updates the existing row in place after the server confirms success. Reset removes the override and restores the server returned fallback label. A failed save keeps the entered value so the user can retry. Controls must be keyboard accessible and translated through the existing i18n catalog. + +## Error handling + +1. Invalid display names return a bounded validation error and do not mutate config or catalog. +2. An unknown provider returns not found and does not create a provider implicitly. +3. A temporarily absent model ID is allowed for an existing provider so labels survive discovery gaps. +4. A config persistence failure restores the previous in memory map. +5. A catalog refresh failure keeps the successfully persisted label and reports that refresh is pending, matching existing management mutation behavior where possible. +6. A malformed hand edited map is ignored entry by entry where the existing parser permits safe degradation. Valid provider settings remain usable. +7. Concurrent mutations must use the existing config mutation serialization path so unrelated entries are not lost. + +## Pull request split + +### Pull request 1: core contract + +1. Provider config type and defensive validation. +2. Display precedence resolver. +3. All catalog construction paths. +4. Management read and mutation API. +5. Configuration reference documentation. +6. Focused runtime, config, API, and catalog regression tests. + +### Pull request 2: dashboard editor + +1. Provider model row rename and reset controls. +2. API client integration and optimistic state rules. +3. Loading, validation, failure, retry, and success states. +4. i18n strings in every supported locale. +5. Component tests, accessibility checks, lint, build, and screenshots. + +The second pull request targets the first branch while the first is open. It is retargeted to `dev` after the core pull request lands. + +## Test strategy + +Tests are written before production code and observed failing for the missing feature. + +### Configuration and validation + +1. Accept one valid provider scoped map. +2. Preserve several labels under one provider. +3. Keep identical native model IDs isolated across two providers. +4. Reject or safely ignore empty, whitespace only, slash containing, control character, nonstring, oversized, array, and prototype shaped values according to the established parser boundary. +5. Preserve valid provider fields when one label is malformed. +6. Preserve labels for model IDs absent from the latest discovery result. +7. Round trip the map through load, mutation, persistence, and reload. +8. Clear one entry without deleting neighboring entries. + +### Catalog behavior + +1. Apply an operator label to a discovered routed row. +2. Keep the routed slug and native model ID unchanged. +3. Keep pricing, disabled model matching, effort levels, context window, modalities, priority, aliases, fallback targets, and outbound wire model unchanged. +4. Use operator override over provider metadata. +5. Restore provider metadata or slug fallback after reset. +6. Preserve labels through repeated catalog generation. +7. Preserve labels through provider discovery success, failure fallback, empty discovery, and later recovery. +8. Avoid duplicate rows when a discovered model has a label. +9. Keep custom model display names unchanged. +10. Keep providers without the new field byte and behavior compatible where the existing writer allows it. + +### Management API + +1. Read effective name and source without exposing secrets. +2. Set a label for a discovered model. +3. Set a label for a temporarily absent model under an existing provider. +4. Reset a label. +5. Reject unknown providers and invalid labels. +6. Prove persistence failure does not leave an in memory partial mutation. +7. Prove successful mutation requests catalog convergence exactly once. +8. Prove concurrent updates do not erase unrelated map entries. + +### Dashboard + +1. Show Rename for a discovered model and not confuse it with Add custom model. +2. Load and display effective and overridden names. +3. Save a trimmed valid label with the correct provider and native model ID. +4. Reset an override. +5. Disable duplicate submits while saving. +6. Keep user input after server or network failure. +7. Display validation, network, persistence, and refresh feedback. +8. Work with filtering, a large capped model list, selected models, default models, configured fallback models, and custom rows. +9. Support keyboard operation and accessible labels. +10. Render correctly at repository required desktop and mobile widths. + +### Full verification before submission + +For the core pull request: + +```text +focused Bun tests +bun run typecheck +bun run test +bun run privacy:scan +``` + +For the dashboard pull request: + +```text +focused GUI tests +cd gui && bun test +bun run lint:gui +bun run typecheck +bun run test +bun run build:gui +bun run privacy:scan +manual dashboard test against a disposable config +desktop and mobile screenshots +``` + +The manual test uses a disposable OpenCodex config and catalog. It must not modify the user's installed configuration, provider credentials, or production catalog. + +## Acceptance criteria + +1. A discovered model can receive a durable operator display name without becoming a custom model. +2. The label survives sync, catalog regeneration, proxy restart, Codex restart, provider discovery gaps, and config reload. +3. Reset restores the deterministic fallback label. +4. Routing identity and all non-display catalog behavior remain unchanged. +5. The dashboard can edit and reset the same core configuration without a local patch script. +6. All focused and full repository checks pass with no secrets or personal data added. +7. Pull requests follow the repository templates, target the correct branches, include required evidence, and remain drafts until review readiness is proven. diff --git a/src/cli/opencode.ts b/src/cli/opencode.ts index 3a9d2ea68f..793ee46cb7 100644 --- a/src/cli/opencode.ts +++ b/src/cli/opencode.ts @@ -94,6 +94,7 @@ export interface OpencodeProxyModelRow { native?: boolean; disabled?: boolean; displayName?: string; + displayNameSource?: "operator" | "provider" | "fallback"; contextWindow?: number; /** Declared effort ladder from `/api/models`; carried into opencode model variants. */ reasoningEfforts?: string[]; @@ -387,7 +388,7 @@ export function opencodeCatalogFromProxyRows( provider: row.provider, id: row.id, contextWindow: row.contextWindow, - displayName: row.displayName, + displayName: row.displayNameSource === "fallback" ? undefined : row.displayName, ...(Array.isArray(row.reasoningEfforts) && row.reasoningEfforts.length > 0 ? { reasoningEfforts: [...row.reasoningEfforts] } : {}), diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 87afd0bb12..f5bacc1491 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -571,6 +571,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco adapter: prov.adapter ?? "", models: [...(prov.models ?? [])].sort(), selected: [...(prov.selectedModels ?? [])].sort(), + displayNames: prov.modelDisplayNames ?? null, defaultModel: prov.defaultModel ?? null, ctx: prov.contextWindow ?? null, ctxW: prov.modelContextWindows ?? null, @@ -626,6 +627,16 @@ export function configuredInputModalities(prov: OcxProviderConfig, id: string): 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; @@ -666,6 +677,7 @@ function configuredVerbositySupport(name: string, prov: OcxProviderConfig | unde } export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel { + const displayName = configuredModelDisplayName(prov, model.id); const configuredCap = configuredContextWindow(prov, model.id); const configuredMaxInput = configuredMaxInputTokens(prov, model.id); const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id); @@ -698,6 +710,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, : (configuredCap ?? (providerCap !== undefined ? resolveUnknownRoutedContextWindow(providerCap) : undefined)); const hinted = { ...modelWithoutServiceTier, + ...(displayName !== undefined ? { displayName } : {}), ...(hintedWindow !== undefined ? { contextWindow: hintedWindow } : {}), ...(inputModalities ? { inputModalities } : {}), ...(reasoningEfforts !== undefined ? { reasoningEfforts } : {}), diff --git a/src/config.ts b/src/config.ts index 11d88af91d..fa82e46c56 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,7 @@ import { apiKeyTransportConfigError, booleanRecordConfigError, modelAdapterRecordConfigError, + modelDisplayNamesConfigError, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, positiveIntegerConfigError, @@ -61,6 +62,7 @@ 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, @@ -493,6 +495,17 @@ const fastWireSchema = z.object({ 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; +}); + /** * Zod schema for one provider entry: known fields are validated strictly while unknown * fields pass through (preserved for runtime extensions). @@ -502,6 +515,7 @@ const providerConfigSchema = z.object({ baseUrl: z.string().min(1), alias: z.string().optional(), modelAliases: z.record(z.string(), z.string()).optional(), + modelDisplayNames: modelDisplayNamesSchema.optional(), defaultAliases: z.boolean().optional(), requestPacing: requestPacingSchema.optional().catch(undefined), mcpMaxTools: z.number().int().positive().optional(), @@ -551,6 +565,7 @@ export { apiKeyTransportConfigError, booleanRecordConfigError, modelAdapterRecordConfigError, + modelDisplayNamesConfigError, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, positiveIntegerConfigError, @@ -1109,6 +1124,16 @@ const configSchema = z.object({ 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({ @@ -1844,6 +1869,7 @@ export function loadConfig(): OcxConfig { const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); const parsed = JSON.parse(raw); sanitizeAliasesForLoad(parsed); + sanitizeModelDisplayNamesForLoad(parsed); sanitizeRetryOn429ForLoad(parsed); sanitizeModelCostsForLoad(parsed); const result = configSchema.safeParse(parsed); @@ -1952,6 +1978,38 @@ function sanitizeAliasesForLoad(raw: unknown): void { } } +/** 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); @@ -2227,6 +2285,7 @@ function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { // 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); sanitizeRetryOn429ForLoad(parsed); sanitizeModelCostsForLoad(parsed); const result = configSchema.safeParse(parsed); diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index 6508a745f8..326914a758 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -1,5 +1,9 @@ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { redactSecretString } from "../lib/redact"; +import { + isValidModelDiscoveryModelId, + MODEL_DISCOVERY_MAX_MODELS, +} from "../providers/model-discovery-limits"; import { modelRecordValue } from "../reasoning-effort"; import { isWirePinnedModel, @@ -20,6 +24,8 @@ const SENSITIVE_PROVIDER_HEADERS = new Set([ "x-amz-security-token", ]); const REASONING_SUMMARY_DELIVERY_SET = new Set(REASONING_SUMMARY_DELIVERY_VALUES); +const DISPLAY_NAME_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/; +const MAX_MODEL_DISPLAY_NAME_LENGTH = 128; /** Validate a provider destination without coupling DTO callers to config persistence. */ export function providerBaseUrlConfigError(baseUrl: string): string | null { @@ -124,6 +130,40 @@ export function booleanRecordConfigError(value: unknown, field: string): string return null; } +/** Validate display-only labels without changing the provider's model identity. */ +export function modelDisplayNamesConfigError( + value: unknown, + field = "modelDisplayNames", +): 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); + // One discovered model can own one label, so both maps share the same safe cap. + if (entries.length > MODEL_DISCOVERY_MAX_MODELS) { + return `${field} must contain at most ${MODEL_DISCOVERY_MAX_MODELS} entries`; + } + for (const [modelId, displayName] of entries) { + if (!isValidModelDiscoveryModelId(modelId)) return `${field} keys must be valid model ids`; + const safeModelId = JSON.stringify(redactSecretString(modelId)); + if (typeof displayName !== "string") return `${field}.${safeModelId} must be a string`; + const trimmed = displayName.trim(); + if (!trimmed) return `${field}.${safeModelId} must be nonblank`; + if (displayName !== trimmed) return `${field}.${safeModelId} must be trimmed`; + if (displayName.length > MAX_MODEL_DISPLAY_NAME_LENGTH) { + return `${field}.${safeModelId} must be at most ${MAX_MODEL_DISPLAY_NAME_LENGTH} characters`; + } + if (displayName.includes("/")) return `${field}.${safeModelId} must not contain /`; + if (DISPLAY_NAME_CONTROL_CHARS.test(displayName)) { + return `${field}.${safeModelId} must not contain control characters`; + } + } + return null; +} + /** Validate the management DTO boundary for the opt-in empty-tool-output annotation. */ export function providerEmptyToolOutputConfigError(name: string, provider: unknown): string | null { const raw = provider as Record | null | undefined; diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index b798b44c18..601444a958 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -72,13 +72,15 @@ function readDefaultReasoningEffort(raw: unknown, efforts: string[] | undefined) import type { CatalogModel } from "../../codex/catalog"; import { accountBoundNativeOpenAiSlugsBySelector, catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, shouldIncludeAccountBoundNativeOpenAi, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch"; +import { clearModelCache, getProviderLiveModelCount } from "../../codex/model-cache"; import { NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models"; -import { getProviderLiveModelCount } from "../../codex/model-cache"; + import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, hasOwnProvider, isValidProviderName, + modelDisplayNamesConfigError, multiAgentGuidanceEnabled, providerBaseUrlConfigError, providerHeadersConfigError, @@ -101,6 +103,7 @@ import { providerCodexAccountMode } from "../../providers/registry"; import { encodedModelIdCollides, routedSlug, slugEquals } from "../../providers/slug-codec"; import { knownModelIdsForProvider } from "../../router"; import { effectiveModelAliases, MODEL_ALIAS_PATTERN } from "../../providers/default-aliases"; +import { isValidModelDiscoveryModelId } from "../../providers/model-discovery-limits"; import { comboPublicModelId } from "../../combos/types"; import { COMBO_NAMESPACE, comboDisabledModelSelectors, comboModelId, preservesPhysicalComboProvider } from "../../combos"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; @@ -353,6 +356,81 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise, + previousDisplayNames ?? {}, + ); + if (displayName === null) delete nextDisplayNames[modelId]; + else nextDisplayNames[modelId] = displayName; + const mergedValidationError = modelDisplayNamesConfigError(nextDisplayNames); + if (mergedValidationError) return jsonResponse({ error: mergedValidationError }, 400, req, config); + if (Object.keys(nextDisplayNames).length > 0) provider.modelDisplayNames = nextDisplayNames; + else delete provider.modelDisplayNames; + + try { + persistConfig(config); + } catch (error) { + if (hadDisplayNames) provider.modelDisplayNames = previousDisplayNames; + else delete provider.modelDisplayNames; + throw error; + } + clearModelCache(name); + const catalogRefresh = await convergeCodexCatalog(); + const storedDisplayName = provider.modelDisplayNames?.[modelId] ?? null; + if (catalogRefresh.status === "failed") { + return jsonResponse({ + error: "model display name saved but catalog refresh failed", + saved: true, + provider: name, + modelId, + displayNameOverride: storedDisplayName, + catalogRefresh, + }, 503, req, config); + } + const row = (await listManagementModelRows(config)).find(candidate => ( + candidate.native !== true + && candidate.custom !== true + && candidate.provider === name + && candidate.id === modelId + )); + return jsonResponse({ + ok: true, + provider: name, + modelId, + displayName: row?.displayName ?? storedDisplayName ?? routedSlug(name, modelId), + displayNameOverride: storedDisplayName, + displayNameSource: row?.displayNameSource ?? (storedDisplayName ? "operator" : "fallback"), + catalogRefresh, + }); + } + /** * Client config document for OpenCode / Pi, built from the SAME function `ocx export` * calls, so the bytes a user downloads here and the bytes they pipe from the CLI cannot diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index c4e6ca0210..ef6a8785a6 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -40,8 +40,28 @@ export type ManagementModelRow = Partial & { native?: boolean; custom?: boolean; customId?: string; + displayNameOverride?: string; + displayNameSource?: "operator" | "provider" | "fallback"; }; +/** Resolve the exact text and source shown for one routed discovered model. */ +export function effectiveManagementDisplayName( + config: Pick, + model: CatalogModel, +): Pick { + const provider = config.providers[model.provider]; + const configured = provider?.modelDisplayNames; + if (configured && Object.hasOwn(configured, model.id)) { + const displayName = configured[model.id]?.trim(); + if (displayName) { + return { displayName, displayNameOverride: displayName, displayNameSource: "operator" }; + } + } + const providerDisplayName = model.displayName?.trim(); + if (providerDisplayName) return { displayName: providerDisplayName, displayNameSource: "provider" }; + return { displayName: catalogModelSlug(model), displayNameSource: "fallback" }; +} + /** * The exact row list `/api/models` returns. Extracted so `/api/client-config` exports the * models the GUI's Models tab shows — including this function's `disabled` computation, @@ -124,8 +144,10 @@ export async function listManagementModelRows(config: OcxConfig): Promise ( (!nativeAlias && stored === namespaced) || slugEquals(stored, m.provider, m.id) @@ -143,7 +165,7 @@ export function toExportModel(row: ManagementModelRow): ExportModel { provider: row.provider, id: row.id, ...(row.native ? { native: true } : {}), - ...(row.displayName ? { displayName: row.displayName } : {}), + ...(row.displayName && row.displayNameSource !== "fallback" ? { displayName: row.displayName } : {}), ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}), ...(row.reasoningEfforts ? { reasoningEfforts: row.reasoningEfforts } : {}), diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index b0314831e1..07968c91fb 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -9,6 +9,7 @@ import { codexAutoStartEnabled, hasOwnProvider, isValidProviderName, + modelDisplayNamesConfigError, multiAgentGuidanceEnabled, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, @@ -570,6 +571,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise short, slash-free request alias. */ modelAliases?: Record; + /** Display-only labels for exact native model ids discovered under this provider. */ + modelDisplayNames?: Record; /** Override the global built-in model-alias switch for this provider. */ defaultAliases?: boolean; adapter: string; diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 8411884b5c..acd68be567 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -252,7 +252,7 @@ matters for maintainers is which groups exist and who resolves them: | --- | --- | --- | | Listener | `port`, `hostname` | The listener owns the port; `runtime-port.json` reports where it actually landed. | | Routing | `defaultProvider`, `providers`, per-provider `selectedModels` | Explicit `provider/model` wins over `defaultProvider`. | -| Catalog | `disabledModels`, `customModels`, `modelCacheTtlMs`, `providerContextCaps`, `contextCapValue`, `codexAccountNamespaces`, `codexAccountPickerEnabled` | Catalog state is derived; config only records intent. The picker flag is an explicit visibility override, while selector mappings remain the durable exact-routing contract. | +| Catalog | `disabledModels`, `customModels`, `modelCacheTtlMs`, `providerContextCaps`, `contextCapValue`, per-provider `modelDisplayNames`, `codexAccountNamespaces`, `codexAccountPickerEnabled` | Catalog state is derived; config only records intent. Exact provider model display names are durable display only overlays. The picker flag is an explicit visibility override, while selector mappings remain the durable exact-routing contract. | | Retained state | `appOwnedMemoryBudgetMb` | Process-wide eviction target for app-owned logs, caches, blobs, and continuation payloads. Default 256 MiB, valid 64..4096; pinned state may temporarily exceed the target, but every pin-capable store has a finite local cap and their documented aggregate stays below `APP_OWNED_WORST_CASE_PINNED_BYTES` (512 MiB). Neither value caps RSS or native runtime memory. | | Transport | stream mode, timeouts, proxy settings, `websockets`, `emptyCompletionRetry` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. Empty-completion replay is an explicit top-level opt-in because its second upstream request may be billable. | | Credentials | `apiKeys` | Data-plane only; never admitted to `/api/*`. | diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 3260fe9de1..d30e3737b3 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -102,8 +102,10 @@ liveness contract. Routed entries keep Codex-required metadata such as reasoning levels, shell type, API support flags, base instructions, modalities, auto-compact fields, and strict parser booleans. The public slug uses -the canonical `provider/model`; its display name uses the qualified provider/model alias when -configured, without changing the routing slug. +the canonical `provider/model`. Its display name uses the provider's exact `modelDisplayNames` override first, +then trusted catalog metadata such as a configured qualified provider/model alias, then the public slug. +This overlay never changes route identity or the upstream wire model, and its catalog fingerprint makes +a label edit refresh Codex output. ## Native passthrough diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index f174acbcde..1acbd31849 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1782,6 +1782,172 @@ describe("Cursor Kimi K3 catalog default effort", () => { }); }); +describe("provider discovered model display names", () => { + const provider = { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + modelDisplayNames: { "grok-4.6": "Grok 4.6" }, + }; + + test("an exact provider model id receives only the configured display name", () => { + const discovered = { + provider: "xai", + id: "grok-4.6", + displayName: "Provider Grok", + contextWindow: 131_072, + maxInputTokens: 100_000, + autoCompactTokenLimit: 90_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "high"], + defaultReasoningEffort: "high", + supportsReasoningSummaries: true, + supportsVerbosity: false, + priority: 17, + fallbackModels: ["grok-4.5"], + owned_by: "xai", + } as const; + + const output = applyProviderConfigHints("xai", provider, discovered); + const { displayName: _beforeDisplayName, ...beforeIdentity } = discovered; + const { displayName: _afterDisplayName, ...afterIdentity } = output; + + expect(output.displayName).toBe("Grok 4.6"); + expect(afterIdentity).toEqual({ ...beforeIdentity, supportsServiceTier: false }); + expect(catalogModelSlug(output)).toBe("xai/grok-4.6"); + }); + + test("display names use exact case-sensitive ids and stay provider scoped", () => { + const wrongCase = applyProviderConfigHints("xai", provider, { provider: "xai", id: "GROK-4.6" }); + const otherProvider = applyProviderConfigHints("other", { + ...provider, + modelDisplayNames: { "grok-4.6": "Other Grok" }, + }, { provider: "other", id: "grok-4.6" }); + + expect(wrongCase.displayName).toBeUndefined(); + expect(otherProvider.displayName).toBe("Other Grok"); + }); + + test("provider metadata remains when no operator display name exists", () => { + const output = applyProviderConfigHints("xai", { + ...provider, + modelDisplayNames: undefined, + }, { + provider: "xai", + id: "grok-4.6", + displayName: "Provider Grok", + }); + + expect(output.displayName).toBe("Provider Grok"); + }); + + test("a configured display name emits into the Codex picker without changing its slug", () => { + const model = applyProviderConfigHints("xai", provider, { provider: "xai", id: "grok-4.6" }); + const row = buildCatalogEntries(nativeTemplate(), [], [model]) + .find(entry => entry.slug === "xai/grok-4.6"); + + expect(row?.display_name).toBe("Grok 4.6"); + expect(row?.slug).toBe("xai/grok-4.6"); + }); + + test("the label survives static, live, and configured failure catalog paths", async () => { + const staticModels = await gatherRoutedModels({ + defaultProvider: "display-static", + providers: { + "display-static": { + adapter: "openai-chat", + baseUrl: "https://static.example.test/v1", + liveModels: false, + models: ["model-a"], + modelDisplayNames: { "model-a": "Static Model" }, + }, + }, + }); + expect(staticModels).toContainEqual(expect.objectContaining({ + provider: "display-static", + id: "model-a", + displayName: "Static Model", + })); + + globalThis.fetch = (async () => new Response(JSON.stringify({ + data: [{ id: "model-a", name: "Provider Model" }], + }), { headers: { "content-type": "application/json" } })) as typeof fetch; + const liveModels = await gatherRoutedModels({ + defaultProvider: "display-live", + providers: { + "display-live": { + adapter: "openai-chat", + baseUrl: "https://93.184.216.34/v1", + apiKey: "sk-test", + modelDisplayNames: { "model-a": "Live Model" }, + }, + }, + }); + expect(liveModels).toContainEqual(expect.objectContaining({ + provider: "display-live", + id: "model-a", + displayName: "Live Model", + })); + + globalThis.fetch = (async () => new Response(null, { status: 503 })) as typeof fetch; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const failedModels = await gatherRoutedModels({ + defaultProvider: "display-failure", + providers: { + "display-failure": { + adapter: "openai-chat", + baseUrl: "https://93.184.216.34/v1", + apiKey: "sk-test", + models: ["model-a"], + modelDisplayNames: { "model-a": "Failure Model" }, + }, + }, + }); + expect(failedModels).toContainEqual(expect.objectContaining({ + provider: "display-failure", + id: "model-a", + displayName: "Failure Model", + })); + } finally { + warning.mockRestore(); + } + }); + + test("a stale cached row receives the current operator label on every gather", async () => { + const providerName = "display-stale"; + setCached(providerName, [{ + provider: providerName, + id: "model-a", + displayName: "Old Provider Name", + }], Date.now() - 10_000); + globalThis.fetch = (async () => new Response(null, { status: 503 })) as typeof fetch; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const config = { + modelCacheTtlMs: 1, + defaultProvider: providerName, + providers: { + [providerName]: { + adapter: "openai-chat" as const, + baseUrl: "https://93.184.216.34/v1", + apiKey: "sk-test", + modelDisplayNames: { "model-a": "Current Name" }, + }, + }, + }; + const first = await gatherRoutedModels(config); + const second = await gatherRoutedModels(config); + + expect(first).toContainEqual(expect.objectContaining({ id: "model-a", displayName: "Current Name" })); + expect(second).toContainEqual(expect.objectContaining({ id: "model-a", displayName: "Current Name" })); + expect(first.filter(model => catalogModelSlug(model) === `${providerName}/model-a`)).toHaveLength(1); + } finally { + warning.mockRestore(); + clearModelCache(providerName); + } + }); +}); + describe("configured CatalogModel displayName -> catalog display_name", () => { test("a routed CatalogModel displayName becomes the catalog display_name", () => { const model = { provider: "deepseek", id: "deepseek-v4", displayName: "DeepSeek V4", owned_by: "deepseek" }; diff --git a/tests/codex-convergence-contract.test.ts b/tests/codex-convergence-contract.test.ts index ff178c0321..f878ab643d 100644 --- a/tests/codex-convergence-contract.test.ts +++ b/tests/codex-convergence-contract.test.ts @@ -372,10 +372,10 @@ test("a failure cause never carries message text, paths or identifiers (#1784)", expect(body).not.toContain("failed writing"); }); -test("the route inventory contains exactly the specified 7 + 13 + 2 + 2 convergence calls", () => { +test("the route inventory contains exactly the specified 7 + 14 + 2 + 2 convergence calls", () => { const counts = Object.fromEntries([ ["provider-routes.ts", 7], - ["model-routes.ts", 13], + ["model-routes.ts", 14], ["combo-routes.ts", 2], ["agent-settings-routes.ts", 2], ].map(([file, expected]) => { @@ -387,7 +387,7 @@ test("the route inventory contains exactly the specified 7 + 13 + 2 + 2 converge })); expect(counts).toEqual({ "provider-routes.ts": 7, - "model-routes.ts": 13, + "model-routes.ts": 14, "combo-routes.ts": 2, "agent-settings-routes.ts": 2, }); diff --git a/tests/config-load-degrade.test.ts b/tests/config-load-degrade.test.ts new file mode 100644 index 0000000000..a6543bf08e --- /dev/null +++ b/tests/config-load-degrade.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + getConfigPath, + getDefaultConfig, + loadConfig, + saveConfig, + validateConfigCandidate, +} from "../src/config"; + +let home = ""; +let previousHome: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-display-names-config-")); + process.env.OPENCODEX_HOME = home; +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); +}); + +function candidate(modelDisplayNames: unknown) { + const defaults = getDefaultConfig(); + return { + ...defaults, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + note: "keep me", + modelDisplayNames, + }, + }, + }; +} + +function writeCandidate(modelDisplayNames: unknown, provider = "xai"): void { + const config = candidate(modelDisplayNames); + config.defaultProvider = provider; + config.providers = { + [provider]: { + ...config.providers.xai, + modelDisplayNames, + }, + }; + writeFileSync(getConfigPath(), JSON.stringify(config), "utf8"); +} + +test("config validation accepts only safe provider model display names", () => { + const valid = validateConfigCandidate(candidate({ + "grok-4.6": "Grok 4.6", + "models/grok-vision": "Grok Vision", + })); + expect(valid.ok).toBe(true); + + const invalid = validateConfigCandidate(candidate({ "grok-4.6": "Grok/4.6" })); + expect(invalid.ok).toBe(false); + if (!invalid.ok) expect(invalid.error).toContain("modelDisplayNames"); +}); + +test("load keeps a provider and valid labels when one hand edited label is invalid", () => { + writeCandidate({ + "grok-4.6": " Grok 4.6 ", + "future-model": "Future Model", + unsafe: "Bad/Name", + }); + + const loaded = loadConfig(); + + expect(loaded.providers.xai).toMatchObject({ + note: "keep me", + modelDisplayNames: { + "grok-4.6": "Grok 4.6", + "future-model": "Future Model", + }, + }); + expect(loaded.providers.xai.modelDisplayNames).not.toHaveProperty("unsafe"); +}); + +test("load and save preserve a prototype shaped model id as data", () => { + writeCandidate(JSON.parse('{"__proto__":"Prototype Model"}')); + + const loaded = loadConfig(); + + expect(Object.hasOwn(loaded.providers.xai.modelDisplayNames ?? {}, "__proto__")).toBe(true); + expect(loaded.providers.xai.modelDisplayNames?.["__proto__"]).toBe("Prototype Model"); + + saveConfig(loaded); + const reloaded = loadConfig(); + + expect(Object.hasOwn(reloaded.providers.xai.modelDisplayNames ?? {}, "__proto__")).toBe(true); + expect(reloaded.providers.xai.modelDisplayNames?.["__proto__"]).toBe("Prototype Model"); +}); + +test("load drops only a malformed display name map", () => { + writeCandidate("not-an-object"); + + const loaded = loadConfig(); + + expect(loaded.providers.xai).toMatchObject({ note: "keep me" }); + expect(loaded.providers.xai.modelDisplayNames).toBeUndefined(); +}); + +test("load warnings never reveal display values or secret shaped provider names", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const displaySecret = ["sk", "secret", "display", "value"].join("-"); + const providerSecret = ["sk", "secret", "provider", "name"].join("-"); + writeCandidate({ model: `${displaySecret}/unsafe` }, providerSecret); + + const loaded = loadConfig(); + + expect(loaded.providers[providerSecret]).toBeDefined(); + const output = warn.mock.calls.map(call => call.join(" ")).join("\n"); + expect(output).not.toContain(displaySecret); + expect(output).not.toContain(providerSecret); + expect(output).toContain("[REDACTED]"); + } finally { + warn.mockRestore(); + } +}); diff --git a/tests/config-user-edits.test.ts b/tests/config-user-edits.test.ts index 85108b0f8e..404ea655a6 100644 --- a/tests/config-user-edits.test.ts +++ b/tests/config-user-edits.test.ts @@ -386,6 +386,29 @@ test("config diagnostics sanitize invalid retryOn429 before schema validation", expect(diagnostics.config.providers.test.retryOn429).toBeUndefined(); }); +test("config diagnostics degrade only invalid provider model display names", () => { + writeDiskConfig({ + providers: { + test: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "k", + allowPrivateNetwork: true, + modelDisplayNames: { + "model-a": " Model Alpha ", + "model-b": "Bad/Name", + }, + }, + }, + }); + + const diagnostics = readConfigDiagnostics(); + + expect(diagnostics.source).toBe("file"); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.providers.test.modelDisplayNames).toEqual({ "model-a": "Model Alpha" }); +}); + test("invalid retryOn429 values never log the raw value", () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); try { @@ -787,6 +810,48 @@ test("a provider deletion from a newer disk snapshot wins over a stale edit to t expect(Object.keys(diskConfig().providers as Record)).toEqual(["test"]); }); +test("independent provider model display name edits survive a guarded stale save", () => { + const live = loadConfig(); + live.providers.test.modelDisplayNames = { "model-a": "Alpha", "model-b": "Beta" }; + saveConfig(live); + armClaudeCodeBaseline(live); + + live.providers.test.modelDisplayNames["model-a"] = "Live Alpha"; + writeDiskConfig({ + providers: { + test: { + ...live.providers.test, + modelDisplayNames: { "model-a": "Alpha", "model-b": "Disk Beta" }, + }, + }, + }); + saveConfigPreservingClaudeCode(live); + + expect((diskConfig().providers as Record }>).test?.modelDisplayNames) + .toEqual({ "model-a": "Live Alpha", "model-b": "Disk Beta" }); +}); + +test("a display name reset preserves a neighboring label added on disk", () => { + const live = loadConfig(); + live.providers.test.modelDisplayNames = { "model-a": "Alpha", "model-b": "Beta" }; + saveConfig(live); + armClaudeCodeBaseline(live); + + delete live.providers.test.modelDisplayNames["model-a"]; + writeDiskConfig({ + providers: { + test: { + ...live.providers.test, + modelDisplayNames: { "model-a": "Alpha", "model-b": "Beta", "model-c": "Disk Gamma" }, + }, + }, + }); + saveConfigPreservingClaudeCode(live); + + expect((diskConfig().providers as Record }>).test?.modelDisplayNames) + .toEqual({ "model-b": "Beta", "model-c": "Disk Gamma" }); +}); + test("independent custom-model edits survive a guarded stale save", () => { const live = loadConfig(); live.customModels = [customModel("one"), customModel("two")]; diff --git a/tests/management-client-config-route.test.ts b/tests/management-client-config-route.test.ts index 0df9ba44b8..0667a322c2 100644 --- a/tests/management-client-config-route.test.ts +++ b/tests/management-client-config-route.test.ts @@ -50,6 +50,7 @@ interface ModelRow { disabled: boolean; native?: boolean; displayName?: string; + displayNameSource?: "operator" | "provider" | "fallback"; contextWindow?: number; inputModalities?: string[]; reasoningEfforts?: string[]; @@ -118,7 +119,7 @@ function toExportModel(row: ModelRow): ExportModel { provider: row.provider, id: row.id, ...(row.native ? { native: true } : {}), - ...(row.displayName ? { displayName: row.displayName } : {}), + ...(row.displayName && row.displayNameSource !== "fallback" ? { displayName: row.displayName } : {}), ...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}), ...(row.inputModalities ? { inputModalities: row.inputModalities } : {}), ...(row.reasoningEfforts ? { reasoningEfforts: row.reasoningEfforts } : {}), diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 22464fb4ba..ba297dfd99 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -755,6 +755,72 @@ describe("provider management validation", () => { } }); + test("provider POST overwrite preserves modelDisplayNames when the payload omits it", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const names = { "grok-4.6": "Grok 4.6" }; + const create = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "custom-display", + provider: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + modelDisplayNames: names, + }, + }), + }); + expect(create.status).toBe(200); + + const overwrite = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "custom-display", + provider: { adapter: "openai-chat", baseUrl: "https://api.example.test/v1" }, + }), + }); + expect(overwrite.status).toBe(200); + expect(loadConfig().providers["custom-display"]?.modelDisplayNames).toEqual(names); + } finally { + await server.stop(true); + } + }); + + test("provider POST rejects unsafe submitted modelDisplayNames", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + saveConfig(config("127.0.0.1")); + + const server = startServer(0); + try { + const response = await fetch(new URL("/api/providers", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "custom-display-invalid", + provider: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + modelDisplayNames: { "model-a": "Bad/Name" }, + }, + }), + }); + + expect(response.status).toBe(400); + expect(loadConfig().providers["custom-display-invalid"]).toBeUndefined(); + } finally { + await server.stop(true); + } + }); + test("provider POST overwrite preserves the account-failover opt-out when the payload omits it (#2568d)", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/model-display-names-management-api.test.ts b/tests/model-display-names-management-api.test.ts new file mode 100644 index 0000000000..468584536e --- /dev/null +++ b/tests/model-display-names-management-api.test.ts @@ -0,0 +1,343 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { clearModelCache, getFreshCached, setCached } from "../src/codex/model-cache"; +import type { CatalogDisposition } from "../src/codex/convergence-types"; +import { listManagementModelRows, toExportModel } from "../src/server/management/model-rows"; +import { handleModelRoutes } from "../src/server/management/model-routes"; +import type { OcxConfig } from "../src/types"; + +const DISPLAY_PROVIDER = "display-test"; + +function config( + modelDisplayNames?: Record, + models: string[] = ["model-a"], +): OcxConfig { + return { + port: 10100, + defaultProvider: DISPLAY_PROVIDER, + modelCacheTtlMs: 60_000, + providers: { + [DISPLAY_PROVIDER]: { + adapter: "openai-chat", + baseUrl: "https://display.example.test/v1", + liveModels: false, + models, + ...(modelDisplayNames ? { modelDisplayNames } : {}), + }, + }, + }; +} + +afterEach(() => { + clearModelCache(); +}); + +describe("model display name management rows", () => { + test("reports operator, provider, and fallback display name sources without provider secrets", async () => { + const operatorConfig = config({ "model-a": "Operator Name" }); + operatorConfig.providers[DISPLAY_PROVIDER].apiKey = "sk-secret-not-for-rows"; + const operatorRow = (await listManagementModelRows(operatorConfig)) + .find(row => row.namespaced === `${DISPLAY_PROVIDER}/model-a`); + + expect(operatorRow).toMatchObject({ + displayName: "Operator Name", + displayNameOverride: "Operator Name", + displayNameSource: "operator", + }); + expect(JSON.stringify(operatorRow)).not.toContain("sk-secret-not-for-rows"); + + clearModelCache(DISPLAY_PROVIDER); + setCached(DISPLAY_PROVIDER, [{ + provider: DISPLAY_PROVIDER, + id: "model-a", + displayName: "Provider Name", + }]); + const providerConfig = config(); + providerConfig.providers[DISPLAY_PROVIDER].liveModels = true; + const providerRow = (await listManagementModelRows(providerConfig)) + .find(row => row.namespaced === `${DISPLAY_PROVIDER}/model-a`); + expect(providerRow).toMatchObject({ + displayName: "Provider Name", + displayNameSource: "provider", + }); + expect(providerRow?.displayNameOverride).toBeUndefined(); + + clearModelCache(DISPLAY_PROVIDER); + const fallbackRow = (await listManagementModelRows(config())) + .find(row => row.namespaced === `${DISPLAY_PROVIDER}/model-a`); + expect(fallbackRow).toMatchObject({ + displayName: `${DISPLAY_PROVIDER}/model-a`, + displayNameSource: "fallback", + }); + expect(fallbackRow?.displayNameOverride).toBeUndefined(); + }); + + test("management fallback text does not add redundant display metadata to client exports", () => { + const fallback = toExportModel({ + provider: DISPLAY_PROVIDER, + id: "model-a", + namespaced: `${DISPLAY_PROVIDER}/model-a`, + disabled: false, + displayName: `${DISPLAY_PROVIDER}/model-a`, + displayNameSource: "fallback", + }); + const operator = toExportModel({ + provider: DISPLAY_PROVIDER, + id: "model-a", + namespaced: `${DISPLAY_PROVIDER}/model-a`, + disabled: false, + displayName: "Model Alpha", + displayNameSource: "operator", + }); + + expect(fallback.displayName).toBeUndefined(); + expect(operator.displayName).toBe("Model Alpha"); + }); +}); + +describe("provider model display name mutation route", () => { + const catalogRefresh = { + status: "committed" as const, + changed: true, + degraded: false, + notices: [], + }; + + async function call( + liveConfig: OcxConfig, + body: unknown, + options: { + provider?: string; + rawBody?: string; + persist?: (saved: OcxConfig) => void; + converge?: () => Promise; + } = {}, + ): Promise<{ response: Response | null; persisted: OcxConfig[]; convergeCalls: number }> { + const provider = options.provider ?? DISPLAY_PROVIDER; + const url = new URL(`http://127.0.0.1:10100/api/providers/${encodeURIComponent(provider)}/model-display-names`); + const req = new Request(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: options.rawBody ?? JSON.stringify(body), + }); + const persisted: OcxConfig[] = []; + let convergeCalls = 0; + const response = await handleModelRoutes({ + req, + url, + config: liveConfig, + deps: { + saveConfigPreservingClaudeCode: saved => { + options.persist?.(saved); + persisted.push(structuredClone(saved)); + }, + }, + convergeCodexCatalog: async () => { + convergeCalls += 1; + return options.converge ? options.converge() : catalogRefresh; + }, + syncClaudeAgentDefsBestEffort: async () => {}, + }); + return { response, persisted, convergeCalls }; + } + + test("sets a trimmed label and returns the effective management state", async () => { + const liveConfig = config(); + + const result = await call(liveConfig, { modelId: "model-a", displayName: " Model Alpha " }); + const payload = await result.response!.json() as Record; + + expect(result.response?.status).toBe(200); + expect(liveConfig.providers[DISPLAY_PROVIDER].modelDisplayNames).toEqual({ "model-a": "Model Alpha" }); + expect(result.persisted).toHaveLength(1); + expect(result.convergeCalls).toBe(1); + expect(payload).toMatchObject({ + ok: true, + provider: DISPLAY_PROVIDER, + modelId: "model-a", + displayName: "Model Alpha", + displayNameOverride: "Model Alpha", + displayNameSource: "operator", + catalogRefresh, + }); + }); + + test("stores a label for a model temporarily absent from discovery", async () => { + const liveConfig = config(undefined, []); + + const result = await call(liveConfig, { modelId: "future/model", displayName: "Future Model" }); + const payload = await result.response!.json() as Record; + + expect(result.response?.status).toBe(200); + expect(liveConfig.providers[DISPLAY_PROVIDER].modelDisplayNames).toEqual({ "future/model": "Future Model" }); + expect(payload).toMatchObject({ + displayName: "Future Model", + displayNameOverride: "Future Model", + displayNameSource: "operator", + }); + }); + + test("rejects an update that would grow the stored map beyond its limit", async () => { + const existing = Object.fromEntries( + Array.from({ length: 2_000 }, (_, index) => [`model-${index}`, `Model ${index}`]), + ); + const liveConfig = config(existing); + + const result = await call(liveConfig, { modelId: "model-over-limit", displayName: "Too Many" }); + + expect(result.response?.status).toBe(400); + expect(result.persisted).toHaveLength(0); + expect(result.convergeCalls).toBe(0); + expect(liveConfig.providers[DISPLAY_PROVIDER].modelDisplayNames).toEqual(existing); + }); + + test("reset removes only the target and returns the fallback name", async () => { + const liveConfig = config({ "model-a": "Alpha", "model-b": "Beta" }, ["model-a", "model-b"]); + + const result = await call(liveConfig, { modelId: "model-a", displayName: null }); + const payload = await result.response!.json() as Record; + + expect(liveConfig.providers[DISPLAY_PROVIDER].modelDisplayNames).toEqual({ "model-b": "Beta" }); + expect(payload).toMatchObject({ + displayName: `${DISPLAY_PROVIDER}/model-a`, + displayNameOverride: null, + displayNameSource: "fallback", + }); + expect(result.persisted).toHaveLength(1); + expect(result.convergeCalls).toBe(1); + }); + + test("reset clears an overlaid discovery cache before catalog convergence", async () => { + const liveConfig = config({ "model-a": "Operator Name" }); + setCached(DISPLAY_PROVIDER, [{ + provider: DISPLAY_PROVIDER, + id: "model-a", + displayName: "Operator Name", + }]); + let cacheWasClearAtConvergence = false; + + const result = await call(liveConfig, { modelId: "model-a", displayName: null }, { + converge: async () => { + cacheWasClearAtConvergence = getFreshCached(DISPLAY_PROVIDER, 60_000) === null; + return catalogRefresh; + }, + }); + + expect(result.response?.status).toBe(200); + expect(cacheWasClearAtConvergence).toBe(true); + }); + + test("reset omits an empty map", async () => { + const liveConfig = config({ "model-a": "Alpha" }); + + const result = await call(liveConfig, { modelId: "model-a", displayName: null }); + + expect(result.response?.status).toBe(200); + expect(liveConfig.providers[DISPLAY_PROVIDER].modelDisplayNames).toBeUndefined(); + }); + + test("rejects unknown providers and malformed updates without side effects", async () => { + const cases: Array<{ body: unknown; rawBody?: string }> = [ + { body: {}, rawBody: "{" }, + { body: {} }, + { body: { modelId: "", displayName: "Name" } }, + { body: { modelId: "", displayName: null } }, + { body: { modelId: "model-a", displayName: " " } }, + { body: { modelId: "model-a", displayName: "Bad/Name" } }, + { body: { modelId: "model-a", displayName: "Bad\nName" } }, + { body: { modelId: "model-a", displayName: "A".repeat(129) } }, + { body: { modelId: "model-a", displayName: 7 } }, + ]; + for (const item of cases) { + const liveConfig = config(); + const result = await call(liveConfig, item.body, { rawBody: item.rawBody }); + expect(result.response?.status).toBe(400); + expect(result.persisted).toHaveLength(0); + expect(result.convergeCalls).toBe(0); + expect(liveConfig.providers[DISPLAY_PROVIDER].modelDisplayNames).toBeUndefined(); + } + + const unknown = await call(config(), { modelId: "model-a", displayName: "Name" }, { provider: "missing" }); + expect(unknown.response?.status).toBe(404); + expect(unknown.persisted).toHaveLength(0); + expect(unknown.convergeCalls).toBe(0); + }); + + test("a persistence failure restores the exact in memory map and never converges", async () => { + const liveConfig = config({ "model-b": "Beta" }); + let convergeCalls = 0; + + await expect(handleModelRoutes({ + req: new Request(`http://127.0.0.1:10100/api/providers/${DISPLAY_PROVIDER}/model-display-names`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ modelId: "model-a", displayName: "Alpha" }), + }), + url: new URL(`http://127.0.0.1:10100/api/providers/${DISPLAY_PROVIDER}/model-display-names`), + config: liveConfig, + deps: { saveConfigPreservingClaudeCode: () => { throw new Error("disk full"); } }, + convergeCodexCatalog: async () => { + convergeCalls += 1; + return catalogRefresh; + }, + syncClaudeAgentDefsBestEffort: async () => {}, + })).rejects.toThrow("disk full"); + + expect(liveConfig.providers[DISPLAY_PROVIDER].modelDisplayNames).toEqual({ "model-b": "Beta" }); + expect(convergeCalls).toBe(0); + }); + + test("a convergence failure keeps the successfully persisted label", async () => { + const liveConfig = config(); + let persisted: OcxConfig | undefined; + + await expect(call(liveConfig, { modelId: "model-a", displayName: "Alpha" }, { + persist: saved => { persisted = structuredClone(saved); }, + converge: async () => { throw new Error("catalog busy"); }, + })).rejects.toThrow("catalog busy"); + + expect(persisted?.providers[DISPLAY_PROVIDER].modelDisplayNames).toEqual({ "model-a": "Alpha" }); + expect(liveConfig.providers[DISPLAY_PROVIDER].modelDisplayNames).toEqual({ "model-a": "Alpha" }); + }); + + test("a failed catalog result reports that the saved label still needs refresh", async () => { + const liveConfig = config(); + const failedRefresh: CatalogDisposition = { + status: "failed", + reason: "disk", + phase: "commit", + retryable: true, + partialWrite: false, + cause: { kind: "io", code: "ENOSPC" }, + }; + + const result = await call(liveConfig, { modelId: "model-a", displayName: "Alpha" }, { + converge: async () => failedRefresh, + }); + const payload = await result.response!.json() as Record; + + expect(result.response?.status).toBe(503); + expect(result.persisted).toHaveLength(1); + expect(liveConfig.providers[DISPLAY_PROVIDER].modelDisplayNames).toEqual({ "model-a": "Alpha" }); + expect(payload).toEqual({ + error: "model display name saved but catalog refresh failed", + saved: true, + provider: DISPLAY_PROVIDER, + modelId: "model-a", + displayNameOverride: "Alpha", + catalogRefresh: failedRefresh, + }); + }); + + test("sequential updates preserve neighboring labels and prototype shaped model ids", async () => { + const liveConfig = config({ "model-b": "Beta" }); + + await call(liveConfig, { modelId: "model-a", displayName: "Alpha" }); + await call(liveConfig, { modelId: "__proto__", displayName: "Prototype Model" }); + + expect(Object.entries(liveConfig.providers[DISPLAY_PROVIDER].modelDisplayNames ?? {})).toEqual([ + ["model-b", "Beta"], + ["model-a", "Alpha"], + ["__proto__", "Prototype Model"], + ]); + }); +}); diff --git a/tests/opencode-cli.test.ts b/tests/opencode-cli.test.ts index 5a14d273f1..1b8cce02d7 100644 --- a/tests/opencode-cli.test.ts +++ b/tests/opencode-cli.test.ts @@ -291,16 +291,21 @@ describe("ocx opencode proxy model catalog", () => { ); const rows = await modelsRes!.json() as Array<{ namespaced?: string; + displayName?: string; + displayNameSource?: "operator" | "provider" | "fallback"; contextWindow?: number; }>; expect(requestedAuth).toBe(`Bearer ${RESOLVED}`); const liveRow = rows.find(r => r.namespaced === `${PROVIDER}/live-via-proxy-env`); expect(liveRow).toBeTruthy(); + expect(liveRow?.displayNameSource).toBe("fallback"); expect(liveRow?.contextWindow).toBe(128_000); const catalog = opencodeCatalogFromProxyRows(rows, config); - expect(catalog.map(m => m.namespaced)).toContain(`${PROVIDER}/live-via-proxy-env`); + const liveCatalogRow = catalog.find(m => m.namespaced === `${PROVIDER}/live-via-proxy-env`); + expect(liveCatalogRow).toBeTruthy(); + expect(liveCatalogRow?.displayName).toBeUndefined(); const block = buildOpencodeProviderBlockFromCatalog(10100, catalog, undefined, config); expect(block.models[`${PROVIDER}/live-via-proxy-env`]?.limit?.context).toBe(128_000); diff --git a/tests/provider-config-validation.test.ts b/tests/provider-config-validation.test.ts index 21f9b53cb1..73238884e4 100644 --- a/tests/provider-config-validation.test.ts +++ b/tests/provider-config-validation.test.ts @@ -3,6 +3,7 @@ import { apiKeyTransportConfigError, booleanRecordConfigError, modelAdapterRecordConfigError, + modelDisplayNamesConfigError, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, positiveIntegerConfigError, @@ -84,4 +85,34 @@ describe("provider config validation leaf", () => { { adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }, )).toContain("canonical ChatGPT forward provider"); }); + + test("accepts safe display names for exact provider model ids", () => { + expect(modelDisplayNamesConfigError(undefined)).toBeNull(); + expect(modelDisplayNamesConfigError({})).toBeNull(); + expect(modelDisplayNamesConfigError({ "models/grok-4.6": "Grok 4.6" })).toBeNull(); + expect(modelDisplayNamesConfigError({ "grok-4.6": "A".repeat(128) })).toBeNull(); + }); + + test("rejects unsafe discovered model display name maps", () => { + expect(modelDisplayNamesConfigError([])).toContain("plain object"); + expect(modelDisplayNamesConfigError(Object.create({ inherited: "Unsafe" }))).toContain("own properties"); + expect(modelDisplayNamesConfigError(Object.fromEntries( + Array.from({ length: 2_001 }, (_, index) => [`model-${index}`, `Model ${index}`]), + ))).toContain("at most 2000 entries"); + expect(modelDisplayNamesConfigError({ " ": "Blank key" })).toContain("valid model ids"); + expect(modelDisplayNamesConfigError({ ["m".repeat(1_025)]: "Long key" })).toContain("valid model ids"); + expect(modelDisplayNamesConfigError({ model: 7 })).toContain("must be a string"); + expect(modelDisplayNamesConfigError({ model: " " })).toContain("nonblank"); + expect(modelDisplayNamesConfigError({ model: " Grok 4.6 " })).toContain("must be trimmed"); + expect(modelDisplayNamesConfigError({ model: "A".repeat(129) })).toContain("at most 128 characters"); + expect(modelDisplayNamesConfigError({ model: "Grok/4.6" })).toContain("must not contain /"); + expect(modelDisplayNamesConfigError({ model: "Grok\n4.6" })).toContain("control characters"); + }); + + test("does not echo a secret shaped model id in display name errors", () => { + const secretModelId = ["sk", "secret", "model", "id", "123456"].join("-"); + const error = modelDisplayNamesConfigError({ [secretModelId]: "Bad/Name" }); + expect(error).not.toContain(secretModelId); + expect(error).toContain("[REDACTED]"); + }); });