Skip to content

Commit 4cd6e64

Browse files
committed
fix(catalog): disambiguate picker label collisions
1 parent 5f9bb33 commit 4cd6e64

9 files changed

Lines changed: 127 additions & 26 deletions

File tree

docs-site/src/content/docs/guides/codex-integration.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,10 +185,12 @@ start and on `ocx sync`, opencodex:
185185
5. **Re-ranks** so featured models sort first (see below), then writes the merged catalog back.
186186

187187
Routed catalog entries also get their GPT-5 identity rewritten to the real upstream model name.
188-
Their default picker label is the final segment of the native model id, so provider namespaces do
189-
not hide the model name in narrow pickers. The full `provider/model` route remains in the catalog
190-
slug and description. Configure a custom display name when the route itself matters visually (for
191-
example, `Claude Opus 5 (TeamClaude)`); custom names continue to take precedence.
188+
When unique, their default picker label is the final segment of the native model id, so provider
189+
namespaces do not hide the model name in narrow pickers. Basename collisions retain enough native
190+
route context to distinguish the rows; if the same native id comes from multiple providers, the
191+
provider is shown too. The full catalog slug remains in the description. Configure a custom display
192+
name when the route itself matters visually (for example, `Claude Opus 5 (TeamClaude)`); custom
193+
names continue to take precedence.
192194
Reasoning controls come from provider/model metadata across Codex's `low | medium | high | xhigh |
193195
max | ultra` ladder; unsupported values are mapped or clamped before the upstream request.
194196

docs-site/src/content/docs/ja/guides/codex-integration.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,10 @@ Codex はディスクのカタログ(デフォルト `$CODEX_HOME/opencodex-cata
148148
5. フィーチャー済みモデルが先に並ぶよう**再整列**した後(下記参照)、マージされたカタログを書き戻します。
149149

150150
ルーティングされたカタログ項目の GPT-5 アイデンティティ文言も実際の上流モデル名に合わせます。
151-
デフォルトのピッカーラベルにはネイティブモデル ID の最後のセグメントが使われるため、幅の狭いピッカーでも
152-
プロバイダーの名前空間がモデル名を隠しません。完全な `provider/model` 経路はカタログのスラッグと説明に残ります。
151+
一意であれば、デフォルトのピッカーラベルにはネイティブモデル ID の最後のセグメントが使われるため、幅の狭い
152+
ピッカーでもプロバイダーの名前空間がモデル名を隠しません。同じ basename が衝突する場合は行を区別できるだけの
153+
ネイティブ経路を残し、同じネイティブ ID が複数のプロバイダーにある場合はプロバイダー名も表示します。完全な
154+
カタログスラッグは説明に残ります。
153155
経路自体を見分ける必要がある場合(例: `Claude Opus 5 (TeamClaude)`)はカスタム表示名を設定してください。
154156
カスタム表示名は引き続きデフォルトラベルより優先されます。推論選択肢は
155157
プロバイダーとモデルメタデータに応じて Codex の `low | medium | high | xhigh | max | ultra` 段階を使い、

docs-site/src/content/docs/ko/guides/codex-integration.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,10 @@ Codex는 디스크의 카탈로그(기본값 `$CODEX_HOME/opencodex-catalog.json
145145
4. `config.disabledModels`와 각 프로바이더의 비어 있지 않은 `selectedModels` 허용 목록을 **적용**합니다.
146146
5. featured 모델이 먼저 정렬되도록 **재정렬**한 뒤(아래 참고), 병합된 카탈로그를 다시 작성합니다.
147147

148-
라우팅된 카탈로그 항목의 GPT-5 정체성 문구도 실제 업스트림 모델 이름에 맞게 바꿉니다. 기본 선택기 레이블은
149-
네이티브 모델 ID의 마지막 부분을 사용하므로 폭이 좁은 선택기에서도 프로바이더 네임스페이스가 모델 이름을
150-
가리지 않습니다. 전체 `provider/model` 경로는 카탈로그 slug와 설명에 그대로 남습니다. 경로 자체를 눈에 띄게
148+
라우팅된 카탈로그 항목의 GPT-5 정체성 문구도 실제 업스트림 모델 이름에 맞게 바꿉니다. 이름이 고유하면 기본 선택기
149+
레이블은 네이티브 모델 ID의 마지막 부분만 사용하므로 폭이 좁은 선택기에서도 프로바이더 네임스페이스가 모델 이름을
150+
가리지 않습니다. 같은 basename이 겹치면 행을 구분하는 데 필요한 네이티브 경로를 남기고, 같은 네이티브 ID가 여러
151+
프로바이더에 있으면 프로바이더 이름도 표시합니다. 전체 카탈로그 slug는 설명에 그대로 남습니다. 경로 자체를 눈에 띄게
151152
구분해야 한다면(예: `Claude Opus 5 (TeamClaude)`) 사용자 지정 표시 이름을 설정하세요. 사용자 지정 이름은
152153
계속 기본 레이블보다 우선합니다. reasoning 선택지는
153154
프로바이더와 모델 메타데이터에 따라 Codex의 `low | medium | high | xhigh | max | ultra` 단계를 사용하며,

docs-site/src/content/docs/ru/guides/codex-integration.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,11 @@ Codex показывает модели из каталога на диске (
166166
объединённый каталог обратно.
167167

168168
У маршрутизируемых записей каталога идентичность GPT-5 также переписывается на настоящее имя
169-
вышестоящей модели. По умолчанию в селекторе показывается последний сегмент нативного идентификатора
170-
модели, поэтому пространство имён провайдера не скрывает имя модели в узком селекторе. Полный маршрут
171-
`provider/model` сохраняется в slug и описании каталога. Если маршрут важно различать визуально
169+
вышестоящей модели. Если имя уникально, по умолчанию в селекторе показывается последний сегмент
170+
нативного идентификатора модели, поэтому пространство имён провайдера не скрывает имя модели в узком
171+
селекторе. При совпадении basename сохраняется достаточная часть нативного маршрута, а если один и тот
172+
же нативный ID доступен у нескольких провайдеров, показывается и провайдер. Полный slug каталога
173+
сохраняется в описании. Если маршрут важно различать визуально
172174
(например, `Claude Opus 5 (TeamClaude)`), задайте пользовательское отображаемое имя; оно по-прежнему
173175
имеет приоритет над меткой по умолчанию. Элементы управления рассуждениями берутся из метаданных провайдера и модели
174176
по шкале Codex `low | medium | high | xhigh | max | ultra`; неподдерживаемые значения

docs-site/src/content/docs/zh-cn/guides/codex-integration.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,9 @@ Codex 显示的模型来自一个磁盘上的目录(默认为 `$CODEX_HOME/ope
139139
5. **重新排序**,使置顶模型排在最前(见下文),然后将合并后的目录写回。
140140

141141
路由目录条目还会把 GPT-5 身份文案改为真实的上游模型名称。
142-
默认的选择器标签使用原生模型 ID 的最后一段,因此在较窄的选择器中,提供商命名空间不会遮住模型名称。
143-
完整的 `provider/model` 路由仍保留在目录 slug 和说明中。如果需要直观看出路由本身(例如
142+
名称唯一时,默认的选择器标签使用原生模型 ID 的最后一段,因此在较窄的选择器中,提供商命名空间不会遮住模型名称。
143+
若 basename 冲突,则保留足以区分各行的原生路由信息;若多个提供商暴露同一个原生 ID,还会显示提供商名称。
144+
完整的目录 slug 仍保留在说明中。如果需要直观看出路由本身(例如
144145
`Claude Opus 5 (TeamClaude)`),请配置自定义显示名称;自定义名称仍然优先于默认标签。reasoning 选项会依据提供商和模型元数据,
145146
使用 Codex 的 `low | medium | high | xhigh | max | ultra` 档位;上游不支持的值会在发送请求前完成
146147
映射或下调。

src/codex/catalog/effort.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,23 +109,28 @@ export function catalogEntryEfforts(entry: RawEntry): string[] {
109109

110110
export const ROUTED_REASONING_LEVELS = [...CODEX_REASONING_LEVELS];
111111

112-
export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel): void {
112+
export function applyCatalogModelMetadata(
113+
entry: RawEntry,
114+
model?: CatalogModel,
115+
fallbackDisplayName?: string,
116+
): void {
113117
if (!model) return;
114118
// This marker survives strict catalog normalization and lets sync distinguish a stale
115119
// bare combo alias from a genuine native model row.
116120
if (model.provider === COMBO_NAMESPACE) entry.owned_by = model.owned_by ?? COMBO_NAMESPACE;
117121
// displayName is DISPLAY-ONLY: it relabels the picker row but never touches the routing
118122
// slug, alias, or provider. A configured displayName wins. Otherwise physical routed rows
119123
// use the final segment of their native id, keeping provider/vendor namespaces out of the
120-
// narrow picker label while the full route remains in slug + description. Aliased combos
121-
// keep their public alias. Natives never reach here (no CatalogModel), so genuine upstream
122-
// marketing names are preserved untouched.
124+
// narrow picker label. buildCatalogEntries supplies a longer fallback only when that basename
125+
// would collide with another visible row. Aliased combos keep their public alias. Natives never
126+
// reach here (no CatalogModel), so genuine upstream marketing names are preserved untouched.
123127
const displayName = typeof model.displayName === "string" ? model.displayName.trim() : "";
124128
if (displayName) entry.display_name = displayName;
125129
else if (!model.alias) {
126130
const nativeId = model.id.trim();
127131
const finalSegment = nativeId.slice(nativeId.lastIndexOf("/") + 1).trim();
128-
if (finalSegment) entry.display_name = finalSegment;
132+
const fallback = fallbackDisplayName?.trim() || finalSegment;
133+
if (fallback) entry.display_name = fallback;
129134
}
130135
if (typeof model.contextWindow === "number" && model.contextWindow > 0) {
131136
entry.context_window = model.contextWindow;

src/codex/catalog/parsing.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ export interface CatalogModel {
9393
* Display-only Codex catalog `display_name` override. Relabels the picker row ONLY — it never
9494
* affects the routing slug, alias-collision order, native marketing-name precedence, or provider
9595
* behavior. When unset, routed entries use the final segment of the native model id so the
96-
* provider namespace does not crowd the picker; aliased combo rows keep their public alias.
96+
* provider namespace does not crowd the picker; basename collisions retain enough route context
97+
* to stay distinguishable, and aliased combo rows keep their public alias.
9798
* Native upstream entries (e.g. gpt-5.6-sol → "GPT-5.6-Sol") come from the pinned snapshot path
9899
* which carries no CatalogModel, so a configured displayName can never override a native name.
99100
*/

src/codex/catalog/sync.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export function deriveEntry(
145145
priority: number,
146146
model?: CatalogModel,
147147
exactComboSlugs: ReadonlySet<string> = new Set(),
148+
fallbackDisplayName?: string,
148149
): RawEntry {
149150
const preserveExact = isExactComboCatalogModel(model, exactComboSlugs);
150151
const isRouted = model !== undefined;
@@ -181,7 +182,7 @@ export function deriveEntry(
181182
applyReasoningLevels(e, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
182183
normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true);
183184
if (model) applyJawcodeCatalogMetadata(e, model.provider, model.id, model.contextCap);
184-
applyCatalogModelMetadata(e, model);
185+
applyCatalogModelMetadata(e, model, fallbackDisplayName);
185186
} else {
186187
applyNativeOpenAiContextOverride(e);
187188
if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e);
@@ -218,14 +219,58 @@ export function deriveEntry(
218219
if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(entry);
219220
}
220221
if (model && isRouted) applyJawcodeCatalogMetadata(entry, model.provider, model.id, model.contextCap);
221-
applyCatalogModelMetadata(entry, model);
222+
applyCatalogModelMetadata(entry, model, fallbackDisplayName);
222223
if (!isRouted) applyNativeOpenAiContextOverride(entry);
223224
return ensureStrictCatalogFields(normalizeServiceTiers(entry), {
224225
preserveExactInputModalities: preserveExact,
225226
isRouted,
226227
});
227228
}
228229

230+
function configuredDisplayName(model: CatalogModel): string {
231+
return typeof model.displayName === "string" ? model.displayName.trim() : "";
232+
}
233+
234+
function defaultPickerLabel(model: CatalogModel): string {
235+
const nativeId = model.id.trim();
236+
return nativeId.slice(nativeId.lastIndexOf("/") + 1).trim();
237+
}
238+
239+
/**
240+
* Keep the common picker path compact, but retain only as much route context as needed when two
241+
* emitted rows would otherwise have the same label. Explicit display names and combo aliases are
242+
* author-owned and never rewritten. If distinct native ids share a basename, the native id is
243+
* enough; if the same native id is exposed by multiple providers, append the provider as well.
244+
*/
245+
function routedFallbackDisplayNames(models: readonly CatalogModel[]): Map<CatalogModel, string> {
246+
const intendedLabel = (model: CatalogModel): string =>
247+
configuredDisplayName(model) || model.alias?.trim() || defaultPickerLabel(model);
248+
const labelCounts = new Map<string, number>();
249+
const nativeIdCounts = new Map<string, number>();
250+
for (const model of models) {
251+
const label = intendedLabel(model);
252+
if (!label) continue;
253+
labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
254+
const nativeKey = `${label}\u0000${model.id.trim()}`;
255+
nativeIdCounts.set(nativeKey, (nativeIdCounts.get(nativeKey) ?? 0) + 1);
256+
}
257+
258+
const out = new Map<CatalogModel, string>();
259+
for (const model of models) {
260+
if (configuredDisplayName(model) || model.alias) continue;
261+
const label = defaultPickerLabel(model);
262+
if (!label) continue;
263+
if ((labelCounts.get(label) ?? 0) <= 1) {
264+
out.set(model, label);
265+
continue;
266+
}
267+
const nativeId = model.id.trim();
268+
const sameNativeIdCount = nativeIdCounts.get(`${label}\u0000${nativeId}`) ?? 0;
269+
out.set(model, sameNativeIdCount > 1 ? `${nativeId} (${model.provider})` : nativeId);
270+
}
271+
return out;
272+
}
273+
229274
export function buildCatalogEntries(
230275
template: RawEntry | null,
231276
gptSlugs: string[],
@@ -250,22 +295,28 @@ export function buildCatalogEntries(
250295
if (rank.has(slug)) e.priority = rank.get(slug)!;
251296
out.push(e);
252297
}
253-
for (const m of goModels) {
254-
if (collisionSkipped.has(m)) continue;
298+
const routedModels = goModels.filter(m => {
299+
if (collisionSkipped.has(m)) return false;
255300
const slug = catalogModelSlug(m);
256301
if (m.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) {
257302
warnComboMasqueradeCollisionOnce(slug);
258-
continue;
303+
return false;
259304
}
305+
return true;
306+
});
307+
const fallbackDisplayNames = routedFallbackDisplayNames(routedModels);
308+
for (const m of routedModels) {
309+
const slug = catalogModelSlug(m);
260310
// Provider rows use the one-slash slug codec; combo aliases intentionally override that
261311
// public slug and may be bare.
262312
const e = deriveEntry(
263313
template,
264314
slug,
265-
`Routed via opencodex → ${m.provider} (${m.owned_by ?? m.provider}).`,
315+
`Routed via opencodex → ${slug} (${m.owned_by ?? m.provider}).`,
266316
5,
267317
m,
268318
exactComboSlugs,
319+
fallbackDisplayNames.get(m),
269320
);
270321
// Featured picks may be stored raw (legacy) or encoded — honor both.
271322
const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`);

tests/codex-catalog.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,42 @@ describe("configured CatalogModel displayName -> catalog display_name", () => {
755755
expect(row?.slug).toBe("openrouter/google-gemini-3.6-flash");
756756
});
757757

758+
test("basename collisions retain only the route context needed to distinguish rows", () => {
759+
const entries = buildCatalogEntries(nativeTemplate(), [], [
760+
{ provider: "openrouter", id: "reka/reka-edge", owned_by: "openrouter" },
761+
{ provider: "openrouter", id: "rekaai/reka-edge", owned_by: "openrouter" },
762+
]);
763+
const reka = entries.find(e => e.slug === "openrouter/reka-reka-edge");
764+
const rekaAi = entries.find(e => e.slug === "openrouter/rekaai-reka-edge");
765+
766+
expect(reka?.display_name).toBe("reka/reka-edge");
767+
expect(rekaAi?.display_name).toBe("rekaai/reka-edge");
768+
expect(reka?.description).toContain("openrouter/reka-reka-edge");
769+
expect(rekaAi?.description).toContain("openrouter/rekaai-reka-edge");
770+
});
771+
772+
test("the same native id exposed by multiple providers also includes the provider", () => {
773+
const entries = buildCatalogEntries(nativeTemplate(), [], [
774+
{ provider: "openrouter", id: "moonshotai/kimi-k3", owned_by: "openrouter" },
775+
{ provider: "nvidia", id: "moonshotai/kimi-k3", owned_by: "nvidia" },
776+
]);
777+
778+
expect(entries.find(e => e.slug === "openrouter/moonshotai-kimi-k3")?.display_name)
779+
.toBe("moonshotai/kimi-k3 (openrouter)");
780+
expect(entries.find(e => e.slug === "nvidia/moonshotai-kimi-k3")?.display_name)
781+
.toBe("moonshotai/kimi-k3 (nvidia)");
782+
});
783+
784+
test("explicit display names keep precedence when they collide with a default label", () => {
785+
const entries = buildCatalogEntries(nativeTemplate(), [], [
786+
{ provider: "openrouter", id: "reka/reka-edge", displayName: "reka-edge", owned_by: "openrouter" },
787+
{ provider: "openrouter", id: "rekaai/reka-edge", owned_by: "openrouter" },
788+
]);
789+
790+
expect(entries.find(e => e.slug === "openrouter/reka-reka-edge")?.display_name).toBe("reka-edge");
791+
expect(entries.find(e => e.slug === "openrouter/rekaai-reka-edge")?.display_name).toBe("rekaai/reka-edge");
792+
});
793+
758794
test("an aliased combo keeps its public alias when no displayName is configured", () => {
759795
const withAlias = { provider: "combo", id: "x", alias: "fast-chat", owned_by: "combo" };
760796
const entries = buildCatalogEntries(nativeTemplate(), [], [withAlias], undefined, false, "default", new Set(["fast-chat"]));

0 commit comments

Comments
 (0)