Skip to content

feat(threads): record and display model provenance in threads - #174

Open
cashcon57 wants to merge 2 commits into
AtomicBot-ai:mainfrom
cashcon57:feat/thread-model-provenance
Open

feat(threads): record and display model provenance in threads#174
cashcon57 wants to merge 2 commits into
AtomicBot-ai:mainfrom
cashcon57:feat/thread-model-provenance

Conversation

@cashcon57

@cashcon57 cashcon57 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Visual

image

Describe Your Changes

Adds model provenance to threads: a quiet divider line showing which model
(and backend) serves the responses that follow, as discussed on Discord.

Recording. Each assistant message gets
metadata.modelProvenance = { modelId, providerId, backend? } stamped as it is
generated, riding the same metadata pipeline that already persists
finishReason / tokenSpeed / usage. The stamp is emitted at stream start
(so it survives an aborted generation) and repeated on finish. backend is the
version_backend setting of the serving provider (e.g. the llama.cpp
TurboQuant build tag), read at send time so a later backend upgrade cannot
rewrite the history of already-generated messages. No storage schema change.

Display. Divider positions are derived from the stamps at render time
(memoized walk in the thread view):

  • First stamped response: Served by <model id> above the prompt that led to it.
  • Any later change of model, provider, or backend build: Switched to <model id>
    above the prompt following the switch. The backend build being part of the
    identity is deliberate: the same model on a different TurboQuant build is a
    provenance change worth surfacing.
  • Regenerating with a different model anchors the line to the regenerated
    response itself (the original response above it was served by the old model).
  • Switching models without sending anything adds nothing; threads that predate
    this feature pick up provenance from their next response onward.

UI. Hairline rule with the model id in small muted text, bookended by the
app's asterisk mark (transparent-logo.png, faded, theme-inverted), deliberately
quiet so it does not compete with the conversation. Hovering shows a tooltip
with the model id, the standard provider display name (via getProviderTitle,
so TurboQuant and upstream llama.cpp are distinguished), and the backend build
when recorded. Strings are localized (en only; other locales fall back to
English per the i18n setup).

Tests. Unit tests for the derivation walk: first response, no-change,
model switch, provider-only change, backend-build-only change, regenerate
anchoring, pre-feature history, malformed stamps.

Fixes Issues

Self Checklist

  • Added relevant comments, esp in complex areas (why the stamp is captured at send time, why backend is part of the identity, anchoring rules)
  • Updated docs (for bug fixes / features): n/a
  • Created issues for follow-up changes or refactoring needed: n/a

@cashcon57
cashcon57 force-pushed the feat/thread-model-provenance branch 2 times, most recently from f3ccd7e to 527b7ae Compare July 13, 2026 21:03
@cashcon57
cashcon57 marked this pull request as ready for review July 14, 2026 14:36
@cashcon57
cashcon57 requested a review from Vect0rM as a code owner July 14, 2026 14:36
@cashcon57
cashcon57 force-pushed the feat/thread-model-provenance branch from 527b7ae to 9e7fa99 Compare July 17, 2026 15:06
@cashcon57

Copy link
Copy Markdown
Contributor Author

Apologies for the edits, I forgot to push the final edit that is represented in the image above. All good now!

@cashcon57
cashcon57 force-pushed the feat/thread-model-provenance branch from 9e7fa99 to 551ad84 Compare July 31, 2026 17:05
@cashcon57

Copy link
Copy Markdown
Contributor Author

Rebased onto current main to resolve conflicts. Typecheck passing.

Vect0rM commented Aug 20, 2026

Copy link
Copy Markdown
Member

Thanks for your patience on this one, @cashcon57 — it sat longer than it should have, and it deserved better, because the craft here is high. readProvenanceStamp defends properly against malformed metadata, the NUL-separated stampKey with its comment about GGUF names containing spaces is the kind of detail most people skip, and the anchoring rule (mark the prompt that led to the response, fall back to the response itself on a regenerate) is genuinely well thought out. Twelve tests covering identity collisions and unstamped history is a good showing.

Verified on your branch merged onto current main:

  • vitest --run src/lib/__tests__/modelProvenance.test.ts — 12 passed.
  • tsc -b — exit 0. eslint on all four changed files — clean. Merges cleanly.

One structural thing needs changing before I can take it, plus a design call and two nits.

1. We're already recording this

custom-chat-transport.ts on main writes into the finish metadata, at lines 848–849:

// The model/provider a message was produced by was previously not
// recorded anywhere, so a finished turn could not be attributed.
modelId,
providerId,

Nothing consumes those yet — they were added in anticipation of exactly this feature. Your modelProvenance object stores the same two facts a second time, alongside them, in the same metadata bag.

Two representations of one fact will drift, and there's a concrete payoff to collapsing them: threads already in users' stores carry modelId and providerId today. If computeProvenanceMarkers reads those, provenance dividers appear retroactively across existing history. As written, every current thread is "unstamped" and only picks up markers from its next response onward — which your own test acknowledges.

What I'd like:

  • readProvenanceStamp reads metadata.modelId / metadata.providerId, keeping the same defensive validation.
  • The backend build joins them as a third field at the same level, rather than nesting a parallel object.
  • The ModelProvenance type stays — it's the right shape, it just describes the existing fields.

2. Should a single-model thread get a divider at all?

computeProvenanceMarkers always emits a served marker at the first stamped response, so every thread renders "Served by X" above its very first prompt, whether or not anything ever switches. Your test at line 34 is named "does not mark responses when the model never changes" but asserts ['u1'] — one marker. The name reads like the opposite of what it checks.

For the large majority of threads — one model, start to finish — that's a persistent line at the top of the conversation restating what the model selector already shows. My preference is to emit markers only where provenance actually changes: walk the thread, and if there's no change, render nothing. The first marker then becomes meaningful precisely because it's rare.

If you'd rather keep the "served" line, make the case — you've clearly thought about this more than I have. But the current behaviour should be a decision rather than a side effect, and the test name should match whichever way it goes.

3. The stamp may flicker mid-stream

You return { modelProvenance } on part.type === 'start' and spread it again on finish, with a comment noting the uncertainty about whether the runtime merges or replaces. But the returns in between — the text-delta path — don't carry it. If the runtime replaces, the divider appears at start, vanishes during streaming, and returns on finish.

Folding this into point 1 makes the problem disappear, since modelId/providerId are already handled consistently by the existing code. If you keep a separate object, it needs to be on every return path, not two of three.

Nits

  • dark:invert on transparent-logo.png — the logo is dark-on-transparent, and the house convention for putting it on a dark ground is dark:brightness-0 dark:invert (see MermaidError.tsx:20). Plain invert gives you a washed grey rather than white.
  • Two logos, one on each side of a single divider, is a lot of ornament for something the docstring calls a "quiet divider". One, or none, would sit better.
  • common.json only — that's fine and matches how we land new strings; the other locales fall back to English until translators catch up.

Point 1 is the one I need; 2 is a question I'd like answered rather than a diff; 3 mostly dissolves with 1. Thanks for building this — the thread-level attribution gap is real, and you're the one who noticed 🧭


Generated by Claude Code

Stamp metadata.modelProvenance = { modelId, providerId, backend? } onto
assistant messages as they are generated, riding the same metadata
pipeline that already persists finishReason/tokenSpeed/usage. The stamp
is emitted at stream start (so an aborted response stays attributed for
the rest of the session) and repeated on finish.

At render time, derive divider positions from the stamps: the first
stamped response yields a 'Served by <model>' line above its prompt,
and any later change of model, provider, or backend build yields a
'Switched to <model>' line at that point. A regenerate with a different
model anchors to the regenerated response. Threads that predate the
stamp pick up provenance from their next response onward.

The divider is a quiet hairline rule with the model id in small muted
text; provider and backend build (differentiating llama.cpp TurboQuant
builds from upstream) are shown in a tooltip. All display comes from
the persisted stamp, never live settings, so later backend upgrades
cannot rewrite history.
Drop the separate modelProvenance object and read modelId/providerId
straight from the finish metadata the transport already writes, adding
the backend build as a sibling field. Existing threads gain provenance
dividers retroactively, and the stamp no longer depends on the start
callback surviving streaming.

Keep the served marker for single-model threads (see PR discussion),
rename the test to match, use the house dark-mode logo treatment, and
drop the second divider logo.
@cashcon57
cashcon57 force-pushed the feat/thread-model-provenance branch from 551ad84 to d961a38 Compare August 20, 2026 18:06

Vect0rM commented Aug 21, 2026

Copy link
Copy Markdown
Member

Thanks for the quick turnaround, @cashcon57 — point 1 landed exactly as I'd hoped, and the result is better than what I asked for.

Verified on your branch (it's now based on 3b625ff, so it fast-forwards — no merge needed):

  • tsc -b — exit 0.
  • Full vitest run — 221 files, 2156 tests, all passing. Baseline on main is 2143, so your 13 land clean with nothing else disturbed.
  • eslint on all changed files — clean.

I also checked the retroactivity claim end-to-end rather than taking it on trust, because it's the most valuable part of this change and the easiest to get wrong. The round trip holds: $threadId.tsx:548 persists metadata: messageMetadata verbatim, and convertThreadMessageToUIMessage restores it wholesale at lib/messages.ts:373. So the modelId/providerId the transport has been recording survive a reload, and threads already sitting in people's stores really do gain dividers. That's a much better outcome than "provenance starts from your next message", and dropping the parallel object made point 3 disappear on its own, as expected.

Three things left. None is large, and only the first needs a diff.

1. version_backend ships as the literal string "none"

Both extensions declare it that way:

{ "key": "version_backend", "controllerProps": { "value": "none", ... } }

Your guard rejects the empty string but not this sentinel:

...(typeof backendVersion === 'string' && backendVersion !== ''
  ? { backend: backendVersion }
  : {}),

So a turn generated while the setting still holds its default records backend: "none". Two consequences: the tooltip reads "Backend: none", and — because backend is part of the identity key, deliberately and correctly — the first real build tag afterwards renders as "Switched to <same model>" with nothing actually having changed.

Honest caveat: I can't run the app from here, so I can't tell you how reachable that state is in practice — inference presumably needs a real backend, which may mean the value is always populated by the time a message exists. But it costs one comparison to rule out, and the sentinel leaking into user-visible text is the kind of thing that gets reported as a bug months later:

backendVersion !== '' && backendVersion !== 'none'

2. The description now describes a design that isn't in the diff

This is the one I'd most like fixed, because the description is what gets read at merge time and later in git log. It still says:

  • metadata.modelProvenance = { modelId, providerId, backend? } — that object no longer exists;
  • "emitted at stream start … and repeated on finish" — it's finish metadata only now;
  • "threads that predate this feature pick up provenance from their next response onward" — the opposite is now true, and it's the best thing about the change;
  • "bookended by the app's asterisk mark" — there's one logo now, not two.

Please rewrite it against what the code does, and lead with the retroactive behaviour.

3. Point 2 is still open

Your commit message says "Keep the served marker for single-model threads (see PR discussion)" — but nothing was posted here, so the reasoning isn't recorded anywhere I can see. I'm not trying to relitigate it; I said I'd take the "served" line if you made the case, and you've thought about this more than I have. I just need the case actually made, in a comment, so the decision is on the record.

The test rename is right, though — marks only the first response as served when the model never changes says what it asserts now.

Nit

prettier --write on the two new files:

src/lib/modelProvenance.ts        — readProvenanceStamp's signature fits on one line
src/lib/__tests__/modelProvenance.test.ts — two expect() blocks

(custom-chat-transport.ts also comes up unformatted, but it's like that on main too — not yours, don't touch it here.)

Fix 1, rewrite the description, answer 2, and I'll take it. The NUL-separated identity key and the backend-build-as-identity call are both still the right instincts 🧭


Generated by Claude Code

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.

Record model provenance in threads (which model/backend served each response)

2 participants