From 47a7a9134f6c6ad695fe03833a60b960f210d828 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:23:12 +0200 Subject: [PATCH 1/2] fix(claude): version slash/tilde aliases under claude-ocx2- --- .../src/content/docs/guides/claude-code.md | 14 +-- .../src/content/docs/ja/guides/claude-code.md | 2 +- .../src/content/docs/ko/guides/claude-code.md | 2 +- .../src/content/docs/ru/guides/claude-code.md | 2 +- .../content/docs/zh-cn/guides/claude-code.md | 2 +- src/claude/alias.ts | 92 +++++++++++++------ tests/claude-alias.test.ts | 84 ++++++++++------- 7 files changed, 129 insertions(+), 69 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 875067ab2..576c363c4 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -175,7 +175,7 @@ with `claude` or `anthropic`, opencodex exposes routed models as stable, reversi | Surface | Format | Example | | --- | --- | --- | -| Claude Code CLI | `claude-ocx---` | `claude-ocx-native--gpt-5.6-sol` | +| Claude Code CLI | `claude-ocx---` (plain) or `claude-ocx2-…` (escaped) | `claude-ocx-native--gpt-5.6-sol` | | Claude Desktop 3P | `claude-opus-4-8-` (3-char base36 hash) | `claude-opus-4-8-ncb` | The proxy picks the family per request: `?ids=cli` or `?ids=desktop` wins; otherwise the @@ -200,11 +200,13 @@ 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 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. +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 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 b2970068f..144bf628e 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -83,7 +83,7 @@ Claude Desktop のフッターピッカーで実行中の 3P 会話のモデル **エイリアス構文ルール:** provider には `/` や `--` を含められず `native` と同じでもいけません。 model ID には `/` を含められ、エイリアス内では `~s` として符号化します(例: `openrouter/anthropic/claude-opus-4-8` → -`claude-ocx-openrouter--anthropic~sclaude-opus-4-8`)。model ID のリテラル `~` は `~t` として符号化します。 +`claude-ocx2-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 2312f207a..e0231f8b7 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -118,7 +118,7 @@ Claude Desktop의 하단 선택기로 이미 실행 중인 3P 대화의 모델 **별칭 문법 규칙:** provider에는 `/`나 `--`를 넣을 수 없고 `native`와 같아도 안 돼요. model ID에 `/`가 있으면 별칭에서 `~s`로 인코딩해요(예: `openrouter/anthropic/claude-opus-4-8` → -`claude-ocx-openrouter--anthropic~sclaude-opus-4-8`). model ID의 리터럴 `~`는 `~t`로 인코딩해요. +`claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`). model ID의 리터럴 `~`는 `~t`로 인코딩해요. `s`/`t`가 따르지 않는 단독 `~`는 예전 설정과의 호환을 위해 리터럴 `~`로 해석해요. 읽기 쉬운 형식으로 표현할 수 없는 라우트는 해시 별칭으로 대체해요. 모델 ID에는 `--`를 넣을 **수 있어요** (해석할 때 첫 번째 `--`만 기준으로 나눠요). `--`가 포함된 네이티브 슬러그는 해시 형식으로 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 6afbdcc7b..c326df2c3 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -88,7 +88,7 @@ user-agent `claude-code/*` получает читаемую CLI-форму, а **Правила грамматики алиасов:** provider не может содержать `/` или `--` и не может быть равен `native`. Id моделей могут содержать `/` — в алиасе это кодируется как `~s` (например, -`openrouter/anthropic/claude-opus-4-8` → `claude-ocx-openrouter--anthropic~sclaude-opus-4-8`). +`openrouter/anthropic/claude-opus-4-8` → `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`). Литеральный `~` в id модели кодируется как `~t`. Голый `~` без следующего `s`/`t` считается литеральной тильдой, чтобы старые сохранённые алиасы продолжали разрешаться. Маршруты, которые невозможно выразить читаемой формой, 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 4cc99cdf6..d2d4ed21a 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 @@ -90,7 +90,7 @@ user-agent 会获得易读的 CLI 形式,其他客户端会获得 Desktop 哈 **别名语法规则:**provider 不得包含 `/` 或 `--`,也不得等于 `native`。 model ID 可以包含 `/` — 在别名中编码为 `~s`(例如 `openrouter/anthropic/claude-opus-4-8` -→ `claude-ocx-openrouter--anthropic~sclaude-opus-4-8`)。model ID 中的字面 `~` 编码为 `~t`。 +→ `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`)。model ID 中的字面 `~` 编码为 `~t`。 后面不是 `s`/`t` 的裸 `~` 视为字面波浪号,以便旧版已持久化的别名继续解析。 易读形式无法表达的路由会回退到哈希别名。模型 ID **可以**包含 `--`(解析时只按第一个 `--` 分割);含 `--` 的原生 slug 会回退到哈希形式。 diff --git a/src/claude/alias.ts b/src/claude/alias.ts index 32a1d672b..db7d74e07 100644 --- a/src/claude/alias.ts +++ b/src/claude/alias.ts @@ -7,19 +7,19 @@ * deterministic, reversible, and STABLE across releases (picker selections * 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-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. + * * Reversibility rules: * - providers containing `--` or `/` are not aliased (split boundary safety); - * - 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 `/` or `~` — minted under the v2 prefix with escapes + * (e.g. openrouter `anthropic/claude-opus-4-8` → + * `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`); * - 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. @@ -27,13 +27,26 @@ import { desktop3pAlias } from "./desktop-3p"; -export const CLAUDE_ALIAS_PREFIX = "claude-ocx-"; -/** Encoded `/` inside the model portion of a Claude Code alias. */ +/** Legacy / plain readable prefix (literal model portion on decode). */ +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-"; +/** + * Current write prefix for plain (unescaped) model ids. + * Escape-needing models mint {@link CLAUDE_ALIAS_PREFIX_V2} instead. + */ +export const CLAUDE_ALIAS_PREFIX = CLAUDE_ALIAS_PREFIX_V1; + +/** Encoded `/` inside the model portion of a v2 Claude Code alias. */ const CLAUDE_ALIAS_SLASH_ENC = "~s"; -/** Encoded literal `~` inside the model portion of a Claude Code alias. */ +/** Encoded literal `~` inside the model portion of a v2 Claude Code alias. */ const CLAUDE_ALIAS_TILDE_ENC = "~t"; const NATIVE_PSEUDO_PROVIDER = "native"; +function modelNeedsEscapeEncoding(modelId: string): boolean { + return modelId.includes("/") || modelId.includes("~"); +} + function encodeModelId(modelId: string): string { // Escape literal tildes first so slash encoding cannot create ambiguity. return modelId @@ -41,7 +54,7 @@ function encodeModelId(modelId: string): string { .replaceAll("/", CLAUDE_ALIAS_SLASH_ENC); } -function decodeModelId(encoded: string): string { +function decodeEscapedModelId(encoded: string): string { let out = ""; for (let i = 0; i < encoded.length; i++) { if (encoded[i] === "~" && i + 1 < encoded.length) { @@ -57,24 +70,39 @@ function decodeModelId(encoded: string): string { continue; } } - // Bare `~` (legacy pre-slash-encoding aliases) stays a literal tilde. out += encoded[i]; } return out; } +function splitAlias(id: string, prefix: string): { provider: string; model: string } | null { + const rest = id.slice(prefix.length); + const sep = rest.indexOf("--"); + if (sep <= 0) return null; + const provider = rest.slice(0, sep); + const model = rest.slice(sep + 2); + if (!provider || !model) return null; + return { provider, model }; +} + /** 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) return null; - return `${CLAUDE_ALIAS_PREFIX}${provider}--${encodeModelId(modelId)}`; + if (modelNeedsEscapeEncoding(modelId)) { + return `${CLAUDE_ALIAS_PREFIX_V2}${provider}--${encodeModelId(modelId)}`; + } + return `${CLAUDE_ALIAS_PREFIX_V1}${provider}--${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. + // Reject "/" — native ids are bare slugs. Literal `~` is fine via v2 + ~t. if (!slug || slug.includes("/") || slug.includes("--")) return null; - return `${CLAUDE_ALIAS_PREFIX}${NATIVE_PSEUDO_PROVIDER}--${encodeModelId(slug)}`; + if (modelNeedsEscapeEncoding(slug)) { + return `${CLAUDE_ALIAS_PREFIX_V2}${NATIVE_PSEUDO_PROVIDER}--${encodeModelId(slug)}`; + } + return `${CLAUDE_ALIAS_PREFIX_V1}${NATIVE_PSEUDO_PROVIDER}--${slug}`; } /** @@ -82,20 +110,28 @@ export function aliasForNative(slug: string): string | null { * routed -> "/", native -> bare slug. Null when not an alias. */ export function resolveAlias(id: string): string | null { - if (!id.startsWith(CLAUDE_ALIAS_PREFIX)) return null; - const rest = id.slice(CLAUDE_ALIAS_PREFIX.length); - const sep = rest.indexOf("--"); - if (sep <= 0) return null; - const provider = rest.slice(0, sep); - const model = decodeModelId(rest.slice(sep + 2)); - if (!model) return null; - return provider === NATIVE_PSEUDO_PROVIDER ? model : `${provider}/${model}`; + // Check v2 before v1 for clarity (prefixes are disjoint: ocx2 vs ocx-). + if (id.startsWith(CLAUDE_ALIAS_PREFIX_V2)) { + const parts = splitAlias(id, CLAUDE_ALIAS_PREFIX_V2); + if (!parts) return null; + const model = decodeEscapedModelId(parts.model); + if (!model) return null; + return parts.provider === NATIVE_PSEUDO_PROVIDER ? model : `${parts.provider}/${model}`; + } + 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}`; + } + return null; } /** * Claude Code (CLI) surface alias — devlog 050 + audit 051 #2. * - * The readable `claude-ocx-*` form when representable; otherwise the desktop-3p + * The readable `claude-ocx*` form when representable; otherwise the desktop-3p * hash so the model still appears in discovery (collisions follow the same * first-wins policy as the desktop registry — audit 051 #1). Real Anthropic * models pass through unchanged (they must keep hitting the sk-ant passthrough). diff --git a/tests/claude-alias.test.ts b/tests/claude-alias.test.ts index 95e2e3a71..213c0af28 100644 --- a/tests/claude-alias.test.ts +++ b/tests/claude-alias.test.ts @@ -1,5 +1,14 @@ import { describe, expect, test } from "bun:test"; -import { aliasForNative, aliasForRoute, CLAUDE_ALIAS_PREFIX, claudeCodeAlias, claudeCodeNativeAlias, resolveAlias } from "../src/claude/alias"; +import { + aliasForNative, + aliasForRoute, + CLAUDE_ALIAS_PREFIX, + CLAUDE_ALIAS_PREFIX_V1, + CLAUDE_ALIAS_PREFIX_V2, + claudeCodeAlias, + claudeCodeNativeAlias, + resolveAlias, +} from "../src/claude/alias"; import { resolveInboundModel } from "../src/claude/inbound"; describe("claude discovery aliases", () => { @@ -18,6 +27,7 @@ describe("claude discovery aliases", () => { const alias = aliasForRoute(provider, model); expect(alias).not.toBeNull(); expect(alias!.startsWith("claude")).toBe(true); // picker prefix rule (003 G3) + expect(alias!.startsWith(CLAUDE_ALIAS_PREFIX_V1)).toBe(true); // plain → v1 expect(resolveAlias(alias!)).toBe(`${provider}/${model}`); } }); @@ -28,6 +38,14 @@ describe("claude discovery aliases", () => { expect(resolveAlias(alias!)).toBe("gpt-5.5"); }); + test("native slugs with literal '~' mint v2 and round-trip via ~t", () => { + const alias = aliasForNative("gpt~special"); + expect(alias).toBe(`${CLAUDE_ALIAS_PREFIX_V2}native--gpt~tspecial`); + expect(resolveAlias(alias!)).toBe("gpt~special"); + expect(claudeCodeNativeAlias("gpt~special")).toBe(`${CLAUDE_ALIAS_PREFIX_V2}native--gpt~tspecial`); + expect(resolveInboundModel(`${CLAUDE_ALIAS_PREFIX_V2}native--gpt~tspecial`, undefined)).toBe("gpt~special"); + }); + test("non-representable shapes are skipped, not mangled", () => { expect(aliasForRoute("has--dashes", "m")).toBeNull(); expect(aliasForRoute("has/slash", "m")).toBeNull(); @@ -38,45 +56,49 @@ describe("claude discovery aliases", () => { expect(aliasForNative("org/model")).toBeNull(); // native ids stay bare }); - test("model ids with '/' encode as '~s' and round-trip (OpenRouter-shaped)", () => { + test("model ids with '/' mint v2 (~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(alias).toBe(`${CLAUDE_ALIAS_PREFIX_V2}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`, + `${CLAUDE_ALIAS_PREFIX_V2}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("literal '~' mints v2 (~t); v1 bare '~' and literal ~s/~t still resolve", () => { + 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"); }); - 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"); + test("v2 reserved escapes round-trip / and ~ without colliding with v1 literals", () => { + expect(aliasForRoute("demo", "a/b")).toBe(`${CLAUDE_ALIAS_PREFIX_V2}demo--a~sb`); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V2}demo--a~sb`)).toBe("demo/a/b"); + expect(aliasForRoute("demo", "a~b")).toBe(`${CLAUDE_ALIAS_PREFIX_V2}demo--a~tb`); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V2}demo--a~tb`)).toBe("demo/a~b"); + 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"); - // 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"); + // 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"); }); test("resolveAlias rejects non-aliases and malformed ids", () => { expect(resolveAlias("claude-sonnet-4-5")).toBeNull(); expect(resolveAlias("gpt-5.5")).toBeNull(); - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX}noseparator`)).toBeNull(); - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX}p--`)).toBeNull(); - expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX}--m`)).toBeNull(); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}noseparator`)).toBeNull(); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}p--`)).toBeNull(); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V1}--m`)).toBeNull(); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V2}noseparator`)).toBeNull(); + expect(resolveAlias(`${CLAUDE_ALIAS_PREFIX_V2}p--`)).toBeNull(); }); test("no collisions across a registry-shaped corpus", () => { @@ -92,8 +114,8 @@ describe("claude discovery aliases", () => { }); test("inbound resolution prefers alias over modelMap, before date-strip", () => { - const cc = { modelMap: { [`${CLAUDE_ALIAS_PREFIX}gemini--gemini-3-pro`]: "should-not-win" } }; - expect(resolveInboundModel(`${CLAUDE_ALIAS_PREFIX}gemini--gemini-3-pro`, cc)).toBe("gemini/gemini-3-pro"); + const cc = { modelMap: { [`${CLAUDE_ALIAS_PREFIX_V1}gemini--gemini-3-pro`]: "should-not-win" } }; + expect(resolveInboundModel(`${CLAUDE_ALIAS_PREFIX_V1}gemini--gemini-3-pro`, cc)).toBe("gemini/gemini-3-pro"); }); }); @@ -114,19 +136,19 @@ 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)", () => { + test("slash-containing model ids stay readable under v2 (no desktop-3p hash)", () => { expect(claudeCodeAlias("openrouter", "anthropic/claude-opus-4-8")).toBe( - "claude-ocx-openrouter--anthropic~sclaude-opus-4-8", + "claude-ocx2-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( + expect(claudeCodeAlias("mock", "path/model")).toBe("claude-ocx2-mock--path~smodel"); + expect(resolveInboundModel("claude-ocx2-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, native slug with "--". - // Model ids with "~" are now representable via ~t encoding. + // Model ids with "~" are now representable via v2 + ~t encoding. for (const id of [ claudeCodeAlias("native", "gpt-5.6-sol"), claudeCodeAlias("weird--provider", "m1"), @@ -135,6 +157,6 @@ describe("claudeCodeAlias — readable-or-hash shared helper (devlog 050 / audit ]) { expect(id).toMatch(/^claude-opus-4-8-[a-z][0-9a-z]{2}$/); } - expect(claudeCodeAlias("mock", "has~tilde")).toBe("claude-ocx-mock--has~ttilde"); + expect(claudeCodeAlias("mock", "has~tilde")).toBe("claude-ocx2-mock--has~ttilde"); }); }); From 0afaf7f658aa2bd36d853f517b40be7e72462f4f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:30:03 +0200 Subject: [PATCH 2/2] docs(claude): sync v1/v2 alias grammar across locales --- .../src/content/docs/guides/claude-code.md | 6 +++--- .../src/content/docs/ja/guides/claude-code.md | 17 +++++++++-------- .../src/content/docs/ko/guides/claude-code.md | 17 +++++++++-------- .../src/content/docs/ru/guides/claude-code.md | 17 +++++++++-------- .../content/docs/zh-cn/guides/claude-code.md | 13 +++++++------ 5 files changed, 37 insertions(+), 33 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 576c363c4..e2d179883 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -204,9 +204,9 @@ Plain model ids (no `/` or `~`) keep the v1 prefix `claude-ocx-…`. Model ids t `~` 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 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. +`~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 +`--` 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 144bf628e..546204648 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -68,8 +68,8 @@ Claude Code 2.1.129 以降は `GET /v1/models?limit=1000` でゲートウェイ 受け付けるため、opencodex はルーティングモデルを安定で元に戻せるエイリアスとして公開します。 | 画面 | 形式 | 例 | - --- | --- | --- | -| Claude Code CLI | `claude-ocx---` | `claude-ocx-native--gpt-5.6-sol` | +| --- | --- | --- | +| Claude Code CLI | `claude-ocx---` (plain) または `claude-ocx2-…` (escaped) | `claude-ocx-native--gpt-5.6-sol` | | Claude Desktop 3P | `claude-opus-4-8-` (3 桁の base36 ハッシュ) | `claude-opus-4-8-ncb` | プロキシはリクエストごとに系列を選びます。`?ids=cli` または `?ids=desktop` が優先し、指定しないと @@ -82,12 +82,13 @@ Claude Desktop のフッターピッカーで実行中の 3P 会話のモデル 含まれるモデル ID をルーティングします。結果は **Logs → requestedModel** で確認できます。 **エイリアス構文ルール:** provider には `/` や `--` を含められず `native` と同じでもいけません。 -model ID には `/` を含められ、エイリアス内では `~s` として符号化します(例: `openrouter/anthropic/claude-opus-4-8` → -`claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`)。model ID のリテラル `~` は `~t` として符号化します。 -`s`/`t` が続かない裸の `~` はリテラルのチルダとして扱い、古い永続化エイリアスも解決し続けます。 -読みやすい形式で表現できないルートはハッシュエイリアスに置き換えます。モデル -ID には `--` を含め**られます**(解析時は最初の `--` だけを基準に分割します)。`--` を含む -ネイティブスラッグはハッシュ形式に置き換えます。 +`/` も `~` も含まない 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 に含まれていた 2 文字列 `~s` / `~t` も保持)。 +v2 エイリアスはエスケープを展開します。読みやすい形式で表現できないルートはハッシュエイリアスに +置き換えます。モデル ID には `--` を含め**られます**(解析時は最初の `--` だけを基準に分割します)。 +`--` を含むネイティブスラッグはハッシュ形式に置き換えます。 **モデル解決順序:** `[1m]` 標識の削除 → 読みやすいエイリアスのデコード → Desktop ハッシュエイリアスのデコード → `modelMap` の完全一致 → 日付を削除した値との一致(`-20250514` 削除) → パススルー順です。 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 e0231f8b7..665c350b0 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -104,7 +104,7 @@ Claude Code 2.1.129 이상은 `GET /v1/models?limit=1000`에서 게이트웨이 | 화면 | 형식 | 예시 | | --- | --- | --- | -| Claude Code CLI | `claude-ocx---` | `claude-ocx-native--gpt-5.6-sol` | +| Claude Code CLI | `claude-ocx---` (plain) 또는 `claude-ocx2-…` (escaped) | `claude-ocx-native--gpt-5.6-sol` | | Claude Desktop 3P | `claude-opus-4-8-` (3자리 base36 해시) | `claude-opus-4-8-ncb` | 프록시는 요청마다 계열을 골라요. `?ids=cli` 또는 `?ids=desktop`이 우선하고, 지정하지 않으면 @@ -116,13 +116,14 @@ Claude Desktop의 하단 선택기로 이미 실행 중인 3P 대화의 모델 `/model `를 사용하세요. OpenCodex는 선택기 상태를 따로 볼 수 없고 각 요청에 실린 모델 ID를 라우팅해요. 적용 결과는 **Logs → requestedModel**에서 확인할 수 있어요. -**별칭 문법 규칙:** provider에는 `/`나 `--`를 넣을 수 없고 `native`와 같아도 안 돼요. model ID에 -`/`가 있으면 별칭에서 `~s`로 인코딩해요(예: `openrouter/anthropic/claude-opus-4-8` → -`claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`). model ID의 리터럴 `~`는 `~t`로 인코딩해요. -`s`/`t`가 따르지 않는 단독 `~`는 예전 설정과의 호환을 위해 리터럴 `~`로 해석해요. 읽기 쉬운 -형식으로 표현할 수 없는 라우트는 해시 별칭으로 대체해요. 모델 ID에는 `--`를 넣을 **수 있어요** -(해석할 때 첫 번째 `--`만 기준으로 나눠요). `--`가 포함된 네이티브 슬러그는 해시 형식으로 -대체해요. +**별칭 문법 규칙:** provider에는 `/`나 `--`를 넣을 수 없고 `native`와 같아도 안 돼요. `/`와 `~`가 +없는 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 별칭은 이스케이프를 펼쳐요. 읽기 쉬운 형식으로 표현할 수 없는 라우트는 해시 별칭으로 대체해요. +모델 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 c326df2c3..7d3a65ce9 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -74,7 +74,7 @@ Claude Code 2.1.129+ обнаруживает модели шлюза через | Интерфейс | Формат | Пример | | --- | --- | --- | -| Claude Code CLI | `claude-ocx---` | `claude-ocx-native--gpt-5.6-sol` | +| Claude Code CLI | `claude-ocx---` (plain) или `claude-ocx2-…` (escaped) | `claude-ocx-native--gpt-5.6-sol` | | Claude Desktop 3P | `claude-opus-4-8-` (3-символьный base36-хеш) | `claude-opus-4-8-ncb` | Прокси выбирает семейство для каждого запроса: приоритет у `?ids=cli` или `?ids=desktop`; иначе @@ -87,13 +87,14 @@ user-agent `claude-code/*` получает читаемую CLI-форму, а маршрутизирует id модели из каждого запроса. Результат можно проверить в **Logs → requestedModel**. **Правила грамматики алиасов:** provider не может содержать `/` или `--` и не может быть равен -`native`. Id моделей могут содержать `/` — в алиасе это кодируется как `~s` (например, -`openrouter/anthropic/claude-opus-4-8` → `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`). -Литеральный `~` в id модели кодируется как `~t`. Голый `~` без следующего `s`/`t` -считается литеральной тильдой, чтобы старые сохранённые алиасы продолжали разрешаться. -Маршруты, которые невозможно выразить читаемой формой, -откатываются на хешированный алиас. Id моделей МОГУТ содержать `--` (при разрешении разбиение -выполняется только по первому `--`); нативные слаги с `--` откатываются на хешированную форму. +`native`. Обычные id моделей (без `/` и `~`) остаются с префиксом v1 `claude-ocx-…`. Id с `/` +или `~` выпускаются с префиксом v2 `claude-ocx2-…` и экранированием (`/` → `~s`, `~` → `~t`), +например `openrouter/anthropic/claude-opus-4-8` → +`claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`. Алиасы v1 декодируются литерально (исторические +двухсимвольные последовательности `~s` / `~t` в id модели сохраняются); алиасы v2 раскрывают +экранирование. Маршруты, которые невозможно выразить читаемой формой, откатываются на +хешированный алиас. 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 d2d4ed21a..31410c921 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 @@ -77,7 +77,7 @@ opencodex 会将已路由模型公开为稳定且可逆的别名: | 界面 | 格式 | 示例 | | --- | --- | --- | -| Claude Code CLI | `claude-ocx---` | `claude-ocx-native--gpt-5.6-sol` | +| Claude Code CLI | `claude-ocx---`(plain)或 `claude-ocx2-…`(escaped) | `claude-ocx-native--gpt-5.6-sol` | | Claude Desktop 3P | `claude-opus-4-8-`(3 字符 base36 哈希) | `claude-opus-4-8-ncb` | 代理会按请求选择别名族:`?ids=cli` 或 `?ids=desktop` 优先;否则,`claude-code/*` @@ -89,11 +89,12 @@ user-agent 会获得易读的 CLI 形式,其他客户端会获得 Desktop 哈 **Logs → requestedModel** 中确认结果。 **别名语法规则:**provider 不得包含 `/` 或 `--`,也不得等于 `native`。 -model ID 可以包含 `/` — 在别名中编码为 `~s`(例如 `openrouter/anthropic/claude-opus-4-8` -→ `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`)。model ID 中的字面 `~` 编码为 `~t`。 -后面不是 `s`/`t` 的裸 `~` 视为字面波浪号,以便旧版已持久化的别名继续解析。 -易读形式无法表达的路由会回退到哈希别名。模型 ID **可以**包含 `--`(解析时只按第一个 -`--` 分割);含 `--` 的原生 slug 会回退到哈希形式。 +不含 `/` 或 `~` 的普通 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 别名会展开转义。 +易读形式无法表达的路由会回退到哈希别名。模型 ID **可以**包含 `--`(解析时只按第一个 `--` 分割); +含 `--` 的原生 slug 会回退到哈希形式。 **模型解析顺序:**移除 `[1m]` 标记 → 解码易读别名 → 解码 Desktop 哈希别名 → `modelMap` 精确匹配 → 移除日期后的匹配(移除 `-20250514`)→ 透传。