diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index ba1786409..550888cc2 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -191,9 +191,12 @@ the alias back to the routed model. On older Claude Code versions the picker sta slots via `ANTHROPIC_MODEL` or type any routed id with `/model` (Claude Code passes strings through). -**Alias grammar rules:** provider must not contain `/` or `--` or equal `native`; model must not -contain `/`. Routes the readable form cannot express fall back to the hashed alias. Model ids -MAY contain `--` (resolution splits on the first `--` only); native slugs containing `--` fall back to the hashed form. +**Alias grammar rules:** provider must not contain `/` or `--` or equal `native`. +Model ids may contain `/` — encoded as `~s` in the alias (e.g. `openrouter/anthropic/claude-opus-4-8` +→ `claude-ocx-openrouter--anthropic~sclaude-opus-4-8`). Literal `~` in a model id is encoded as `~t`. +Bare `~` not followed by `s`/`t` is treated as a literal tilde so older persisted aliases keep resolving. +Routes the readable form cannot express fall back to the hashed alias. Model ids MAY contain `--` +(resolution splits on the first `--` only); native slugs containing `--` fall back to the hashed form. **Model resolution order:** `[1m]` marker stripped → readable alias decoded → Desktop hashed alias decoded → `modelMap` exact match → date-stripped match (`-20250514` removed) → passthrough. diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index eebe91822..b2970068f 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -81,8 +81,11 @@ Claude Desktop のフッターピッカーで実行中の 3P 会話のモデル `/model ` を使用してください。OpenCodex はピッカーの状態を直接参照できず、各リクエストに 含まれるモデル ID をルーティングします。結果は **Logs → requestedModel** で確認できます。 -**エイリアス構文ルール:** provider には `/` や `--` を含められず `native` と同じでもいけません。model には -`/` を含められません。読みやすい形式で表現できないルートはハッシュエイリアスに置き換えます。モデル +**エイリアス構文ルール:** provider には `/` や `--` を含められず `native` と同じでもいけません。 +model ID には `/` を含められ、エイリアス内では `~s` として符号化します(例: `openrouter/anthropic/claude-opus-4-8` → +`claude-ocx-openrouter--anthropic~sclaude-opus-4-8`)。model ID のリテラル `~` は `~t` として符号化します。 +`s`/`t` が続かない裸の `~` はリテラルのチルダとして扱い、古い永続化エイリアスも解決し続けます。 +読みやすい形式で表現できないルートはハッシュエイリアスに置き換えます。モデル ID には `--` を含め**られます**(解析時は最初の `--` だけを基準に分割します)。`--` を含む ネイティブスラッグはハッシュ形式に置き換えます。 diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 82519f215..2312f207a 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -116,10 +116,13 @@ Claude Desktop의 하단 선택기로 이미 실행 중인 3P 대화의 모델 `/model `를 사용하세요. OpenCodex는 선택기 상태를 따로 볼 수 없고 각 요청에 실린 모델 ID를 라우팅해요. 적용 결과는 **Logs → requestedModel**에서 확인할 수 있어요. -**별칭 문법 규칙:** provider에는 `/`나 `--`를 넣을 수 없고 `native`와 같아도 안 돼요. model에는 -`/`를 넣을 수 없어요. 읽기 쉬운 형식으로 표현할 수 없는 라우트는 해시 별칭으로 대체해요. 모델 -ID에는 `--`를 넣을 **수 있어요**(해석할 때 첫 번째 `--`만 기준으로 나눠요). `--`가 포함된 -네이티브 슬러그는 해시 형식으로 대체해요. +**별칭 문법 규칙:** provider에는 `/`나 `--`를 넣을 수 없고 `native`와 같아도 안 돼요. model ID에 +`/`가 있으면 별칭에서 `~s`로 인코딩해요(예: `openrouter/anthropic/claude-opus-4-8` → +`claude-ocx-openrouter--anthropic~sclaude-opus-4-8`). model ID의 리터럴 `~`는 `~t`로 인코딩해요. +`s`/`t`가 따르지 않는 단독 `~`는 예전 설정과의 호환을 위해 리터럴 `~`로 해석해요. 읽기 쉬운 +형식으로 표현할 수 없는 라우트는 해시 별칭으로 대체해요. 모델 ID에는 `--`를 넣을 **수 있어요** +(해석할 때 첫 번째 `--`만 기준으로 나눠요). `--`가 포함된 네이티브 슬러그는 해시 형식으로 +대체해요. **모델 해석 순서:** `[1m]` 표식 제거 → 읽기 쉬운 별칭 디코딩 → Desktop 해시 별칭 디코딩 → `modelMap` 정확히 일치 → 날짜를 제거한 값과 일치(`-20250514` 제거) → 패스스루 순서예요. diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index ee03eca39..6afbdcc7b 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -87,9 +87,13 @@ user-agent `claude-code/*` получает читаемую CLI-форму, а маршрутизирует id модели из каждого запроса. Результат можно проверить в **Logs → requestedModel**. **Правила грамматики алиасов:** provider не может содержать `/` или `--` и не может быть равен -`native`; model не может содержать `/`. Маршруты, которые невозможно выразить читаемой формой, -откатываются на хешированный алиас. Id моделей МОГУТ содержать `--` (при разрешении деление -происходит только по первому `--`); нативные слаги с `--` откатываются на хешированную форму. +`native`. Id моделей могут содержать `/` — в алиасе это кодируется как `~s` (например, +`openrouter/anthropic/claude-opus-4-8` → `claude-ocx-openrouter--anthropic~sclaude-opus-4-8`). +Литеральный `~` в id модели кодируется как `~t`. Голый `~` без следующего `s`/`t` +считается литеральной тильдой, чтобы старые сохранённые алиасы продолжали разрешаться. +Маршруты, которые невозможно выразить читаемой формой, +откатываются на хешированный алиас. Id моделей МОГУТ содержать `--` (при разрешении разбиение +выполняется только по первому `--`); нативные слаги с `--` откатываются на хешированную форму. **Порядок разрешения модели:** удаление маркера `[1m]` → декодирование читаемого алиаса → декодирование Desktop-хеша → точное совпадение в `modelMap` → совпадение без даты (удаляется diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 63b86547a..4cc99cdf6 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -88,9 +88,12 @@ user-agent 会获得易读的 CLI 形式,其他客户端会获得 Desktop 哈 `/model `。OpenCodex 无法读取选择器状态,只会路由每个请求实际携带的模型 ID;可在 **Logs → requestedModel** 中确认结果。 -**别名语法规则:**provider 不得包含 `/` 或 `--`,也不得等于 `native`;model 不得包含 -`/`。易读形式无法表达的路由会回退到哈希别名。模型 ID **可以**包含 `--`(解析时只按第一个 -`--` 拆分);包含 `--` 的原生 slug 会回退到哈希形式。 +**别名语法规则:**provider 不得包含 `/` 或 `--`,也不得等于 `native`。 +model ID 可以包含 `/` — 在别名中编码为 `~s`(例如 `openrouter/anthropic/claude-opus-4-8` +→ `claude-ocx-openrouter--anthropic~sclaude-opus-4-8`)。model ID 中的字面 `~` 编码为 `~t`。 +后面不是 `s`/`t` 的裸 `~` 视为字面波浪号,以便旧版已持久化的别名继续解析。 +易读形式无法表达的路由会回退到哈希别名。模型 ID **可以**包含 `--`(解析时只按第一个 +`--` 分割);含 `--` 的原生 slug 会回退到哈希形式。 **模型解析顺序:**移除 `[1m]` 标记 → 解码易读别名 → 解码 Desktop 哈希别名 → `modelMap` 精确匹配 → 移除日期后的匹配(移除 `-20250514`)→ 透传。 diff --git a/src/claude/alias.ts b/src/claude/alias.ts index 074cef0fc..32a1d672b 100644 --- a/src/claude/alias.ts +++ b/src/claude/alias.ts @@ -9,7 +9,17 @@ * * Reversibility rules: * - providers containing `--` or `/` are not aliased (split boundary safety); - * - model ids containing `/` are not aliased (would be ambiguous on resolve); + * - model ids MAY contain `/` — encoded as `~s` so the alias stays slash-free + * for Claude Code's picker (e.g. openrouter `anthropic/claude-opus-4-8` → + * `claude-ocx-openrouter--anthropic~sclaude-opus-4-8`); + * - model ids MAY contain `~` — encoded as `~t` (so slash encoding cannot + * collide with a literal tilde that older releases already persisted); + * - `~s` / `~t` are reserved escape sequences: decode always treats them as + * `/` and `~` respectively. Model ids that historically contained the literal + * two-char sequences `~s` / `~t` are not preserved (extremely rare; encode would + * write `~ts` / `~tt` for those characters after a literal tilde today); + * - bare `~` not followed by `s`/`t` is left as a literal tilde on decode + * (legacy aliases from before slash encoding); * - model ids MAY contain `--` (resolve splits on the FIRST `--` only); * - native OpenAI slugs use the pseudo-provider `native` and resolve back to * the bare slug; a real provider named "native" is therefore never aliased. @@ -18,19 +28,53 @@ import { desktop3pAlias } from "./desktop-3p"; export const CLAUDE_ALIAS_PREFIX = "claude-ocx-"; +/** Encoded `/` inside the model portion of a Claude Code alias. */ +const CLAUDE_ALIAS_SLASH_ENC = "~s"; +/** Encoded literal `~` inside the model portion of a Claude Code alias. */ +const CLAUDE_ALIAS_TILDE_ENC = "~t"; const NATIVE_PSEUDO_PROVIDER = "native"; +function encodeModelId(modelId: string): string { + // Escape literal tildes first so slash encoding cannot create ambiguity. + return modelId + .replaceAll("~", CLAUDE_ALIAS_TILDE_ENC) + .replaceAll("/", CLAUDE_ALIAS_SLASH_ENC); +} + +function decodeModelId(encoded: string): string { + let out = ""; + for (let i = 0; i < encoded.length; i++) { + if (encoded[i] === "~" && i + 1 < encoded.length) { + const next = encoded[i + 1]; + if (next === "s") { + out += "/"; + i += 1; + continue; + } + if (next === "t") { + out += "~"; + i += 1; + continue; + } + } + // Bare `~` (legacy pre-slash-encoding aliases) stays a literal tilde. + out += encoded[i]; + } + return out; +} + /** Alias for a routed "/" pair; null when not representable. */ export function aliasForRoute(provider: string, modelId: string): string | null { if (!provider || provider.includes("--") || provider.includes("/") || provider === NATIVE_PSEUDO_PROVIDER) return null; - if (!modelId || modelId.includes("/")) return null; - return `${CLAUDE_ALIAS_PREFIX}${provider}--${modelId}`; + if (!modelId) return null; + return `${CLAUDE_ALIAS_PREFIX}${provider}--${encodeModelId(modelId)}`; } /** Alias for a native OpenAI slug (bare model id, no provider namespace). */ export function aliasForNative(slug: string): string | null { + // Reject "/" — native ids are bare slugs. Literal `~` is fine via ~t encoding. if (!slug || slug.includes("/") || slug.includes("--")) return null; - return `${CLAUDE_ALIAS_PREFIX}${NATIVE_PSEUDO_PROVIDER}--${slug}`; + return `${CLAUDE_ALIAS_PREFIX}${NATIVE_PSEUDO_PROVIDER}--${encodeModelId(slug)}`; } /** @@ -43,7 +87,7 @@ export function resolveAlias(id: string): string | null { const sep = rest.indexOf("--"); if (sep <= 0) return null; const provider = rest.slice(0, sep); - const model = rest.slice(sep + 2); + const model = decodeModelId(rest.slice(sep + 2)); if (!model) return null; return provider === NATIVE_PSEUDO_PROVIDER ? model : `${provider}/${model}`; } diff --git a/src/cli/catalog-prewarm.ts b/src/cli/catalog-prewarm.ts new file mode 100644 index 000000000..505a0bf0e --- /dev/null +++ b/src/cli/catalog-prewarm.ts @@ -0,0 +1,24 @@ +import type { OcxConfig } from "../types"; + +type GatherRoutedModels = (config: OcxConfig) => Promise; + +export type CatalogPrewarmDeps = { + loadConfig?: () => OcxConfig; + importCatalog?: () => Promise<{ gatherRoutedModels: GatherRoutedModels }>; +}; + +/** + * After the listen port is bound, kick off live provider discovery so the first + * GUI /v1/models and syncModelsToCodex share one gather flight instead of racing + * duplicate upstream /models fetches. + */ +export function scheduleCatalogPrewarm(deps: CatalogPrewarmDeps = {}): void { + void Promise.resolve() + .then(async () => { + const load = deps.loadConfig ?? (await import("../config")).loadConfig; + const { gatherRoutedModels } = await (deps.importCatalog?.() ?? import("../codex/catalog")); + return gatherRoutedModels(load()); + }) + .catch(() => {}); +} + diff --git a/src/cli/index.ts b/src/cli/index.ts index e0e894f4e..0cc8a45d2 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -38,6 +38,7 @@ import { startTokenGuardian } from "../oauth/token-guardian"; import { startHistoryMigrationGuardian } from "../codex/history-migration-guardian"; import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore"; import { maybeShowStarPrompt } from "./star-prompt"; +import { scheduleCatalogPrewarm } from "./catalog-prewarm"; import { maybeShowUpdatePrompt } from "../update/notify"; import { syncModelsToCodex } from "../codex/sync"; import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job"; @@ -190,6 +191,10 @@ async function handleStart(options: { block?: boolean } = {}) { for (let attempt = 0; ; attempt++) { try { server = startServer(port); + // Prewarm the live provider model cache as soon as the port is bound so the + // first GUI /v1/models (and syncModelsToCodex below) share one discovery flight + // instead of racing duplicate upstream /models fetches. + scheduleCatalogPrewarm(); break; } catch (err) { if (!isAddrInUse(err) || attempt >= 2) throw err; diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 0b4337f5e..03173400b 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -5,7 +5,7 @@ export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort"; -export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch"; +export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch"; export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation"; export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation"; export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync"; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 113c746ef..404441ae2 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -61,6 +61,74 @@ import { disabledNativeSlugs, hasComboTargets, nativeInputModalities, nativeOpen import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; +/** Concurrent gatherRoutedModels callers with the same catalog identity share one live discovery. + * Keyed by gatherFlightKey so a different config cannot join or evict the wrong flight. */ +interface GatherFlightResult { + models: CatalogModel[]; + comboOmissions: ComboCatalogOmission[]; +} + +const gatherInflight = new Map>(); + +function stableJson(value: unknown): string { + return JSON.stringify(value, (_key, nested) => { + if (nested && typeof nested === "object" && !Array.isArray(nested)) { + return Object.fromEntries(Object.entries(nested as Record).sort(([a], [b]) => a.localeCompare(b))); + } + return nested; + }); +} + +function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Record { + return { + n: name, + live: prov.liveModels !== false, + base: prov.baseUrl ?? "", + adapter: prov.adapter ?? "", + models: [...(prov.models ?? [])].sort(), + selected: [...(prov.selectedModels ?? [])].sort(), + defaultModel: prov.defaultModel ?? null, + ctx: prov.contextWindow ?? null, + ctxW: prov.modelContextWindows ?? null, + maxIn: prov.modelMaxInputTokens ?? null, + inMod: prov.modelInputModalities ?? null, + re: prov.modelReasoningEfforts ?? null, + defRe: prov.modelDefaultReasoningEfforts ?? null, + rsSum: prov.modelSupportsReasoningSummaries ?? null, + rsDel: prov.modelReasoningSummaryDelivery ?? null, + noVis: [...(prov.noVisionModels ?? [])].sort(), + ptc: prov.parallelToolCalls ?? null, + gMode: prov.googleMode ?? null, + }; +} + +function gatherFlightKey(config: OcxConfig): string { + const providers = Object.entries(config.providers) + .filter(([, prov]) => prov.disabled !== true) + .map(([name, prov]) => providerCatalogFingerprint(name, prov)) + .sort((a, b) => String(a.n).localeCompare(String(b.n))); + const assembly = stableJson({ + providers, + disabledModels: [...(config.disabledModels ?? [])].sort(), + combos: config.combos ?? {}, + customModels: (config.customModels ?? []).map((cm) => ({ + p: cm.provider, + m: cm.modelId, + d: cm.displayName ?? null, + cw: cm.contextWindow ?? null, + im: cm.inputModalities ?? null, + })), + caps: config.providerContextCaps ?? null, + }); + const digest = createHash("sha256").update(assembly).digest("hex").slice(0, 16); + return `${digest}#${config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS}`; +} + +/** Drop in-flight gather so tests / full cache clears do not reuse a stale promise. */ +export function clearGatherRoutedModelsInflight(): void { + gatherInflight.clear(); +} + export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined { const configured = modelRecordValue(prov.modelContextWindows, id) ?? prov.contextWindow; return typeof configured === "number" && configured > 0 ? configured : undefined; @@ -572,7 +640,29 @@ export async function gatherRoutedModels( config: OcxConfig, options?: { comboOmissions?: ComboCatalogOmission[] }, ): Promise { - // Per-invocation list: sync passes `comboOmissions` so overlapping gathers cannot race. + const key = gatherFlightKey(config); + let promise = gatherInflight.get(key); + if (!promise) { + // Claim the slot synchronously before any await so same-key callers join this flight. + // Distinct keys keep their own entries — a second config must not evict the first. + const flight = gatherRoutedModelsUncached(config).finally(() => { + if (gatherInflight.get(key) === flight) gatherInflight.delete(key); + }); + gatherInflight.set(key, flight); + promise = flight; + } + const { models, comboOmissions } = await promise; + if (options?.comboOmissions) { + options.comboOmissions.length = 0; + options.comboOmissions.push(...comboOmissions); + } + return models; +} + +async function gatherRoutedModelsUncached( + config: OcxConfig, +): Promise { + // Flight-local list: joiners copy from the resolved promise, not a process-global last write. const localOmissions: ComboCatalogOmission[] = []; const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS; // Persisted provider entries can predate newer registry fields (noVisionModels, @@ -654,10 +744,6 @@ export async function gatherRoutedModels( else warnUncataloguedComboOnce(id, combo, members, localOmissions); } replaceLastComboCatalogOmissions(localOmissions); - if (options?.comboOmissions) { - options.comboOmissions.length = 0; - options.comboOmissions.push(...localOmissions); - } all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider))); // Enriched (registry-hydrated) provider clones, keyed by name — the same view used above so // custom rows get the same noVisionModels / inputModalities treatment as discovered rows. @@ -691,7 +777,7 @@ export async function gatherRoutedModels( // Custom rows override discovered rows that encode to the same Codex-facing slug. const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id))); const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id))); - return [...deduped, ...customModels]; + return { models: [...deduped, ...customModels], comboOmissions: localOmissions }; } export function augmentRoutedModelsWithRegistryOpenAiApiRows( diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 3189c1e73..d7724439f 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -36,7 +36,7 @@ import type { CatalogModel, MultiAgentMode, RawEntry } from "./parsing"; import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, shouldUpgradeToUpstreamEntry, upstreamNativeEntry } from "./metadata"; import { loadCatalogForSync, resetBundledCatalogCacheForTests } from "./bundled"; import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; -import { filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch"; +import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch"; import { clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnComboMasqueradeCollisionOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; @@ -296,6 +296,7 @@ export function resetCatalogRuntimeStateForTests(): void { comboMasqueradeCollisionWarnings.clear(); clearLastComboCatalogOmissions(); clearModelCache(); + clearGatherRoutedModelsInflight(); } export function orderForSubagents(goModels: CatalogModel[], featured?: string[]): CatalogModel[] { diff --git a/tests/claude-alias.test.ts b/tests/claude-alias.test.ts index 393616fd7..95e2e3a71 100644 --- a/tests/claude-alias.test.ts +++ b/tests/claude-alias.test.ts @@ -32,10 +32,43 @@ describe("claude discovery aliases", () => { expect(aliasForRoute("has--dashes", "m")).toBeNull(); expect(aliasForRoute("has/slash", "m")).toBeNull(); expect(aliasForRoute("native", "m")).toBeNull(); // reserved pseudo-provider - expect(aliasForRoute("p", "openrouter/meta-llama")).toBeNull(); // slash in model expect(aliasForRoute("", "m")).toBeNull(); expect(aliasForRoute("p", "")).toBeNull(); expect(aliasForNative("a--b")).toBeNull(); + expect(aliasForNative("org/model")).toBeNull(); // native ids stay bare + }); + + test("model ids with '/' encode as '~s' and round-trip (OpenRouter-shaped)", () => { + const alias = aliasForRoute("openrouter", "anthropic/claude-opus-4-8"); + expect(alias).toBe(`${CLAUDE_ALIAS_PREFIX}openrouter--anthropic~sclaude-opus-4-8`); + expect(resolveAlias(alias!)).toBe("openrouter/anthropic/claude-opus-4-8"); + expect(claudeCodeAlias("openrouter", "meta-llama/llama-3.3-70b-instruct:free")).toBe( + `${CLAUDE_ALIAS_PREFIX}openrouter--meta-llama~sllama-3.3-70b-instruct:free`, + ); + expect(resolveAlias(claudeCodeAlias("openrouter", "meta-llama/llama-3.3-70b-instruct:free"))).toBe( + "openrouter/meta-llama/llama-3.3-70b-instruct:free", + ); + }); + + test("literal '~' in model ids encodes as '~t' and legacy bare '~' still resolves", () => { + expect(aliasForRoute("demo", "old~model")).toBe(`${CLAUDE_ALIAS_PREFIX}demo--old~tmodel`); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX}demo--old~tmodel`)).toBe("demo/old~model"); + // Pre-slash-encoding aliases kept literal tildes in the model portion. + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX}demo--old~model`)).toBe("demo/old~model"); + }); + + test("~s/~t are reserved escapes: round-trip / and ~; legacy ~s decodes as slash", () => { + // Intentional encode/decode round-trips for the reserved escape alphabet. + expect(aliasForRoute("demo", "a/b")).toBe(`${CLAUDE_ALIAS_PREFIX}demo--a~sb`); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX}demo--a~sb`)).toBe("demo/a/b"); + expect(aliasForRoute("demo", "a~b")).toBe(`${CLAUDE_ALIAS_PREFIX}demo--a~tb`); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX}demo--a~tb`)).toBe("demo/a~b"); + expect(aliasForRoute("demo", "a~/b")).toBe(`${CLAUDE_ALIAS_PREFIX}demo--a~t~sb`); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX}demo--a~t~sb`)).toBe("demo/a~/b"); + + // Pre-~s-encoding aliases that stored a literal "~s" sequence are not preserved: + // decode always treats ~s as "/". Document current behavior only. + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX}demo--old~smodel`)).toBe("demo/old/model"); }); test("resolveAlias rejects non-aliases and malformed ids", () => { @@ -49,7 +82,7 @@ describe("claude discovery aliases", () => { test("no collisions across a registry-shaped corpus", () => { const corpus: [string, string][] = []; for (const p of ["a", "b", "a-b", "ab"]) { - for (const m of ["x", "y-z", "y--z", "x.1"]) corpus.push([p, m]); + for (const m of ["x", "y-z", "y--z", "x.1", "org/model"]) corpus.push([p, m]); } const aliases = corpus.map(([p, m]) => aliasForRoute(p, m)).filter((a): a is string => a !== null); expect(new Set(aliases).size).toBe(aliases.length); @@ -81,17 +114,27 @@ describe("claudeCodeAlias — readable-or-hash shared helper (devlog 050 / audit expect(claudeCodeAlias("anthropic", "claude-fable-5")).toBe("claude-fable-5"); }); + test("slash-containing model ids stay readable (no desktop-3p hash)", () => { + expect(claudeCodeAlias("openrouter", "anthropic/claude-opus-4-8")).toBe( + "claude-ocx-openrouter--anthropic~sclaude-opus-4-8", + ); + expect(claudeCodeAlias("mock", "path/model")).toBe("claude-ocx-mock--path~smodel"); + expect(resolveInboundModel("claude-ocx-openrouter--anthropic~sclaude-opus-4-8", undefined)).toBe( + "openrouter/anthropic/claude-opus-4-8", + ); + }); + test("unrepresentable shapes fall back to the desktop-3p hash — model never disappears", () => { - // provider literally "native", provider with separators, model id with "/", - // native slug with "--" (audit 051 #2 null-case coverage). + // provider literally "native", provider with separators, native slug with "--". + // Model ids with "~" are now representable via ~t encoding. for (const id of [ claudeCodeAlias("native", "gpt-5.6-sol"), claudeCodeAlias("weird--provider", "m1"), claudeCodeAlias("a/b", "m2"), - claudeCodeAlias("mock", "path/model"), claudeCodeNativeAlias("slug--with-sep"), ]) { expect(id).toMatch(/^claude-opus-4-8-[a-z][0-9a-z]{2}$/); } + expect(claudeCodeAlias("mock", "has~tilde")).toBe("claude-ocx-mock--has~ttilde"); }); }); diff --git a/tests/cli-catalog-prewarm.test.ts b/tests/cli-catalog-prewarm.test.ts new file mode 100644 index 000000000..2f21dfb84 --- /dev/null +++ b/tests/cli-catalog-prewarm.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { OcxConfig } from "../src/types"; +import { scheduleCatalogPrewarm } from "../src/cli/catalog-prewarm"; + +const root = new URL("../", import.meta.url); + +async function readText(path: string): Promise { + return await Bun.file(new URL(path, root)).text(); +} + +describe("catalog prewarm on handleStart bind", () => { + test("scheduleCatalogPrewarm calls gatherRoutedModels(loadConfig()) once", async () => { + const config = { port: 9_001, providers: {}, defaultProvider: "fixture" } as OcxConfig; + const gatherRoutedModels = mock(async (_config: OcxConfig) => []); + const load = mock(() => config); + const importCatalog = mock(async () => ({ gatherRoutedModels })); + + scheduleCatalogPrewarm({ loadConfig: load, importCatalog }); + + await Bun.sleep(0); + expect(importCatalog).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledTimes(1); + expect(gatherRoutedModels).toHaveBeenCalledTimes(1); + expect(gatherRoutedModels.mock.calls[0]?.[0]).toBe(config); + }); + + test("scheduleCatalogPrewarm swallows gather failures", async () => { + const gatherRoutedModels = mock(async () => { + throw new Error("discovery failed"); + }); + scheduleCatalogPrewarm({ + loadConfig: () => ({ port: 9_002, providers: {}, defaultProvider: "fixture" }) as OcxConfig, + importCatalog: async () => ({ gatherRoutedModels }), + }); + await Bun.sleep(0); + expect(gatherRoutedModels).toHaveBeenCalledTimes(1); + }); + + test("handleStart schedules catalog prewarm immediately after a successful bind", async () => { + const cli = (await readText("src/cli/index.ts")).replace(/\r\n/g, "\n"); + const bindIdx = cli.indexOf("server = startServer(port);"); + const prewarmIdx = cli.indexOf("scheduleCatalogPrewarm()"); + const breakIdx = cli.indexOf("\n break;", bindIdx); + + expect(cli).toContain('from "./catalog-prewarm"'); + expect(bindIdx).toBeGreaterThan(-1); + expect(prewarmIdx).toBeGreaterThan(bindIdx); + expect(breakIdx).toBeGreaterThan(prewarmIdx); + // Must stay inside the successful-bind try path, not only on a later sync. + expect(cli.slice(bindIdx, breakIdx)).toContain("scheduleCatalogPrewarm()"); + expect(cli).not.toContain('void import("../codex/catalog").then(({ gatherRoutedModels })'); + }); +}); + diff --git a/tests/gather-routed-models-single-flight.test.ts b/tests/gather-routed-models-single-flight.test.ts new file mode 100644 index 000000000..d2a7ff501 --- /dev/null +++ b/tests/gather-routed-models-single-flight.test.ts @@ -0,0 +1,341 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + clearGatherRoutedModelsInflight, + filterCatalogVisibleModels, + gatherRoutedModels as gatherRoutedModelsDirect, + resetCatalogRuntimeStateForTests, + type ComboCatalogOmission, +} from "../src/codex/catalog"; +import { clearModelCache } from "../src/codex/model-cache"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; +import type { OcxConfig } from "../src/types"; + +const originalFetch = globalThis.fetch; + +const gatherRoutedModels: typeof gatherRoutedModelsDirect = (config, options) => + gatherRoutedModelsDirect(withStubbedProviderFetch(config), options); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(); + clearGatherRoutedModelsInflight(); + resetCatalogRuntimeStateForTests(); +}); + +describe("gatherRoutedModels single-flight", () => { + test("concurrent callers with the same provider set share one upstream discovery", async () => { + let fetchCount = 0; + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + + globalThis.fetch = (async () => { + fetchCount += 1; + await gate; + return new Response(JSON.stringify({ data: [{ id: "model-a" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const config: OcxConfig = { + port: 10100, + defaultProvider: "slow", + providers: { + slow: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + models: [], + }, + }, + }; + + const first = gatherRoutedModels(config); + const second = gatherRoutedModels(config); + // Both must have joined before the live fetch resolves. + await Promise.resolve(); + expect(fetchCount).toBe(1); + release(); + const [a, b] = await Promise.all([first, second]); + expect(fetchCount).toBe(1); + expect(a.map(m => `${m.provider}/${m.id}`)).toEqual(["slow/model-a"]); + expect(b).toEqual(a); + }); + + test("joiners still receive comboOmissions from the shared flight", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ data: [{ id: "m1" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + + const config: OcxConfig = { + port: 10100, + defaultProvider: "a", + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + models: [], + }, + }, + combos: { + incomplete: { + strategy: "failover", + stickyLimit: 1, + defaultEffort: "medium", + alias: null, + targets: [ + { provider: "a", model: "m1", weight: 1 }, + { provider: "missing", model: "x", weight: 1 }, + ], + }, + }, + }; + + const omissionsA: ComboCatalogOmission[] = []; + const omissionsB: ComboCatalogOmission[] = []; + await Promise.all([ + gatherRoutedModels(config, { comboOmissions: omissionsA }), + gatherRoutedModels(config, { comboOmissions: omissionsB }), + ]); + expect(omissionsA.some(item => item.id === "incomplete")).toBe(true); + expect(omissionsB).toEqual(omissionsA); + }); + + test("distinct provider sets keep separate in-flight gathers (no slot eviction)", async () => { + let releaseA!: () => void; + let releaseB!: () => void; + const gateA = new Promise(resolve => { releaseA = resolve; }); + const gateB = new Promise(resolve => { releaseB = resolve; }); + const fetchByHost = new Map(); + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + const host = url.includes("provider-a") ? "a" : url.includes("provider-b") ? "b" : "other"; + fetchByHost.set(host, (fetchByHost.get(host) ?? 0) + 1); + if (host === "a") await gateA; + else await gateB; + return new Response(JSON.stringify({ data: [{ id: `model-${host}` }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const configA: OcxConfig = { + port: 10100, + defaultProvider: "a", + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://provider-a.example.test/v1", + models: [], + }, + }, + }; + const configB: OcxConfig = { + port: 10100, + defaultProvider: "b", + providers: { + b: { + adapter: "openai-chat", + baseUrl: "https://provider-b.example.test/v1", + models: [], + }, + }, + }; + + const firstA = gatherRoutedModels(configA); + const firstB = gatherRoutedModels(configB); + const secondA = gatherRoutedModels(configA); + await Promise.resolve(); + expect(fetchByHost.get("a")).toBe(1); + expect(fetchByHost.get("b")).toBe(1); + + releaseA(); + releaseB(); + const [a1, b1, a2] = await Promise.all([firstA, firstB, secondA]); + expect(fetchByHost.get("a")).toBe(1); + expect(fetchByHost.get("b")).toBe(1); + expect(a1.map(m => `${m.provider}/${m.id}`)).toEqual(["a/model-a"]); + expect(b1.map(m => `${m.provider}/${m.id}`)).toEqual(["b/model-b"]); + expect(a2).toEqual(a1); + }); + + test("concurrent distinct keys keep flight-local combo omissions", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ data: [{ id: "m1" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; + + const mk = (comboId: string, provider: string, baseUrl: string): OcxConfig => ({ + port: 10100, + defaultProvider: provider, + providers: { + [provider]: { + adapter: "openai-chat", + baseUrl, + models: [], + }, + }, + combos: { + [comboId]: { + strategy: "failover", + stickyLimit: 1, + defaultEffort: "medium", + alias: null, + targets: [ + { provider, model: "m1", weight: 1 }, + { provider: "missing", model: "x", weight: 1 }, + ], + }, + }, + }); + + const oA: ComboCatalogOmission[] = []; + const oB: ComboCatalogOmission[] = []; + await Promise.all([ + gatherRoutedModels(mk("incomplete-a", "a", "https://a.example.test/v1"), { comboOmissions: oA }), + gatherRoutedModels(mk("incomplete-b", "b", "https://b.example.test/v1"), { comboOmissions: oB }), + ]); + expect(oA.some(item => item.id === "incomplete-a")).toBe(true); + expect(oB.some(item => item.id === "incomplete-b")).toBe(true); + expect(oA).not.toEqual(oB); + }); + + test("catalog-hint-only config changes do not join a prior flight", async () => { + let fetchCount = 0; + globalThis.fetch = (async () => { + fetchCount += 1; + return new Response(JSON.stringify({ data: [{ id: "m1" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const baseProv = { + adapter: "openai-chat" as const, + baseUrl: "https://hint.example.test/v1", + models: [] as string[], + liveModels: false as const, + }; + // liveModels:false uses configured models — still fingerprints context hints. + const configA: OcxConfig = { + port: 10100, + defaultProvider: "p", + providers: { + p: { ...baseProv, models: ["m1"], modelContextWindows: { m1: 100_000 } }, + }, + }; + const configB: OcxConfig = { + port: 10100, + defaultProvider: "p", + providers: { + p: { ...baseProv, models: ["m1"], modelContextWindows: { m1: 200_000 } }, + }, + }; + + const [a, b] = await Promise.all([gatherRoutedModels(configA), gatherRoutedModels(configB)]); + expect(fetchCount).toBe(0); // configured models, no live fetch + expect(a.find(m => m.id === "m1")?.contextWindow).toBe(100_000); + expect(b.find(m => m.id === "m1")?.contextWindow).toBe(200_000); + }); + + test("selectedModels-only config changes do not share a flight", async () => { + let fetchCount = 0; + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + + globalThis.fetch = (async () => { + fetchCount += 1; + await gate; + return new Response( + JSON.stringify({ data: [{ id: "keep-me" }, { id: "drop-me" }] }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; + + const base: OcxConfig = { + port: 10100, + defaultProvider: "p", + providers: { + p: { + adapter: "openai-chat", + baseUrl: "https://sel.example.test/v1", + models: [], + }, + }, + }; + const withSel: OcxConfig = { + ...base, + providers: { + p: { + ...base.providers.p, + selectedModels: ["keep-me"], + }, + }, + }; + + const first = gatherRoutedModels(base); + const second = gatherRoutedModels(withSel); + await Promise.resolve(); + // Distinct flight keys => two live discoveries (cannot reuse unfiltered result). + expect(fetchCount).toBe(2); + release(); + const [all, withSelModels] = await Promise.all([first, second]); + // gather itself is unfiltered; visibility is applied by callers. + expect(all.map(m => m.id).sort()).toEqual(["drop-me", "keep-me"]); + expect(withSelModels.map(m => m.id).sort()).toEqual(["drop-me", "keep-me"]); + expect(filterCatalogVisibleModels(all, base).map(m => m.id).sort()).toEqual(["drop-me", "keep-me"]); + expect(filterCatalogVisibleModels(withSelModels, withSel).map(m => m.id)).toEqual(["keep-me"]); + }); + + test("disabledModels-only config changes do not share a flight", async () => { + let fetchCount = 0; + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + + globalThis.fetch = (async () => { + fetchCount += 1; + await gate; + return new Response( + JSON.stringify({ data: [{ id: "keep-me" }, { id: "drop-me" }] }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; + + const base: OcxConfig = { + port: 10100, + defaultProvider: "p", + providers: { + p: { + adapter: "openai-chat", + baseUrl: "https://dis.example.test/v1", + models: [], + }, + }, + }; + const withDisabled: OcxConfig = { + ...base, + disabledModels: ["p/drop-me"], + }; + + const first = gatherRoutedModels(base); + const second = gatherRoutedModels(withDisabled); + await Promise.resolve(); + expect(fetchCount).toBe(2); + release(); + const [all, withDisabledModels] = await Promise.all([first, second]); + expect(all.map(m => m.id).sort()).toEqual(["drop-me", "keep-me"]); + expect(withDisabledModels.map(m => m.id).sort()).toEqual(["drop-me", "keep-me"]); + expect(filterCatalogVisibleModels(all, base).map(m => m.id).sort()).toEqual(["drop-me", "keep-me"]); + expect(filterCatalogVisibleModels(withDisabledModels, withDisabled).map(m => m.id)).toEqual(["keep-me"]); + }); + +});