Skip to content

Commit 9d94c92

Browse files
authored
Merge pull request lidge-jun#738 from Wibias/stack/gui-catalog-cold-load
fix(catalog): single-flight gather, prewarm, and readable slash aliases
2 parents 45e9dc4 + ca05c57 commit 9d94c92

14 files changed

Lines changed: 647 additions & 33 deletions

File tree

docs-site/src/content/docs/guides/claude-code.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,9 +191,12 @@ the alias back to the routed model. On older Claude Code versions the picker sta
191191
slots via
192192
`ANTHROPIC_MODEL` or type any routed id with `/model` (Claude Code passes strings through).
193193

194-
**Alias grammar rules:** provider must not contain `/` or `--` or equal `native`; model must not
195-
contain `/`. Routes the readable form cannot express fall back to the hashed alias. Model ids
196-
MAY contain `--` (resolution splits on the first `--` only); native slugs containing `--` fall back to the hashed form.
194+
**Alias grammar rules:** provider must not contain `/` or `--` or equal `native`.
195+
Model ids may contain `/` — encoded as `~s` in the alias (e.g. `openrouter/anthropic/claude-opus-4-8`
196+
`claude-ocx-openrouter--anthropic~sclaude-opus-4-8`). Literal `~` in a model id is encoded as `~t`.
197+
Bare `~` not followed by `s`/`t` is treated as a literal tilde so older persisted aliases keep resolving.
198+
Routes the readable form cannot express fall back to the hashed alias. Model ids MAY contain `--`
199+
(resolution splits on the first `--` only); native slugs containing `--` fall back to the hashed form.
197200

198201
**Model resolution order:** `[1m]` marker stripped → readable alias decoded → Desktop hashed
199202
alias decoded → `modelMap` exact match → date-stripped match (`-20250514` removed) → passthrough.

docs-site/src/content/docs/ja/guides/claude-code.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,11 @@ Claude Desktop のフッターピッカーで実行中の 3P 会話のモデル
8181
`/model <id>` を使用してください。OpenCodex はピッカーの状態を直接参照できず、各リクエストに
8282
含まれるモデル ID をルーティングします。結果は **Logs → requestedModel** で確認できます。
8383

84-
**エイリアス構文ルール:** provider には `/``--` を含められず `native` と同じでもいけません。model には
85-
`/` を含められません。読みやすい形式で表現できないルートはハッシュエイリアスに置き換えます。モデル
84+
**エイリアス構文ルール:** provider には `/``--` を含められず `native` と同じでもいけません。
85+
model ID には `/` を含められ、エイリアス内では `~s` として符号化します(例: `openrouter/anthropic/claude-opus-4-8`
86+
`claude-ocx-openrouter--anthropic~sclaude-opus-4-8`)。model ID のリテラル `~``~t` として符号化します。
87+
`s`/`t` が続かない裸の `~` はリテラルのチルダとして扱い、古い永続化エイリアスも解決し続けます。
88+
読みやすい形式で表現できないルートはハッシュエイリアスに置き換えます。モデル
8689
ID には `--` を含め**られます**(解析時は最初の `--` だけを基準に分割します)。`--` を含む
8790
ネイティブスラッグはハッシュ形式に置き換えます。
8891

docs-site/src/content/docs/ko/guides/claude-code.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,13 @@ Claude Desktop의 하단 선택기로 이미 실행 중인 3P 대화의 모델
116116
`/model <id>`를 사용하세요. OpenCodex는 선택기 상태를 따로 볼 수 없고 각 요청에 실린 모델 ID를
117117
라우팅해요. 적용 결과는 **Logs → requestedModel**에서 확인할 수 있어요.
118118

119-
**별칭 문법 규칙:** provider에는 `/``--`를 넣을 수 없고 `native`와 같아도 안 돼요. model에는
120-
`/`를 넣을 수 없어요. 읽기 쉬운 형식으로 표현할 수 없는 라우트는 해시 별칭으로 대체해요. 모델
121-
ID에는 `--`를 넣을 **수 있어요**(해석할 때 첫 번째 `--`만 기준으로 나눠요). `--`가 포함된
122-
네이티브 슬러그는 해시 형식으로 대체해요.
119+
**별칭 문법 규칙:** provider에는 `/``--`를 넣을 수 없고 `native`와 같아도 안 돼요. model ID에
120+
`/`가 있으면 별칭에서 `~s`로 인코딩해요(예: `openrouter/anthropic/claude-opus-4-8`
121+
`claude-ocx-openrouter--anthropic~sclaude-opus-4-8`). model ID의 리터럴 `~``~t`로 인코딩해요.
122+
`s`/`t`가 따르지 않는 단독 `~`는 예전 설정과의 호환을 위해 리터럴 `~`로 해석해요. 읽기 쉬운
123+
형식으로 표현할 수 없는 라우트는 해시 별칭으로 대체해요. 모델 ID에는 `--`를 넣을 **수 있어요**
124+
(해석할 때 첫 번째 `--`만 기준으로 나눠요). `--`가 포함된 네이티브 슬러그는 해시 형식으로
125+
대체해요.
123126

124127
**모델 해석 순서:** `[1m]` 표식 제거 → 읽기 쉬운 별칭 디코딩 → Desktop 해시 별칭 디코딩 →
125128
`modelMap` 정확히 일치 → 날짜를 제거한 값과 일치(`-20250514` 제거) → 패스스루 순서예요.

docs-site/src/content/docs/ru/guides/claude-code.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,13 @@ user-agent `claude-code/*` получает читаемую CLI-форму, а
8787
маршрутизирует id модели из каждого запроса. Результат можно проверить в **Logs → requestedModel**.
8888

8989
**Правила грамматики алиасов:** provider не может содержать `/` или `--` и не может быть равен
90-
`native`; model не может содержать `/`. Маршруты, которые невозможно выразить читаемой формой,
91-
откатываются на хешированный алиас. Id моделей МОГУТ содержать `--` (при разрешении деление
92-
происходит только по первому `--`); нативные слаги с `--` откатываются на хешированную форму.
90+
`native`. Id моделей могут содержать `/` — в алиасе это кодируется как `~s` (например,
91+
`openrouter/anthropic/claude-opus-4-8``claude-ocx-openrouter--anthropic~sclaude-opus-4-8`).
92+
Литеральный `~` в id модели кодируется как `~t`. Голый `~` без следующего `s`/`t`
93+
считается литеральной тильдой, чтобы старые сохранённые алиасы продолжали разрешаться.
94+
Маршруты, которые невозможно выразить читаемой формой,
95+
откатываются на хешированный алиас. Id моделей МОГУТ содержать `--` (при разрешении разбиение
96+
выполняется только по первому `--`); нативные слаги с `--` откатываются на хешированную форму.
9397

9498
**Порядок разрешения модели:** удаление маркера `[1m]` → декодирование читаемого алиаса →
9599
декодирование Desktop-хеша → точное совпадение в `modelMap` → совпадение без даты (удаляется

docs-site/src/content/docs/zh-cn/guides/claude-code.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,12 @@ user-agent 会获得易读的 CLI 形式,其他客户端会获得 Desktop 哈
8888
`/model <id>`。OpenCodex 无法读取选择器状态,只会路由每个请求实际携带的模型 ID;可在
8989
**Logs → requestedModel** 中确认结果。
9090

91-
**别名语法规则:**provider 不得包含 `/``--`,也不得等于 `native`;model 不得包含
92-
`/`。易读形式无法表达的路由会回退到哈希别名。模型 ID **可以**包含 `--`(解析时只按第一个
93-
`--` 拆分);包含 `--` 的原生 slug 会回退到哈希形式。
91+
**别名语法规则:**provider 不得包含 `/``--`,也不得等于 `native`
92+
model ID 可以包含 `/` — 在别名中编码为 `~s`(例如 `openrouter/anthropic/claude-opus-4-8`
93+
`claude-ocx-openrouter--anthropic~sclaude-opus-4-8`)。model ID 中的字面 `~` 编码为 `~t`
94+
后面不是 `s`/`t` 的裸 `~` 视为字面波浪号,以便旧版已持久化的别名继续解析。
95+
易读形式无法表达的路由会回退到哈希别名。模型 ID **可以**包含 `--`(解析时只按第一个
96+
`--` 分割);含 `--` 的原生 slug 会回退到哈希形式。
9497

9598
**模型解析顺序:**移除 `[1m]` 标记 → 解码易读别名 → 解码 Desktop 哈希别名 →
9699
`modelMap` 精确匹配 → 移除日期后的匹配(移除 `-20250514`)→ 透传。

src/claude/alias.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,17 @@
99
*
1010
* Reversibility rules:
1111
* - providers containing `--` or `/` are not aliased (split boundary safety);
12-
* - model ids containing `/` are not aliased (would be ambiguous on resolve);
12+
* - model ids MAY contain `/` — encoded as `~s` so the alias stays slash-free
13+
* for Claude Code's picker (e.g. openrouter `anthropic/claude-opus-4-8` →
14+
* `claude-ocx-openrouter--anthropic~sclaude-opus-4-8`);
15+
* - model ids MAY contain `~` — encoded as `~t` (so slash encoding cannot
16+
* collide with a literal tilde that older releases already persisted);
17+
* - `~s` / `~t` are reserved escape sequences: decode always treats them as
18+
* `/` and `~` respectively. Model ids that historically contained the literal
19+
* two-char sequences `~s` / `~t` are not preserved (extremely rare; encode would
20+
* write `~ts` / `~tt` for those characters after a literal tilde today);
21+
* - bare `~` not followed by `s`/`t` is left as a literal tilde on decode
22+
* (legacy aliases from before slash encoding);
1323
* - model ids MAY contain `--` (resolve splits on the FIRST `--` only);
1424
* - native OpenAI slugs use the pseudo-provider `native` and resolve back to
1525
* the bare slug; a real provider named "native" is therefore never aliased.
@@ -18,19 +28,53 @@
1828
import { desktop3pAlias } from "./desktop-3p";
1929

2030
export const CLAUDE_ALIAS_PREFIX = "claude-ocx-";
31+
/** Encoded `/` inside the model portion of a Claude Code alias. */
32+
const CLAUDE_ALIAS_SLASH_ENC = "~s";
33+
/** Encoded literal `~` inside the model portion of a Claude Code alias. */
34+
const CLAUDE_ALIAS_TILDE_ENC = "~t";
2135
const NATIVE_PSEUDO_PROVIDER = "native";
2236

37+
function encodeModelId(modelId: string): string {
38+
// Escape literal tildes first so slash encoding cannot create ambiguity.
39+
return modelId
40+
.replaceAll("~", CLAUDE_ALIAS_TILDE_ENC)
41+
.replaceAll("/", CLAUDE_ALIAS_SLASH_ENC);
42+
}
43+
44+
function decodeModelId(encoded: string): string {
45+
let out = "";
46+
for (let i = 0; i < encoded.length; i++) {
47+
if (encoded[i] === "~" && i + 1 < encoded.length) {
48+
const next = encoded[i + 1];
49+
if (next === "s") {
50+
out += "/";
51+
i += 1;
52+
continue;
53+
}
54+
if (next === "t") {
55+
out += "~";
56+
i += 1;
57+
continue;
58+
}
59+
}
60+
// Bare `~` (legacy pre-slash-encoding aliases) stays a literal tilde.
61+
out += encoded[i];
62+
}
63+
return out;
64+
}
65+
2366
/** Alias for a routed "<provider>/<model>" pair; null when not representable. */
2467
export function aliasForRoute(provider: string, modelId: string): string | null {
2568
if (!provider || provider.includes("--") || provider.includes("/") || provider === NATIVE_PSEUDO_PROVIDER) return null;
26-
if (!modelId || modelId.includes("/")) return null;
27-
return `${CLAUDE_ALIAS_PREFIX}${provider}--${modelId}`;
69+
if (!modelId) return null;
70+
return `${CLAUDE_ALIAS_PREFIX}${provider}--${encodeModelId(modelId)}`;
2871
}
2972

3073
/** Alias for a native OpenAI slug (bare model id, no provider namespace). */
3174
export function aliasForNative(slug: string): string | null {
75+
// Reject "/" — native ids are bare slugs. Literal `~` is fine via ~t encoding.
3276
if (!slug || slug.includes("/") || slug.includes("--")) return null;
33-
return `${CLAUDE_ALIAS_PREFIX}${NATIVE_PSEUDO_PROVIDER}--${slug}`;
77+
return `${CLAUDE_ALIAS_PREFIX}${NATIVE_PSEUDO_PROVIDER}--${encodeModelId(slug)}`;
3478
}
3579

3680
/**
@@ -43,7 +87,7 @@ export function resolveAlias(id: string): string | null {
4387
const sep = rest.indexOf("--");
4488
if (sep <= 0) return null;
4589
const provider = rest.slice(0, sep);
46-
const model = rest.slice(sep + 2);
90+
const model = decodeModelId(rest.slice(sep + 2));
4791
if (!model) return null;
4892
return provider === NATIVE_PSEUDO_PROVIDER ? model : `${provider}/${model}`;
4993
}

src/cli/catalog-prewarm.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { OcxConfig } from "../types";
2+
3+
type GatherRoutedModels = (config: OcxConfig) => Promise<unknown>;
4+
5+
export type CatalogPrewarmDeps = {
6+
loadConfig?: () => OcxConfig;
7+
importCatalog?: () => Promise<{ gatherRoutedModels: GatherRoutedModels }>;
8+
};
9+
10+
/**
11+
* After the listen port is bound, kick off live provider discovery so the first
12+
* GUI /v1/models and syncModelsToCodex share one gather flight instead of racing
13+
* duplicate upstream /models fetches.
14+
*/
15+
export function scheduleCatalogPrewarm(deps: CatalogPrewarmDeps = {}): void {
16+
void Promise.resolve()
17+
.then(async () => {
18+
const load = deps.loadConfig ?? (await import("../config")).loadConfig;
19+
const { gatherRoutedModels } = await (deps.importCatalog?.() ?? import("../codex/catalog"));
20+
return gatherRoutedModels(load());
21+
})
22+
.catch(() => {});
23+
}
24+

src/cli/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { startTokenGuardian } from "../oauth/token-guardian";
3838
import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian";
3939
import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore";
4040
import { maybeShowStarPrompt } from "./star-prompt";
41+
import { scheduleCatalogPrewarm } from "./catalog-prewarm";
4142
import { maybeShowUpdatePrompt } from "../update/notify";
4243
import { syncModelsToCodex } from "../codex/sync";
4344
import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
@@ -190,6 +191,10 @@ async function handleStart(options: { block?: boolean } = {}) {
190191
for (let attempt = 0; ; attempt++) {
191192
try {
192193
server = startServer(port);
194+
// Prewarm the live provider model cache as soon as the port is bound so the
195+
// first GUI /v1/models (and syncModelsToCodex below) share one discovery flight
196+
// instead of racing duplicate upstream /models fetches.
197+
scheduleCatalogPrewarm();
193198
break;
194199
} catch (err) {
195200
if (!isAddrInUse(err) || attempt >= 2) throw err;

src/codex/catalog.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
55
export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata";
66
export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
77
export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
8-
export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
8+
export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
99
export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation";
1010
export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation";
1111
export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync";

0 commit comments

Comments
 (0)