diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index 7076e7df862..8a9398988f6 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -294,8 +294,8 @@ ocx combo remove --yes ``` `set` accepte également `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, -`--display-name` et `--rename-from`. Utilisez `-` comme valeur de `--effort`, `--alias` ou -`--display-name` pour effacer ce champ. `--native-alias` exige un alias de modèle natif non qualifié actuellement +`--display-name`, `--description` et `--rename-from`. Utilisez `-` comme valeur de `--effort`, `--alias`, +`--display-name` ou `--description` pour effacer ce champ. `--native-alias` exige un alias de modèle natif non qualifié actuellement pris en charge et un nom d’affichage non vide. `create` et `update` sont des alias pour `set` ; `delete` est un alias pour `remove` ; et les mêmes sous-commandes sont disponibles sous `ocx route combo`. @@ -341,6 +341,7 @@ Les combos sont stockés dans l'objet `combos` de niveau supérieur, saisi par l | `alias` | Non | aucun | Identifiant de modèle public tronqué facultatif ; utilisez les règles d'alias ci-dessus. Une valeur vide est stockée sans alias. | | `nativeAlias` | Non | `false` | Autoriser explicitement un `alias` natif nu actuellement pris en charge à avoir la priorité sur le routage et le catalogue. Jamais déduit de l'alias. | | `displayName` | Non | aucun | Étiquette de catalogue délimitée en affichage uniquement. Obligatoire et non vide lorsque `nativeAlias` est vrai. | +| `description` | Non | aucun | Description de ligne de catalogue en affichage uniquement, avec les mêmes limites que `displayName`. Sans valeur, le texte généré `Routed via opencodex → combo (combo).` est conservé. | ## Dépannage diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 4fa8724c5c6..33ad8676cd7 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -792,8 +792,8 @@ ocx combo remove --yes ``` `set` also accepts `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, -`--allow-metered-targets on|off`, `--display-name`, `--decision-provider`, `--decision-timeout`, and `--rename-from`. Use `-` as the -value of `--effort`, `--alias`, `--display-name`, `--decision-provider`, or `--decision-timeout` to +`--allow-metered-targets on|off`, `--display-name`, `--description`, `--decision-provider`, `--decision-timeout`, and `--rename-from`. Use `-` as the +value of `--effort`, `--alias`, `--display-name`, `--description`, `--decision-provider`, or `--decision-timeout` to clear that field. The two decision flags apply only to `--strategy jev`. `--native-alias` requires a currently supported bare native model alias and a non-empty display name. `create` and `update` are aliases for `set`; `delete` is an alias for `remove`; and the same subcommands are available under `ocx route combo`. @@ -859,6 +859,7 @@ Combos are stored in the top-level `combos` object, keyed by combo id: | `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. | | `nativeAlias` | No | `false` | Explicitly permit a currently supported bare native `alias` to take routing and catalog precedence. Never inferred from the alias. | | `displayName` | No | none | Bounded display-only catalog label. Required and non-empty when `nativeAlias` is true. | +| `description` | No | none | Display-only catalog row description, bounded like `displayName` (at most 128 characters, no control characters). Unset keeps the generated `Routed via opencodex → combo (combo).` text. Management API writes that omit it keep the saved value; an empty string clears it. | | `decisionProvider` | No | `"jev"` | JEV only. Provider id of the decision service: `"jev"` (TypeSafe, valid without a provider row; the same as omission) or a configured `adapter: "jev-decision"` row with a full HTTPS decision URL or a local HTTP `/systemone` URL, such as a self-hosted Ollama `tev1`. | | `decisionTimeoutMs` | No | `4000` | JEV only. Integer from 1000 to 120000: the decision deadline before failing open to the first eligible target. | diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 7ed25d4cb27..cb31f349e60 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -201,9 +201,9 @@ ocx combo remove --yes ``` `set` は、`--strategy`、`--sticky`、`--effort`、`--alias`、`--native-alias`、 -`--display-name`、および `--rename-from` も受け入れます。`--native-alias` には現在 +`--display-name`、`--description`、および `--rename-from` も受け入れます。`--native-alias` には現在 サポートされている bare native alias と空でない display name が必要です。`--effort`、`--alias`、 -`--display-name` に `-` を指定すると、その値をクリアできます。`create` および `update` は `set` の +`--display-name`、`--description` に `-` を指定すると、その値をクリアできます。`create` および `update` は `set` の エイリアス、`delete` は `remove` のエイリアスです。すべてのコンボ サブコマンドは `ocx route combo ...` からも利用できます。 ### 管理 API @@ -248,6 +248,7 @@ ocx combo remove --yes | `alias` |いいえ |なし |オプションのトリミングされたパブリック モデル ID。上記のエイリアス ルールを使用します。空の値はエイリアスなしで保存されます。 | | `nativeAlias` |いいえ | `false` | 現在サポートされている bare native alias に routing/catalog の優先権を明示的に与えます。 | | `displayName` |いいえ |なし | catalog 表示専用ラベル。`nativeAlias` が true の場合は必須です。 | +| `description` |いいえ |なし | catalog 行の表示専用の説明。制限は `displayName` と同じです。未設定の場合は生成された `Routed via opencodex → combo (combo).` が使われます。 | ## トラブルシューティング diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index a9a69b76b5a..e5d8c844516 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -209,9 +209,9 @@ ocx combo set --targets provider/model[:weight],... ocx combo remove --yes ``` -`set`은 `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, `--display-name`, +`set`은 `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, `--display-name`, `--description`, `--rename-from`도 받습니다. `--native-alias`에는 현재 지원되는 bare native alias와 비어 있지 않은 display name이 필요합니다. `--effort`, `--alias`, -`--display-name`에 `-`를 지정하면 해당 값을 지울 수 있습니다. `create`와 `update`는 `set`의 +`--display-name`, `--description`에 `-`를 지정하면 해당 값을 지울 수 있습니다. `create`와 `update`는 `set`의 별칭이고 `delete`는 `remove`의 별칭입니다. 모든 combo 하위 명령은 `ocx route combo ...`에서도 사용할 수 있습니다. ### Management API @@ -256,6 +256,7 @@ ocx combo remove --yes | `alias` | 아니요 | 없음 | 선택적으로 앞뒤 공백을 제거한 공개 모델 ID입니다. 위의 alias 규칙을 따릅니다. 빈 값은 alias 없음으로 저장됩니다. | | `nativeAlias` | 아니요 | `false` | 현재 지원되는 bare native alias가 routing/catalog 우선권을 갖도록 명시적으로 허용합니다. | | `displayName` | 아니요 | 없음 | catalog 표시 전용 label입니다. `nativeAlias`가 true이면 필수입니다. | +| `description` | 아니요 | 없음 | catalog 행에 표시되는 설명입니다. `displayName`과 같은 제한이 적용됩니다. 설정하지 않으면 생성된 `Routed via opencodex → combo (combo).` 문구가 유지됩니다. | ## 문제 해결 diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index 29c59a402b4..31b760bfd3d 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -251,7 +251,7 @@ ocx combo remove --yes ``` `set` также принимает `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, -`--display-name` и `--rename-from`. Значение `-` у `--effort`, `--alias` или `--display-name` +`--display-name`, `--description` и `--rename-from`. Значение `-` у `--effort`, `--alias`, `--display-name` или `--description` очищает соответствующее поле. Для `--native-alias` нужны поддерживаемый сейчас bare native alias и непустой display name. `create` и `update` — alias для `set`, а `delete` — alias для `remove`; те же подкоманды доступны через `ocx route combo`. @@ -301,6 +301,7 @@ Combo хранятся в объекте верхнего уровня `combos`, | `alias` | No | none | Необязательный обрезанный публичный id модели; используйте правила alias выше. Пустое значение хранится как отсутствие alias. | | `nativeAlias` | No | `false` | Явно разрешает поддерживаемому сейчас bare native alias перехватить приоритет routing/catalog только для неквалифицированного id. Bare `gpt-5.6-*` использует учётные данные Codex Pool/Direct; маршруты с квалификатором аккаунта сохраняют свою идентичность, а provider-qualified `openai-apikey/gpt-5.6-*` использует API-ключ и никогда не переходит на native alias. | | `displayName` | No | none | Метка только для отображения в catalog; обязательна при `nativeAlias: true`. | +| `description` | No | none | Описание строки catalog только для отображения, с теми же ограничениями, что и `displayName`. Без него остаётся сгенерированный текст `Routed via opencodex → combo (combo).`. | ## Устранение неполадок diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index 6e8b5444780..e2063712afc 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -324,8 +324,8 @@ ocx combo remove --yes ``` `set` ayrıca `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, -`--display-name` ve `--rename-from` kabul eder. Bu alanı temizlemek için -`--effort`, `--alias` veya `--display-name` değeri olarak `-` kullanın. +`--display-name`, `--description` ve `--rename-from` kabul eder. Bu alanı temizlemek için +`--effort`, `--alias`, `--display-name` veya `--description` değeri olarak `-` kullanın. `--native-alias`, şu anda desteklenen yalın bir yerel model takma adı ve boş olmayan bir görüntüleme adı gerektirir. `create` ve `update`, `set`'in takma adlarıdır; `delete`, `remove`'un takma adıdır; ve aynı alt komutlar `ocx route @@ -375,6 +375,7 @@ saklanır: | `alias` | Hayır | yok | İsteğe bağlı kırpılmış genel model kimliği; yukarıdaki takma ad kurallarını kullanın. Boş bir değer takma ad yok olarak saklanır. | | `nativeAlias` | Hayır | `false` | Şu anda desteklenen yalın bir yerel `alias`'ın yönlendirme ve katalog önceliği almasına açıkça izin verin. Asla takma addan çıkarılmaz. | | `displayName` | Hayır | yok | Sınırlı salt görüntüleme katalog etiketi. `nativeAlias` true olduğunda gerekli ve boş değildir. | +| `description` | Hayır | yok | Salt görüntüleme katalog satırı açıklaması; `displayName` ile aynı sınırlar geçerlidir. Ayarlanmazsa üretilen `Routed via opencodex → combo (combo).` metni korunur. | ## Sorun Giderme diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index 53e918bd56e..efb8ddc85a5 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -230,7 +230,7 @@ ocx combo remove --yes ``` `set` 也接受 `--strategy`、`--sticky`、`--effort`、`--alias`、`--native-alias`、 -`--display-name` 和 `--rename-from`。将 `--effort`、`--alias` 或 `--display-name` 的值设为 +`--display-name`、`--description` 和 `--rename-from`。将 `--effort`、`--alias`、`--display-name` 或 `--description` 的值设为 `-` 可清除该字段。`--native-alias` 必须配合当前受支持的裸原生 alias 和非空显示名称使用。 `create` 和 `update` 是 `set` 的别名;`delete` 是 `remove` 的别名;同样的子命令也可通过 `ocx route combo` 使用。 @@ -278,6 +278,7 @@ combo 会存储在顶层的 `combos` 对象中,并以 combo id 作为键: | `alias` | 否 | 无 | 可选的、已修剪的公开模型 id;使用上面的别名规则。空值会以“无别名”形式存储。 | | `nativeAlias` | 否 | `false` | 显式允许当前受支持的裸原生 alias 接管路由和 catalog 优先级;绝不会根据 alias 自动推断。 | | `displayName` | 否 | 无 | 仅用于 catalog 展示的有界标签;`nativeAlias` 为 true 时必须非空。 | +| `description` | 否 | 无 | 仅用于 catalog 展示的行描述,限制与 `displayName` 相同。未设置时保留生成的 `Routed via opencodex → combo (combo).` 文本。 | ## 故障排查 diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md index d95c8409370..86d26e0d0b9 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -236,7 +236,7 @@ ocx combo set --targets provider/model[:weight],... ocx combo remove --yes ``` -`set` 也接受 `--strategy`、`--sticky`、`--effort`、`--alias` 與 `--rename-from`。用 `-` 作為 `--effort` 或 `--alias` 的值可清除該欄位。`create` 與 `update` 為 `set` 的別名;`delete` 為 `remove` 的別名;且相同子指令在 `ocx route combo` 下也可用。 +`set` 也接受 `--strategy`、`--sticky`、`--effort`、`--alias`、`--display-name`、`--description` 與 `--rename-from`。用 `-` 作為 `--effort`、`--alias`、`--display-name` 或 `--description` 的值可清除該欄位。`create` 與 `update` 為 `set` 的別名;`delete` 為 `remove` 的別名;且相同子指令在 `ocx route combo` 下也可用。 ### 管理 API @@ -275,6 +275,7 @@ Combo 儲存於頂層 `combos` 物件中,以 combo id 為 key: | `defaultEffort` | 否 | `null` | `low`、`medium`、`high`、`xhigh`、`max` 或 `ultra`;僅在呼叫者省略 effort 且目標宣告支援時套用。 | | `reasoningEffortMode` | 否 | `"strict"` | `strict` 或 `adaptive`;選擇混合能力交集及目標層級控制正規化。 | | `alias` | 否 | 無 | 可選的修剪後公開模型 id;使用上述別名規則。空值儲存為無別名。 | +| `description` | 否 | 無 | 僅供 catalog 顯示的列描述,最多 128 個字元且不含控制字元。未設定時保留產生的 `Routed via opencodex → combo (combo).` 文字。 | ## 疑難排解 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index edab985cdac..4949715da9c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", + "explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "owner-registry-isolation.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", diff --git a/skills/ocx/references/01_surface_agents-routing.md b/skills/ocx/references/01_surface_agents-routing.md index 5e7d9168895..f8e1e9b10db 100644 --- a/skills/ocx/references/01_surface_agents-routing.md +++ b/skills/ocx/references/01_surface_agents-routing.md @@ -704,7 +704,7 @@ State-changing: no. JSON mode: `payload`. ### `ocx combo set` -Usage: `ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--decision-provider | --decision-model ] [--decision-timeout ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]` +Usage: `ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--description ] [--decision-provider | --decision-model ] [--decision-timeout ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]` Create, update or rename a combo. @@ -725,6 +725,7 @@ State-changing: yes. JSON mode: `payload`. | `--effort-mode` | string | fallback or force; - resets to fallback. | | `--alias` | string | Alias; - clears. | | `--display-name` | string | Label; - clears. | +| `--description` | string | Catalog row description; - clears. | | `--decision-provider` | string | JEV provider; - clears. | | `--decision-model` | string | JEV route; - clears; exclusive with provider. | | `--decision-timeout` | string | JEV deadline 1000-120000 ms; - clears. | diff --git a/src/cli/capabilities-agents-routing.ts b/src/cli/capabilities-agents-routing.ts index 62e93f6eb7a..dd047a5e091 100644 --- a/src/cli/capabilities-agents-routing.ts +++ b/src/cli/capabilities-agents-routing.ts @@ -298,10 +298,10 @@ export const AGENT_ROUTING_CAPABILITIES: readonly Capability[] = [ }, { command: ["combo","set"], - usage: "ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--decision-provider | --decision-model ] [--decision-timeout ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]", + usage: "ocx combo set [--targets | --targets-file ] [--strategy ] [--sticky <1-100|->] [--effort ] [--effort-mode ] [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] [--description ] [--decision-provider | --decision-model ] [--decision-timeout ] [--rename-from ] [--image-input ] [--reasoning-effort-mode ] [--json]", summary: "Create, update or rename a combo.", routes: [{"method":"GET","path":"/api/combos"},{"method":"PUT","path":"/api/combos"}], - flags: [{"name":"--allow-metered-targets","value":"string","summary":"on grants operator consent to metered targets only on a loopback-only proxy; off clears it."},{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."}], + flags: [{"name":"--allow-metered-targets","value":"string","summary":"on grants operator consent to metered targets only on a loopback-only proxy; off clears it."},{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--description","value":"string","summary":"Catalog row description; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."}], mutates: true, json: "payload", details: ["create and update are aliases of set. Existing fields are read before writing; --targets replaces targets.","force overrides valid client effort and may increase cost and latency.","Only the implemented target grammar is supported; full GUI target metadata and routing-profile writes are separate tasks.","New file/flag conflicts refuse before transport. Partial edits preserve target metadata; explicit false/default values override server carry. Turning a native alias off may also require clearing its incompatible alias.","Read/merge/write uses one target and remains an upsert without revision CAS. Saved-but-incomplete catalog outcomes return nonzero with their receipt."], diff --git a/src/cli/combo-result.ts b/src/cli/combo-result.ts index 97a0ad0aac3..9af467dc5a5 100644 --- a/src/cli/combo-result.ts +++ b/src/cli/combo-result.ts @@ -7,7 +7,7 @@ const ENUMS: Readonly> = { defaultEffortMode: ["fallback", "force"], reasoningEffortMode: ["strict", "adaptive"], imageInput: ["auto", "disabled"], cooldownWaitPolicy: ["before-last-resort"], }; -const STRINGS = new Set(["alias", "displayName", "decisionProvider", "decisionModel"]); +const STRINGS = new Set(["alias", "displayName", "description", "decisionProvider", "decisionModel"]); const RANGES: Readonly> = { stickyLimit: [1, 100], cooldownMs: [1, 600_000], waitForCooldownMs: [0, 600_000], decisionTimeoutMs: [1_000, 120_000], diff --git a/src/cli/combo.ts b/src/cli/combo.ts index c29dbf1fa35..cf91f6426e8 100644 --- a/src/cli/combo.ts +++ b/src/cli/combo.ts @@ -24,6 +24,7 @@ const USAGE = `Usage: [--effort ] [--effort-mode ] (force overrides valid client effort and can increase cost/latency) [--alias ] [--native-alias [on|off]] [--allow-metered-targets ] [--display-name ] + [--description ] [--image-input ] [--reasoning-effort-mode ] [--decision-provider | --decision-model ] [--decision-timeout ] (jev only; the provider must be a configured jev-decision row) @@ -118,6 +119,7 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { const alias = takeOption(args, "--alias"); const displayName = takeOption(args, "--display-name"); + const description = takeOption(args, "--description"); const decisionProvider = takeOption(args, "--decision-provider"); const decisionModel = takeOption(args, "--decision-model"); if (decisionModel !== undefined && decisionModel !== "-" && decisionProvider !== undefined && decisionProvider !== "-") { @@ -164,6 +166,7 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { if (additional.imageInput !== undefined) combo.imageInput = additional.imageInput; if (additional.reasoningEffortMode !== undefined) combo.reasoningEffortMode = additional.reasoningEffortMode; if (displayName !== undefined) combo.displayName = displayName === "-" ? "" : displayName; + if (description !== undefined) combo.description = description === "-" ? "" : description; if (decisionProvider !== undefined) combo.decisionProvider = decisionProvider === "-" ? null : decisionProvider; if (decisionModel !== undefined) combo.decisionModel = decisionModel === "-" ? null : decisionModel; if (decisionProvider !== undefined && decisionProvider !== "-") combo.decisionModel = null; diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index 8352eaabc9f..d56b07db21d 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -195,6 +195,7 @@ export function deriveComboCatalogModel( ...(combo.alias ? { alias: combo.alias } : {}), ...(combo.nativeAlias ? { nativeAlias: true } : {}), ...(combo.displayName ? { displayName: combo.displayName } : {}), + ...(combo.description ? { description: combo.description } : {}), ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(members.every(member => member.parallelToolCalls === true) ? { parallelToolCalls: true } @@ -401,6 +402,20 @@ export function exactComboCatalogSlugs( })); } +/** Current combo row descriptions by public catalog slug, for retained-row re-projection (#7229). */ +export function comboCatalogDescriptions( + config: Pick, +): Map { + const current = exactComboCatalogSlugs(config); + return new Map(listComboIds(config).flatMap(id => { + const raw = config.combos?.[id]; + const alias = typeof raw?.alias === "string" ? raw.alias.trim() : ""; + const publicSlug = alias || comboModelId(id); + const description = typeof raw?.description === "string" ? raw.description.trim() : ""; + return description && current.has(publicSlug) ? [[publicSlug, description] as const] : []; + })); +} + export function normalizedOpenAiApiSignature(model: CatalogModel): string { const normalized = { provider: model.provider, diff --git a/src/codex/catalog/auto-review.ts b/src/codex/catalog/auto-review.ts index dfb4b603ba2..243d4fd8ba6 100644 --- a/src/codex/catalog/auto-review.ts +++ b/src/codex/catalog/auto-review.ts @@ -5,6 +5,7 @@ import { encodeRoutedModelId } from "../../providers/slug-codec"; import { canonicalAutoReviewModelKey, isValidAutoReviewModel as isValidAutoReviewTarget } from "../../config/provider-validation"; import { readConfiguredAutoReviewModel } from "./parsing"; import type { RawEntry } from "./parsing"; +import { hasOcxRoutedAuthorship } from "./ownership"; import { configuredCatalogEntry } from "./subagent-roster"; const AUTO_REVIEW_ROOT_MARKER = "opencodex_auto_review_root"; @@ -38,7 +39,7 @@ function isRoutedCatalogEntry(entry: RawEntry): boolean { if (isCodexControlPlaneModel(entry.slug)) return false; const slug = typeof entry.slug === "string" ? entry.slug : ""; return slug.includes("/") - || (typeof entry.description === "string" && entry.description.startsWith("Routed via opencodex → ")); + || hasOcxRoutedAuthorship(entry); } /** Restore an owned native value, retaining provenance to avoid legacy reclassification. */ diff --git a/src/codex/catalog/build-entries.ts b/src/codex/catalog/build-entries.ts index 61ea74f1bec..205e78d77ba 100644 --- a/src/codex/catalog/build-entries.ts +++ b/src/codex/catalog/build-entries.ts @@ -4,6 +4,7 @@ import { CODEX_REASONING_LEVELS, type CodexReasoningLevel } from "../../reasonin import { clearModelCache } from "../model-cache"; import { routedSlug, slugEquivalenceKey } from "../../providers/slug-codec"; import { COMBO_NAMESPACE } from "../../combos"; +import { hasOcxComboAuthorship, hasOcxRoutedAuthorship, reapplyRetainedComboDescription, ROUTED_DESCRIPTION_PREFIX } from "./ownership"; import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, @@ -218,7 +219,7 @@ export function buildCatalogEntriesFromObservedState({ const routed = deriveEntry( template, slug, - `Routed via opencodex → ${nativeAlias.provider} (${nativeAlias.owned_by ?? nativeAlias.provider}).`, + `${ROUTED_DESCRIPTION_PREFIX}${nativeAlias.provider} (${nativeAlias.owned_by ?? nativeAlias.provider}).`, 5, nativeAlias, exactComboSlugs, @@ -276,7 +277,7 @@ export function buildCatalogEntriesFromObservedState({ const e = deriveEntry( template, slug, - `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`, + `${ROUTED_DESCRIPTION_PREFIX}${m.provider} (${m.owned_by ?? m.provider}).`, 5, m, exactComboSlugs, @@ -380,15 +381,15 @@ export function orderForModelPicker( * True when an existing catalog row was authored by OpenCodex routing (#855). * Every generated routed row — current full-slug form, the June–July 2026 * provider-name form, and legacy combo aliases — carries the stable - * description prefix `Routed via opencodex → `; foreign rows from Cursor or + * description prefix `Routed via opencodex → `, or — when a combo overrides its description + * (#7229) — the `opencodex_routed_origin` marker (./ownership); foreign rows from Cursor or * user tooling do not. `owned_by` describes upstream ownership, and `comp_hash` * describes history compatibility; neither is an authorship signal. */ export function isOcxAuthoredRoutedEntry(entry: RawEntry): boolean { if (isNativeAliasCatalogEntry(entry)) return true; - const desc = typeof entry.description === "string" ? entry.description : ""; const slug = typeof entry.slug === "string" ? entry.slug : ""; - return slug.includes("/") && desc.startsWith("Routed via opencodex → "); + return slug.includes("/") && hasOcxRoutedAuthorship(entry); } function recoverableNativeSlug(entry: RawEntry): string | null { @@ -503,6 +504,11 @@ export interface ObservedCatalogMergeInput { readonly multiAgentV2Enabled: boolean; readonly keepNativeChatGptOnV1?: boolean; readonly exactComboSlugs: ReadonlySet; + /** + * Current combo row descriptions by public slug (#7229). When supplied, retained combo rows + * are re-projected onto it; absent means the caller has no config and leaves them unchanged. + */ + readonly comboDescriptions?: ReadonlyMap; readonly hasPhysicalComboProvider: boolean; readonly includeNativeOpenAi: boolean; readonly accountBoundEntries: readonly RawEntry[]; @@ -545,6 +551,7 @@ export function mergeCatalogEntriesFromObservedState({ multiAgentV2Enabled, keepNativeChatGptOnV1, exactComboSlugs, + comboDescriptions, hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, @@ -639,8 +646,7 @@ export function mergeCatalogEntriesFromObservedState({ } const freshExactComboEntries = new Set(admittedRoutedEntries.filter(entry => ( isExactComboCatalogEntry(entry, exactComboSlugs) - && typeof entry.description === "string" - && entry.description.startsWith(`Routed via opencodex → ${COMBO_NAMESPACE} (`) + && hasOcxComboAuthorship(entry) ))); const rank = new Map(featured.map((slug, i) => [slug, i] as const)); const freshEquivalentKeys = new Set(admittedRoutedEntries.flatMap(entry => ( @@ -802,6 +808,10 @@ export function mergeCatalogEntriesFromObservedState({ delete entry[SPAWN_PRIORITY_FIELD]; } const slug = String(entry.slug); + if (comboDescriptions !== undefined && exactComboSlugs.has(slug) + && (isNativeAliasCatalogEntry(entry) || (entry.owned_by === COMBO_NAMESPACE && hasOcxComboAuthorship(entry)))) { + reapplyRetainedComboDescription(entry, comboDescriptions.get(slug)); + } if (!isOcxAuthoredRoutedEntry(entry)) continue; // The builder no longer copies a template's comp_hash onto routed rows (#5796), but a row // kept from disk may still carry one. Custom rows, Codex-forward aliases included, never diff --git a/src/codex/catalog/derive-entry.ts b/src/codex/catalog/derive-entry.ts index 98bd424e712..8a710a295a3 100644 --- a/src/codex/catalog/derive-entry.ts +++ b/src/codex/catalog/derive-entry.ts @@ -26,6 +26,7 @@ import { isGpt56NativeSlug, } from "./effort"; import { CATALOG_INACTIVE_REASON_FIELD, SPAWN_PRIORITY_FIELD } from "./subagent-roster"; +import { applyRoutedDescription } from "./ownership"; export function finishUpstreamNativeEntry(clone: RawEntry, priority: number, contextCap?: NativeContextLimitsInput): RawEntry { if (priority !== 9) clone.priority = priority; @@ -128,7 +129,7 @@ export function deriveEntry( delete e[SPAWN_PRIORITY_FIELD]; e.slug = slug; e.display_name = routedDisplayName(slug, model); - e.description = desc; + applyRoutedDescription(e, desc, model); e.priority = priority; e.visibility = "list"; if ("upgrade" in e) e.upgrade = null; @@ -212,6 +213,7 @@ export function deriveEntry( : { web_search_tool_type: "text_and_image", supports_search_tool: true } : {}), }; + applyRoutedDescription(entry, desc, model); if (isRouted) { applyRoutedCodexToolMode(entry, model?.codexToolMode); applyReasoningLevels( diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 4bc001a5a41..ada0e738937 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -38,6 +38,7 @@ import type { RawEntry } from "./parsing"; import { readCurrentCatalogOrCache, readCurrentCodexCatalog, readCurrentCodexModelsCache, unique } from "./bundled"; import { trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./account-models"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; +import { ROUTED_ORIGIN_FIELD } from "./ownership"; import { RESERVE_METADATA_SOURCE_FIELD } from "./reserve"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, @@ -813,6 +814,7 @@ export function observedReserveCatalogSource( } source.slug = NATIVE_RESERVE_MODEL; delete source.opencodex_catalog_kind; + delete source[ROUTED_ORIGIN_FIELD]; delete source[RESERVE_METADATA_SOURCE_FIELD]; delete source[ACCOUNT_BOUND_OBSERVED_NATIVE_MARKER]; delete source[ACCOUNT_BOUND_OBSERVED_SELECTORS_MARKER]; diff --git a/src/codex/catalog/ownership.ts b/src/codex/catalog/ownership.ts new file mode 100644 index 00000000000..b27119c6716 --- /dev/null +++ b/src/codex/catalog/ownership.ts @@ -0,0 +1,71 @@ +import { COMBO_NAMESPACE } from "../../combos/identifiers"; +import type { CatalogModel, RawEntry } from "./parsing"; + +/** + * Authorship of OpenCodex-generated routed catalog rows (#855, #7229). + * + * A generated routed row normally carries the description prefix below, and that prefix is what + * removal, template selection, auto-review and residue detection have always recognized. A combo + * may replace its row description (#7229), so such a row carries a stable marker instead. Rows + * with the generated description stay byte-identical and keep prefix recognition; only an exact + * known marker value confers authorship. + */ +export const ROUTED_DESCRIPTION_PREFIX = "Routed via opencodex → "; +export const ROUTED_ORIGIN_FIELD = "opencodex_routed_origin"; +export const COMBO_ROUTED_ORIGIN = "combo-v1"; +const COMBO_DESCRIPTION_PREFIX = `${ROUTED_DESCRIPTION_PREFIX}${COMBO_NAMESPACE} (`; + +function descriptionStartsWith(entry: RawEntry, prefix: string): boolean { + return typeof entry.description === "string" && entry.description.startsWith(prefix); +} + +/** True for a row whose description was overridden by a combo and stamped by OpenCodex. */ +export function hasComboRoutedOriginMarker(entry: RawEntry): boolean { + return entry[ROUTED_ORIGIN_FIELD] === COMBO_ROUTED_ORIGIN; +} + +/** Generated by OpenCodex routing: the stable marker, or the legacy generated description. */ +export function hasOcxRoutedAuthorship(entry: RawEntry): boolean { + return hasComboRoutedOriginMarker(entry) || descriptionStartsWith(entry, ROUTED_DESCRIPTION_PREFIX); +} + +/** Generated for a combo: the stable marker, or the legacy generated combo description. */ +export function hasOcxComboAuthorship(entry: RawEntry): boolean { + return hasComboRoutedOriginMarker(entry) || descriptionStartsWith(entry, COMBO_DESCRIPTION_PREFIX); +} + +/** The operator's combo description override, if this routed model has one. */ +export function comboDescriptionOverride(model: CatalogModel | undefined): string | undefined { + if (model?.provider !== COMBO_NAMESPACE) return undefined; + const description = typeof model.description === "string" ? model.description.trim() : ""; + return description || undefined; +} + +/** + * Set a routed row's description and its authorship marker together. A template clone may carry + * another row's marker, so the marker is always rewritten from this model's own override. + */ +export function applyRoutedDescription(entry: RawEntry, generated: string, model: CatalogModel | undefined): void { + const override = comboDescriptionOverride(model); + entry.description = override ?? generated; + if (override !== undefined) entry[ROUTED_ORIGIN_FIELD] = COMBO_ROUTED_ORIGIN; + else delete entry[ROUTED_ORIGIN_FIELD]; +} + +/** + * A combo row retained from the previous catalog (its fresh derivation failed, e.g. a member + * provider is temporarily unavailable) bypasses deriveEntry. Re-project the combo's CURRENT + * description onto it so clearing or changing the description in config still reaches the + * picker. A cleared override returns the row to the generated combo text without the marker; + * an unmarked row without a current override is left exactly as it was. + */ +export function reapplyRetainedComboDescription(entry: RawEntry, current: string | undefined): void { + const description = current?.trim(); + if (description) { + entry.description = description; + entry[ROUTED_ORIGIN_FIELD] = COMBO_ROUTED_ORIGIN; + } else if (hasComboRoutedOriginMarker(entry)) { + entry.description = `${ROUTED_DESCRIPTION_PREFIX}${COMBO_NAMESPACE} (${COMBO_NAMESPACE}).`; + delete entry[ROUTED_ORIGIN_FIELD]; + } +} diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 1db62301cd2..f5d0a860f8e 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -43,6 +43,7 @@ import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREA import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; +import { hasComboRoutedOriginMarker, hasOcxRoutedAuthorship } from "./ownership"; import { NATIVE_GPT6_ASTRA_MODEL } from "./native-models"; import { recordOwnedConfigPath } from "../../lib/config-ownership"; @@ -95,6 +96,12 @@ export interface CatalogModel { * which carries no CatalogModel, so a configured displayName can never override a native name. */ displayName?: string; + /** + * Display-only Codex catalog `description` override, honored for combo rows only (#7229). + * Unset keeps the generated "Routed via opencodex → …" text; a set value stamps the row with + * the ownership marker in ./ownership so removal and template selection still recognize it. + */ + description?: string; owned_by?: string; reasoningEfforts?: string[]; /** Exact CCA family evidence, retained through the in-memory discovery cache. */ @@ -297,7 +304,7 @@ export function findNativeTemplate(catalog: RawCatalog | null): RawEntry | null && "base_instructions" in m && m.opencodex_catalog_kind !== CODEX_NATIVE_ALIAS_CATALOG_KIND && m.owned_by !== COMBO_NAMESPACE - && !(typeof m.description === "string" && m.description.startsWith("Routed via opencodex → ")), + && !hasOcxRoutedAuthorship(m), ) ?? null; } @@ -325,7 +332,7 @@ export function findSupportedNativeTemplate(catalog: RawCatalog | null): RawEntr && "base_instructions" in m && m.opencodex_catalog_kind !== CODEX_NATIVE_ALIAS_CATALOG_KIND && m.owned_by !== COMBO_NAMESPACE - && !(typeof m.description === "string" && m.description.startsWith("Routed via opencodex → ")), + && !hasOcxRoutedAuthorship(m), ) ?? null; } @@ -925,7 +932,9 @@ export function readCatalogBackup(catalogPath: string): RawCatalog | null { export function catalogHasRoutedEntries(catalog: RawCatalog | null): boolean { return (catalog?.models ?? []).some(m => typeof m.slug === "string" - && (m.slug.includes("/") || m.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND)); + && (m.slug.includes("/") + || m.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND + || hasComboRoutedOriginMarker(m))); } export function writePristineCatalogBackup(backupPath: string, catalogPath: string, catalog: RawCatalog): void { diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 1175d3525dc..4994f28dca7 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -60,7 +60,7 @@ import { isMultiAgentV2Enabled } from "../features"; import { clampCatalogModelsToCodexSupport } from "./effort"; import { suppressedSyntheticMaxCatalogSlugs } from "./model-hints"; import { filterCatalogVisibleModels, gatherRoutedModels, type CatalogGatherProviderModelOutcome } from "./provider-fetch"; -import { dedupeCatalogEntriesBySlug, enforceCatalogSlugUniqueness, exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation"; +import { comboCatalogDescriptions, dedupeCatalogEntriesBySlug, enforceCatalogSlugUniqueness, exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation"; import { withCatalogWriteSerialization, type CatalogWritePermit, @@ -530,6 +530,7 @@ function writeRetainedCatalogSync({ multiAgentV2Enabled, keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, exactComboSlugs, + comboDescriptions: comboCatalogDescriptions(config), hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, diff --git a/src/codex/catalog/routed-removal.ts b/src/codex/catalog/routed-removal.ts index cdd1f4b0f2c..642bb5f3ec5 100644 --- a/src/codex/catalog/routed-removal.ts +++ b/src/codex/catalog/routed-removal.ts @@ -4,6 +4,7 @@ import type { OcxConfig } from "../../types"; import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; import { isOcxAuthoredRoutedEntry } from "./build-entries"; import { isNativeAliasCatalogEntry } from "./metadata"; +import { hasOcxRoutedAuthorship } from "./ownership"; import type { RawCatalog, RawEntry } from "./parsing"; /** @@ -25,10 +26,9 @@ function routedNamespace(entry: RawEntry): string | null { if (trustedAccountBoundNativeCatalogSlug(entry) !== undefined) return null; if (isNativeAliasCatalogEntry(entry)) return COMBO_NAMESPACE; // Combo aliases may be bare or use an unrelated provider namespace. Ownership selects the - // combo namespace only with the same generated-description authorship signal as routed rows. - if (entry.owned_by === COMBO_NAMESPACE - && typeof entry.description === "string" - && entry.description.startsWith("Routed via opencodex → ")) return COMBO_NAMESPACE; + // combo namespace only with the same authorship signal as routed rows (generated description or + // the #7229 marker a custom combo description carries). + if (entry.owned_by === COMBO_NAMESPACE && hasOcxRoutedAuthorship(entry)) return COMBO_NAMESPACE; // Foreign rows (Cursor, user tooling) are never removed by the provider rule. if (!isOcxAuthoredRoutedEntry(entry)) return null; return entry.slug.slice(0, entry.slug.indexOf("/")); diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index b6e4979334b..fc4de4a6b20 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -53,7 +53,7 @@ import { orderForSubagents, } from "./catalog/sync"; import { multiAgentV2EnabledFromConfigText } from "./features"; - import { enforceCatalogSlugUniqueness, exactComboCatalogSlugs } from "./catalog/aggregation"; + import { comboCatalogDescriptions, enforceCatalogSlugUniqueness, exactComboCatalogSlugs } from "./catalog/aggregation"; import { isNativeAliasCatalogEntry, accountBoundNativeOpenAiSlugs, @@ -392,6 +392,7 @@ function prepareCatalog( multiAgentV2Enabled, keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, exactComboSlugs, + comboDescriptions: comboCatalogDescriptions(config), hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, diff --git a/src/codex/native-residue.ts b/src/codex/native-residue.ts index c7898b12412..a8cd47f58fe 100644 --- a/src/codex/native-residue.ts +++ b/src/codex/native-residue.ts @@ -17,6 +17,7 @@ import { Database } from "bun:sqlite"; import { getConfigDir } from "../config"; import { catalogHasRoutedEntries, parseCatalogJson } from "./catalog/parsing"; +import { hasOcxRoutedAuthorship } from "./catalog/ownership"; import { codexHistoryBackupId, legacyCodexHistoryBackupId, validateCodexHistoryBackupManifest } from "./history-manifest"; import { hasInjectedCodexRouting, @@ -79,7 +80,6 @@ const PROFILE_FILE_NAME = basename(CODEX_PROFILE_PATH); const CATALOG_FILE_NAME = basename(DEFAULT_CATALOG_PATH); const MODELS_CACHE_FILE_NAME = basename(CODEX_MODELS_CACHE_PATH); const JOURNAL_FILE_NAME = "opencodex-journal.json"; -const ROUTED_CATALOG_DESCRIPTION_PREFIX = "Routed via opencodex → "; const MAX_ROLLOUT_INSPECTION_BYTES = 64 * 1024 * 1024; const ROLLOUT_READ_CHUNK_BYTES = 64 * 1024; // Bound one observation across every referenced rollout, not just each file. @@ -339,8 +339,7 @@ function classifyProfile(path: string): NativeRoutedResidueResult { } function isOcxRoutedCatalogEntry(entry: Record): boolean { - return typeof entry.description === "string" - && entry.description.startsWith(ROUTED_CATALOG_DESCRIPTION_PREFIX); + return hasOcxRoutedAuthorship(entry); } function classifyCatalogLike( diff --git a/src/combos/types.ts b/src/combos/types.ts index 2823301dd1c..336a464476c 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -68,6 +68,8 @@ export interface NormalizedComboConfig { nativeAlias: boolean; /** Display-only label for the catalog row, or null when unset. */ displayName: string | null; + /** Display-only catalog row description; absent keeps the generated text (#7229). */ + description?: string; /** JEV decision service provider id; absent means the canonical `jev` service. */ decisionProvider?: string; /** Ordinary inference route used for JEV decisions. */ @@ -277,6 +279,16 @@ export function comboConfigIssues( }); } } + if (body.description !== undefined) { + if (typeof body.description !== "string") { + issues.push({ path: ["description"], message: "description must be a string" }); + } else if (body.description.trim().length > 128 || /\p{Cc}/u.test(body.description)) { + issues.push({ + path: ["description"], + message: "description must be at most 128 characters and contain no control characters", + }); + } + } const alias = typeof body.alias === "string" ? body.alias.trim() : ""; const nativeAlias = body.nativeAlias === true; if (nativeAlias && !SUPPORTED_NATIVE_OPENAI_SLUGS.has(alias)) { @@ -483,6 +495,7 @@ export function comboConfigError( export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig { const alias = typeof raw.alias === "string" ? raw.alias.trim() : ""; const displayName = typeof raw.displayName === "string" ? raw.displayName.trim() : ""; + const description = typeof raw.description === "string" ? raw.description.trim() : ""; const decisionProvider = typeof raw.decisionProvider === "string" ? raw.decisionProvider.trim() : ""; const decisionModel = typeof raw.decisionModel === "string" ? raw.decisionModel.trim() : ""; const defaultEffort = typeof raw.defaultEffort === "string" && isCodexReasoningEffort(raw.defaultEffort) @@ -502,6 +515,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig alias: alias || null, nativeAlias: raw.nativeAlias === true, displayName: displayName || null, + ...(description ? { description } : {}), // Explicit "jev" is the default and stays sparse. ...(decisionProvider && decisionProvider !== CANONICAL_JEV_DECISION_PROVIDER ? { decisionProvider } : {}), ...(decisionModel ? { decisionModel } : {}), diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index 6f4979e0073..c9537d9659f 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -244,6 +244,11 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise[`catalog-effort-clamp.md`](catalog-effort-clamp.md) | | `src/codex/catalog/model-hints.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | +| `src/codex/catalog/ownership.ts` | [`catalog-routed-ownership.md`](catalog-routed-ownership.md) | | `src/codex/catalog/parsing.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | | `src/codex/catalog/routed-gather.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | | `src/codex/catalog/vendor-context.ts` | [`catalog-vendor-context.md`](catalog-vendor-context.md) | diff --git a/structure/catalog-routed-ownership.md b/structure/catalog-routed-ownership.md new file mode 100644 index 00000000000..07e31e7cad5 --- /dev/null +++ b/structure/catalog-routed-ownership.md @@ -0,0 +1,36 @@ +# Routed-row authorship and combo descriptions + +Catalog maintenance has to tell rows OpenCodex generated from rows Codex, Cursor or the user put +there: removal of a deleted provider's or combo's rows, baseline-shadow restoration, native +template selection, auto-review routed detection, exact-combo admission and native-residue +detection all ask that question. Historically every generated routed row answered it through its +description, which always starts with `Routed via opencodex → `. + +A combo may replace its own row description (#7229, `combos..description`, validated like +`displayName`: string, at most 128 characters after trimming, no control characters; empty +clears). Such a row carries `opencodex_routed_origin: "combo-v1"` instead of the generated +prefix. `src/codex/catalog/ownership.ts` owns both signals and is the only place they are read: +`hasOcxRoutedAuthorship` accepts the exact marker or the generic prefix, and +`hasOcxComboAuthorship` accepts the marker or the combo-specific prefix. Unknown marker values +confer nothing. Callers keep their own extra conditions (slash-qualified slug, `owned_by`, +native-alias kind, exact-combo membership); the predicates replace only the text test. + +`deriveEntry` sets the description and the marker together through `applyRoutedDescription`, +so a template clone never inherits another row's marker, and the Reserve native reconstruction +in `src/codex/catalog/metadata.ts` strips it. Only combo models are eligible; member +descriptions are never inherited. Rows without an override keep the generated text and no +marker, so their bytes do not change. The management API keeps a saved description when a +write omits it (the dashboard does not edit it yet), and `ocx combo set --description ` +sets or clears it. + +A combo row retained from the previous catalog because its fresh derivation failed (for example a +native-alias combo whose member provider is disabled) bypasses `deriveEntry`. Config-aware merges +(`src/codex/convergence.ts`, `src/codex/catalog/retained-sync.ts`) pass +`comboCatalogDescriptions(config)`, and `reapplyRetainedComboDescription` re-projects the current +value onto such rows: a set description replaces the old one with the marker, a cleared one returns +the generated combo text without it. Merges without config leave retained rows untouched. + +`tests/codex-integration/combo-description-catalog.test.ts` covers ordinary, bare and native +aliases, removal and shadow restoration, foreign and unknown-marker rows, template exclusion, +auto-review, residue detection and byte-idempotent sync. `tests/routing/combo-description.test.ts` +covers validation, management API set/clear/omission/rename, CLI receipts and config salvage. diff --git a/structure/manifest.json b/structure/manifest.json index 8d556d6d56c..2c26f539809 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -203,6 +203,13 @@ "scope": "Persisted effort-clamp observation identity and idempotent sync notices.", "documents": ["src/codex/runtime.ts", "src/codex/catalog/effort.ts"] }, + { + "path": "catalog-routed-ownership.md", + "tier": 2, + "title": "Routed-row authorship", + "scope": "Generated routed-row ownership signals and combo catalog description overrides.", + "documents": ["src/codex/catalog/ownership.ts"] + }, { "path": "subagents.md", "tier": 2, diff --git a/tests/codex-integration/combo-description-catalog.test.ts b/tests/codex-integration/combo-description-catalog.test.ts new file mode 100644 index 00000000000..828b9bad5c6 --- /dev/null +++ b/tests/codex-integration/combo-description-catalog.test.ts @@ -0,0 +1,343 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; +import { normalizeComboConfig } from "../../src/combos"; +import { saveConfig } from "../../src/config"; +import { SUBAGENT_MODELS_VERSION } from "../../src/config/subagent-models"; +import { comboCatalogDescriptions, deriveComboCatalogModel } from "../../src/codex/catalog/aggregation"; +import { applyAutoReviewModelOverride } from "../../src/codex/catalog/auto-review"; +import { + buildCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + isOcxAuthoredRoutedEntry, mergeCatalogEntriesFromObservedState, + resetCatalogRuntimeStateForTests, type ObservedCatalogMergeInput, +} from "../../src/codex/catalog/build-entries"; +import { deriveEntry, finishUpstreamNativeEntry } from "../../src/codex/catalog/derive-entry"; +import { upstreamNativeEntry } from "../../src/codex/catalog/metadata"; +import { catalogHasRoutedEntries, findNativeTemplate, findSupportedNativeTemplate, + type CatalogModel, type RawEntry } from "../../src/codex/catalog/parsing"; +import { ocxRoutedNamespaceCounts } from "../../src/codex/catalog/routed-removal"; +import { syncCatalogModels } from "../../src/codex/catalog/retained-sync"; +import { hasOcxComboAuthorship, hasOcxRoutedAuthorship } from "../../src/codex/catalog/ownership"; +import { classifyNativeRoutedResidue } from "../../src/codex/native-residue"; +import { resetCodexModelEntitlementCacheForTests } from "../../src/codex/model-entitlements"; +import { resetCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import type { OcxComboConfig, OcxConfig } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +const generated = "Routed via opencodex → combo (combo)."; +const marker = { opencodex_routed_origin: "combo-v1" }; +const targets = [{ provider: "fixture", model: "model" }]; +const member: CatalogModel = { provider: "fixture", id: "model", contextWindow: 128_000, + inputModalities: ["text"], reasoningEfforts: ["high"] }; +const native = (): RawEntry => ({ slug: "gpt-5.6-sol", display_name: "Fixture Sol", description: "Upstream Sol description", + priority: 9, visibility: "list", base_instructions: "You are Codex.", input_modalities: ["text"], + supported_reasoning_levels: [{ effort: "high", description: "High" }], default_reasoning_level: "high" }); +let home: TempHome; +let previousCli: string | undefined; +let network: ReturnType; +beforeEach(() => { + home = createTempHome("ocx-combo-description-catalog-"); + mkdirSync(home.codexHome); + previousCli = process.env.CODEX_CLI_PATH; + network = spyOn(globalThis, "fetch").mockImplementation(() => { throw new Error("Network forbidden"); }); +}); +afterEach(() => { + expect(network).not.toHaveBeenCalled(); + network.mockRestore(); + if (previousCli === undefined) delete process.env.CODEX_CLI_PATH; + else process.env.CODEX_CLI_PATH = previousCli; + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); + home.remove(); +}); +function comboModel(extra: Partial = {}): CatalogModel { + const model = deriveComboCatalogModel("saved", normalizeComboConfig({ targets, ...extra }), [member]); + expect(model).not.toBeNull(); + return model!; +} +function build(model: CatalogModel, template: RawEntry | null = native()): RawEntry[] { + const slug = model.alias ?? "combo/saved"; + return buildCatalogEntriesFromObservedState({ + template, gptSlugs: ["gpt-5.6-sol"], goModels: [model], featured: [], + wsEnabled: false, multiAgentMode: "default", multiAgentV2Enabled: false, + exactComboSlugs: new Set([slug]), accountSelectors: ["account"], + suppressedBareNativeSlugs: new Set(), disabledNativeAccountSlugs: new Set(), + }); +} +function merge(input: Pick & Partial): RawEntry[] { + return mergeCatalogEntriesFromObservedState({ + baselineCatalogModels: [], baseline: new Map(), featured: [], wsEnabled: false, + template: native(), disabledModels: new Set(), selectedModelsByProvider: new Map(), + gatheredProviderNames: new Set(), degradedProviderNames: new Set(), legacyCustomModelSlugs: new Set(), + multiAgentMode: "default", multiAgentV2Enabled: false, exactComboSlugs: new Set(), + hasPhysicalComboProvider: false, includeNativeOpenAi: false, accountBoundEntries: [], + policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "silent" }, ...input, + }); +} +function retain(row: RawEntry, comboDescriptions?: ReadonlyMap): RawEntry[] { + return merge({ catalogModels: [row], routedEntries: [], comboDescriptions, + exactComboSlugs: new Set([String(row.slug)]), hasPhysicalComboProvider: true, + gatheredProviderNames: new Set(["vendor"]), degradedProviderNames: new Set(["vendor"]) }); +} + +describe("retained combo descriptions (#7229)", () => { + const oldNativeAlias = () => build(comboModel({ alias: "gpt-5.6-sol", nativeAlias: true, description: "Old" })) + .find(row => row.slug === "gpt-5.6-sol")!; + + test("retained native alias replaces its stale marked description with the current override", () => { + const old = oldNativeAlias(); + expect(old).toMatchObject({ description: "Old", ...marker }); + const rows = retain(old, new Map([["gpt-5.6-sol", "New"]])); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ slug: "gpt-5.6-sol", description: "New", ...marker }); + expect(old.description).toBe("Old"); + }); + + test("clearing a retained native alias description restores generated text and removes its marker", () => { + const rows = retain(oldNativeAlias(), new Map()); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ slug: "gpt-5.6-sol", description: generated }); + expect(rows[0]).not.toHaveProperty("opencodex_routed_origin"); + }); + + test("legacy merges without comboDescriptions preserve the retained description and marker", () => { + const old = oldNativeAlias(); + expect(retain(old)).toEqual([old]); + }); + + test("a retained slash alias with generated combo authorship gains the current description", () => { + const old = build(comboModel({ alias: "vendor/custom" })).find(row => row.slug === "vendor/custom")!; + expect(old).toMatchObject({ owned_by: "combo", description: generated }); + expect(old).not.toHaveProperty("opencodex_routed_origin"); + const rows = retain(old, new Map([["vendor/custom", "New"]])); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ slug: "vendor/custom", description: "New", ...marker }); + }); + + test("an exact combo slug and owned_by alone cannot rewrite a foreign retained row", () => { + const foreign = { slug: "vendor/custom", owned_by: "combo", description: "Another tool's description", + priority: 5, visibility: "list", input_modalities: ["text"] }; + expect(hasOcxComboAuthorship(foreign)).toBe(false); + const rows = retain(foreign, new Map([["vendor/custom", "New"]])); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject(foreign); + expect(rows[0]).not.toHaveProperty("opencodex_routed_origin"); + }); + + test("config descriptions use public aliases, exclude disabled combos, and omit empty overrides", () => { + expect(comboCatalogDescriptions({ disabledModels: ["combo/disabled"], combos: { + saved: { targets, description: " Canonical " }, + aliased: { targets, alias: " vendor/custom ", description: " Alias " }, + disabled: { targets, alias: "vendor/disabled", description: "Hidden" }, + empty: { targets, description: "" }, blank: { targets, description: " " }, absent: { targets }, + } })).toEqual(new Map([["combo/saved", "Canonical"], ["vendor/custom", "Alias"]])); + }); +}); + +describe("described combo catalog rows (#7229)", () => { + for (const [label, identity] of [ + ["ordinary combo", {}], ["namespaced alias", { alias: "vendor/custom" }], + ["bare alias", { alias: "fast-coding" }], + ["native alias", { alias: "gpt-5.6-sol", nativeAlias: true, displayName: "My Sol combo" }], + ] as const) test(`${label} carries the override while account-native descriptions stay unchanged`, () => { + for (const template of [null, native()]) { + const cfg = { ...identity, description: " Code quickly " }; + const model = comboModel(cfg); + expect(model.description).toBe("Code quickly"); + const rows = build(model, template); + expect(rows.find(row => row.slug === (model.alias ?? "combo/saved"))).toMatchObject({ description: "Code quickly", ...marker }); + const account = rows.find(row => row.slug === "account/gpt-5.6-sol"); + const baseline = build(comboModel(identity), template).find(row => row.slug === "account/gpt-5.6-sol"); + expect(account).toBeDefined(); + expect(account?.description).toBe(baseline?.description); + expect(account).not.toHaveProperty("opencodex_routed_origin"); + } + }); + + test("unset and empty descriptions produce identical bytes with the generated description and no marker", () => { + const absent = comboModel(); + expect(absent).not.toHaveProperty("description"); + const rows = build(absent); + expect(rows.find(row => row.slug === "combo/saved")).toMatchObject({ description: generated }); + expect(rows.find(row => row.slug === "combo/saved")).not.toHaveProperty("opencodex_routed_origin"); + expect(JSON.stringify(build(comboModel({ description: "" })))).toBe(JSON.stringify(rows)); + expect(JSON.stringify(build(comboModel({ description: " " })))).toBe(JSON.stringify(rows)); + }); + + test("derivation strips a stale template marker and ignores non-combo description overrides", () => { + const template = { ...native(), ...marker }; + const without = deriveEntry(template, "combo/saved", generated, 5, comboModel(), new Set(["combo/saved"])); + expect(without.description).toBe(generated); + expect(without).not.toHaveProperty("opencodex_routed_origin"); + const provider = deriveEntry(template, "fixture/model", "Routed via opencodex → fixture (fixture).", 5, + { ...member, description: "Must not override providers" }); + expect(provider.description).toBe("Routed via opencodex → fixture (fixture)."); + expect(provider).not.toHaveProperty("opencodex_routed_origin"); + }); + + test("removing a described combo removes its row and restores a pristine foreign baseline shadow", () => { + const rawPristine = { slug: "vendor/custom", description: "Foreign pristine description", display_name: "Foreign original", + input_modalities: ["text"], priority: 5, visibility: "list", custom_metadata: { preserved: true } }; + const pristine = merge({ catalogModels: [rawPristine], routedEntries: [] })[0]!; + const row = build(comboModel({ alias: "vendor/custom", description: "Combo replacement" })) + .find(entry => entry.slug === pristine.slug)!; + const installed = merge({ catalogModels: [pristine], baselineCatalogModels: [pristine], routedEntries: [row], + exactComboSlugs: new Set([pristine.slug]) }); + expect(installed.find(entry => entry.slug === pristine.slug)).toMatchObject({ description: "Combo replacement", ...marker }); + const restored = merge({ catalogModels: installed, baselineCatalogModels: [pristine], routedEntries: [] }); + expect(restored.filter(entry => entry.slug === pristine.slug)).toEqual([pristine]); + expect(restored.some(entry => entry.opencodex_routed_origin === "combo-v1")).toBe(false); + const ordinary = build(comboModel({ description: "Remove me" })).find(entry => entry.slug === "combo/saved")!; + expect(merge({ catalogModels: [ordinary], routedEntries: [] }).some(entry => entry.slug === "combo/saved")).toBe(false); + }); + + test("foreign arbitrary descriptions survive, and unknown marker values confer no ownership", () => { + const foreign = { slug: "foreign/model", description: "Another tool's custom description", priority: 5, visibility: "list" }; + for (const row of [foreign, { ...foreign, opencodex_routed_origin: "combo-v2" }]) { + expect(hasOcxRoutedAuthorship(row)).toBe(false); + expect(hasOcxComboAuthorship(row)).toBe(false); + expect(isOcxAuthoredRoutedEntry(row)).toBe(false); + expect(ocxRoutedNamespaceCounts({ models: [row] }).size).toBe(0); + const retained = merge({ catalogModels: [row], routedEntries: [] }); + expect(retained).toHaveLength(1); + expect(retained[0]).toMatchObject(row); + } + expect(catalogHasRoutedEntries({ models: [{ slug: "bare", description: "Foreign", opencodex_routed_origin: "combo-v2" }] })).toBe(false); + }); + + test("marked rows are excluded from native templates independently of owned_by or catalog kind", () => { + const marked = { ...native(), ...marker }; + const real = { ...native(), slug: "gpt-5.5" }; + for (const find of [findNativeTemplate, findSupportedNativeTemplate]) { + expect(find({ models: [marked] })).toBeNull(); + expect(find({ models: [marked, real] })).toBe(real); + expect(find({ models: [{ ...marked, opencodex_routed_origin: "unknown" }] })).not.toBeNull(); + } + }); + + test("marked combo ownership counts bare and namespaced aliases under combo and protects bare-only catalogs", () => { + const rows = ["combo/saved", "vendor/custom", "fast-coding"].map(slug => ({ slug, owned_by: "combo", description: "Custom", ...marker })); + expect(isOcxAuthoredRoutedEntry(rows[0]!)).toBe(true); + expect(isOcxAuthoredRoutedEntry(rows[1]!)).toBe(true); + expect(ocxRoutedNamespaceCounts({ models: rows })).toEqual(new Map([["combo", 3]])); + expect(catalogHasRoutedEntries({ models: [rows[2]!] })).toBe(true); + expect(hasOcxRoutedAuthorship({ slug: "combo/legacy", description: generated })).toBe(true); + expect(hasOcxComboAuthorship({ slug: "combo/legacy", description: generated })).toBe(true); + }); + + test("a described exact alias stays admitted through provider allowlist and pending namespace filters", () => { + const row = build(comboModel({ alias: "vendor/custom", description: "Exact combo" })).find(entry => entry.slug === "vendor/custom")!; + for (const pendingProviderNames of [new Set(), new Set(["vendor"])]) { + const rows = merge({ catalogModels: [], routedEntries: [row], exactComboSlugs: new Set(["vendor/custom"]), + selectedModelsByProvider: new Map([["vendor", new Set(["vendor/other"])]]), + gatheredProviderNames: new Set(["vendor"]), pendingProviderNames }); + expect(rows.filter(entry => entry.slug === "vendor/custom")).toEqual([row]); + } + }); + + test("auto-review classifies a bare marked row as routed when applying and clearing root overrides", () => { + const row = { slug: "fast-coding", description: "Custom", ...marker }; + const real = native(); + const rows: RawEntry[] = [row, real]; + expect(applyAutoReviewModelOverride(rows, "gpt-5.6-sol")).toBe("applied"); + expect(row).not.toHaveProperty("opencodex_auto_review_root"); + expect(real).toHaveProperty("opencodex_auto_review_root"); + expect(applyAutoReviewModelOverride(rows, undefined)).toBe("absent"); + expect(rows[0]?.auto_review_model_override).toBeNull(); + }); + + test("native residue detection recognizes a bare marked catalog through the public classifier", () => { + const path = join(home.codexHome, "custom-catalog.json"); + writeFileSync(join(home.codexHome, "config.toml"), 'model_catalog_json = "custom-catalog.json"\n'); + writeFileSync(path, JSON.stringify({ models: [{ slug: "fast-coding", description: "Custom", ...marker }] })); + expect(classifyNativeRoutedResidue()).toEqual({ kind: "residue", surface: "catalog", path }); + writeFileSync(path, JSON.stringify({ models: [{ slug: "fast-coding", description: "Foreign", opencodex_routed_origin: "unknown" }] })); + expect(classifyNativeRoutedResidue()).toEqual({ kind: "clean" }); + }); +}); + +function installCodexFixture(): string { + const catalog = { models: [finishUpstreamNativeEntry(upstreamNativeEntry("gpt-5.6-sol")!, 9)] }; + const script = home.path("fixture-codex.js"); + writeFileSync(script, [ + 'if (process.argv.includes("--version")) console.log("codex-cli 0.145.0");', + `else process.stdout.write(${JSON.stringify(JSON.stringify(catalog))});`, + ].join("\n")); + const executable = home.path(process.platform === "win32" ? "fixture-codex.cmd" : "fixture-codex"); + if (process.platform === "win32") writeFileSync(executable, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); + else { + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + writeFileSync(executable, `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(script)} "$@"\n`); + chmodSync(executable, 0o755); + } + process.env.CODEX_CLI_PATH = executable; + resetCatalogRuntimeStateForTests(); resetCodexRuntimeResolveCacheForTests(); resetCodexModelEntitlementCacheForTests(); + const path = join(home.codexHome, "custom-catalog.json"); + writeFileSync(join(home.codexHome, "config.toml"), 'model_catalog_json = "custom-catalog.json"\n'); + writeFileSync(path, JSON.stringify(catalog)); + return path; +} + +function fixtureConfig(): OcxConfig { + return { + port: 10100, defaultProvider: "fixture", subagentModels: [], subagentModelsVersion: SUBAGENT_MODELS_VERSION, + providers: { openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward" }, + fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", liveModels: false, + models: ["model"], modelContextWindows: { model: 128_000 }, modelInputModalities: { model: ["text"] }, + modelReasoningEfforts: { model: ["high"] } } }, + combos: { saved: { targets } }, + }; +} + +test("real sync is byte-idempotent with a description and after clearing it matches an absent field", async () => { + const path = installCodexFixture(); + const cfg = fixtureConfig(); + const sync = async () => { + saveConfig(cfg); + const result = await syncCatalogModels(cfg, { allowWhenDesiredDisabled: true }); + expect(result.refreshOutcome).toBe("committed"); + expect(result.skippedReason).toBeUndefined(); + expect(result.path).toBe(path); + return readFileSync(path, "utf8"); + }; + const absent = await sync(); + expect(await sync()).toBe(absent); + cfg.combos!.saved!.description = "A custom catalog description"; + const described = await sync(); + expect(JSON.parse(described).models.find((row: RawEntry) => row.slug === "combo/saved")) + .toMatchObject({ description: "A custom catalog description", ...marker }); + expect(await sync()).toBe(described); + cfg.combos!.saved!.description = ""; + const cleared = await sync(); + expect(cleared).toBe(absent); + expect(JSON.parse(cleared).models.find((row: RawEntry) => row.slug === "combo/saved")) + .not.toHaveProperty("opencodex_routed_origin"); + expect(await sync()).toBe(cleared); +}, 30_000); + +test("real sync reprojects current config descriptions onto a native alias retained while its provider is disabled", async () => { + const path = installCodexFixture(); + const cfg = fixtureConfig(); + cfg.combos!.saved = { targets, alias: "gpt-5.6-sol", nativeAlias: true, + displayName: "Retained Sol combo", description: "Old" }; + const sync = async () => { + saveConfig(cfg); + const result = await syncCatalogModels(cfg, { allowWhenDesiredDisabled: true }); + expect(result.refreshOutcome).toBe("committed"); + expect(result.path).toBe(path); + return (JSON.parse(readFileSync(path, "utf8")).models as RawEntry[]) + .find(row => row.slug === "gpt-5.6-sol"); + }; + expect(await sync()).toMatchObject({ description: "Old", ...marker }); + cfg.providers.fixture!.disabled = true; + cfg.combos!.saved!.description = "New"; + expect(comboCatalogDescriptions(cfg)).toEqual(new Map([["gpt-5.6-sol", "New"]])); + expect(await sync()).toMatchObject({ description: "New", ...marker }); + delete cfg.combos!.saved!.description; + expect(comboCatalogDescriptions(cfg).size).toBe(0); + const cleared = await sync(); + expect(cleared).toMatchObject({ description: generated }); + expect(cleared).not.toHaveProperty("opencodex_routed_origin"); +}, 30_000); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b29ec798a94..eb8f478052c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,4 @@ -{ "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", +{ "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", diff --git a/tests/routing/combo-description.test.ts b/tests/routing/combo-description.test.ts new file mode 100644 index 00000000000..28a3983c142 --- /dev/null +++ b/tests/routing/combo-description.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { readFileSync, writeFileSync } from "node:fs"; +import { comboConfigIssues, normalizeComboConfig } from "../../src/combos"; +import { getConfigPath, readConfigDiagnostics, saveConfig } from "../../src/config"; +import { handleComboCommand } from "../../src/cli/combo"; +import { printComboWriteResult } from "../../src/cli/combo-result"; +import type { RuntimeApiDeps } from "../../src/cli/runtime-api"; +import { handleComboRoutes } from "../../src/server/management/combo-routes"; +import type { OcxConfig } from "../../src/types"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; + +const targets = [{ provider: "fixture", model: "model" }]; +const refresh = { status: "committed" as const, changed: false, degraded: false, notices: [] }; +let home: TempHome; +let token: string | undefined; +let output: ReturnType; +let errors: ReturnType; +let network: ReturnType; +beforeEach(() => { + home = createTempHome("ocx-combo-description-"); + token = process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + output = spyOn(console, "log").mockImplementation(() => {}); + errors = spyOn(console, "error").mockImplementation(() => {}); + network = spyOn(globalThis, "fetch").mockImplementation(() => { throw new Error("Network forbidden"); }); +}); +afterEach(() => { + expect(network).not.toHaveBeenCalled(); + output.mockRestore(); errors.mockRestore(); network.mockRestore(); + if (token === undefined) delete process.env.OPENCODEX_ADMIN_AUTH_TOKEN; + else process.env.OPENCODEX_ADMIN_AUTH_TOKEN = token; + home.remove(); +}); +function config(): OcxConfig { + return { + port: 10100, defaultProvider: "fixture", + providers: { fixture: { adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", models: ["model"], liveModels: false } }, + combos: { saved: { targets } }, + }; +} +async function api(config: OcxConfig, method: string, body?: unknown): Promise { + const req = new Request("http://localhost/api/combos", { + method, headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const response = await handleComboRoutes({ + req, url: new URL(req.url), config, deps: {}, version: "fixture", + trustedLoopbackIngress: true, guiSessionIssuance: null, + convergeCodexCatalog: async () => refresh, + syncClaudeAgentDefsBestEffort: async () => {}, + }); + expect(response).not.toBeNull(); + return response!; +} +async function listed(config: OcxConfig): Promise[]> { + const response = await api(config, "GET"); + expect(response.status).toBe(200); + return (await response.json()).combos; +} + +describe("combo description contract (#7229)", () => { + for (const [label, description] of [ + ["number", 42], ["null", null], ["129 characters", "x".repeat(129)], + ["C0", "before\u0000after"], ["newline", "before\nafter"], ["DEL", "before\u007fafter"], + ["C1 NEL", "before\u0085after"], ["C1 CSI", "before\u009bafter"], + ] as const) test(`rejects ${label} with the description path`, () => { + const issues = comboConfigIssues("saved", { targets, description }, config().providers); + expect(issues).toHaveLength(1); + expect(issues[0]?.path).toEqual(["description"]); + expect(issues[0]?.message).toContain("description must be"); + }); + test("accepts the trimmed 128-character boundary and normalizes sparsely", () => { + const description = "x".repeat(128); + expect(comboConfigIssues("saved", { targets, description: ` ${description} ` }, config().providers)).toEqual([]); + expect(normalizeComboConfig({ targets, description: " A useful combo " }).description).toBe("A useful combo"); + for (const description of [undefined, "", " "]) { + expect(normalizeComboConfig({ targets, description })).not.toHaveProperty("description"); + } + }); + + test("management set, GET, omission, rename and explicit clear preserve sparse disk state", async () => { + const cfg = config(); + saveConfig(cfg); + const set = await api(cfg, "PUT", { id: "saved", combo: { targets, description: " Fast coding route " } }); + expect(set.status).toBe(200); + expect(await set.json()).toMatchObject({ combo: { description: "Fast coding route" } }); + expect((await listed(cfg))[0]).toMatchObject({ id: "saved", description: "Fast coding route" }); + expect((await api(cfg, "PUT", { id: "saved", combo: { targets } })).status).toBe(200); + expect((await listed(cfg))[0]?.description).toBe("Fast coding route"); + expect((await api(cfg, "PUT", { id: "renamed", renameFrom: "saved", combo: { targets } })).status).toBe(200); + expect(cfg.combos).not.toHaveProperty("saved"); + expect((await listed(cfg))[0]).toMatchObject({ id: "renamed", description: "Fast coding route" }); + expect(readConfigDiagnostics().config.combos?.renamed?.description).toBe("Fast coding route"); + expect((await api(cfg, "PUT", { id: "renamed", combo: { targets, description: "" } })).status).toBe(200); + expect((await listed(cfg))[0]).not.toHaveProperty("description"); + expect(cfg.combos?.renamed).not.toHaveProperty("description"); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).combos.renamed).not.toHaveProperty("description"); + }); + + test("invalid management descriptions return 400 and leave memory and disk intact", async () => { + const cfg = config(); + saveConfig(cfg); + const before = structuredClone(cfg); + const disk = readFileSync(getConfigPath(), "utf8"); + for (const description of [false, null, "x".repeat(129), "bad\u001ftext", "bad\u007ftext"]) { + const response = await api(cfg, "PUT", { id: "saved", combo: { targets, description } }); + expect(response.status).toBe(400); + expect((await response.json()).error).toContain("description must be"); + expect(cfg).toEqual(before); + expect(readFileSync(getConfigPath(), "utf8")).toBe(disk); + } + }); + + test("CLI --description and '-' round-trip through the real management handler and receipt", async () => { + const cfg = config(); + saveConfig(cfg); + const deps: RuntimeApiDeps = { + findLiveProxy: async () => ({ pid: null, port: 14000, source: "runtime" }), + fetchImpl: (async (input, init) => { + expect(new URL(String(input)).pathname).toBe("/api/combos"); + return api(cfg, init?.method ?? "GET", init?.body ? JSON.parse(String(init.body)) : undefined); + }) as typeof fetch, + }; + expect(await handleComboCommand(["set", "saved", "--description", "CLI description", "--json"], deps)).toBe(0); + expect(cfg.combos?.saved?.description).toBe("CLI description"); + const receipt = JSON.parse(String(output.mock.calls.at(-1)?.[0])); + expect(receipt.combo.description).toBe("CLI description"); + expect(printComboWriteResult(receipt, "saved", true)).toBe(0); + expect(() => printComboWriteResult({ ...receipt, combo: { ...receipt.combo, description: 1 } }, "saved", true)) + .toThrow("Invalid combo receipt"); + expect(await handleComboCommand(["set", "saved", "--description", "-", "--json"], deps)).toBe(0); + expect(cfg.combos?.saved).not.toHaveProperty("description"); + expect(JSON.parse(String(output.mock.calls.at(-1)?.[0])).combo).not.toHaveProperty("description"); + expect(readConfigDiagnostics().config.combos?.saved).not.toHaveProperty("description"); + }); + + test("config salvage drops the whole invalid combo just as it does an invalid displayName", () => { + for (const field of ["displayName", "description"] as const) { + const cfg = config(); + cfg.combos = { valid: { targets, description: "Keep this description" }, invalid: { targets, [field]: "x".repeat(129) } }; + writeFileSync(getConfigPath(), JSON.stringify(cfg)); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain(`combos.invalid.${field}`); + expect(diagnostics.config.combos).not.toHaveProperty("invalid"); + expect(diagnostics.config.combos?.valid?.description).toBe("Keep this description"); + expect(diagnostics.config.providers.fixture).toEqual(expect.objectContaining(cfg.providers.fixture!)); + } + }); + + test("a valid description survives save and reload without dropping sibling combos", () => { + const cfg = config(); + cfg.combos!.saved!.description = "A persisted description"; + cfg.combos!.other = { targets }; + saveConfig(cfg); + const first = readConfigDiagnostics(); + expect(first.source).toBe("file"); + expect(first.error).toBeNull(); + expect(first.config.combos?.saved?.description).toBe("A persisted description"); + saveConfig(first.config); + expect(readConfigDiagnostics().config.combos?.saved?.description).toBe("A persisted description"); + expect(readConfigDiagnostics().config.combos?.other).not.toHaveProperty("description"); + }); +});