Skip to content

feat(patch): tell a custom model its real name in the identity line - #218

Closed
TweedBeetle wants to merge 1 commit into
bman654:mainfrom
TweedBeetle:feat/patch-model-self-identity
Closed

feat(patch): tell a custom model its real name in the identity line#218
TweedBeetle wants to merge 1 commit into
bman654:mainfrom
TweedBeetle:feat/patch-model-self-identity

Conversation

@TweedBeetle

Copy link
Copy Markdown
Contributor

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_info system-prompt section:

function ...(e){return e.marketingName
  ?`You are powered by the model named ${e.marketingName}. The exact model ID is ${e.modelId}.`
  :`You are powered by the model ${e.modelId}.`}

marketingName comes from a native-catalog lookup that answers only for Anthropic models, and a
custom model's modelId is its alias (the identity PATCH 1/3/5/6 install), so a routed session is
told exactly one thing about itself: luna. PATCH 4 does put the alias→label listing into the Agent
tool's model description, but that text is about which models the agent may REQUEST, not about
what 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 claude and proxy mode alike. The
trigger 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:

marketingName: <native>(e) ?? /*ccpatch:identity*/({"luna":"GPT-5.6 Luna (OpenAI (ChatGPT)); upstream
model id gpt-5.6-luna, served through Clodex", ...})[String(e||"").trim().toLowerCase()] ?? null
  • The native lookup keeps priority, so Anthropic models are untouched; an unconfigured id still
    yields null and the old one-name sentence.
  • Only the object that feeds this sentence is patched. Every other caller of the marketing-name
    function is left alone.
  • The label is the same display PATCH 4 and PATCH 5 use, plus the upstream model id parsed out of
    the clodex:<provider>:<model> key (a [1m] suffix stripped). No label configured → the upstream
    id stands in, so the sentence is still more informative than the alias alone.
  • Best-effort (required: false), like PATCH 4: a bundle where the site has drifted still patches
    and 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_VERSION 12 → 13, the canary probe's site list, and a postcondition proof.

Deliberately left out: the sibling knowledgeCutoff field in the same object. Clodex carries no
cutoff metadata today, and inventing one from model ids would be guesswork — that wants a real
metadata field first.

Evidence

  • pnpm typecheck && pnpm test && pnpm build on 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 #213feat(oauth): keep every parallel subagent's ChatGPT chain alive #215: 117 files / 2576 tests, also green.)
  • Feature-deletion mutation, whole file: replacing the replacement callback with the identity
    ((m) => m) fails both positive tests — the site reports FAIL instead of OK, and the routed
    lookups come back null. The third test (absent site) keeps passing by design, which is what it
    is for.
  • Three tests, executing the patched code rather than matching strings: the identity object is
    evaluated with new Function and called, so the assertions are on what Claude Code would actually
    read. 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 a
    native model keeps its native name and an unknown id stays null; and that a bundle without the
    site still patches.
  • The shared bundle fixture now carries the real 2.1.268 shape of the site, with identifiers renamed
    from the real build so the wildcards are exercised rather than the spelling.
  • Live, on macOS 15, node 22.23.2, Claude Code 2.1.268, isolated CLODEX_HOME, proxy mode on a
    standalone clodex server --proxy:
    patched a copy first, then the real install. An Opus main
    spawned luna, flash and haiku subagents in one Agent call and each answered with its full
    name — "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.
  • Providers tested: openai-oauth (ChatGPT subscription: GPT-5.6 Luna) and opencode-go (DeepSeek
    V4.1 Flash), plus Anthropic passthrough. Not tested: any API-key provider, and gpt-6-astra
    is 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 ~/.clodex config on every patch. A runtime failure is not really available — the
injected expression is a property lookup on an object literal, and a miss yields undefined, which
the existing ?? null turns into the old one-name sentence. If the anchor ever drifts, the site
reports FAIL and the patch proceeds (it is required: false), so the only consequence is that
routed models go back to knowing only their alias. clodex patch --restore, or re-patching from
the pristine backup, removes it like any other site.

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 bman654 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bman654

bman654 commented Sep 11, 2026

Copy link
Copy Markdown
Owner

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.

@bman654 bman654 closed this Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants