fix: retain every direct-model usage call

Multiple model invocations in one task could share a call identifier when
a provider omitted or reused its response ID. Database upserts then replaced
earlier tool-round or summary usage, causing cumulative activity totals to
undercount successful calls.

Each completed direct-model invocation now receives a unique local call ID
while retaining the provider ID as diagnostic context. Tool rounds, final
responses, image calls, and repeated compression summaries are therefore
stored independently.

Release note: 修复连续工具调用或上下文摘要可能覆盖前序模型用量的问题;现在每次成功调用都会分别计入运行记录和累计统计。
This commit is contained in:
mesalogo
2026-08-16 01:18:07 +08:00
parent c98fe67f1a
commit 9e6f664e06
2 changed files with 59 additions and 8 deletions
+55 -7
View File
@@ -363,7 +363,9 @@ describe('ModelAgentRuntime', () => {
{ {
requestId: 'a431666e-5ec8-45e6-beb4-654132eed125', requestId: 'a431666e-5ec8-45e6-beb4-654132eed125',
type: 'model-usage', type: 'model-usage',
callId: 'message-1', callId: expect.stringMatching(
/^model-call:[0-9a-f]{32}:message-1$/u
),
runtime: 'model', runtime: 'model',
provider: 'anthropic', provider: 'anthropic',
model: 'claude-sonnet-provider', model: 'claude-sonnet-provider',
@@ -492,7 +494,9 @@ describe('ModelAgentRuntime', () => {
expect(events).toContainEqual( expect(events).toContainEqual(
expect.objectContaining({ expect.objectContaining({
type: 'model-usage', type: 'model-usage',
callId: 'context-summary:message-1' callId: expect.stringMatching(
/^context-summary:model-call:[0-9a-f]{32}:message-1$/u
)
}) })
) )
expect(events).toContainEqual( expect(events).toContainEqual(
@@ -1416,7 +1420,9 @@ describe('ModelAgentRuntime', () => {
{ {
requestId: 'a431666e-5ec8-45e6-beb4-654132eed127', requestId: 'a431666e-5ec8-45e6-beb4-654132eed127',
type: 'model-usage', type: 'model-usage',
callId: 'chatcmpl-provider-1', callId: expect.stringMatching(
/^model-call:[0-9a-f]{32}:chatcmpl-provider-1$/u
),
runtime: 'model', runtime: 'model',
provider: 'openai', provider: 'openai',
model: 'qwen3-provider', model: 'qwen3-provider',
@@ -1613,7 +1619,9 @@ describe('ModelAgentRuntime', () => {
{ {
requestId: 'a431666e-5ec8-45e6-beb4-654132eed133', requestId: 'a431666e-5ec8-45e6-beb4-654132eed133',
type: 'model-usage', type: 'model-usage',
callId: 'resp-provider-1', callId: expect.stringMatching(
/^model-call:[0-9a-f]{32}:resp-provider-1$/u
),
runtime: 'model', runtime: 'model',
provider: 'openai', provider: 'openai',
model: 'gpt-5-provider', model: 'gpt-5-provider',
@@ -1660,7 +1668,6 @@ describe('ModelAgentRuntime', () => {
it('runs approved direct-model tools and returns their results to OpenAI', async () => { it('runs approved direct-model tools and returns their results to OpenAI', async () => {
const responses = [ const responses = [
{ {
id: 'chatcmpl-tool-1',
model: 'qwen3', model: 'qwen3',
choices: [ choices: [
{ {
@@ -1683,7 +1690,6 @@ describe('ModelAgentRuntime', () => {
usage: { prompt_tokens: 10, completion_tokens: 4 } usage: { prompt_tokens: 10, completion_tokens: 4 }
}, },
{ {
id: 'chatcmpl-tool-2',
model: 'qwen3', model: 'qwen3',
choices: [ choices: [
{ {
@@ -1817,6 +1823,27 @@ describe('ModelAgentRuntime', () => {
delta: '文件内容已读取。' delta: '文件内容已读取。'
}) })
) )
const usageEvents = events.filter(
(event) => event.type === 'model-usage'
)
expect(usageEvents).toHaveLength(2)
expect(
usageEvents.map((event) => ({
inputTokens: event.inputTokens,
outputTokens: event.outputTokens
}))
).toEqual([
{ inputTokens: 10, outputTokens: 4 },
{ inputTokens: 18, outputTokens: 7 }
])
expect(
usageEvents.every((event) =>
/^model-call:[0-9a-f]{32}$/u.test(event.callId)
)
).toBe(true)
expect(new Set(usageEvents.map((event) => event.callId)).size).toBe(
2
)
expect(events.at(-1)).toMatchObject({ type: 'done' }) expect(events.at(-1)).toMatchObject({ type: 'done' })
await runtime.dispose() await runtime.dispose()
expect(toolProvider.dispose).toHaveBeenCalledOnce() expect(toolProvider.dispose).toHaveBeenCalledOnce()
@@ -2017,6 +2044,25 @@ describe('ModelAgentRuntime', () => {
expect( expect(
contextMetrics.every((event) => event.source === 'provider') contextMetrics.every((event) => event.source === 'provider')
).toBe(true) ).toBe(true)
const agentSummaryUsageEvents = events.flatMap((event) =>
event.type === 'model-usage' &&
event.callId.startsWith('agent-context-summary:')
? [event]
: []
)
expect(agentSummaryUsageEvents).toHaveLength(2)
expect(
agentSummaryUsageEvents.every((event) =>
/^agent-context-summary:model-call:[0-9a-f]{32}:message-1$/u.test(
event.callId
)
)
).toBe(true)
expect(
new Set(
agentSummaryUsageEvents.map((event) => event.callId)
).size
).toBe(2)
expect(events.at(-1)).toMatchObject({ type: 'done' }) expect(events.at(-1)).toMatchObject({ type: 'done' })
}) })
@@ -4271,7 +4317,9 @@ describe('ModelAgentRuntime', () => {
{ {
requestId: 'a431666e-5ec8-45e6-beb4-654132eed128', requestId: 'a431666e-5ec8-45e6-beb4-654132eed128',
type: 'model-usage', type: 'model-usage',
callId: 'image-provider-1', callId: expect.stringMatching(
/^model-call:[0-9a-f]{32}:image-provider-1$/u
),
runtime: 'model', runtime: 'model',
provider: 'openai', provider: 'openai',
model: 'gpt-image-provider', model: 'gpt-image-provider',
+4 -1
View File
@@ -483,10 +483,13 @@ function createUsageEvent(
if (!usage.reported) { if (!usage.reported) {
return undefined return undefined
} }
const localCallId = `model-call:${randomBytes(16).toString('hex')}`
return { return {
requestId, requestId,
type: 'model-usage', type: 'model-usage',
callId: (usage.callId ?? requestId).slice(0, 256), callId: usage.callId
? `${localCallId}:${usage.callId}`.slice(0, 256)
: localCallId,
runtime: 'model', runtime: 'model',
provider, provider,
model: (usage.model ?? fallbackModel).slice(0, 500), model: (usage.model ?? fallbackModel).slice(0, 500),