fix(host): price assistant replies and tool results by their content
The TS port passed the event ENVELOPE (data) instead of the model-visible message (data.message) for assistant/message and tool/result — every such node was priced at a flat 4 tokens (role framing only), so tool-heavy and assistant-rich sessions were massively undercounted in the Current composition. This is why the overview (20.4k) disagreed with the real usage. Mirrors dsh's deriveEventMessage exactly now; empty-content assistant messages price 0; tools total uses dsh's whole-array price; the overview additionally shows the last provider-reported actual prompt alongside the estimate.
This commit is contained in:
1 parent
72667cd5d3
commit
f8d66d08b2
4 files changed
+69
-11
No files matched your search
@@ -152,7 +152,7 @@ const requireStateful = (spec) => {
|
||||
const m2 = { exports: {} }
|
||||
const pluginExports2 = factory(requireStateful, m2, globalThis.window, fakeDoc)
|
||||
|
||||
const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'T{t} · step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'T{t} · {n} steps', 'detail.lastStep': 'last step' }
|
||||
const DICT_FOR_TEST = { 'tab': 'Context', 'loading': '…', 'error': 'x', 'detail.step': 'T{t} · step {s}', 'gran.step': 'Step', 'gran.turn': 'Turn', 'detail.turn': 'T{t} · {n} steps', 'detail.lastStep': 'last step', 'overview.actual': '· last actual prompt {n}' }
|
||||
let viewComponent = null
|
||||
const fakeCtx2 = {
|
||||
get: () => undefined,
|
||||
@@ -234,7 +234,7 @@ const snapshot = {
|
||||
{ seq: 1, turn: 1, step: 0, time: 1000, system: 10, tools: 20, user: 30, inject: 5, assistant: 15, tool: 20, total: 100, prompt: 95 },
|
||||
{ seq: 2, turn: 1, step: 1, time: 2000, system: 10, tools: 20, user: 25, inject: 5, assistant: 10, tool: 20, total: 90 },
|
||||
{ seq: 3, turn: 2, step: 0, time: 3000, system: 10, tools: 20, user: 40, inject: 5, assistant: 12, tool: 20, total: 107 },
|
||||
{ seq: 4, turn: 3, step: 0, time: 4000, system: 10, tools: 20, user: 20, inject: 5, assistant: 8, tool: 20, total: 83 },
|
||||
{ seq: 4, turn: 3, step: 0, time: 4000, system: 10, tools: 20, user: 20, inject: 5, assistant: 8, tool: 20, total: 83, prompt: 83000 },
|
||||
],
|
||||
events: [], nodes: [
|
||||
{ seq: 1, cat: 'user', tokens: 10, text: 'first message', time: 1000 },
|
||||
@@ -469,4 +469,9 @@ const fmtTimeLocal = (t) => {
|
||||
assert.equal(nodeTimes[0].args[2], fmtTimeLocal(65000), 'formatted time (65000ms)')
|
||||
assert.equal(nodeTimes[1].args[2], fmtTimeLocal(1000), 'formatted time (1000ms)')
|
||||
|
||||
console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history, right-anchored default, message times)')
|
||||
// ---- overview shows the provider-reported actual prompt of the last request ----
|
||||
const overviewNum = byClass(tr, 'lc-overview-num')[0]
|
||||
assert.ok(overviewNum, 'overview number row present')
|
||||
assert.match(textOf(overviewNum), /· last actual prompt 83\.0k/, 'overview shows the last actual prompt alongside the estimate')
|
||||
|
||||
console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history, right-anchored default, message times, overview actual)')
|
||||
@@ -77,6 +77,14 @@ assert.ok(v.events.some(e => e.kind === 'inject' && e.form === 'notice'), 'injec
|
||||
assert.ok(v.events.some(e => e.kind === 'compaction'), 'compaction event recorded')
|
||||
assert.ok(v.nodes.length >= 4, 'surface nodes folded')
|
||||
assert.equal(v.nodes[0].time, 2000, 'nodes carry their event timestamp')
|
||||
// Message projection must price CONTENT: assistant replies and tool results
|
||||
// carry their message body (data.message), not a flat envelope price.
|
||||
const asstNode = v.nodes.find(n => n.seq === 7)
|
||||
assert.equal(asstNode.tokens, 9, "assistant 'Hi!' prices its text content (1 + 4 + 4)")
|
||||
const toolNode = v.nodes.find(n => n.seq === 6)
|
||||
assert.equal(toolNode.tokens, 13, 'tool result prices its nested content (5 + 4 + 4)')
|
||||
assert.equal(toolNode.tool, 'bash', 'tool result node names its tool')
|
||||
assert.equal(asstNode.text, 'Hi!', 'assistant node carries its text preview')
|
||||
|
||||
// -- second snapshot: incremental (same count) must be served from cache --
|
||||
const before = await handler('snapshot', { sessionId: 's1' })
|
||||
@@ -89,6 +97,14 @@ assert.equal(after.ok, true)
|
||||
assert.equal(after.value.requests.length, 2, 'new request folded')
|
||||
assert.equal(after.value.requests[1].turn, 2)
|
||||
|
||||
// -- an empty-content assistant message (usage-only step) prices 0 tokens --
|
||||
live.events.push({ seq: 10, type: 'assistant/message', time: 8000, data: { turn: 2, step: 2, message: { content: [] } } })
|
||||
const emptyMsg = await handler('snapshot', { sessionId: 's1' })
|
||||
assert.equal(emptyMsg.ok, true)
|
||||
const emptyNode = emptyMsg.value.nodes.find(n => n.seq === 10)
|
||||
assert.ok(emptyNode, 'usage-only message is still a surface node')
|
||||
assert.equal(emptyNode.tokens, 0, 'empty assistant message prices 0, like dsh deriveEventMessage')
|
||||
|
||||
// -- error paths --
|
||||
const bad = await handler('snapshot', {})
|
||||
assert.equal(bad.ok, false)
|
||||
|
||||
+11
-1
@@ -145,6 +145,7 @@ const DICT_ZH: Record<string, string> = {
|
||||
'overview.title': '当前构成',
|
||||
'overview.ofWindow': 'tokens(约 {p}%)',
|
||||
'overview.estimate': 'tokens(估算)',
|
||||
'overview.actual': '· 上次实际 prompt {n}',
|
||||
'tools.top': '工具定义 Top:',
|
||||
'tools.more': '等 {n} 个',
|
||||
'trend.title': '历史趋势',
|
||||
@@ -190,6 +191,7 @@ const DICT_EN: Record<string, string> = {
|
||||
'overview.title': 'Current composition',
|
||||
'overview.ofWindow': 'tokens (~{p}%)',
|
||||
'overview.estimate': 'tokens (estimated)',
|
||||
'overview.actual': '· last actual prompt {n}',
|
||||
'tools.top': 'Top tool schemas:',
|
||||
'tools.more': 'of {n}',
|
||||
'trend.title': 'History',
|
||||
@@ -721,7 +723,15 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re
|
||||
h('b', null, fmt(current.total)),
|
||||
h('span', null, data.contextWindow
|
||||
? ' / ' + fmt(data.contextWindow) + ' ' + tr('overview.ofWindow', { p: windowPct ?? 0 })
|
||||
: ' ' + t('overview.estimate'))),
|
||||
: ' ' + t('overview.estimate')),
|
||||
// The provider-reported prompt of the last request is the best
|
||||
// ground truth for what the model actually received; the fixed
|
||||
// density heuristic can undercount CJK-heavy content, so show the
|
||||
// real number alongside the estimate.
|
||||
displayRequests.length > 0 && displayRequests[displayRequests.length - 1].prompt !== undefined
|
||||
? h('span', { className: 'lc-actual' },
|
||||
tr('overview.actual', { n: fmt(displayRequests[displayRequests.length - 1].prompt ?? 0) }))
|
||||
: null),
|
||||
h(StackedBar, { parts: partsOf(current), height: 16, max: data.contextWindow, hoverKey: hoverCat, onHoverKey: setHoverCat }),
|
||||
h(Legend, { parts: partsOf(current), hoverKey: hoverCat, onHoverKey: setHoverCat }),
|
||||
(data.toolList && data.toolList.length > 0) ? h('div', { className: 'lc-tools' },
|
||||
|
||||
+34
-7
@@ -198,7 +198,16 @@ function estimateBlocks(blocks: ContentBlock[] | undefined): number {
|
||||
return tokens
|
||||
}
|
||||
|
||||
function estimateMessage(message: { content?: ContentBlock[] } | undefined | null): number {
|
||||
/**
|
||||
* Price one surface message exactly like dsh's token-meter estimate:
|
||||
* an empty-content assistant/message projects to NO message (it only hosts
|
||||
* usage), so it prices 0; every other message pays content + role framing.
|
||||
*/
|
||||
function estimateMessage(message: { content?: ContentBlock[] } | undefined | null, emptyIsZero = false): number {
|
||||
if (emptyIsZero && (message === null || message === undefined
|
||||
|| !Array.isArray(message.content) || message.content.length === 0)) {
|
||||
return 0
|
||||
}
|
||||
return estimateBlocks(message?.content) + ROLE_OVERHEAD
|
||||
}
|
||||
|
||||
@@ -207,6 +216,7 @@ function estimateSystem(text: unknown): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD
|
||||
}
|
||||
|
||||
/** Per-tool price for the top-tools display (the total uses dsh's whole-array price). */
|
||||
function estimateToolSchema(tool: unknown): number {
|
||||
return Math.ceil(JSON.stringify(tool).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
|
||||
}
|
||||
@@ -321,7 +331,14 @@ function applySurface(
|
||||
message: MessageLike | undefined,
|
||||
): SurfaceNode {
|
||||
const cat = categoryOf(type, message)
|
||||
const node: SurfaceNode = { seq: ev.seq, time: ev.time, cat, tokens: estimateMessage(message) }
|
||||
const node: SurfaceNode = {
|
||||
seq: ev.seq,
|
||||
time: ev.time,
|
||||
cat,
|
||||
// Empty assistant messages project to no model message (usage-only), so
|
||||
// they price 0 — mirroring dsh's deriveEventMessage/estimate.
|
||||
tokens: estimateMessage(message, type === 'assistant/message'),
|
||||
}
|
||||
const source = message?.source
|
||||
const form = source?.form
|
||||
if (typeof form === 'string') node.form = form
|
||||
@@ -389,8 +406,11 @@ function foldInto(st: FoldState, events: readonly SessionEvent[]): void {
|
||||
name: typeof (t as { name?: unknown }).name === 'string' ? (t as { name: string }).name : '?',
|
||||
tokens: estimateToolSchema(t),
|
||||
}))
|
||||
st.toolsTokens = st.toolList.reduce((a, t) => a + t.tokens, 0)
|
||||
if (tools.length > 0) st.toolsTokens += BLOCK_OVERHEAD
|
||||
// The tools TOTAL uses dsh's whole-array price (one JSON string of
|
||||
// every schema); per-tool prices above are display-only rankings.
|
||||
st.toolsTokens = tools.length > 0
|
||||
? Math.ceil(JSON.stringify(tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
|
||||
: 0
|
||||
st.systemTokens = estimateSystem(header.system)
|
||||
if (header.config && typeof header.config.model === 'string') st.model = header.config.model
|
||||
if (header.config && typeof header.config.provider === 'string') st.provider = header.config.provider
|
||||
@@ -426,9 +446,13 @@ function foldInto(st: FoldState, events: readonly SessionEvent[]): void {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool/result':
|
||||
applySurface(st, ev, ev.type, data, data as unknown as MessageLike)
|
||||
case 'tool/result': {
|
||||
// The model-visible message is data.message (the envelope also
|
||||
// carries callId/error); pricing the envelope would miss all content.
|
||||
const toolMsg = (data?.message ?? null) as MessageLike | undefined
|
||||
applySurface(st, ev, ev.type, data, toolMsg)
|
||||
break
|
||||
}
|
||||
case 'assistant/message': {
|
||||
// Snapshot the request exactly as dispatched: current surface + header,
|
||||
// before this response joins the surface.
|
||||
@@ -451,7 +475,10 @@ function foldInto(st: FoldState, events: readonly SessionEvent[]): void {
|
||||
if (typeof usage.outputTokens === 'number') record.output = usage.outputTokens
|
||||
}
|
||||
st.requests.push(record)
|
||||
applySurface(st, ev, ev.type, data, data as unknown as MessageLike)
|
||||
// The model-visible message is data.message (mirrors dsh's
|
||||
// deriveEventMessage); the envelope also carries turn/step/usage.
|
||||
const asstMsg = (data?.message ?? null) as MessageLike | undefined
|
||||
applySurface(st, ev, ev.type, data, asstMsg)
|
||||
break
|
||||
}
|
||||
case 'compaction/summary':
|
||||
|
||||
Reference in new issue
Block a user