Skip to content

Commit 818fcd8

Browse files
committed
fix(catalog): single-flight gather, prewarm, and readable slash aliases
Share one live discovery per provider-set key (Map), prewarm after bind, and encode OpenRouter-style model ids with ~ so Claude Available models stay readable.
1 parent 0666b41 commit 818fcd8

7 files changed

Lines changed: 265 additions & 12 deletions

File tree

src/claude/alias.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
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 `~` 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~claude-opus-4-8`);
15+
* - model ids that already contain `~` are not aliased (encode collision);
1316
* - model ids MAY contain `--` (resolve splits on the FIRST `--` only);
1417
* - native OpenAI slugs use the pseudo-provider `native` and resolve back to
1518
* the bare slug; a real provider named "native" is therefore never aliased.
@@ -18,18 +21,32 @@
1821
import { desktop3pAlias } from "./desktop-3p";
1922

2023
export const CLAUDE_ALIAS_PREFIX = "claude-ocx-";
24+
/** Stand-in for "/" inside the model portion of a Claude Code alias. */
25+
const CLAUDE_ALIAS_SLASH_ENC = "~";
2126
const NATIVE_PSEUDO_PROVIDER = "native";
2227

28+
function encodeModelId(modelId: string): string | null {
29+
if (modelId.includes(CLAUDE_ALIAS_SLASH_ENC)) return null;
30+
return modelId.replaceAll("/", CLAUDE_ALIAS_SLASH_ENC);
31+
}
32+
33+
function decodeModelId(encoded: string): string {
34+
return encoded.replaceAll(CLAUDE_ALIAS_SLASH_ENC, "/");
35+
}
36+
2337
/** Alias for a routed "<provider>/<model>" pair; null when not representable. */
2438
export function aliasForRoute(provider: string, modelId: string): string | null {
2539
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}`;
40+
if (!modelId) return null;
41+
const encoded = encodeModelId(modelId);
42+
if (encoded === null) return null;
43+
return `${CLAUDE_ALIAS_PREFIX}${provider}--${encoded}`;
2844
}
2945

3046
/** Alias for a native OpenAI slug (bare model id, no provider namespace). */
3147
export function aliasForNative(slug: string): string | null {
32-
if (!slug || slug.includes("/") || slug.includes("--")) return null;
48+
// Reject "/" and "~" — "~" is the slash stand-in; allowing it would round-trip wrong via decodeModelId.
49+
if (!slug || slug.includes("/") || slug.includes("--") || slug.includes(CLAUDE_ALIAS_SLASH_ENC)) return null;
3350
return `${CLAUDE_ALIAS_PREFIX}${NATIVE_PSEUDO_PROVIDER}--${slug}`;
3451
}
3552

@@ -43,7 +60,7 @@ export function resolveAlias(id: string): string | null {
4360
const sep = rest.indexOf("--");
4461
if (sep <= 0) return null;
4562
const provider = rest.slice(0, sep);
46-
const model = rest.slice(sep + 2);
63+
const model = decodeModelId(rest.slice(sep + 2));
4764
if (!model) return null;
4865
return provider === NATIVE_PSEUDO_PROVIDER ? model : `${provider}/${model}`;
4966
}

src/cli/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,12 @@ async function handleStart(options: { block?: boolean } = {}) {
190190
for (let attempt = 0; ; attempt++) {
191191
try {
192192
server = startServer(port);
193+
// Prewarm the live provider model cache as soon as the port is bound so the
194+
// first GUI /v1/models (and syncModelsToCodex below) share one discovery flight
195+
// instead of racing duplicate upstream /models fetches.
196+
void import("../codex/catalog").then(({ gatherRoutedModels }) => {
197+
gatherRoutedModels(loadConfig()).catch(() => {});
198+
});
193199
break;
194200
} catch (err) {
195201
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";

src/codex/catalog/provider-fetch.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,27 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
5858
import { JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing";
5959
import type { CatalogModel } from "./parsing";
6060
import { disabledNativeSlugs, hasComboTargets, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata";
61-
import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
61+
import { deriveComboCatalogModel, getLastComboCatalogOmissions, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
6262
import type { ComboCatalogOmission } from "./aggregation";
6363

64+
/** Concurrent gatherRoutedModels callers with the same provider set share one live discovery.
65+
* Keyed by gatherFlightKey so a different provider set cannot evict an in-flight gather. */
66+
const gatherInflight = new Map<string, Promise<CatalogModel[]>>();
67+
68+
function gatherFlightKey(config: OcxConfig): string {
69+
const providers = Object.entries(config.providers)
70+
.filter(([, prov]) => prov.disabled !== true)
71+
.map(([name, prov]) => `${name}\0${prov.liveModels === false ? "0" : "1"}\0${prov.baseUrl ?? ""}`)
72+
.sort()
73+
.join("\n");
74+
return `${providers}\n#${config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS}`;
75+
}
76+
77+
/** Drop in-flight gather so tests / full cache clears do not reuse a stale promise. */
78+
export function clearGatherRoutedModelsInflight(): void {
79+
gatherInflight.clear();
80+
}
81+
6482
export function configuredContextWindow(prov: OcxProviderConfig, id: string): number | undefined {
6583
const configured = modelRecordValue(prov.modelContextWindows, id) ?? prov.contextWindow;
6684
return typeof configured === "number" && configured > 0 ? configured : undefined;
@@ -571,6 +589,30 @@ export function filterCatalogVisibleModels(
571589
export async function gatherRoutedModels(
572590
config: OcxConfig,
573591
options?: { comboOmissions?: ComboCatalogOmission[] },
592+
): Promise<CatalogModel[]> {
593+
const key = gatherFlightKey(config);
594+
let promise = gatherInflight.get(key);
595+
if (!promise) {
596+
// Claim the slot synchronously before any await so same-key callers join this flight.
597+
// Distinct keys keep their own entries — a second provider set must not evict the first.
598+
const flight = gatherRoutedModelsUncached(config, options).finally(() => {
599+
if (gatherInflight.get(key) === flight) gatherInflight.delete(key);
600+
});
601+
gatherInflight.set(key, flight);
602+
promise = flight;
603+
}
604+
const models = await promise;
605+
if (options?.comboOmissions) {
606+
const last = getLastComboCatalogOmissions();
607+
options.comboOmissions.length = 0;
608+
options.comboOmissions.push(...last);
609+
}
610+
return models;
611+
}
612+
613+
async function gatherRoutedModelsUncached(
614+
config: OcxConfig,
615+
options?: { comboOmissions?: ComboCatalogOmission[] },
574616
): Promise<CatalogModel[]> {
575617
// Per-invocation list: sync passes `comboOmissions` so overlapping gathers cannot race.
576618
const localOmissions: ComboCatalogOmission[] = [];

src/codex/catalog/sync.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import type { CatalogModel, MultiAgentMode, RawEntry } from "./parsing";
3636
import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, shouldUpgradeToUpstreamEntry, upstreamNativeEntry } from "./metadata";
3737
import { loadCatalogForSync, resetBundledCatalogCacheForTests } from "./bundled";
3838
import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort";
39-
import { filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch";
39+
import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch";
4040
import { clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnComboMasqueradeCollisionOnce } from "./aggregation";
4141
import type { ComboCatalogOmission } from "./aggregation";
4242

@@ -296,6 +296,7 @@ export function resetCatalogRuntimeStateForTests(): void {
296296
comboMasqueradeCollisionWarnings.clear();
297297
clearLastComboCatalogOmissions();
298298
clearModelCache();
299+
clearGatherRoutedModelsInflight();
299300
}
300301

301302
export function orderForSubagents(goModels: CatalogModel[], featured?: string[]): CatalogModel[] {

tests/claude-alias.test.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,23 @@ describe("claude discovery aliases", () => {
3232
expect(aliasForRoute("has--dashes", "m")).toBeNull();
3333
expect(aliasForRoute("has/slash", "m")).toBeNull();
3434
expect(aliasForRoute("native", "m")).toBeNull(); // reserved pseudo-provider
35-
expect(aliasForRoute("p", "openrouter/meta-llama")).toBeNull(); // slash in model
35+
expect(aliasForRoute("p", "has~tilde")).toBeNull(); // encode collision with slash stand-in
3636
expect(aliasForRoute("", "m")).toBeNull();
3737
expect(aliasForRoute("p", "")).toBeNull();
3838
expect(aliasForNative("a--b")).toBeNull();
39+
expect(aliasForNative("a~b")).toBeNull(); // same encode collision as routed models
40+
});
41+
42+
test("model ids with '/' encode as '~' and round-trip (OpenRouter-shaped)", () => {
43+
const alias = aliasForRoute("openrouter", "anthropic/claude-opus-4-8");
44+
expect(alias).toBe(`${CLAUDE_ALIAS_PREFIX}openrouter--anthropic~claude-opus-4-8`);
45+
expect(resolveAlias(alias!)).toBe("openrouter/anthropic/claude-opus-4-8");
46+
expect(claudeCodeAlias("openrouter", "meta-llama/llama-3.3-70b-instruct:free")).toBe(
47+
`${CLAUDE_ALIAS_PREFIX}openrouter--meta-llama~llama-3.3-70b-instruct:free`,
48+
);
49+
expect(resolveAlias(claudeCodeAlias("openrouter", "meta-llama/llama-3.3-70b-instruct:free"))).toBe(
50+
"openrouter/meta-llama/llama-3.3-70b-instruct:free",
51+
);
3952
});
4053

4154
test("resolveAlias rejects non-aliases and malformed ids", () => {
@@ -49,7 +62,7 @@ describe("claude discovery aliases", () => {
4962
test("no collisions across a registry-shaped corpus", () => {
5063
const corpus: [string, string][] = [];
5164
for (const p of ["a", "b", "a-b", "ab"]) {
52-
for (const m of ["x", "y-z", "y--z", "x.1"]) corpus.push([p, m]);
65+
for (const m of ["x", "y-z", "y--z", "x.1", "org/model"]) corpus.push([p, m]);
5366
}
5467
const aliases = corpus.map(([p, m]) => aliasForRoute(p, m)).filter((a): a is string => a !== null);
5568
expect(new Set(aliases).size).toBe(aliases.length);
@@ -81,14 +94,24 @@ describe("claudeCodeAlias — readable-or-hash shared helper (devlog 050 / audit
8194
expect(claudeCodeAlias("anthropic", "claude-fable-5")).toBe("claude-fable-5");
8295
});
8396

97+
test("slash-containing model ids stay readable (no desktop-3p hash)", () => {
98+
expect(claudeCodeAlias("openrouter", "anthropic/claude-opus-4-8")).toBe(
99+
"claude-ocx-openrouter--anthropic~claude-opus-4-8",
100+
);
101+
expect(claudeCodeAlias("mock", "path/model")).toBe("claude-ocx-mock--path~model");
102+
expect(resolveInboundModel("claude-ocx-openrouter--anthropic~claude-opus-4-8", undefined)).toBe(
103+
"openrouter/anthropic/claude-opus-4-8",
104+
);
105+
});
106+
84107
test("unrepresentable shapes fall back to the desktop-3p hash — model never disappears", () => {
85-
// provider literally "native", provider with separators, model id with "/",
108+
// provider literally "native", provider with separators, model id with "~",
86109
// native slug with "--" (audit 051 #2 null-case coverage).
87110
for (const id of [
88111
claudeCodeAlias("native", "gpt-5.6-sol"),
89112
claudeCodeAlias("weird--provider", "m1"),
90113
claudeCodeAlias("a/b", "m2"),
91-
claudeCodeAlias("mock", "path/model"),
114+
claudeCodeAlias("mock", "has~tilde"),
92115
claudeCodeNativeAlias("slug--with-sep"),
93116
]) {
94117
expect(id).toMatch(/^claude-opus-4-8-[a-z][0-9a-z]{2}$/);
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import {
3+
clearGatherRoutedModelsInflight,
4+
gatherRoutedModels as gatherRoutedModelsDirect,
5+
resetCatalogRuntimeStateForTests,
6+
type ComboCatalogOmission,
7+
} from "../src/codex/catalog";
8+
import { clearModelCache } from "../src/codex/model-cache";
9+
import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch";
10+
import type { OcxConfig } from "../src/types";
11+
12+
const originalFetch = globalThis.fetch;
13+
14+
const gatherRoutedModels: typeof gatherRoutedModelsDirect = (config, options) =>
15+
gatherRoutedModelsDirect(withStubbedProviderFetch(config), options);
16+
17+
afterEach(() => {
18+
globalThis.fetch = originalFetch;
19+
clearModelCache();
20+
clearGatherRoutedModelsInflight();
21+
resetCatalogRuntimeStateForTests();
22+
});
23+
24+
describe("gatherRoutedModels single-flight", () => {
25+
test("concurrent callers with the same provider set share one upstream discovery", async () => {
26+
let fetchCount = 0;
27+
let release!: () => void;
28+
const gate = new Promise<void>(resolve => {
29+
release = resolve;
30+
});
31+
32+
globalThis.fetch = (async () => {
33+
fetchCount += 1;
34+
await gate;
35+
return new Response(JSON.stringify({ data: [{ id: "model-a" }] }), {
36+
status: 200,
37+
headers: { "content-type": "application/json" },
38+
});
39+
}) as typeof fetch;
40+
41+
const config: OcxConfig = {
42+
port: 10100,
43+
defaultProvider: "slow",
44+
providers: {
45+
slow: {
46+
adapter: "openai-chat",
47+
baseUrl: "https://api.example.test/v1",
48+
models: [],
49+
},
50+
},
51+
};
52+
53+
const first = gatherRoutedModels(config);
54+
const second = gatherRoutedModels(config);
55+
// Both must have joined before the live fetch resolves.
56+
await Promise.resolve();
57+
expect(fetchCount).toBe(1);
58+
release();
59+
const [a, b] = await Promise.all([first, second]);
60+
expect(fetchCount).toBe(1);
61+
expect(a.map(m => `${m.provider}/${m.id}`)).toEqual(["slow/model-a"]);
62+
expect(b).toEqual(a);
63+
});
64+
65+
test("joiners still receive comboOmissions from the shared flight", async () => {
66+
globalThis.fetch = (async () =>
67+
new Response(JSON.stringify({ data: [{ id: "m1" }] }), {
68+
status: 200,
69+
headers: { "content-type": "application/json" },
70+
})) as typeof fetch;
71+
72+
const config: OcxConfig = {
73+
port: 10100,
74+
defaultProvider: "a",
75+
providers: {
76+
a: {
77+
adapter: "openai-chat",
78+
baseUrl: "https://api.example.test/v1",
79+
models: [],
80+
},
81+
},
82+
combos: {
83+
incomplete: {
84+
strategy: "failover",
85+
stickyLimit: 1,
86+
defaultEffort: "medium",
87+
alias: null,
88+
targets: [
89+
{ provider: "a", model: "m1", weight: 1 },
90+
{ provider: "missing", model: "x", weight: 1 },
91+
],
92+
},
93+
},
94+
};
95+
96+
const omissionsA: ComboCatalogOmission[] = [];
97+
const omissionsB: ComboCatalogOmission[] = [];
98+
await Promise.all([
99+
gatherRoutedModels(config, { comboOmissions: omissionsA }),
100+
gatherRoutedModels(config, { comboOmissions: omissionsB }),
101+
]);
102+
expect(omissionsA.some(item => item.id === "incomplete")).toBe(true);
103+
expect(omissionsB).toEqual(omissionsA);
104+
});
105+
106+
test("distinct provider sets keep separate in-flight gathers (no slot eviction)", async () => {
107+
let releaseA!: () => void;
108+
let releaseB!: () => void;
109+
const gateA = new Promise<void>(resolve => { releaseA = resolve; });
110+
const gateB = new Promise<void>(resolve => { releaseB = resolve; });
111+
const fetchByHost = new Map<string, number>();
112+
113+
globalThis.fetch = (async (input: RequestInfo | URL) => {
114+
const url = String(input);
115+
const host = url.includes("provider-a") ? "a" : url.includes("provider-b") ? "b" : "other";
116+
fetchByHost.set(host, (fetchByHost.get(host) ?? 0) + 1);
117+
if (host === "a") await gateA;
118+
else await gateB;
119+
return new Response(JSON.stringify({ data: [{ id: `model-${host}` }] }), {
120+
status: 200,
121+
headers: { "content-type": "application/json" },
122+
});
123+
}) as typeof fetch;
124+
125+
const configA: OcxConfig = {
126+
port: 10100,
127+
defaultProvider: "a",
128+
providers: {
129+
a: {
130+
adapter: "openai-chat",
131+
baseUrl: "https://provider-a.example.test/v1",
132+
models: [],
133+
},
134+
},
135+
};
136+
const configB: OcxConfig = {
137+
port: 10100,
138+
defaultProvider: "b",
139+
providers: {
140+
b: {
141+
adapter: "openai-chat",
142+
baseUrl: "https://provider-b.example.test/v1",
143+
models: [],
144+
},
145+
},
146+
};
147+
148+
const firstA = gatherRoutedModels(configA);
149+
const firstB = gatherRoutedModels(configB);
150+
const secondA = gatherRoutedModels(configA);
151+
await Promise.resolve();
152+
expect(fetchByHost.get("a")).toBe(1);
153+
expect(fetchByHost.get("b")).toBe(1);
154+
155+
releaseA();
156+
releaseB();
157+
const [a1, b1, a2] = await Promise.all([firstA, firstB, secondA]);
158+
expect(fetchByHost.get("a")).toBe(1);
159+
expect(fetchByHost.get("b")).toBe(1);
160+
expect(a1.map(m => `${m.provider}/${m.id}`)).toEqual(["a/model-a"]);
161+
expect(b1.map(m => `${m.provider}/${m.id}`)).toEqual(["b/model-b"]);
162+
expect(a2).toEqual(a1);
163+
});
164+
});

0 commit comments

Comments
 (0)