Skip to content

Commit 8ac098a

Browse files
committed
fix(catalog): harden alias encoding and gather single-flight identity
Encode / as ~s and literal ~ as ~t so legacy bare-~ aliases keep resolving. Hash catalog-affecting config into the gather flight key and return flight-local combo omissions so concurrent keys cannot cross-contaminate.
1 parent 818fcd8 commit 8ac098a

6 files changed

Lines changed: 211 additions & 54 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/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` 제거) → 패스스루 순서예요.

src/claude/alias.ts

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
*
1010
* Reversibility rules:
1111
* - providers containing `--` or `/` are not aliased (split boundary safety);
12-
* - model ids MAY contain `/` — encoded as `~` so the alias stays slash-free
12+
* - model ids MAY contain `/` — encoded as `~s` so the alias stays slash-free
1313
* 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);
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+
* - bare `~` not followed by `s`/`t` is left as a literal tilde on decode
18+
* (legacy aliases from before slash encoding);
1619
* - model ids MAY contain `--` (resolve splits on the FIRST `--` only);
1720
* - native OpenAI slugs use the pseudo-provider `native` and resolve back to
1821
* the bare slug; a real provider named "native" is therefore never aliased.
@@ -21,33 +24,53 @@
2124
import { desktop3pAlias } from "./desktop-3p";
2225

2326
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 = "~";
27+
/** Encoded `/` inside the model portion of a Claude Code alias. */
28+
const CLAUDE_ALIAS_SLASH_ENC = "~s";
29+
/** Encoded literal `~` inside the model portion of a Claude Code alias. */
30+
const CLAUDE_ALIAS_TILDE_ENC = "~t";
2631
const NATIVE_PSEUDO_PROVIDER = "native";
2732

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);
33+
function encodeModelId(modelId: string): string {
34+
// Escape literal tildes first so slash encoding cannot create ambiguity.
35+
return modelId
36+
.replaceAll("~", CLAUDE_ALIAS_TILDE_ENC)
37+
.replaceAll("/", CLAUDE_ALIAS_SLASH_ENC);
3138
}
3239

3340
function decodeModelId(encoded: string): string {
34-
return encoded.replaceAll(CLAUDE_ALIAS_SLASH_ENC, "/");
41+
let out = "";
42+
for (let i = 0; i < encoded.length; i++) {
43+
if (encoded[i] === "~" && i + 1 < encoded.length) {
44+
const next = encoded[i + 1];
45+
if (next === "s") {
46+
out += "/";
47+
i += 1;
48+
continue;
49+
}
50+
if (next === "t") {
51+
out += "~";
52+
i += 1;
53+
continue;
54+
}
55+
}
56+
// Bare `~` (legacy pre-slash-encoding aliases) stays a literal tilde.
57+
out += encoded[i];
58+
}
59+
return out;
3560
}
3661

3762
/** Alias for a routed "<provider>/<model>" pair; null when not representable. */
3863
export function aliasForRoute(provider: string, modelId: string): string | null {
3964
if (!provider || provider.includes("--") || provider.includes("/") || provider === NATIVE_PSEUDO_PROVIDER) return null;
4065
if (!modelId) return null;
41-
const encoded = encodeModelId(modelId);
42-
if (encoded === null) return null;
43-
return `${CLAUDE_ALIAS_PREFIX}${provider}--${encoded}`;
66+
return `${CLAUDE_ALIAS_PREFIX}${provider}--${encodeModelId(modelId)}`;
4467
}
4568

4669
/** Alias for a native OpenAI slug (bare model id, no provider namespace). */
4770
export function aliasForNative(slug: string): string | 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;
50-
return `${CLAUDE_ALIAS_PREFIX}${NATIVE_PSEUDO_PROVIDER}--${slug}`;
71+
// Reject "/" — native ids are bare slugs. Literal `~` is fine via ~t encoding.
72+
if (!slug || slug.includes("/") || slug.includes("--")) return null;
73+
return `${CLAUDE_ALIAS_PREFIX}${NATIVE_PSEUDO_PROVIDER}--${encodeModelId(slug)}`;
5174
}
5275

5376
/**

src/codex/catalog/provider-fetch.ts

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -58,20 +58,68 @@ 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, getLastComboCatalogOmissions, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation";
61+
import { deriveComboCatalogModel, 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[]>>();
64+
/** Concurrent gatherRoutedModels callers with the same catalog identity share one live discovery.
65+
* Keyed by gatherFlightKey so a different config cannot join or evict the wrong flight. */
66+
interface GatherFlightResult {
67+
models: CatalogModel[];
68+
comboOmissions: ComboCatalogOmission[];
69+
}
70+
71+
const gatherInflight = new Map<string, Promise<GatherFlightResult>>();
72+
73+
function stableJson(value: unknown): string {
74+
return JSON.stringify(value, (_key, nested) => {
75+
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
76+
return Object.fromEntries(Object.entries(nested as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)));
77+
}
78+
return nested;
79+
});
80+
}
81+
82+
function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Record<string, unknown> {
83+
return {
84+
n: name,
85+
live: prov.liveModels !== false,
86+
base: prov.baseUrl ?? "",
87+
adapter: prov.adapter ?? "",
88+
models: [...(prov.models ?? [])].sort(),
89+
defaultModel: prov.defaultModel ?? null,
90+
ctx: prov.contextWindow ?? null,
91+
ctxW: prov.modelContextWindows ?? null,
92+
maxIn: prov.modelMaxInputTokens ?? null,
93+
inMod: prov.modelInputModalities ?? null,
94+
re: prov.modelReasoningEfforts ?? null,
95+
defRe: prov.modelDefaultReasoningEfforts ?? null,
96+
rsSum: prov.modelSupportsReasoningSummaries ?? null,
97+
rsDel: prov.modelReasoningSummaryDelivery ?? null,
98+
noVis: [...(prov.noVisionModels ?? [])].sort(),
99+
ptc: prov.parallelToolCalls ?? null,
100+
gMode: prov.googleMode ?? null,
101+
};
102+
}
67103

68104
function gatherFlightKey(config: OcxConfig): string {
69105
const providers = Object.entries(config.providers)
70106
.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}`;
107+
.map(([name, prov]) => providerCatalogFingerprint(name, prov))
108+
.sort((a, b) => String(a.n).localeCompare(String(b.n)));
109+
const assembly = stableJson({
110+
providers,
111+
combos: config.combos ?? {},
112+
customModels: (config.customModels ?? []).map((cm) => ({
113+
p: cm.provider,
114+
m: cm.modelId,
115+
d: cm.displayName ?? null,
116+
cw: cm.contextWindow ?? null,
117+
im: cm.inputModalities ?? null,
118+
})),
119+
caps: config.providerContextCaps ?? null,
120+
});
121+
const digest = createHash("sha256").update(assembly).digest("hex").slice(0, 16);
122+
return `${digest}#${config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS}`;
75123
}
76124

77125
/** Drop in-flight gather so tests / full cache clears do not reuse a stale promise. */
@@ -594,27 +642,25 @@ export async function gatherRoutedModels(
594642
let promise = gatherInflight.get(key);
595643
if (!promise) {
596644
// 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(() => {
645+
// Distinct keys keep their own entries — a second config must not evict the first.
646+
const flight = gatherRoutedModelsUncached(config).finally(() => {
599647
if (gatherInflight.get(key) === flight) gatherInflight.delete(key);
600648
});
601649
gatherInflight.set(key, flight);
602650
promise = flight;
603651
}
604-
const models = await promise;
652+
const { models, comboOmissions } = await promise;
605653
if (options?.comboOmissions) {
606-
const last = getLastComboCatalogOmissions();
607654
options.comboOmissions.length = 0;
608-
options.comboOmissions.push(...last);
655+
options.comboOmissions.push(...comboOmissions);
609656
}
610657
return models;
611658
}
612659

613660
async function gatherRoutedModelsUncached(
614661
config: OcxConfig,
615-
options?: { comboOmissions?: ComboCatalogOmission[] },
616-
): Promise<CatalogModel[]> {
617-
// Per-invocation list: sync passes `comboOmissions` so overlapping gathers cannot race.
662+
): Promise<GatherFlightResult> {
663+
// Flight-local list: joiners copy from the resolved promise, not a process-global last write.
618664
const localOmissions: ComboCatalogOmission[] = [];
619665
const ttlMs = config.modelCacheTtlMs ?? DEFAULT_MODEL_CACHE_TTL_MS;
620666
// Persisted provider entries can predate newer registry fields (noVisionModels,
@@ -696,10 +742,6 @@ async function gatherRoutedModelsUncached(
696742
else warnUncataloguedComboOnce(id, combo, members, localOmissions);
697743
}
698744
replaceLastComboCatalogOmissions(localOmissions);
699-
if (options?.comboOmissions) {
700-
options.comboOmissions.length = 0;
701-
options.comboOmissions.push(...localOmissions);
702-
}
703745
all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
704746
// Enriched (registry-hydrated) provider clones, keyed by name — the same view used above so
705747
// custom rows get the same noVisionModels / inputModalities treatment as discovered rows.
@@ -733,7 +775,7 @@ async function gatherRoutedModelsUncached(
733775
// Custom rows override discovered rows that encode to the same Codex-facing slug.
734776
const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
735777
const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
736-
return [...deduped, ...customModels];
778+
return { models: [...deduped, ...customModels], comboOmissions: localOmissions };
737779
}
738780

739781
export function augmentRoutedModelsWithRegistryOpenAiApiRows(

tests/claude-alias.test.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,25 +32,31 @@ 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", "has~tilde")).toBeNull(); // encode collision with slash stand-in
3635
expect(aliasForRoute("", "m")).toBeNull();
3736
expect(aliasForRoute("p", "")).toBeNull();
3837
expect(aliasForNative("a--b")).toBeNull();
39-
expect(aliasForNative("a~b")).toBeNull(); // same encode collision as routed models
38+
expect(aliasForNative("org/model")).toBeNull(); // native ids stay bare
4039
});
4140

42-
test("model ids with '/' encode as '~' and round-trip (OpenRouter-shaped)", () => {
41+
test("model ids with '/' encode as '~s' and round-trip (OpenRouter-shaped)", () => {
4342
const alias = aliasForRoute("openrouter", "anthropic/claude-opus-4-8");
44-
expect(alias).toBe(`${CLAUDE_ALIAS_PREFIX}openrouter--anthropic~claude-opus-4-8`);
43+
expect(alias).toBe(`${CLAUDE_ALIAS_PREFIX}openrouter--anthropic~sclaude-opus-4-8`);
4544
expect(resolveAlias(alias!)).toBe("openrouter/anthropic/claude-opus-4-8");
4645
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`,
46+
`${CLAUDE_ALIAS_PREFIX}openrouter--meta-llama~sllama-3.3-70b-instruct:free`,
4847
);
4948
expect(resolveAlias(claudeCodeAlias("openrouter", "meta-llama/llama-3.3-70b-instruct:free"))).toBe(
5049
"openrouter/meta-llama/llama-3.3-70b-instruct:free",
5150
);
5251
});
5352

53+
test("literal '~' in model ids encodes as '~t' and legacy bare '~' still resolves", () => {
54+
expect(aliasForRoute("demo", "old~model")).toBe(`${CLAUDE_ALIAS_PREFIX}demo--old~tmodel`);
55+
expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX}demo--old~tmodel`)).toBe("demo/old~model");
56+
// Pre-slash-encoding aliases kept literal tildes in the model portion.
57+
expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX}demo--old~model`)).toBe("demo/old~model");
58+
});
59+
5460
test("resolveAlias rejects non-aliases and malformed ids", () => {
5561
expect(resolveAlias("claude-sonnet-4-5")).toBeNull();
5662
expect(resolveAlias("gpt-5.5")).toBeNull();
@@ -96,25 +102,25 @@ describe("claudeCodeAlias — readable-or-hash shared helper (devlog 050 / audit
96102

97103
test("slash-containing model ids stay readable (no desktop-3p hash)", () => {
98104
expect(claudeCodeAlias("openrouter", "anthropic/claude-opus-4-8")).toBe(
99-
"claude-ocx-openrouter--anthropic~claude-opus-4-8",
105+
"claude-ocx-openrouter--anthropic~sclaude-opus-4-8",
100106
);
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(
107+
expect(claudeCodeAlias("mock", "path/model")).toBe("claude-ocx-mock--path~smodel");
108+
expect(resolveInboundModel("claude-ocx-openrouter--anthropic~sclaude-opus-4-8", undefined)).toBe(
103109
"openrouter/anthropic/claude-opus-4-8",
104110
);
105111
});
106112

107113
test("unrepresentable shapes fall back to the desktop-3p hash — model never disappears", () => {
108-
// provider literally "native", provider with separators, model id with "~",
109-
// native slug with "--" (audit 051 #2 null-case coverage).
114+
// provider literally "native", provider with separators, native slug with "--".
115+
// Model ids with "~" are now representable via ~t encoding.
110116
for (const id of [
111117
claudeCodeAlias("native", "gpt-5.6-sol"),
112118
claudeCodeAlias("weird--provider", "m1"),
113119
claudeCodeAlias("a/b", "m2"),
114-
claudeCodeAlias("mock", "has~tilde"),
115120
claudeCodeNativeAlias("slug--with-sep"),
116121
]) {
117122
expect(id).toMatch(/^claude-opus-4-8-[a-z][0-9a-z]{2}$/);
118123
}
124+
expect(claudeCodeAlias("mock", "has~tilde")).toBe("claude-ocx-mock--has~ttilde");
119125
});
120126
});

0 commit comments

Comments
 (0)