77 * deterministic, reversible, and STABLE across releases (picker selections
88 * persist to Claude Code's settings.json `model` field).
99 *
10+ * Versioned prefixes:
11+ * - `claude-ocx-` (v1) — legacy / plain model ids with no `/` or `~`. Decode
12+ * is literal (no escape expansion), so a persisted model id that literally
13+ * contained the two-char sequences `~s` / `~t` keeps resolving.
14+ * - `claude-ocx2-` (v2) — used whenever the model id needs escape encoding
15+ * (`/` → `~s`, `~` → `~t`). Decode expands those escapes. New slash/tilde
16+ * models always mint v2 so they cannot collide with v1 literals.
17+ *
1018 * Reversibility rules:
1119 * - providers containing `--` or `/` are not aliased (split boundary safety);
12- * - model ids MAY contain `/` — encoded as `~s` 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~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- * - `~s` / `~t` are reserved escape sequences: decode always treats them as
18- * `/` and `~` respectively. Model ids that historically contained the literal
19- * two-char sequences `~s` / `~t` are not preserved (extremely rare; encode would
20- * write `~ts` / `~tt` for those characters after a literal tilde today);
21- * - bare `~` not followed by `s`/`t` is left as a literal tilde on decode
22- * (legacy aliases from before slash encoding);
20+ * - model ids MAY contain `/` or `~` — minted under the v2 prefix with escapes
21+ * (e.g. openrouter `anthropic/claude-opus-4-8` →
22+ * `claude-ocx2-openrouter--anthropic~sclaude-opus-4-8`);
2323 * - model ids MAY contain `--` (resolve splits on the FIRST `--` only);
2424 * - native OpenAI slugs use the pseudo-provider `native` and resolve back to
2525 * the bare slug; a real provider named "native" is therefore never aliased.
2626 */
2727
2828import { desktop3pAlias } from "./desktop-3p" ;
2929
30- export const CLAUDE_ALIAS_PREFIX = "claude-ocx-" ;
31- /** Encoded `/` inside the model portion of a Claude Code alias. */
30+ /** Legacy / plain readable prefix (literal model portion on decode). */
31+ export const CLAUDE_ALIAS_PREFIX_V1 = "claude-ocx-" ;
32+ /** Escape-encoded readable prefix (`~s`/`~t` expanded on decode). */
33+ export const CLAUDE_ALIAS_PREFIX_V2 = "claude-ocx2-" ;
34+ /**
35+ * Current write prefix for plain (unescaped) model ids.
36+ * Escape-needing models mint {@link CLAUDE_ALIAS_PREFIX_V2} instead.
37+ */
38+ export const CLAUDE_ALIAS_PREFIX = CLAUDE_ALIAS_PREFIX_V1 ;
39+
40+ /** Encoded `/` inside the model portion of a v2 Claude Code alias. */
3241const CLAUDE_ALIAS_SLASH_ENC = "~s" ;
33- /** Encoded literal `~` inside the model portion of a Claude Code alias. */
42+ /** Encoded literal `~` inside the model portion of a v2 Claude Code alias. */
3443const CLAUDE_ALIAS_TILDE_ENC = "~t" ;
3544const NATIVE_PSEUDO_PROVIDER = "native" ;
3645
46+ function modelNeedsEscapeEncoding ( modelId : string ) : boolean {
47+ return modelId . includes ( "/" ) || modelId . includes ( "~" ) ;
48+ }
49+
3750function encodeModelId ( modelId : string ) : string {
3851 // Escape literal tildes first so slash encoding cannot create ambiguity.
3952 return modelId
4053 . replaceAll ( "~" , CLAUDE_ALIAS_TILDE_ENC )
4154 . replaceAll ( "/" , CLAUDE_ALIAS_SLASH_ENC ) ;
4255}
4356
44- function decodeModelId ( encoded : string ) : string {
57+ function decodeEscapedModelId ( encoded : string ) : string {
4558 let out = "" ;
4659 for ( let i = 0 ; i < encoded . length ; i ++ ) {
4760 if ( encoded [ i ] === "~" && i + 1 < encoded . length ) {
@@ -57,45 +70,68 @@ function decodeModelId(encoded: string): string {
5770 continue ;
5871 }
5972 }
60- // Bare `~` (legacy pre-slash-encoding aliases) stays a literal tilde.
6173 out += encoded [ i ] ;
6274 }
6375 return out ;
6476}
6577
78+ function splitAlias ( id : string , prefix : string ) : { provider : string ; model : string } | null {
79+ const rest = id . slice ( prefix . length ) ;
80+ const sep = rest . indexOf ( "--" ) ;
81+ if ( sep <= 0 ) return null ;
82+ const provider = rest . slice ( 0 , sep ) ;
83+ const model = rest . slice ( sep + 2 ) ;
84+ if ( ! provider || ! model ) return null ;
85+ return { provider, model } ;
86+ }
87+
6688/** Alias for a routed "<provider>/<model>" pair; null when not representable. */
6789export function aliasForRoute ( provider : string , modelId : string ) : string | null {
6890 if ( ! provider || provider . includes ( "--" ) || provider . includes ( "/" ) || provider === NATIVE_PSEUDO_PROVIDER ) return null ;
6991 if ( ! modelId ) return null ;
70- return `${ CLAUDE_ALIAS_PREFIX } ${ provider } --${ encodeModelId ( modelId ) } ` ;
92+ if ( modelNeedsEscapeEncoding ( modelId ) ) {
93+ return `${ CLAUDE_ALIAS_PREFIX_V2 } ${ provider } --${ encodeModelId ( modelId ) } ` ;
94+ }
95+ return `${ CLAUDE_ALIAS_PREFIX_V1 } ${ provider } --${ modelId } ` ;
7196}
7297
7398/** Alias for a native OpenAI slug (bare model id, no provider namespace). */
7499export function aliasForNative ( slug : string ) : string | null {
75- // Reject "/" — native ids are bare slugs. Literal `~` is fine via ~t encoding .
100+ // Reject "/" — native ids are bare slugs. Literal `~` is fine via v2 + ~t .
76101 if ( ! slug || slug . includes ( "/" ) || slug . includes ( "--" ) ) return null ;
77- return `${ CLAUDE_ALIAS_PREFIX } ${ NATIVE_PSEUDO_PROVIDER } --${ encodeModelId ( slug ) } ` ;
102+ if ( modelNeedsEscapeEncoding ( slug ) ) {
103+ return `${ CLAUDE_ALIAS_PREFIX_V2 } ${ NATIVE_PSEUDO_PROVIDER } --${ encodeModelId ( slug ) } ` ;
104+ }
105+ return `${ CLAUDE_ALIAS_PREFIX_V1 } ${ NATIVE_PSEUDO_PROVIDER } --${ slug } ` ;
78106}
79107
80108/**
81109 * Reverse an alias to the inbound model string routeModel understands:
82110 * routed -> "<provider>/<model>", native -> bare slug. Null when not an alias.
83111 */
84112export function resolveAlias ( id : string ) : string | null {
85- if ( ! id . startsWith ( CLAUDE_ALIAS_PREFIX ) ) return null ;
86- const rest = id . slice ( CLAUDE_ALIAS_PREFIX . length ) ;
87- const sep = rest . indexOf ( "--" ) ;
88- if ( sep <= 0 ) return null ;
89- const provider = rest . slice ( 0 , sep ) ;
90- const model = decodeModelId ( rest . slice ( sep + 2 ) ) ;
91- if ( ! model ) return null ;
92- return provider === NATIVE_PSEUDO_PROVIDER ? model : `${ provider } /${ model } ` ;
113+ // Check v2 before v1 for clarity (prefixes are disjoint: ocx2 vs ocx-).
114+ if ( id . startsWith ( CLAUDE_ALIAS_PREFIX_V2 ) ) {
115+ const parts = splitAlias ( id , CLAUDE_ALIAS_PREFIX_V2 ) ;
116+ if ( ! parts ) return null ;
117+ const model = decodeEscapedModelId ( parts . model ) ;
118+ if ( ! model ) return null ;
119+ return parts . provider === NATIVE_PSEUDO_PROVIDER ? model : `${ parts . provider } /${ model } ` ;
120+ }
121+ if ( id . startsWith ( CLAUDE_ALIAS_PREFIX_V1 ) ) {
122+ const parts = splitAlias ( id , CLAUDE_ALIAS_PREFIX_V1 ) ;
123+ if ( ! parts ) return null ;
124+ // Literal decode — preserves pre-escape aliases whose model id contained
125+ // the two-char sequences ~s / ~t.
126+ return parts . provider === NATIVE_PSEUDO_PROVIDER ? parts . model : `${ parts . provider } /${ parts . model } ` ;
127+ }
128+ return null ;
93129}
94130
95131/**
96132 * Claude Code (CLI) surface alias — devlog 050 + audit 051 #2.
97133 *
98- * The readable `claude-ocx- *` form when representable; otherwise the desktop-3p
134+ * The readable `claude-ocx*` form when representable; otherwise the desktop-3p
99135 * hash so the model still appears in discovery (collisions follow the same
100136 * first-wins policy as the desktop registry — audit 051 #1). Real Anthropic
101137 * models pass through unchanged (they must keep hitting the sk-ant passthrough).
0 commit comments