Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions web-app/src/containers/ModelProvenanceDivider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
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 (
<div
className="flex items-center gap-3 my-4 select-none"
data-testid="model-provenance-divider"
>
<img
src="/images/transparent-logo.png"
alt=""
aria-hidden="true"
className="size-4 shrink-0 object-contain opacity-50 dark:brightness-0 dark:invert"
/>
<div className="flex-1 border-t border-border/60" />
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-muted-foreground cursor-default shrink-0 max-w-[70%] truncate">
{kind === 'served'
? t('common:modelProvenance.servedBy', {
model: stamp.modelId,
})
: t('common:modelProvenance.switchedTo', {
model: stamp.modelId,
})}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-72">
<div className="space-y-0.5 text-left">
<div className="break-all">
<span className="font-medium">
{t('common:modelProvenance.model')}:
</span>{' '}
{stamp.modelId}
</div>
<div>
<span className="font-medium">
{t('common:modelProvenance.provider')}:
</span>{' '}
{getProviderTitle(stamp.providerId)}
</div>
{stamp.backend && (
<div className="break-all">
<span className="font-medium">
{t('common:modelProvenance.backend')}:
</span>{' '}
{stamp.backend}
</div>
)}
</div>
</TooltipContent>
</Tooltip>
<div className="flex-1 border-t border-border/60" />
</div>
)
}
)

ModelProvenanceDivider.displayName = 'ModelProvenanceDivider'
181 changes: 181 additions & 0 deletions web-app/src/lib/__tests__/modelProvenance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, it, expect } from 'vitest'
import {
computeProvenanceMarkers,
readProvenanceStamp,
} from '../modelProvenance'

// Shape of the fields the transport records in finish metadata.
const stamp = (modelId: string, providerId = 'llamacpp', backend?: string) => ({
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('marks only the first response as served 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', {
modelId: 'model v1',
providerId: 'llamacpp',
backend: 'beta',
}),
user('u2'),
assistant('a2', {
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', { modelId: 42, providerId: 'llamacpp' }),
assistant('a2', 'nope'),
assistant('a3', { providerId: 'llamacpp' }),
])

expect(markers.size).toBe(0)
})
})

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'))
).toEqual({ modelId: 'm', providerId: 'llamacpp', backend: 'b1' })
})

it('omits a non-string backend', () => {
expect(
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({ modelId: null, providerId: 'p' })).toBeNull()
expect(readProvenanceStamp({ modelId: '', providerId: 'p' })).toBeNull()
})
})
11 changes: 11 additions & 0 deletions web-app/src/lib/custom-chat-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,14 @@ export class CustomChatTransport implements ChatTransport<UIMessage> {
const providerId = useModelProvider.getState().selectedProvider
const effectiveProviderName = providerId
const provider = useModelProvider.getState().getProviderByName(providerId)

// 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
if (this.serviceHub && modelId && provider) {
try {
const updatedProvider = useModelProvider
Expand Down Expand Up @@ -847,6 +855,9 @@ export class CustomChatTransport implements ChatTransport<UIMessage> {
// recorded anywhere, so a finished turn could not be attributed.
modelId,
providerId,
...(typeof backendVersion === 'string' && backendVersion !== ''
? { backend: backendVersion }
: {}),
usage: {
inputTokens: inputTokens,
outputTokens: outputTokens,
Expand Down
93 changes: 93 additions & 0 deletions web-app/src/lib/modelProvenance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Model provenance: which model/backend produced each assistant response.
*
* 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.
*
* 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 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 { modelId, providerId, backend } = metadata as Record<string, unknown>
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. 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[]
): Map<string, ProvenanceMarker> {
const markers = new Map<string, ProvenanceMarker>()
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
}
7 changes: 7 additions & 0 deletions web-app/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading