From a1cd600073d6743f275be3003f7059ebb36b35bb Mon Sep 17 00:00:00 2001 From: Christo Wilken Date: Fri, 11 Sep 2026 13:46:19 +0200 Subject: [PATCH] feat(patch): tell a custom model its real name in the identity line Claude Code's env_info section tells every model what it is: "You are powered by the model named . The exact model ID is ." when its catalog knows the id, and only "You are powered by the model ." when it does not. A custom model's id is its short alias, so a routed model is told it is "luna" or "flash" and nothing more. Asked what it is, it answers with the alias; measured on 2.1.268, a Flash session quoted PATCH 4's "Additional custom models" listing and still called itself "flash". PATCH 11 extends the marketing-name lookup that feeds that sentence with a baked table keyed by alias and full id, so the line reads e.g. "the model named GPT-5.6 Luna (OpenAI (ChatGPT)); upstream model id gpt-5.6-luna, served through Clodex. The exact model ID is luna." Only the object feeding the sentence is touched; every other caller of the marketing-name function keeps its native answer, and native models keep theirs. Best-effort like PATCH 4, with an in-place refresh path like PATCH 7, a postcondition proof, the canary probe's site list, and the fixture carrying the real 2.1.268 shape. PATCH_TRANSFORMS_VERSION 12 -> 13. Tests: three new (routed name via alias / full id / case; label fallback and refresh-not-stack; absent site stays best-effort). Feature-deletion mutation fails both positive tests. Full suite 2559/2559, tsc clean. Session: a5ae056f-79a5-4beb-831d-c16d7b2d37dc --- scripts/probe-patch-sites.mjs | 1 + src/built-in-patch-proofs.ts | 4 +++ src/patch-transforms.ts | 61 ++++++++++++++++++++++++++++++- tests/fixtures/claude-bundle.ts | 11 ++++++ tests/patcher.test.ts | 63 +++++++++++++++++++++++++++++---- 5 files changed, 133 insertions(+), 7 deletions(-) diff --git a/scripts/probe-patch-sites.mjs b/scripts/probe-patch-sites.mjs index ef60bf22..efe9e764 100644 --- a/scripts/probe-patch-sites.mjs +++ b/scripts/probe-patch-sites.mjs @@ -51,6 +51,7 @@ export const EXPECTED_PATCH_SITES = Object.freeze([ 'PATCH 6: alias resolver switch', 'PATCH 5: model picker options', 'PATCH 4: Agent tool model description', + 'PATCH 11: model self-identity', 'PATCH 7: per-model context window', 'PATCH 8a: effort capability', 'PATCH 8b: xhigh effort capability', diff --git a/src/built-in-patch-proofs.ts b/src/built-in-patch-proofs.ts index a0eab621..cb789f3f 100644 --- a/src/built-in-patch-proofs.ts +++ b/src/built-in-patch-proofs.ts @@ -166,6 +166,10 @@ export function captureBuiltInPatchProofs( 'PATCH 9: default effort', /\/\*ccpatch:default-effort\*\/var _cce=Object\.assign\(Object\.create\(null\),\{[^{}]*\}\)\[String\([\w$]+\|\|""\)\.trim\(\)\.toLowerCase\(\)\];if\(_cce!==void 0\)return _cce;/, ); + addPattern( + 'PATCH 11: model self-identity', + /\/\*ccpatch:identity\*\/\(\{[^{}]*\}\)\[String\([\w$]+\|\|""\)\.trim\(\)\.toLowerCase\(\)\]/, + ); addPattern( 'PATCH 10: child network environment', /\/\*ccpatch:child-network-env\*\/let _clodexChildEnv=process\.env,[\s\S]*?catch\(_clodexError\)\{\}\}/, diff --git a/src/patch-transforms.ts b/src/patch-transforms.ts index 0258b840..3d7f58fe 100644 --- a/src/patch-transforms.ts +++ b/src/patch-transforms.ts @@ -86,8 +86,13 @@ import { * bumped because the rule above says a changed anchor is bumped — an install * patched by an older clodex is not wrong, but it was produced by a transform set * whose anchors are narrower, so it re-reads as stale rather than current. + * + * 13 — PATCH 11 added: a custom model's identity line in the system prompt now + * carries its real label and upstream model id instead of only its short alias. + * An install without it still routes correctly; it re-reads as stale so the next + * patch run gives routed models their names. */ -export const PATCH_TRANSFORMS_VERSION = 12; +export const PATCH_TRANSFORMS_VERSION = 13; export interface PatchScriptModelEntry { alias?: string; @@ -519,6 +524,60 @@ export function applyClodexPatches(source: string, config: PatchScriptModelConfi ); } + // --------------------------------------------------------------------------- + // PATCH 11 — the model's own identity line in the system prompt. + // + // Claude Code tells every model what it is in the env_info section: "You are + // powered by the model named . The exact model ID is ." + // when its catalog knows the id, and only "You are powered by the model ." + // when it does not. A custom model's id is its short alias, so without this a + // routed model is told it is "luna" or "flash" and nothing else: asked what it + // is, it answers with the alias, and it cannot tie itself to the "Additional + // custom models" listing PATCH 4 writes (measured on 2.1.268: a Flash session + // quoted that listing and still called itself "flash"). + // + // We extend the marketing-name lookup with a baked table, so a configured model + // renders the first sentence with its real label and upstream model id. Only + // the object that feeds that sentence is touched; every other caller of the + // marketing-name function keeps its native answer. + // + // Anchor: the object literal `{modelId:X,marketingName:F(X)??null, + // knowledgeCutoff:G(X)}`, every identifier wildcarded and the parameter tied + // together by back-reference. Best-effort (cosmetic), like PATCH 4. + // --------------------------------------------------------------------------- + { + const IDENTITY_MARKER = '/*ccpatch:identity*/'; + const NAME_BY_KEY: Record = Object.create(null); + for (const [id, value] of Object.entries(MODEL_CONFIG)) { + const spec: PatchScriptModelEntry = value && typeof value === 'object' ? value : { alias: value as unknown as string }; + const segments = String(id).split(':'); + const upstream = (segments.length >= 3 ? segments.slice(2).join(':') : String(id)) + .replace(/\[1m\]$/i, ''); + const label = spec.display ? String(spec.display) : upstream; + const name = label + '; upstream model id ' + upstream + ', served through Clodex'; + if (spec.alias !== undefined) NAME_BY_KEY[String(spec.alias).trim().toLowerCase()] = name; + NAME_BY_KEY[String(id).trim().toLowerCase()] = name; + } + const lookupFor = (modelParam: string) => + IDENTITY_MARKER + '(' + JSON.stringify(NAME_BY_KEY) + ')[String(' + modelParam + '||"").trim().toLowerCase()]'; + + if (js.includes(IDENTITY_MARKER)) { + applyOnce( + 'PATCH 11: model self-identity (refresh)', + /\/\*ccpatch:identity\*\/\(\{[^{}]*\}\)\[String\(([\w$]+)\|\|""\)\.trim\(\)\.toLowerCase\(\)\]/, + (_m, modelParam) => lookupFor(modelParam!), + { required: false, noopIsSkip: true } + ); + } else { + applyOnce( + 'PATCH 11: model self-identity', + /(\{modelId:([\w$]+),marketingName:[\w$]+\(\2\))(\?\?null,knowledgeCutoff:[\w$]+\(\2\)\})/, + (_m, head, modelParam, tail) => head! + '??' + lookupFor(modelParam!) + tail!, + { required: false } + ); + } + } + // --------------------------------------------------------------------------- // PATCH 7 — per-model context window. // diff --git a/tests/fixtures/claude-bundle.ts b/tests/fixtures/claude-bundle.ts index f26cb473..14991d7e 100644 --- a/tests/fixtures/claude-bundle.ts +++ b/tests/fixtures/claude-bundle.ts @@ -31,6 +31,16 @@ export function contextResolver(modelParam: string, windowParam: string): string + `if(EHi(${modelParam},${windowParam}))return Dve;return $Ac(${modelParam},${windowParam})}`; } +/** + * The object feeding Claude Code's env_info identity sentence ("You are powered by the model + * named . The exact model ID is ."), as 2.1.268 spells it. PATCH 11 keys on + * it. The marketing-name lookup answers only for native models, so a custom model is otherwise + * told nothing but its short alias. + */ +export const IDENTITY_SITE = + 'function Ot(e){return{modelId:e,marketingName:Bu(e)??null,knowledgeCutoff:Lc(e)}}' + + 'function Bu(e){return e==="claude-opus-5"?"Opus 5":void 0}function Lc(e){return null}'; + export const CLAUDE_CORE_FIXTURE = [ ENUM_AND_DESCRIPTION, 'var KNOWN=["sonnet","opus","haiku","fable","opusplan"];', @@ -38,6 +48,7 @@ export const CLAUDE_CORE_FIXTURE = [ 'function opts(e,t,r){let n=cur(),o=(n==="opus"||n==="sonnet")&&n!==r?[n,r]:[r];for(let i of o)Dlh(e,i,t);return e}', CONTEXT_RESOLVER, 'function cwdOf(){let p=process.env.PWD;return p}', + IDENTITY_SITE, 'function childEnv(){let e=extra(),t=Object.keys(e).length>0,n=Object.keys(e).length>0,s=flag(process.env.CLAUDE_CODE_REMOTE)?remote():{};let o=[process.env.CLAUDE_CODE_OAUTH_TOKEN,process.env.CLAUDE_CODE_SUBSCRIPTION_TYPE,process.env.CLAUDE_BG_PTY_AUTH,"OTEL_",process.env.CLAUDE_CODE_OTEL_DIAG_STDERR],u=["CLAUDE_CODE_OAUTH_TOKEN"];if(!t&&!n&&!o[0])return process.env;let v={...process.env,...e,...s};for(let k of u)delete v[k],delete v[`INPUT_${k}`];return v}function mcpAllow(){let e=process.env.CLAUDE_CODE_MCP_ALLOWLIST_ENV;return e}', ].join('\n'); diff --git a/tests/patcher.test.ts b/tests/patcher.test.ts index 568f3fe0..8773a3ad 100644 --- a/tests/patcher.test.ts +++ b/tests/patcher.test.ts @@ -8,6 +8,7 @@ import { CLAUDE_SPLIT_MODULES, CONTEXT_RESOLVER, contextResolver, + IDENTITY_SITE, } from './fixtures/claude-bundle.js'; import { buildFakeElfClaude, @@ -588,8 +589,8 @@ describe('PATCH_TRANSFORMS_VERSION', () => { .join('\n'); const digest = createHash('sha256').update(source).digest('hex'); expect({ version: PATCH_TRANSFORMS_VERSION, digest }).toEqual({ - version: 12, - digest: 'd9dff2594fc60dcae83fb34846c681ee75fb3b0d7f49e5c26cb1c3c415cbcc4c', + version: 13, + digest: '745929da01e2e7e67c1f7db53e76eb1078eceade571e36ca173f938accb22b04', }); }); }); @@ -2722,6 +2723,53 @@ describe('patch script identity naming', () => { + 'clodex:openai:mystery = Mystery (OpenAI).'); }); + const identityOf = (source: string, model: string): { modelId: string; marketingName: string | null } => + new Function(source.split('\n').filter(line => line.includes('knowledgeCutoff:')).join('\n') + + ';return Ot(' + JSON.stringify(model) + ')')(); + + it('PATCH 11 gives a routed model its real name and upstream id in the identity line', () => { + const result = applyClodexPatches(CLAUDE_FIXTURE, { + 'clodex:openai-oauth:gpt-5.6-luna': { alias: 'luna', context: 272_000, display: 'GPT-5.6 Luna (OpenAI (ChatGPT))' }, + 'clodex:opencode-go:deepseek-v4.1-flash': { alias: 'flash', display: 'DeepSeek V4.1 Flash (OpenCode Go)' }, + }); + expect(result.results.find(r => r.name === 'PATCH 11: model self-identity')!.status).toBe('OK'); + const want = 'GPT-5.6 Luna (OpenAI (ChatGPT)); upstream model id gpt-5.6-luna, served through Clodex'; + // Reached by the alias Claude Code sends AND by the full clodex: id, case-insensitively. + expect(identityOf(result.content, 'luna').marketingName).toBe(want); + expect(identityOf(result.content, 'LUNA').marketingName).toBe(want); + expect(identityOf(result.content, 'clodex:openai-oauth:gpt-5.6-luna').marketingName).toBe(want); + expect(identityOf(result.content, 'flash').marketingName) + .toBe('DeepSeek V4.1 Flash (OpenCode Go); upstream model id deepseek-v4.1-flash, served through Clodex'); + // Native models keep the native answer; unknown ids stay null. + expect(identityOf(result.content, 'claude-opus-5').marketingName).toBe('Opus 5'); + expect(identityOf(result.content, 'some-other-model').marketingName).toBeNull(); + }); + + it('PATCH 11 falls back to the upstream id when no display label is known, and refreshes in place', () => { + const first = applyClodexPatches(CLAUDE_FIXTURE, { + 'clodex:openai-oauth:gpt-6-astra': { alias: 'astra' }, + }); + expect(identityOf(first.content, 'astra').marketingName) + .toBe('gpt-6-astra; upstream model id gpt-6-astra, served through Clodex'); + // Re-patching an already-patched source replaces the table rather than stacking a second one. + const second = applyClodexPatches(first.content, { + 'clodex:openai-oauth:gpt-6-astra': { alias: 'astra', display: 'GPT-6 Astra (OpenAI (ChatGPT))' }, + }); + expect(second.content.split('/*ccpatch:identity*/').length).toBe(2); + expect(identityOf(second.content, 'astra').marketingName) + .toBe('GPT-6 Astra (OpenAI (ChatGPT)); upstream model id gpt-6-astra, served through Clodex'); + }); + + it('PATCH 11 is best-effort: a bundle without the identity site still patches', () => { + const withoutSite = CLAUDE_FIXTURE.replace(IDENTITY_SITE, 'function Ot(e){return e}'); + expect(withoutSite).not.toBe(CLAUDE_FIXTURE); + const result = applyClodexPatches(withoutSite, { + 'clodex:openai-oauth:gpt-5.6-luna': { alias: 'luna' }, + }); + expect(result.results.find(r => r.name === 'PATCH 11: model self-identity')!.status).not.toBe('OK'); + expect(result.content).not.toContain('/*ccpatch:identity*/'); + }); + it('falls back to the old "Custom model (id)" description when no label is known', () => { const out = runPatchScript({ 'clodex:openai-oauth:gpt-5.6-sol': { alias: 'sol', context: 272_000 } }); expect(out).toContain('{value:"sol",label:"Sol",description:"Custom model (clodex:openai-oauth:gpt-5.6-sol)"}'); @@ -2933,6 +2981,7 @@ describe('patch script identity naming', () => { ['PATCH 6: alias resolver switch', 'OK'], ['PATCH 5: model picker options', 'OK'], ['PATCH 4: Agent tool model description', 'OK'], + ['PATCH 11: model self-identity', 'OK'], ['PATCH 7: per-model context window', 'OK'], ['PATCH 8a: effort capability', 'OK'], ['PATCH 8b: xhigh effort capability', 'OK'], @@ -2947,8 +2996,9 @@ describe('patch script identity naming', () => { ['PATCH 6: alias resolver switch', 'SKIP'], ['PATCH 5: model picker options', 'SKIP'], ['PATCH 4: Agent tool model description', 'SKIP'], - // PATCH 7 re-runs through the in-place refresh path; an unchanged config - // rewrites the identical table, which reports as already patched. + // PATCH 7 and PATCH 11 re-run through their in-place refresh paths; an + // unchanged config rewrites the identical table, which reports as already patched. + ['PATCH 11: model self-identity (refresh)', 'SKIP'], ['PATCH 7: per-model context window (refresh)', 'SKIP'], ['PATCH 8a: effort capability (refresh)', 'SKIP'], ['PATCH 8b: xhigh effort capability (refresh)', 'SKIP'], @@ -3094,15 +3144,16 @@ describe('patch script identity naming', () => { expect(patched.content).toContain('.enum(["sonnet","opus","haiku","fable","sol","clodex:openai:mystery"])'); expect(patched.content).toContain('/*ccpatch:ctx*/'); - expect(patched.results.slice(0, 6).map(result => [result.name, result.status])).toEqual([ + expect(patched.results.slice(0, 7).map(result => [result.name, result.status])).toEqual([ ['PATCH 1: Agent tool model enum', 'OK'], ['PATCH 3: known-alias validator list', 'OK'], ['PATCH 6: alias resolver switch', 'OK'], ['PATCH 5: model picker options', 'OK'], ['PATCH 4: Agent tool model description', 'OK'], + ['PATCH 11: model self-identity', 'OK'], ['PATCH 7: per-model context window', 'OK'], ]); - expect(patched.results.slice(6, -1)).toEqual([ + expect(patched.results.slice(7, -1)).toEqual([ { status: 'FAIL', name: 'PATCH 8a: effort capability', extra: 'anchor not found' }, { status: 'FAIL', name: 'PATCH 8b: xhigh effort capability', extra: 'anchor not found' }, { status: 'FAIL', name: 'PATCH 8c: max effort capability', extra: 'anchor not found' },