From 61972aa456d766297c9cb90046490d7572518943 Mon Sep 17 00:00:00 2001 From: cashcon57 Date: Mon, 13 Jul 2026 14:58:24 -0500 Subject: [PATCH 1/2] feat(threads): record and display model provenance in threads 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 ' line above its prompt, and any later change of model, provider, or backend build yields a 'Switched to ' 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. --- .../src/containers/ModelProvenanceDivider.tsx | 87 ++++++++++ .../src/lib/__tests__/modelProvenance.test.ts | 164 ++++++++++++++++++ web-app/src/lib/custom-chat-transport.ts | 31 ++++ web-app/src/lib/modelProvenance.ts | 90 ++++++++++ web-app/src/locales/en/common.json | 7 + web-app/src/routes/threads/$threadId.tsx | 61 +++++-- 6 files changed, 421 insertions(+), 19 deletions(-) create mode 100644 web-app/src/containers/ModelProvenanceDivider.tsx create mode 100644 web-app/src/lib/__tests__/modelProvenance.test.ts create mode 100644 web-app/src/lib/modelProvenance.ts diff --git a/web-app/src/containers/ModelProvenanceDivider.tsx b/web-app/src/containers/ModelProvenanceDivider.tsx new file mode 100644 index 000000000..e80e9058f --- /dev/null +++ b/web-app/src/containers/ModelProvenanceDivider.tsx @@ -0,0 +1,87 @@ +import { memo } from 'react' +import { useTranslation } from '@/i18n/react-i18next-compat' +import { getProviderTitle } from '@/lib/utils' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import type { ProvenanceMarker } from '@/lib/modelProvenance' + +interface ModelProvenanceDividerProps { + marker: ProvenanceMarker +} + +/** + * Quiet divider marking which model serves the messages that follow. + * Shows the model id only; provider and backend build live in the tooltip. + * All content comes from the persisted stamp, never from live settings, so + * later model/backend changes cannot rewrite a thread's history. + */ +export const ModelProvenanceDivider = memo( + ({ marker }: ModelProvenanceDividerProps) => { + const { t } = useTranslation() + const { kind, stamp } = marker + + return ( +
+ +
+ + + + {kind === 'served' + ? t('common:modelProvenance.servedBy', { + model: stamp.modelId, + }) + : t('common:modelProvenance.switchedTo', { + model: stamp.modelId, + })} + + + +
+
+ + {t('common:modelProvenance.model')}: + {' '} + {stamp.modelId} +
+
+ + {t('common:modelProvenance.provider')}: + {' '} + {getProviderTitle(stamp.providerId)} +
+ {stamp.backend && ( +
+ + {t('common:modelProvenance.backend')}: + {' '} + {stamp.backend} +
+ )} +
+
+
+
+ +
+ ) + } +) + +ModelProvenanceDivider.displayName = 'ModelProvenanceDivider' diff --git a/web-app/src/lib/__tests__/modelProvenance.test.ts b/web-app/src/lib/__tests__/modelProvenance.test.ts new file mode 100644 index 000000000..32a3d404e --- /dev/null +++ b/web-app/src/lib/__tests__/modelProvenance.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from 'vitest' +import { + computeProvenanceMarkers, + readProvenanceStamp, +} from '../modelProvenance' + +const stamp = (modelId: string, providerId = 'llamacpp', backend?: string) => ({ + modelProvenance: { modelId, providerId, ...(backend ? { backend } : {}) }, +}) + +const user = (id: string) => ({ id, role: 'user' }) +const assistant = (id: string, metadata?: unknown) => ({ + id, + role: 'assistant', + metadata, +}) + +describe('computeProvenanceMarkers', () => { + it('marks the first stamped response as served, anchored to the prompt', () => { + const markers = computeProvenanceMarkers([ + user('u1'), + assistant('a1', stamp('model-a')), + user('u2'), + assistant('a2', stamp('model-a')), + ]) + + expect(markers.size).toBe(1) + expect(markers.get('u1')).toEqual({ + kind: 'served', + stamp: { modelId: 'model-a', providerId: 'llamacpp' }, + }) + }) + + it('does not mark responses when the model never changes', () => { + const markers = computeProvenanceMarkers([ + user('u1'), + assistant('a1', stamp('model-a')), + user('u2'), + assistant('a2', stamp('model-a')), + user('u3'), + assistant('a3', stamp('model-a')), + ]) + + expect([...markers.keys()]).toEqual(['u1']) + }) + + it('marks a model switch, anchored to the prompt that follows the switch', () => { + const markers = computeProvenanceMarkers([ + user('u1'), + assistant('a1', stamp('model-a')), + user('u2'), + assistant('a2', stamp('model-b')), + ]) + + expect(markers.get('u2')).toEqual({ + kind: 'switched', + stamp: { modelId: 'model-b', providerId: 'llamacpp' }, + }) + }) + + it('anchors a regenerate-with-different-model to the response itself', () => { + const markers = computeProvenanceMarkers([ + user('u1'), + assistant('a1', stamp('model-a')), + assistant('a2', stamp('model-b')), + ]) + + expect(markers.get('a2')).toEqual({ + kind: 'switched', + stamp: { modelId: 'model-b', providerId: 'llamacpp' }, + }) + }) + + it('skips unstamped history and serves at the first stamped response', () => { + const markers = computeProvenanceMarkers([ + user('u1'), + assistant('a1'), + user('u2'), + assistant('a2', { finishReason: 'stop' }), + user('u3'), + assistant('a3', stamp('model-a')), + ]) + + expect([...markers.entries()]).toEqual([ + ['u3', { kind: 'served', stamp: { modelId: 'model-a', providerId: 'llamacpp' } }], + ]) + }) + + it('treats a backend build change as a provenance change', () => { + const markers = computeProvenanceMarkers([ + user('u1'), + assistant('a1', stamp('model-a', 'llamacpp', 'turboquant-519f0c5')), + user('u2'), + assistant('a2', stamp('model-a', 'llamacpp', 'turboquant-abc1234')), + ]) + + expect(markers.get('u2')?.kind).toBe('switched') + expect(markers.get('u2')?.stamp.backend).toBe('turboquant-abc1234') + }) + + it('treats a provider change with the same model id as a provenance change', () => { + const markers = computeProvenanceMarkers([ + user('u1'), + assistant('a1', stamp('model-a', 'llamacpp')), + user('u2'), + assistant('a2', stamp('model-a', 'llamacpp-upstream')), + ]) + + expect(markers.get('u2')?.kind).toBe('switched') + }) + + it('does not collide identities when ids contain spaces', () => { + // Local GGUF model names can contain spaces; the two stamps below would + // collide with a space-joined identity key. + const markers = computeProvenanceMarkers([ + user('u1'), + assistant('a1', { + modelProvenance: { modelId: 'model v1', providerId: 'llamacpp', backend: 'beta' }, + }), + user('u2'), + assistant('a2', { + modelProvenance: { modelId: 'model', providerId: 'llamacpp', backend: 'v1 beta' }, + }), + ]) + + expect(markers.get('u2')?.kind).toBe('switched') + }) + + it('ignores malformed stamps', () => { + const markers = computeProvenanceMarkers([ + user('u1'), + assistant('a1', { modelProvenance: { modelId: 42 } }), + assistant('a2', { modelProvenance: 'nope' }), + assistant('a3', { modelProvenance: { providerId: 'llamacpp' } }), + ]) + + expect(markers.size).toBe(0) + }) +}) + +describe('readProvenanceStamp', () => { + it('reads a full stamp', () => { + expect( + readProvenanceStamp(stamp('m', 'llamacpp', 'b1')) + ).toEqual({ modelId: 'm', providerId: 'llamacpp', backend: 'b1' }) + }) + + it('omits a non-string backend', () => { + expect( + readProvenanceStamp({ + modelProvenance: { modelId: 'm', providerId: 'p', backend: 7 }, + }) + ).toEqual({ modelId: 'm', providerId: 'p' }) + }) + + it('returns null for absent or malformed metadata', () => { + expect(readProvenanceStamp(undefined)).toBeNull() + expect(readProvenanceStamp({})).toBeNull() + expect(readProvenanceStamp({ modelProvenance: null })).toBeNull() + expect( + readProvenanceStamp({ modelProvenance: { modelId: '', providerId: 'p' } }) + ).toBeNull() + }) +}) diff --git a/web-app/src/lib/custom-chat-transport.ts b/web-app/src/lib/custom-chat-transport.ts index 0a5c4c119..90cda1a1d 100644 --- a/web-app/src/lib/custom-chat-transport.ts +++ b/web-app/src/lib/custom-chat-transport.ts @@ -18,6 +18,7 @@ import { splitAnthropicSerialToolUse, } from './custom-chat-transport-helpers' import type { MCPTool } from '@/types/completion' +import type { ModelProvenance } from './modelProvenance' /// Hugging Face special-token convention (`<|im_end|>`, `<|eot_id|>`, /// `<|endoftext|>`, etc.). Some MLX backends — most visibly the DFlash @@ -530,6 +531,25 @@ export class CustomChatTransport implements ChatTransport { const providerId = useModelProvider.getState().selectedProvider const effectiveProviderName = providerId const provider = useModelProvider.getState().getProviderByName(providerId) + + // Model provenance stamp: records which model/backend serves THIS + // response, persisted with the message metadata so threads can show where + // the model changed (see lib/modelProvenance.ts). The backend build is + // read at send time on purpose — later backend upgrades must not rewrite + // the history of already-generated messages. + const backendVersion = provider?.settings?.find( + (setting) => setting.key === 'version_backend' + )?.controller_props?.value + const modelProvenance: ModelProvenance | undefined = + modelId && providerId + ? { + modelId, + providerId, + ...(typeof backendVersion === 'string' && backendVersion !== '' + ? { backend: backendVersion } + : {}), + } + : undefined if (this.serviceHub && modelId && provider) { try { const updatedProvider = useModelProvider @@ -780,6 +800,14 @@ export class CustomChatTransport implements ChatTransport { const uiStream = result.toUIMessageStream({ messageMetadata: ({ part }) => { + // Stamp provenance as soon as the message starts so it is present + // while streaming and keeps an aborted response attributed for the + // rest of the session (aborted messages are never persisted, so the + // stamp only outlives a reload for completed responses). + if (part.type === 'start' && modelProvenance) { + return { modelProvenance } + } + // Start the wall-clock timer on the first generated delta (text or // reasoning), NOT on `start` — the latter fires before prefill, so // including it would tank the fallback TPS on long prompts. @@ -864,6 +892,9 @@ export class CustomChatTransport implements ChatTransport { } : {}), }, + // Repeated on finish so the stamp survives regardless of whether + // the runtime merges or replaces metadata across callbacks. + ...(modelProvenance ? { modelProvenance } : {}), } } diff --git a/web-app/src/lib/modelProvenance.ts b/web-app/src/lib/modelProvenance.ts new file mode 100644 index 000000000..cb8d27eba --- /dev/null +++ b/web-app/src/lib/modelProvenance.ts @@ -0,0 +1,90 @@ +/** + * Model provenance: which model/backend produced each assistant response. + * + * The chat transport stamps `metadata.modelProvenance` onto assistant + * messages as they are generated (see custom-chat-transport.ts). This module + * derives, at render time, where a thread should show a provenance divider: + * one "served by" marker at the first stamped response, and a "switched to" + * marker wherever the recorded model/backend changes afterwards. + * + * Markers are anchored to the user prompt that led to the response, so the + * divider reads "everything after this line came from X". When there is no + * user prompt directly before the response (e.g. a regenerate with a + * different model), the marker anchors to the assistant message itself. + */ + +export type ModelProvenance = { + modelId: string + providerId: string + /** Backend build tag (e.g. llama.cpp TurboQuant version) when available. */ + backend?: string +} + +export type ProvenanceMarker = { + kind: 'served' | 'switched' + stamp: ModelProvenance +} + +type ProvenanceMessage = { + id: string + role: string + metadata?: unknown +} + +/** Defensive read of the stamp — malformed metadata is treated as absent. */ +export function readProvenanceStamp( + metadata: unknown +): ModelProvenance | null { + if (!metadata || typeof metadata !== 'object') return null + const stamp = (metadata as Record).modelProvenance + if (!stamp || typeof stamp !== 'object') return null + const { modelId, providerId, backend } = stamp as Record + if (typeof modelId !== 'string' || modelId === '') return null + if (typeof providerId !== 'string' || providerId === '') return null + return { + modelId, + providerId, + ...(typeof backend === 'string' && backend !== '' ? { backend } : {}), + } +} + +// The backend build is part of the identity on purpose: the same model served +// by a different backend build (e.g. a TurboQuant update mid-thread) is a +// provenance change worth surfacing. NUL-separated because model ids can +// contain spaces (local GGUF names), so a printable delimiter could collide. +const stampKey = (stamp: ModelProvenance) => + `${stamp.providerId}\u0000${stamp.modelId}\u0000${stamp.backend ?? ''}` + +/** + * Walk the thread once and return a map of message id → marker to render + * above that message. Messages without a stamp (threads that predate the + * feature) are skipped; the first stamped response then yields a "served" + * marker, so old threads pick up provenance from their next response onward. + */ +export function computeProvenanceMarkers( + messages: readonly ProvenanceMessage[] +): Map { + const markers = new Map() + let lastKey: string | null = null + + for (let i = 0; i < messages.length; i++) { + const message = messages[i] + if (message.role !== 'assistant') continue + const stamp = readProvenanceStamp(message.metadata) + if (!stamp) continue + + const key = stampKey(stamp) + if (key === lastKey) continue + + const previous = messages[i - 1] + const anchorId = + previous && previous.role === 'user' ? previous.id : message.id + markers.set(anchorId, { + kind: lastKey === null ? 'served' : 'switched', + stamp, + }) + lastKey = key + } + + return markers +} diff --git a/web-app/src/locales/en/common.json b/web-app/src/locales/en/common.json index c077db4e4..093afae72 100644 --- a/web-app/src/locales/en/common.json +++ b/web-app/src/locales/en/common.json @@ -298,6 +298,13 @@ "title": "Model Settings - {{modelId}}", "description": "Configure model settings to optimize performance and behavior." }, + "modelProvenance": { + "servedBy": "Served by {{model}}", + "switchedTo": "Switched to {{model}}", + "model": "Model", + "provider": "Provider", + "backend": "Backend" + }, "dialogs": { "changeDataFolder": { "title": "Change Data Folder Location", diff --git a/web-app/src/routes/threads/$threadId.tsx b/web-app/src/routes/threads/$threadId.tsx index 384256b16..3abe31c4a 100644 --- a/web-app/src/routes/threads/$threadId.tsx +++ b/web-app/src/routes/threads/$threadId.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import { createFileRoute, useParams, useSearch } from '@tanstack/react-router' import { cn, isLlamacppProvider } from '@/lib/utils' @@ -7,6 +14,8 @@ import { useThreads } from '@/hooks/useThreads' import ChatInput from '@/containers/ChatInput' import { useShallow } from 'zustand/react/shallow' import { MessageItem } from '@/containers/MessageItem' +import { ModelProvenanceDivider } from '@/containers/ModelProvenanceDivider' +import { computeProvenanceMarkers } from '@/lib/modelProvenance' import { useMessages } from '@/hooks/useMessages' import { useServiceHub } from '@/hooks/useServiceHub' @@ -653,6 +662,13 @@ function ThreadDetail() { disabledTools, // Re-run when tools are enabled/disabled ]) + // Where to render model provenance dividers ("served by" / "switched to"), + // derived from the modelProvenance metadata stamped on assistant messages. + const provenanceMarkers = useMemo( + () => computeProvenanceMarkers(chatMessages), + [chatMessages] + ) + // Ref for reasoning container auto-scroll const reasoningContainerRef = useRef(null) @@ -1749,26 +1765,33 @@ function ThreadDetail() { {chatMessages.map((message, index) => { const isLastMessage = index === chatMessages.length - 1 const isFirstMessage = index === 0 + const provenanceMarker = provenanceMarkers.get(message.id) return ( - + {provenanceMarker && ( + )} - /> + + ) })} {pendingInitialUserMessage && ( From d961a38e0962091e3655360085d07a4c720c7504 Mon Sep 17 00:00:00 2001 From: cashcon57 Date: Thu, 20 Aug 2026 13:06:54 -0500 Subject: [PATCH 2/2] fix(threads): read provenance from the finish metadata already recorded 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. --- .../src/containers/ModelProvenanceDivider.tsx | 8 +--- .../src/lib/__tests__/modelProvenance.test.ts | 45 +++++++++++++------ web-app/src/lib/custom-chat-transport.ts | 34 +++----------- web-app/src/lib/modelProvenance.ts | 23 +++++----- 4 files changed, 52 insertions(+), 58 deletions(-) diff --git a/web-app/src/containers/ModelProvenanceDivider.tsx b/web-app/src/containers/ModelProvenanceDivider.tsx index e80e9058f..faef52ea7 100644 --- a/web-app/src/containers/ModelProvenanceDivider.tsx +++ b/web-app/src/containers/ModelProvenanceDivider.tsx @@ -32,7 +32,7 @@ export const ModelProvenanceDivider = memo( src="/images/transparent-logo.png" alt="" aria-hidden="true" - className="size-4 shrink-0 object-contain opacity-50 dark:invert" + className="size-4 shrink-0 object-contain opacity-50 dark:brightness-0 dark:invert" />
@@ -73,12 +73,6 @@ export const ModelProvenanceDivider = memo(
-
) } diff --git a/web-app/src/lib/__tests__/modelProvenance.test.ts b/web-app/src/lib/__tests__/modelProvenance.test.ts index 32a3d404e..78ce345f8 100644 --- a/web-app/src/lib/__tests__/modelProvenance.test.ts +++ b/web-app/src/lib/__tests__/modelProvenance.test.ts @@ -4,8 +4,11 @@ import { readProvenanceStamp, } from '../modelProvenance' +// Shape of the fields the transport records in finish metadata. const stamp = (modelId: string, providerId = 'llamacpp', backend?: string) => ({ - modelProvenance: { modelId, providerId, ...(backend ? { backend } : {}) }, + modelId, + providerId, + ...(backend ? { backend } : {}), }) const user = (id: string) => ({ id, role: 'user' }) @@ -31,7 +34,7 @@ describe('computeProvenanceMarkers', () => { }) }) - it('does not mark responses when the model never changes', () => { + it('marks only the first response as served when the model never changes', () => { const markers = computeProvenanceMarkers([ user('u1'), assistant('a1', stamp('model-a')), @@ -115,11 +118,15 @@ describe('computeProvenanceMarkers', () => { const markers = computeProvenanceMarkers([ user('u1'), assistant('a1', { - modelProvenance: { modelId: 'model v1', providerId: 'llamacpp', backend: 'beta' }, + modelId: 'model v1', + providerId: 'llamacpp', + backend: 'beta', }), user('u2'), assistant('a2', { - modelProvenance: { modelId: 'model', providerId: 'llamacpp', backend: 'v1 beta' }, + modelId: 'model', + providerId: 'llamacpp', + backend: 'v1 beta', }), ]) @@ -129,9 +136,9 @@ describe('computeProvenanceMarkers', () => { it('ignores malformed stamps', () => { const markers = computeProvenanceMarkers([ user('u1'), - assistant('a1', { modelProvenance: { modelId: 42 } }), - assistant('a2', { modelProvenance: 'nope' }), - assistant('a3', { modelProvenance: { providerId: 'llamacpp' } }), + assistant('a1', { modelId: 42, providerId: 'llamacpp' }), + assistant('a2', 'nope'), + assistant('a3', { providerId: 'llamacpp' }), ]) expect(markers.size).toBe(0) @@ -139,6 +146,20 @@ describe('computeProvenanceMarkers', () => { }) describe('readProvenanceStamp', () => { + it('reads provenance from real finish metadata alongside unrelated fields', () => { + // Threads recorded before this feature already carry modelId/providerId + // in their finish metadata, so they gain dividers retroactively. + expect( + readProvenanceStamp({ + finishReason: 'stop', + ttftMs: 120, + modelId: 'Qwen3.6-27B', + providerId: 'llamacpp', + usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 }, + }) + ).toEqual({ modelId: 'Qwen3.6-27B', providerId: 'llamacpp' }) + }) + it('reads a full stamp', () => { expect( readProvenanceStamp(stamp('m', 'llamacpp', 'b1')) @@ -147,18 +168,14 @@ describe('readProvenanceStamp', () => { it('omits a non-string backend', () => { expect( - readProvenanceStamp({ - modelProvenance: { modelId: 'm', providerId: 'p', backend: 7 }, - }) + readProvenanceStamp({ modelId: 'm', providerId: 'p', backend: 7 }) ).toEqual({ modelId: 'm', providerId: 'p' }) }) it('returns null for absent or malformed metadata', () => { expect(readProvenanceStamp(undefined)).toBeNull() expect(readProvenanceStamp({})).toBeNull() - expect(readProvenanceStamp({ modelProvenance: null })).toBeNull() - expect( - readProvenanceStamp({ modelProvenance: { modelId: '', providerId: 'p' } }) - ).toBeNull() + expect(readProvenanceStamp({ modelId: null, providerId: 'p' })).toBeNull() + expect(readProvenanceStamp({ modelId: '', providerId: 'p' })).toBeNull() }) }) diff --git a/web-app/src/lib/custom-chat-transport.ts b/web-app/src/lib/custom-chat-transport.ts index 90cda1a1d..ff2050666 100644 --- a/web-app/src/lib/custom-chat-transport.ts +++ b/web-app/src/lib/custom-chat-transport.ts @@ -18,7 +18,6 @@ import { splitAnthropicSerialToolUse, } from './custom-chat-transport-helpers' import type { MCPTool } from '@/types/completion' -import type { ModelProvenance } from './modelProvenance' /// Hugging Face special-token convention (`<|im_end|>`, `<|eot_id|>`, /// `<|endoftext|>`, etc.). Some MLX backends — most visibly the DFlash @@ -532,24 +531,13 @@ export class CustomChatTransport implements ChatTransport { const effectiveProviderName = providerId const provider = useModelProvider.getState().getProviderByName(providerId) - // Model provenance stamp: records which model/backend serves THIS - // response, persisted with the message metadata so threads can show where - // the model changed (see lib/modelProvenance.ts). The backend build is - // read at send time on purpose — later backend upgrades must not rewrite - // the history of already-generated messages. + // Backend build that serves THIS response, recorded alongside modelId and + // providerId in the finish metadata so threads can show where provenance + // changed (see lib/modelProvenance.ts). Read at send time on purpose: + // later backend upgrades must not rewrite already-generated history. const backendVersion = provider?.settings?.find( (setting) => setting.key === 'version_backend' )?.controller_props?.value - const modelProvenance: ModelProvenance | undefined = - modelId && providerId - ? { - modelId, - providerId, - ...(typeof backendVersion === 'string' && backendVersion !== '' - ? { backend: backendVersion } - : {}), - } - : undefined if (this.serviceHub && modelId && provider) { try { const updatedProvider = useModelProvider @@ -800,14 +788,6 @@ export class CustomChatTransport implements ChatTransport { const uiStream = result.toUIMessageStream({ messageMetadata: ({ part }) => { - // Stamp provenance as soon as the message starts so it is present - // while streaming and keeps an aborted response attributed for the - // rest of the session (aborted messages are never persisted, so the - // stamp only outlives a reload for completed responses). - if (part.type === 'start' && modelProvenance) { - return { modelProvenance } - } - // Start the wall-clock timer on the first generated delta (text or // reasoning), NOT on `start` — the latter fires before prefill, so // including it would tank the fallback TPS on long prompts. @@ -875,6 +855,9 @@ export class CustomChatTransport implements ChatTransport { // recorded anywhere, so a finished turn could not be attributed. modelId, providerId, + ...(typeof backendVersion === 'string' && backendVersion !== '' + ? { backend: backendVersion } + : {}), usage: { inputTokens: inputTokens, outputTokens: outputTokens, @@ -892,9 +875,6 @@ export class CustomChatTransport implements ChatTransport { } : {}), }, - // Repeated on finish so the stamp survives regardless of whether - // the runtime merges or replaces metadata across callbacks. - ...(modelProvenance ? { modelProvenance } : {}), } } diff --git a/web-app/src/lib/modelProvenance.ts b/web-app/src/lib/modelProvenance.ts index cb8d27eba..da9e1f6f6 100644 --- a/web-app/src/lib/modelProvenance.ts +++ b/web-app/src/lib/modelProvenance.ts @@ -1,9 +1,10 @@ /** * Model provenance: which model/backend produced each assistant response. * - * The chat transport stamps `metadata.modelProvenance` onto assistant - * messages as they are generated (see custom-chat-transport.ts). This module - * derives, at render time, where a thread should show a provenance divider: + * The chat transport records `modelId`, `providerId` and (when known) + * `backend` in each assistant message's finish metadata (see + * custom-chat-transport.ts). This module derives, at render time, where a + * thread should show a provenance divider: * one "served by" marker at the first stamped response, and a "switched to" * marker wherever the recorded model/backend changes afterwards. * @@ -31,14 +32,16 @@ type ProvenanceMessage = { metadata?: unknown } -/** Defensive read of the stamp — malformed metadata is treated as absent. */ +/** + * Defensive read of the provenance fields straight from message metadata. + * Malformed or missing values are treated as absent, so history recorded + * before these fields existed simply yields no stamp. + */ export function readProvenanceStamp( metadata: unknown ): ModelProvenance | null { if (!metadata || typeof metadata !== 'object') return null - const stamp = (metadata as Record).modelProvenance - if (!stamp || typeof stamp !== 'object') return null - const { modelId, providerId, backend } = stamp as Record + const { modelId, providerId, backend } = metadata as Record if (typeof modelId !== 'string' || modelId === '') return null if (typeof providerId !== 'string' || providerId === '') return null return { @@ -57,9 +60,9 @@ const stampKey = (stamp: ModelProvenance) => /** * Walk the thread once and return a map of message id → marker to render - * above that message. Messages without a stamp (threads that predate the - * feature) are skipped; the first stamped response then yields a "served" - * marker, so old threads pick up provenance from their next response onward. + * above that message. Responses without recorded provenance (history older + * than the finish-metadata fields) are skipped; the first stamped response + * yields a "served" marker and every later change yields a "switched" one. */ export function computeProvenanceMarkers( messages: readonly ProvenanceMessage[]