diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index bef498ce4..84dee9b8a 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -184,7 +184,15 @@ start and on `ocx sync`, opencodex: 4. **Filters** `config.disabledModels` and each provider's non-empty `selectedModels` allowlist. 5. **Re-ranks** so featured models sort first (see below), then writes the merged catalog back. -Routed catalog entries also get their GPT-5 identity rewritten to the real upstream model name. +The cloned catalog `base_instructions` identity line is rewritten to the real upstream model name +as static catalog metadata. Runtime request identity is handled separately: routed adapters replace +Codex's live GPT-5 identity line with a model-agnostic coding-agent introduction. +When unique, their default picker label is the final segment of the native model id, so provider +namespaces do not hide the model name in narrow pickers. Basename collisions retain enough native +route context to distinguish the rows; if the same native id comes from multiple providers, the +provider is shown too. The full catalog slug remains in the description. Configure a custom display +name when the route itself matters visually (for example, `Claude Opus 5 (TeamClaude)`); custom +names continue to take precedence. Reasoning controls come from provider/model metadata across Codex's `low | medium | high | xhigh | max | ultra` ladder; unsupported values are mapped or clamped before the upstream request. diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md index 4530561a6..5002c66dc 100644 --- a/docs-site/src/content/docs/ja/guides/codex-integration.md +++ b/docs-site/src/content/docs/ja/guides/codex-integration.md @@ -147,7 +147,15 @@ Codex はディスクのカタログ(デフォルト `$CODEX_HOME/opencodex-cata 4. `config.disabledModels` と各プロバイダーの空でない `selectedModels` 許可リストを**適用**します。 5. フィーチャー済みモデルが先に並ぶよう**再整列**した後(下記参照)、マージされたカタログを書き戻します。 -ルーティングされたカタログ項目の GPT-5 アイデンティティ文言も実際の上流モデル名に合わせます。推論選択肢は +複製されたカタログの `base_instructions` にあるアイデンティティ行は、静的なカタログメタデータとして実際の +上流モデル名に合わせて書き換えられます。実行時リクエストのアイデンティティは別に処理され、ルーティング +アダプターが Codex のライブ GPT-5 アイデンティティ行をモデル非依存のコーディングエージェント文言に置き換えます。 +一意であれば、デフォルトのピッカーラベルにはネイティブモデル ID の最後のセグメントが使われるため、幅の狭い +ピッカーでもプロバイダーの名前空間がモデル名を隠しません。同じ basename が衝突する場合は行を区別できるだけの +ネイティブ経路を残し、同じネイティブ ID が複数のプロバイダーにある場合はプロバイダー名も表示します。完全な +カタログスラッグは説明に残ります。 +経路自体を見分ける必要がある場合(例: `Claude Opus 5 (TeamClaude)`)はカスタム表示名を設定してください。 +カスタム表示名は引き続きデフォルトラベルより優先されます。推論選択肢は プロバイダーとモデルメタデータに応じて Codex の `low | medium | high | xhigh | max | ultra` 段階を使い、 上流がサポートしない値はリクエスト送信前にマッピングまたはサポート範囲に下げます。 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 3dd928e49..4d1061e36 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -145,7 +145,14 @@ Codex는 디스크의 카탈로그(기본값 `$CODEX_HOME/opencodex-catalog.json 4. `config.disabledModels`와 각 프로바이더의 비어 있지 않은 `selectedModels` 허용 목록을 **적용**합니다. 5. featured 모델이 먼저 정렬되도록 **재정렬**한 뒤(아래 참고), 병합된 카탈로그를 다시 작성합니다. -라우팅된 카탈로그 항목의 GPT-5 정체성 문구도 실제 업스트림 모델 이름에 맞게 바꿉니다. reasoning 선택지는 +복제된 카탈로그의 `base_instructions` 정체성 문구는 정적 카탈로그 메타데이터에서만 실제 업스트림 모델 이름으로 +바뀝니다. 런타임 요청의 정체성은 별도로 처리되며, 라우팅 어댑터가 Codex의 실시간 GPT-5 정체성 문구를 모델과 무관한 +코딩 에이전트 문구로 바꿉니다. 이름이 고유하면 기본 선택기 +레이블은 네이티브 모델 ID의 마지막 부분만 사용하므로 폭이 좁은 선택기에서도 프로바이더 네임스페이스가 모델 이름을 +가리지 않습니다. 같은 basename이 겹치면 행을 구분하는 데 필요한 네이티브 경로를 남기고, 같은 네이티브 ID가 여러 +프로바이더에 있으면 프로바이더 이름도 표시합니다. 전체 카탈로그 slug는 설명에 그대로 남습니다. 경로 자체를 눈에 띄게 +구분해야 한다면(예: `Claude Opus 5 (TeamClaude)`) 사용자 지정 표시 이름을 설정하세요. 사용자 지정 이름은 +계속 기본 레이블보다 우선합니다. reasoning 선택지는 프로바이더와 모델 메타데이터에 따라 Codex의 `low | medium | high | xhigh | max | ultra` 단계를 사용하며, 업스트림이 지원하지 않는 값은 요청을 보내기 전에 매핑하거나 지원 범위로 낮춥니다. diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md index e77970afd..1eedc25ec 100644 --- a/docs-site/src/content/docs/ru/guides/codex-integration.md +++ b/docs-site/src/content/docs/ru/guides/codex-integration.md @@ -165,8 +165,16 @@ Codex показывает модели из каталога на диске ( 5. **Переранжирует** так, чтобы избранные модели шли первыми (см. ниже), и записывает объединённый каталог обратно. -У маршрутизируемых записей каталога идентичность GPT-5 также переписывается на настоящее имя -вышестоящей модели. Элементы управления рассуждениями берутся из метаданных провайдера и модели +В клонированном `base_instructions` строка идентичности переписывается на настоящее имя вышестоящей +модели только как статические метаданные каталога. Идентичность рабочего запроса обрабатывается +отдельно: маршрутизируемые адаптеры заменяют живую строку Codex о GPT-5 нейтральным описанием +агента программирования. Если имя уникально, по умолчанию в селекторе показывается последний сегмент +нативного идентификатора модели, поэтому пространство имён провайдера не скрывает имя модели в узком +селекторе. При совпадении basename сохраняется достаточная часть нативного маршрута, а если один и тот +же нативный ID доступен у нескольких провайдеров, показывается и провайдер. Полный slug каталога +сохраняется в описании. Если маршрут важно различать визуально +(например, `Claude Opus 5 (TeamClaude)`), задайте пользовательское отображаемое имя; оно по-прежнему +имеет приоритет над меткой по умолчанию. Элементы управления рассуждениями берутся из метаданных провайдера и модели по шкале Codex `low | medium | high | xhigh | max | ultra`; неподдерживаемые значения сопоставляются или ограничиваются перед запросом к вышестоящему провайдеру. diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md index 53fceb9c9..43c9e6b58 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md @@ -138,7 +138,12 @@ Codex 显示的模型来自一个磁盘上的目录(默认为 `$CODEX_HOME/ope 4. **应用过滤**:`config.disabledModels`,以及每个提供商非空的 `selectedModels` allowlist。 5. **重新排序**,使置顶模型排在最前(见下文),然后将合并后的目录写回。 -路由目录条目还会把 GPT-5 身份文案改为真实的上游模型名称。reasoning 选项会依据提供商和模型元数据, +克隆目录中的 `base_instructions` 身份行只会作为静态目录元数据改写为真实的上游模型名称。 +运行时请求的身份信息单独处理:路由适配器会把 Codex 实时发送的 GPT-5 身份行替换为与模型无关的编码代理说明。 +名称唯一时,默认的选择器标签使用原生模型 ID 的最后一段,因此在较窄的选择器中,提供商命名空间不会遮住模型名称。 +若 basename 冲突,则保留足以区分各行的原生路由信息;若多个提供商暴露同一个原生 ID,还会显示提供商名称。 +完整的目录 slug 仍保留在说明中。如果需要直观看出路由本身(例如 +`Claude Opus 5 (TeamClaude)`),请配置自定义显示名称;自定义名称仍然优先于默认标签。reasoning 选项会依据提供商和模型元数据, 使用 Codex 的 `low | medium | high | xhigh | max | ultra` 档位;上游不支持的值会在发送请求前完成 映射或下调。 diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 13fd33bba..9e30813a1 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -109,19 +109,29 @@ export function catalogEntryEfforts(entry: RawEntry): string[] { export const ROUTED_REASONING_LEVELS = [...CODEX_REASONING_LEVELS]; -export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void { +export function applyCatalogModelMetadata( + entry: RawEntry, + model?: CatalogModel, + fallbackDisplayName?: string, +): void { if (!model) return; // This marker survives strict catalog normalization and lets sync distinguish a stale // bare combo alias from a genuine native model row. if (model.provider === COMBO_NAMESPACE) entry.owned_by = model.owned_by ?? COMBO_NAMESPACE; // displayName is DISPLAY-ONLY: it relabels the picker row but never touches the routing - // slug, alias, or provider. deriveEntry already stamped the slug as display_name; a - // configured displayName overrides just the label. The `/` separator is rejected at every - // input boundary (CLI `ocx models add`, management API), so the catalog trusts its source. - // Combos carry no displayName, and natives never reach here (no CatalogModel), so genuine - // upstream marketing names and combo alias labels are preserved untouched. + // slug, alias, or provider. A configured displayName wins. Otherwise physical routed rows + // use the final segment of their native id, keeping provider/vendor namespaces out of the + // narrow picker label. buildCatalogEntries supplies a longer fallback only when that basename + // would collide with another visible row. Aliased combos keep their public alias. Natives never + // reach here (no CatalogModel), so genuine upstream marketing names are preserved untouched. const displayName = typeof model.displayName === "string" ? model.displayName.trim() : ""; if (displayName) entry.display_name = displayName; + else if (!model.alias) { + const nativeId = model.id.trim(); + const finalSegment = nativeId.slice(nativeId.lastIndexOf("/") + 1).trim(); + const fallback = fallbackDisplayName?.trim() || finalSegment; + if (fallback) entry.display_name = fallback; + } if (typeof model.contextWindow === "number" && model.contextWindow > 0) { entry.context_window = model.contextWindow; entry.max_context_window = model.contextWindow; diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 432ace821..f83176e84 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -92,7 +92,9 @@ export interface CatalogModel { /** * Display-only Codex catalog `display_name` override. Relabels the picker row ONLY — it never * affects the routing slug, alias-collision order, native marketing-name precedence, or provider - * behavior. When unset, the entry falls back to its Codex-facing slug (the historical behavior). + * behavior. When unset, routed entries use the final segment of the native model id so the + * provider namespace does not crowd the picker; basename collisions retain enough route context + * to stay distinguishable, and aliased combo rows keep their public alias. * Native upstream entries (e.g. gpt-5.6-sol → "GPT-5.6-Sol") come from the pinned snapshot path * which carries no CatalogModel, so a configured displayName can never override a native name. */ diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index d7724439f..43084877b 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -145,6 +145,7 @@ export function deriveEntry( priority: number, model?: CatalogModel, exactComboSlugs: ReadonlySet = new Set(), + fallbackDisplayName?: string, ): RawEntry { const preserveExact = isExactComboCatalogModel(model, exactComboSlugs); const isRouted = model !== undefined; @@ -181,7 +182,7 @@ export function deriveEntry( applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true); if (model) applyJawcodeCatalogMetadata(e, model.provider, model.id, model.contextCap); - applyCatalogModelMetadata(e, model); + applyCatalogModelMetadata(e, model, fallbackDisplayName); } else { applyNativeOpenAiContextOverride(e); if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e); @@ -218,7 +219,7 @@ export function deriveEntry( if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(entry); } if (model && isRouted) applyJawcodeCatalogMetadata(entry, model.provider, model.id, model.contextCap); - applyCatalogModelMetadata(entry, model); + applyCatalogModelMetadata(entry, model, fallbackDisplayName); if (!isRouted) applyNativeOpenAiContextOverride(entry); return ensureStrictCatalogFields(normalizeServiceTiers(entry), { preserveExactInputModalities: preserveExact, @@ -226,6 +227,50 @@ export function deriveEntry( }); } +function configuredDisplayName(model: CatalogModel): string { + return typeof model.displayName === "string" ? model.displayName.trim() : ""; +} + +function defaultPickerLabel(model: CatalogModel): string { + const nativeId = model.id.trim(); + return nativeId.slice(nativeId.lastIndexOf("/") + 1).trim(); +} + +/** + * Keep the common picker path compact, but retain only as much route context as needed when two + * emitted rows would otherwise have the same label. Explicit display names and combo aliases are + * author-owned and never rewritten. If distinct native ids share a basename, the native id is + * enough; if the same native id is exposed by multiple providers, append the provider as well. + */ +function routedFallbackDisplayNames(models: readonly CatalogModel[]): Map { + const intendedLabel = (model: CatalogModel): string => + configuredDisplayName(model) || model.alias?.trim() || defaultPickerLabel(model); + const labelCounts = new Map(); + const nativeIdCounts = new Map(); + for (const model of models) { + const label = intendedLabel(model); + if (!label) continue; + labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1); + const nativeKey = `${label}\u0000${model.id.trim()}`; + nativeIdCounts.set(nativeKey, (nativeIdCounts.get(nativeKey) ?? 0) + 1); + } + + const out = new Map(); + for (const model of models) { + if (configuredDisplayName(model) || model.alias) continue; + const label = defaultPickerLabel(model); + if (!label) continue; + if ((labelCounts.get(label) ?? 0) <= 1) { + out.set(model, label); + continue; + } + const nativeId = model.id.trim(); + const sameNativeIdCount = nativeIdCounts.get(`${label}\u0000${nativeId}`) ?? 0; + out.set(model, sameNativeIdCount > 1 ? `${nativeId} (${model.provider})` : nativeId); + } + return out; +} + export function buildCatalogEntries( template: RawEntry | null, gptSlugs: string[], @@ -250,22 +295,28 @@ export function buildCatalogEntries( if (rank.has(slug)) e.priority = rank.get(slug)!; out.push(e); } - for (const m of goModels) { - if (collisionSkipped.has(m)) continue; + const routedModels = goModels.filter(m => { + if (collisionSkipped.has(m)) return false; const slug = catalogModelSlug(m); if (m.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) { warnComboMasqueradeCollisionOnce(slug); - continue; + return false; } + return true; + }); + const fallbackDisplayNames = routedFallbackDisplayNames(routedModels); + for (const m of routedModels) { + const slug = catalogModelSlug(m); // Provider rows use the one-slash slug codec; combo aliases intentionally override that // public slug and may be bare. const e = deriveEntry( template, slug, - `Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`, + `Routed via opencodex → ${slug} (${m.owned_by ?? m.provider}).`, 5, m, exactComboSlugs, + fallbackDisplayNames.get(m), ); // Featured picks may be stored raw (legacy) or encoded — honor both. const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 84f85cb37..acda7ef2d 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -736,22 +736,65 @@ describe("configured CatalogModel displayName -> catalog display_name", () => { expect(catalogModelSlug(model)).toBe("deepseek/deepseek-v4"); }); - test("absent displayName leaves display_name as the slug (unchanged behavior)", () => { + test("absent displayName uses the native model id without the provider namespace", () => { const entries = buildCatalogEntries(nativeTemplate(), [], [ { provider: "anthropic", id: "claude-sonnet-4-6", owned_by: "anthropic" }, ]); const row = entries.find(e => e.slug === "anthropic/claude-sonnet-4-6"); - expect(row?.display_name).toBe("anthropic/claude-sonnet-4-6"); + expect(row?.display_name).toBe("claude-sonnet-4-6"); expect(row?.slug).toBe("anthropic/claude-sonnet-4-6"); }); - test("empty/whitespace displayName is ignored and falls back to the slug", () => { + test("namespaced native ids use only their final segment as the default label", () => { const entries = buildCatalogEntries(nativeTemplate(), [], [ - { provider: "deepseek", id: "deepseek-v4", displayName: " ", owned_by: "deepseek" }, + { provider: "openrouter", id: "google/gemini-3.6-flash", displayName: " ", owned_by: "openrouter" }, ]); - const row = entries.find(e => e.slug === "deepseek/deepseek-v4"); - expect(row?.display_name).toBe("deepseek/deepseek-v4"); + const row = entries.find(e => e.slug === "openrouter/google-gemini-3.6-flash"); + expect(row?.display_name).toBe("gemini-3.6-flash"); + expect(row?.slug).toBe("openrouter/google-gemini-3.6-flash"); + }); + + test("basename collisions retain only the route context needed to distinguish rows", () => { + const entries = buildCatalogEntries(nativeTemplate(), [], [ + { provider: "openrouter", id: "reka/reka-edge", owned_by: "openrouter" }, + { provider: "openrouter", id: "rekaai/reka-edge", owned_by: "openrouter" }, + ]); + const reka = entries.find(e => e.slug === "openrouter/reka-reka-edge"); + const rekaAi = entries.find(e => e.slug === "openrouter/rekaai-reka-edge"); + + expect(reka?.display_name).toBe("reka/reka-edge"); + expect(rekaAi?.display_name).toBe("rekaai/reka-edge"); + expect(reka?.description).toContain("openrouter/reka-reka-edge"); + expect(rekaAi?.description).toContain("openrouter/rekaai-reka-edge"); + }); + + test("the same native id exposed by multiple providers also includes the provider", () => { + const entries = buildCatalogEntries(nativeTemplate(), [], [ + { provider: "openrouter", id: "moonshotai/kimi-k3", owned_by: "openrouter" }, + { provider: "nvidia", id: "moonshotai/kimi-k3", owned_by: "nvidia" }, + ]); + + expect(entries.find(e => e.slug === "openrouter/moonshotai-kimi-k3")?.display_name) + .toBe("moonshotai/kimi-k3 (openrouter)"); + expect(entries.find(e => e.slug === "nvidia/moonshotai-kimi-k3")?.display_name) + .toBe("moonshotai/kimi-k3 (nvidia)"); + }); + + test("explicit display names keep precedence when they collide with a default label", () => { + const entries = buildCatalogEntries(nativeTemplate(), [], [ + { provider: "openrouter", id: "reka/reka-edge", displayName: "reka-edge", owned_by: "openrouter" }, + { provider: "openrouter", id: "rekaai/reka-edge", owned_by: "openrouter" }, + ]); + + expect(entries.find(e => e.slug === "openrouter/reka-reka-edge")?.display_name).toBe("reka-edge"); + expect(entries.find(e => e.slug === "openrouter/rekaai-reka-edge")?.display_name).toBe("rekaai/reka-edge"); + }); + + test("an aliased combo keeps its public alias when no displayName is configured", () => { + const withAlias = { provider: "combo", id: "x", alias: "fast-chat", owned_by: "combo" }; + const entries = buildCatalogEntries(nativeTemplate(), [], [withAlias], undefined, false, "default", new Set(["fast-chat"])); + expect(entries.find(e => e.slug === "fast-chat")?.display_name).toBe("fast-chat"); }); test("displayName never affects the routing slug, alias, or provider", () => { diff --git a/tests/slug-codec.test.ts b/tests/slug-codec.test.ts index 6360ecd42..9a7d8ba01 100644 --- a/tests/slug-codec.test.ts +++ b/tests/slug-codec.test.ts @@ -158,7 +158,9 @@ describe("catalog emission (Codex-facing)", () => { const routed = entries.find(e => typeof e.slug === "string" && e.slug.startsWith("zenmux/")); expect(routed?.slug).toBe("zenmux/moonshotai-kimi-k3-free"); expect((routed?.slug as string).split("/")).toHaveLength(2); - expect(routed?.display_name).toBe("zenmux/moonshotai-kimi-k3-free"); + // The picker label is display-only and keeps the model name visible; routing still uses the + // exactly-one-slash Codex slug asserted above. + expect(routed?.display_name).toBe("kimi-k3-free"); // Identity text uses the NATIVE model name, not the encoded alias. expect(String(routed?.base_instructions)).toContain("moonshotai/kimi-k3-free"); });