feat(patch): tell a custom model its real name in the identity line - #218
feat(patch): tell a custom model its real name in the identity line#218TweedBeetle wants to merge 1 commit into
Conversation
Claude Code's env_info section tells every model what it is: "You are powered by the model named <marketing name>. The exact model ID is <id>." when its catalog knows the id, and only "You are powered by the model <id>." 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
bman654
left a comment
There was a problem hiding this comment.
Thank you for this — the implementation is careful, the tests execute the emitted identity object rather than string-matching it, and you verified it live on two providers. We're going to close it, but as a scope decision rather than a quality one, and we want to hand you a working alternative rather than just a "no".
Why not a built-in patch site
Giving a routed model accurate information about itself is useful, and we agree it changes answers — it isn't merely a display change. But every built-in patch site is something the project then owns across every Claude Code release: the anchor has to keep binding, the canary has to cover it, and a drift becomes a project failure. For an optional prompt customisation that the existing extension mechanism can express, we'd rather not take that on. To be clear about what the evidence says: your anchor binds correctly on every cached bundle from 2.1.252 through 2.1.268 (26 distinct bundles, no ambiguous matches, patched producer executed on all of them), so this isn't a judgement that this site is fragile. It's about ownership.
The alternative, verified
clodex already ships local patches: ~/.clodex/local-patches.mjs (respects CLODEX_HOME), enabled with clodex patch --enable-local-patches, removed with --disable-local-patches. A local patch receives every JavaScript module joined together — the surface PATCH 11 needs — and must emit its marker exactly once.
We didn't want to say "use local patches" without proving it can express this, so here's a working module. Executed against the real 2.1.268 bundle: all 11 built-in sites applied, the local patch changed one of 1,668 modules, all 15 built-in postconditions stayed intact, re-applying skipped without stacking, re-patching from pristine bytes reproduced identical output, editing the table refreshed the label, and disabling removed it while preserving the built-ins. The extracted identity producer returned the same values as PATCH 11 for nine inputs including native and unknown models.
local-patches.mjs
// Keep this table in this file: clodex hashes the entry module for freshness.
// Use your saved full IDs/aliases and the provider's actual upstream model ID.
const models = [
{
id: 'clodex:openai-oauth:gpt-5.6-luna',
alias: 'luna',
display: 'GPT-5.6 Luna (OpenAI (ChatGPT))',
upstream: 'gpt-5.6-luna',
},
{
id: 'clodex:opencode-go:deepseek-v4.1-flash',
alias: 'flash',
display: 'DeepSeek V4.1 Flash (OpenCode Go)',
upstream: 'deepseek-v4.1-flash',
},
{
id: 'clodex:openai-oauth:gpt-6-astra',
alias: 'astra',
display: 'GPT-6 Astra (OpenAI (ChatGPT))',
upstream: 'gpt-6-astra',
},
];
export default [{
id: 'model-self-identity',
apply(source, { marker }) {
const names = new Map();
for (const { id, alias, display, upstream } of models) {
if (typeof id !== 'string' || !id.trim()
|| typeof upstream !== 'string' || !upstream.trim()
|| (alias !== undefined && (typeof alias !== 'string' || !alias.trim()))
|| (display !== undefined && typeof display !== 'string')) {
throw new Error('Each model needs nonempty id/upstream strings; alias/display must be strings');
}
const name = `${display || upstream}; upstream model id ${upstream}, served through Clodex`;
for (const key of alias === undefined ? [id] : [alias, id]) {
const normalized = key.trim().toLowerCase();
if (names.has(normalized) && names.get(normalized) !== name) {
throw new Error(`Conflicting identity entries for ${normalized}`);
}
names.set(normalized, name);
}
}
// Claude Code 2.1.268's identity object. Refuse a missing/ambiguous site.
const anchor = /(\{modelId:([\w$]+),marketingName:[\w$]+\(\2\))(\?\?null,knowledgeCutoff:[\w$]+\(\2\)\})/g;
const matches = [...source.matchAll(anchor)];
if (matches.length !== 1) {
throw new Error(`Expected one model identity site; found ${matches.length}`);
}
return source.replace(anchor, (_match, head, param, tail) =>
`${head}??${marker}(new Map(${JSON.stringify([...names])})).get(String(${param}||"").trim().toLowerCase())${tail}`,
);
},
}];Edit the models table to match your favourites, save it as local-patches.mjs in your clodex config directory, and run clodex patch --enable-local-patches --trace.
The honest limitation: the local-patch API receives no config object, so the table lives in the module and you keep it aligned with your aliases and upstream ids yourself, and it's verified on the current source shape rather than guaranteed across future releases. It's equivalent behaviour, not equivalent integration — we'd rather say that plainly than imply the mechanism makes the maintenance disappear.
Two things worth knowing regardless
- A display label containing braces breaks the postcondition. A label like
GPT-X {preview}patches successfully but fails PATCH 11's[^{}]*postcondition regex, and the failure cascade discards enabled local patches. Reproduced through the real proof-capture function. The local module above validates its inputs, but if you keep the built-in version privately, escape or reject braces. - The fixture has no competing candidate before the target, which this repo's patcher rules require: weakening the anchor's parameter back-references leaves the behavioural tests green with only the source-digest pin red. We added a decoy with the same property layout and different parameters — the anchor survived it, so the anchor is sound; the fixture just doesn't prove it. If you carry this locally, that decoy is worth keeping in your own tests.
One correction to the description for your notes: on 2.1.268 this text isn't part of the top-level system prompt — it's a conversation attachment rendered as a system-reminder user message. That's actually good news for caching: it doesn't churn prompt_cache_key or invalidate the system prefix; a resumed transcript just gains one appended reminder.
Gate on your head: typecheck, build, 2559 tests green.
|
Closing as a scope decision — see the review above for the verified local-patch alternative and the two findings worth carrying into it. Thanks again for the work. |
What this changes for users
A custom model can now tell you what it is. Ask a session running on one of your clodex models
which model it is, and until now it could only answer with the short alias you gave it — "luna",
"flash" — because that is the only name Claude Code ever tells it. It now says "GPT-5.6 Luna
(OpenAI (ChatGPT))" or "DeepSeek V4.1 Flash (OpenCode Go)", with the upstream model id, in the same
sentence Claude Code already uses for its own models. Subagents included, which is where it matters
most: a fan-out across several providers used to come back with three answers that all named
aliases.
Problem and root cause
Claude Code builds one sentence about the running model into the
env_infosystem-prompt section:marketingNamecomes from a native-catalog lookup that answers only for Anthropic models, and acustom model's
modelIdis its alias (the identity PATCH 1/3/5/6 install), so a routed session istold exactly one thing about itself:
luna. PATCH 4 does put the alias→label listing into the Agenttool's
modeldescription, but that text is about which models the agent may REQUEST, not aboutwhat the reader is — measured on Claude Code 2.1.268, a Flash session quoted that listing verbatim
and still answered "flash" when asked what model it was.
Reachability: every launch on a configured custom model,
clodex claudeand proxy mode alike. Thetrigger is the ordinary system prompt, not an opt-in path; the user must already have the model as
a favorite (with or without an alias) and a patched binary.
The change
PATCH 11 extends the marketing-name lookup that feeds that one sentence with a baked table, keyed by
alias and by full id, lowercased:
yields
nulland the old one-name sentence.function is left alone.
displayPATCH 4 and PATCH 5 use, plus the upstream model id parsed out ofthe
clodex:<provider>:<model>key (a[1m]suffix stripped). No label configured → the upstreamid stands in, so the sentence is still more informative than the alias alone.
required: false), like PATCH 4: a bundle where the site has drifted still patchesand still routes. It has an in-place refresh path like PATCH 7, so a re-run rewrites the table
rather than stacking a second one.
PATCH_TRANSFORMS_VERSION12 → 13, the canary probe's site list, and a postcondition proof.Deliberately left out: the sibling
knowledgeCutofffield in the same object. Clodex carries nocutoff metadata today, and inventing one from model ids would be guesswork — that wants a real
metadata field first.
Evidence
pnpm typecheck && pnpm test && pnpm buildon this branch: tsc clean, 115 files / 2559 tests.(Merged into my local union branch alongside fix(opencode-go): send the session header Go now requires, add DeepSeek V4.1 Flash #213–feat(oauth): keep every parallel subagent's ChatGPT chain alive #215: 117 files / 2576 tests, also green.)
(
(m) => m) fails both positive tests — the site reportsFAILinstead ofOK, and the routedlookups come back
null. The third test (absent site) keeps passing by design, which is what itis for.
evaluated with
new Functionand called, so the assertions are on what Claude Code would actuallyread. They cover reach by alias, by full id and case-insensitively; the no-label fallback; that a
re-patch refreshes rather than stacks (
split('/*ccpatch:identity*/').length === 2); that anative model keeps its native name and an unknown id stays
null; and that a bundle without thesite still patches.
from the real build so the wildcards are exercised rather than the spelling.
CLODEX_HOME, proxy mode on astandalone
clodex server --proxy: patched a copy first, then the real install. An Opus mainspawned
luna,flashandhaikusubagents in one Agent call and each answered with its fullname — "GPT-5.6 Luna (OpenAI (ChatGPT))", "DeepSeek V4.1 Flash (OpenCode Go) — upstream id
deepseek-v4.1-flash, served through Clodex", "Claude Haiku 4.5". Anthropic passthrough(
--model haiku) smoke-tested bridged and unbridged, unchanged.openai-oauth(ChatGPT subscription: GPT-5.6 Luna) andopencode-go(DeepSeekV4.1 Flash), plus Anthropic passthrough. Not tested: any API-key provider, and
gpt-6-astrais configured here but was not exercised in this run.
Failure and rollback behavior
Nothing is persisted and nothing needs migrating: the table is baked into the patched binary and
rebuilt from
~/.clodexconfig on every patch. A runtime failure is not really available — theinjected expression is a property lookup on an object literal, and a miss yields
undefined, whichthe existing
?? nullturns into the old one-name sentence. If the anchor ever drifts, the sitereports
FAILand the patch proceeds (it isrequired: false), so the only consequence is thatrouted models go back to knowing only their alias.
clodex patch --restore, or re-patching fromthe pristine backup, removes it like any other site.