diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index e2d1798832..815702359d 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -203,9 +203,9 @@ slots via Plain model ids (no `/` or `~`) keep the v1 prefix `claude-ocx-…`. Model ids that contain `/` or `~` mint the v2 prefix `claude-ocx2-…` with escapes (`/` → `~s`, `~` → `~t`), e.g. `openrouter/anthropic/claude-opus-4-8` → `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`. -v1 aliases decode literally (so a historical model id that contained the two-char sequences -`~s` / `~t` is preserved); v2 aliases expand the escapes. Routes that the readable form cannot -express fall back to the hashed alias. Model ids MAY contain `--` (resolution splits on the first +Legacy v1 aliases decode every bare `~` as `/`; v2 aliases expand the unambiguous `~s` / `~t` +escapes. Routes that 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 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 665c350b02..29f4dec10d 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -120,8 +120,8 @@ Claude Desktop의 하단 선택기로 이미 실행 중인 3P 대화의 모델 없는 plain model ID는 v1 접두사 `claude-ocx-…`를 유지해요. `/` 또는 `~`가 있는 model ID는 v2 접두사 `claude-ocx2-…`로 만들고 이스케이프해요(`/` → `~s`, `~` → `~t`). 예: `openrouter/anthropic/claude-opus-4-8` → `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`. -v1 별칭은 리터럴로 디코딩해요(예전 model ID에 들어 있던 두 글자 시퀀스 `~s` / `~t`도 그대로 보존). -v2 별칭은 이스케이프를 펼쳐요. 읽기 쉬운 형식으로 표현할 수 없는 라우트는 해시 별칭으로 대체해요. +레거시 v1 별칭은 모든 `~`를 `/`로 디코딩하고, v2 별칭은 명확한 `~s` / `~t` 이스케이프를 확장해요. +읽기 쉬운 형식으로 표현할 수 없는 라우트는 해시 별칭으로 대체해요. 모델 ID에는 `--`를 넣을 **수 있어요**(해석할 때 첫 번째 `--`만 기준으로 나눠요). `--`가 포함된 네이티브 슬러그는 해시 형식으로 대체해요. diff --git a/src/claude/alias.ts b/src/claude/alias.ts index db7d74e077..223f5b595b 100644 --- a/src/claude/alias.ts +++ b/src/claude/alias.ts @@ -8,9 +8,9 @@ * persist to Claude Code's settings.json `model` field). * * Versioned prefixes: - * - `claude-ocx-` (v1) — legacy / plain model ids with no `/` or `~`. Decode - * is literal (no escape expansion), so a persisted model id that literally - * contained the two-char sequences `~s` / `~t` keeps resolving. + * - `claude-ocx-` (v1) — legacy aliases encoded `/` as bare `~`. Decode maps + * every `~` back to `/` so aliases persisted by that encoder keep routing. + * New plain model ids use this prefix only when they contain neither byte. * - `claude-ocx2-` (v2) — used whenever the model id needs escape encoding * (`/` → `~s`, `~` → `~t`). Decode expands those escapes. New slash/tilde * models always mint v2 so they cannot collide with v1 literals. @@ -27,7 +27,7 @@ import { desktop3pAlias } from "./desktop-3p"; -/** Legacy / plain readable prefix (literal model portion on decode). */ +/** Legacy / plain readable prefix (bare `~` decodes to `/`). */ export const CLAUDE_ALIAS_PREFIX_V1 = "claude-ocx-"; /** Escape-encoded readable prefix (`~s`/`~t` expanded on decode). */ export const CLAUDE_ALIAS_PREFIX_V2 = "claude-ocx2-"; @@ -121,9 +121,11 @@ export function resolveAlias(id: string): string | null { if (id.startsWith(CLAUDE_ALIAS_PREFIX_V1)) { const parts = splitAlias(id, CLAUDE_ALIAS_PREFIX_V1); if (!parts) return null; - // Literal decode — preserves pre-escape aliases whose model id contained - // the two-char sequences ~s / ~t. - return parts.provider === NATIVE_PSEUDO_PROVIDER ? parts.model : `${parts.provider}/${parts.model}`; + // The original v1 encoder rejected literal tildes and used every bare `~` + // as the stand-in for `/`. Do not interpret ~s/~t as v2 escapes here: the + // following character belongs to the legacy model id. + const model = parts.model.replaceAll("~", "/"); + return parts.provider === NATIVE_PSEUDO_PROVIDER ? model : `${parts.provider}/${model}`; } return null; } diff --git a/tests/claude-alias.test.ts b/tests/claude-alias.test.ts index 213c0af28d..5afdcbdcef 100644 --- a/tests/claude-alias.test.ts +++ b/tests/claude-alias.test.ts @@ -68,14 +68,17 @@ describe("claude discovery aliases", () => { ); }); - test("literal '~' mints v2 (~t); v1 bare '~' and literal ~s/~t still resolve", () => { + test("literal '~' mints v2 (~t); v1 bare '~' aliases remain legacy slash escapes", () => { expect(aliasForRoute("demo", "old~model")).toBe(`${CLAUDE_ALIAS_PREFIX_V2}demo--old~tmodel`); expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V2}demo--old~tmodel`)).toBe("demo/old~model"); - // Pre-escape v1 aliases kept literal tildes in the model portion. - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}demo--old~model`)).toBe("demo/old~model"); - // v1 literal ~s / ~t are preserved (the versioned-prefix compatibility fix). - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}demo--old~smodel`)).toBe("demo/old~smodel"); - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}demo--old~tmodel`)).toBe("demo/old~tmodel"); + // The original v1 encoder rejected literal tildes and encoded every slash + // as bare `~`, including when the next model-id character was s or t. + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}demo--old~model`)).toBe("demo/old/model"); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}demo--old~smodel`)).toBe("demo/old/smodel"); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}demo--old~tmodel`)).toBe("demo/old/tmodel"); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}openrouter--anthropic~claude-opus-4-8`)).toBe( + "openrouter/anthropic/claude-opus-4-8", + ); }); test("v2 reserved escapes round-trip / and ~ without colliding with v1 literals", () => { @@ -86,9 +89,9 @@ describe("claude discovery aliases", () => { expect(aliasForRoute("demo", "a~/b")).toBe(`${CLAUDE_ALIAS_PREFIX_V2}demo--a~t~sb`); expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V2}demo--a~t~sb`)).toBe("demo/a~/b"); - // Same wire bytes under v1 stay literal — no silent remap to slash/tilde. - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}demo--a~sb`)).toBe("demo/a~sb"); - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}demo--a~tb`)).toBe("demo/a~tb"); + // Under v1, the bare tilde is the legacy slash escape and s/t are data. + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}demo--a~sb`)).toBe("demo/a/sb"); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}demo--a~tb`)).toBe("demo/a/tb"); }); test("resolveAlias rejects non-aliases and malformed ids", () => {