Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
luvs01 marked this conversation as resolved.
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
Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/ko/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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에는 `--`를 넣을 **수 있어요**(해석할 때 첫 번째 `--`만 기준으로 나눠요). `--`가 포함된
네이티브 슬러그는 해시 형식으로 대체해요.

Expand Down
16 changes: 9 additions & 7 deletions src/claude/alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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-";
Expand Down Expand Up @@ -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("~", "/");
Comment thread
luvs01 marked this conversation as resolved.
return parts.provider === NATIVE_PSEUDO_PROVIDER ? model : `${parts.provider}/${model}`;
}
return null;
}
Expand Down
21 changes: 12 additions & 9 deletions tests/claude-alias.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
Loading