Skip to content

Commit d3229ad

Browse files
committed
log claude model in backfill
1 parent 54eb842 commit d3229ad

6 files changed

Lines changed: 102 additions & 1 deletion

File tree

collectivus-plugin-kernel-types.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1422,6 +1422,14 @@ export interface AiGatewayProjectedMessage {
14221422
prompt_id?: string
14231423
provider_type?: string
14241424
provider_subtype?: string
1425+
/**
1426+
* Per-message model id. Live capture sets one model per exchange (one
1427+
* request, one model) on the exchange, but a backfilled exchange spans a
1428+
* whole session that can switch models mid-stream, so backfill stamps the
1429+
* model per assistant message. The gateway prefers this over the exchange
1430+
* `model` when present.
1431+
*/
1432+
model?: string
14251433
entrypoint?: string
14261434
user_type?: string
14271435
permission_mode?: string

hypaware-core/plugins-workspace/ai-gateway/src/message_projector.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,10 @@ function expandMessageParts(ctx) {
649649
conversation_id: ctx.conversationId,
650650
user_id: ctx.projection.user_id,
651651
provider: ctx.projection.provider,
652-
model: ctx.projection.model,
652+
// Per-message model wins when present (backfill stamps it per assistant
653+
// line so mixed-model sessions stay accurate); live capture leaves it
654+
// unset and falls back to the one-model-per-exchange value.
655+
model: stringValue(ctx.message.model) ?? ctx.projection.model,
653656
system_text: ctx.projection.system_text,
654657
tools: ctx.projection.tools,
655658
conversation_started_at: ctx.conversationStarted,

hypaware-core/plugins-workspace/claude/src/backfill.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,10 @@ function projectedMessageFromEntry(entry, agentMeta) {
391391
if (entry.prompt_id) message.prompt_id = entry.prompt_id
392392
if (entry.provider_type) message.provider_type = entry.provider_type
393393
if (entry.provider_subtype) message.provider_subtype = entry.provider_subtype
394+
// Per-message model: live capture sets one model per exchange, but a
395+
// backfilled session can switch models mid-stream, so stamp it per assistant
396+
// line and let the gateway prefer it over the exchange model.
397+
if (entry.model) message.model = entry.model
394398
if (entry.entrypoint) message.entrypoint = entry.entrypoint
395399
if (entry.user_type) message.user_type = entry.user_type
396400
if (entry.permission_mode) message.permission_mode = entry.permission_mode

hypaware-core/plugins-workspace/claude/src/transcripts.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,7 @@ function transcriptEntryFromRow(row) {
449449
prompt_id: stringValue(row.promptId) ?? stringValue(row.prompt_id),
450450
provider_type: stringValue(row.type),
451451
provider_subtype: stringValue(row.subtype),
452+
model: transcriptModel(message),
452453
entrypoint: stringValue(row.entrypoint),
453454
client_version: stringValue(row.version) ?? stringValue(row.claude_version),
454455
user_type: stringValue(row.userType) ?? stringValue(row.user_type),
@@ -524,6 +525,18 @@ function readKey(obj, key) {
524525
return /** @type {Record<string, unknown>} */ (obj)[key]
525526
}
526527

528+
/**
529+
* The model id from an assistant transcript line's `message.model`. Claude
530+
* Code stamps `<synthetic>` on assistant lines it generates locally (interrupt
531+
* notices, injected errors) that never hit a model — that is a sentinel, not a
532+
* model id, so it is dropped to undefined.
533+
* @param {unknown} message
534+
*/
535+
function transcriptModel(message) {
536+
const model = stringValue(readKey(message, 'model'))
537+
return model === '<synthetic>' ? undefined : model
538+
}
539+
527540
/**
528541
* @param {unknown} value
529542
* @returns {value is Record<string, unknown>}

hypaware-core/plugins-workspace/claude/src/types.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ export interface TranscriptEntry {
3434
prompt_id: string | undefined
3535
provider_type: string | undefined
3636
provider_subtype: string | undefined
37+
/**
38+
* The model Claude Code stamps on each assistant transcript line. Backfill
39+
* surfaces it per message so the gateway `model` column matches live
40+
* capture, including sessions that switch models mid-stream. The
41+
* `<synthetic>` sentinel (locally-generated assistant lines that never hit a
42+
* model) is dropped to undefined.
43+
*/
44+
model: string | undefined
3745
entrypoint: string | undefined
3846
client_version: string | undefined
3947
user_type: string | undefined

test/plugins/claude-backfill.test.js

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,71 @@ test('assistant token usage is folded into attributes.usage like live capture',
365365
}
366366
})
367367

368+
test('assistant model is surfaced per message, switches mid-session, and drops <synthetic>', async () => {
369+
const env = await stageEnv()
370+
try {
371+
await writeTranscript(env, 'repo-a', 'sess-1', [
372+
{
373+
sessionId: 'sess-1',
374+
uuid: 'u-user-1',
375+
parentUuid: null,
376+
type: 'user',
377+
message: { role: 'user', content: 'hello' },
378+
timestamp: '2026-05-20T10:00:00.000Z',
379+
},
380+
{
381+
sessionId: 'sess-1',
382+
uuid: 'u-asst-1',
383+
parentUuid: 'u-user-1',
384+
type: 'assistant',
385+
// The model Claude Code stamps onto each assistant transcript line.
386+
message: { role: 'assistant', content: [{ type: 'text', text: 'hi' }], model: 'claude-opus-4-8' },
387+
timestamp: '2026-05-20T10:00:05.000Z',
388+
},
389+
{
390+
sessionId: 'sess-1',
391+
uuid: 'u-asst-2',
392+
parentUuid: 'u-asst-1',
393+
type: 'assistant',
394+
// A mid-session model switch must be preserved per message, not
395+
// collapsed to one value for the whole session.
396+
message: { role: 'assistant', content: [{ type: 'text', text: 'still here' }], model: 'claude-fable-5' },
397+
timestamp: '2026-05-20T10:00:06.000Z',
398+
},
399+
{
400+
sessionId: 'sess-1',
401+
uuid: 'u-asst-3',
402+
parentUuid: 'u-asst-2',
403+
type: 'assistant',
404+
// `<synthetic>` is a sentinel for locally-generated assistant lines
405+
// that never hit a model — it must not land in the model column.
406+
message: { role: 'assistant', content: [{ type: 'text', text: '[interrupted]' }], model: '<synthetic>' },
407+
timestamp: '2026-05-20T10:00:07.000Z',
408+
},
409+
])
410+
const provider = createClaudeBackfillProvider({ homeDir: env.homeDir, stateFile: env.stateFile })
411+
const [item] = await collectItems(provider.run(runContext().ctx))
412+
assert.ok(item)
413+
414+
const rows = await materialize(item)
415+
const asstRows = rowsByRole(rows, 'assistant')
416+
/** @param {string} text */
417+
const byText = (text) => {
418+
const row = asstRows.find((r) => r.content_text === text)
419+
assert.ok(row, `assistant row for "${text}" present`)
420+
return row
421+
}
422+
assert.equal(byText('hi').model, 'claude-opus-4-8')
423+
assert.equal(byText('still here').model, 'claude-fable-5')
424+
// The <synthetic> line records no model.
425+
assert.equal(byText('[interrupted]').model ?? null, null)
426+
// A user turn carries no model.
427+
assert.equal(rowsByRole(rows, 'user')[0].model ?? null, null)
428+
} finally {
429+
await env.cleanup()
430+
}
431+
})
432+
368433
test('token usage merges with subagent spawn provenance', async () => {
369434
const env = await stageEnv()
370435
try {

0 commit comments

Comments
 (0)