Skip to content

Commit 79c5435

Browse files
committed
fix(billing): count a resumed turn's journaled spend toward the usage limit once
- A turn an earlier process journaled adds its model spend to the run's unrecorded total the first time this run opens it; reopening the turn (fallback model, block retry) never counts it again - Type the continued-call test's tool as a ProviderToolConfig
1 parent 9d7aa68 commit 79c5435

5 files changed

Lines changed: 74 additions & 3 deletions

File tree

‎apps/sim/executor/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,8 @@ export interface ExecutionContext {
680680
export interface UnrecordedModelCost {
681681
total: number
682682
readDeferredUntil?: number
683+
/** Agent turns whose journaled spend `total` already holds. */
684+
countedTurnIds?: Set<string>
683685
}
684686

685687
/**

‎apps/sim/lib/memory/conversation-types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export interface AgentConversationSession {
9191
getFinalResponse(): AgentTurnState['final']
9292
getFinalAssistantContent(): string | undefined
9393
readonly memoryId?: string
94+
readonly turnId?: string
9495
getUsage(): ConversationUsageTotal
9596
recordContextUsage?(usage: ConversationUsageTotal): Promise<void>
9697
captureStep(step: CapturedConversationStep): Promise<void>

‎apps/sim/providers/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import {
3737
} from '@/providers/file-attachments.server'
3838
import { isEvaluationModel, isKnownModelId, supportsTemperature } from '@/providers/models'
3939
import { getProviderExecutor } from '@/providers/registry'
40-
import { addSettledCost } from '@/providers/run-usage-limit'
40+
import { addSettledCost, recordJournaledModelCost } from '@/providers/run-usage-limit'
4141
import {
4242
type ProviderRuntimeContext,
4343
runWithProviderRuntimeContext,
@@ -358,6 +358,7 @@ export async function executeProviderRequest(
358358
priorConversationUsage = session.getUsage()
359359
// A stop bills what success would: this journaled usage plus the request's own steps.
360360
addSettledCost(settledCost, priorConversationUsage.cost)
361+
recordJournaledModelCost(requestRuntimeContext, session.turnId, priorConversationUsage)
361362
bindConversationGenerationCompactor(
362363
modelSafeRequest,
363364
createAgentConversationCompactor(

‎apps/sim/providers/run-usage-limit.test.ts‎

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { resetMidRunUsageCaches } from '@/lib/billing/core/mid-run-usage'
3939
import { NEAR_LIMIT_USAGE_GATE_TTL_MS } from '@/lib/billing/core/usage-gate-cache'
4040
import { markFailureKind } from '@/lib/core/errors/failure-log'
4141
import { isNonRetryableExecutionError } from '@/lib/execution/non-retryable-error'
42+
import { AgentTurnSession } from '@/lib/memory/agent-turn-session'
4243
import { AgentTurnStateMachine } from '@/lib/memory/turn-state'
4344
import type { ExecutionContext } from '@/executor/types'
4445
import { readTrustedExecutionCost } from '@/executor/utils/errors'
@@ -55,7 +56,7 @@ import {
5556
} from '@/providers/conversation-history'
5657
import { executeProviderTool } from '@/providers/runtime-context'
5758
import { providerFailureLogLevel } from '@/providers/streaming-tool-loop-shared'
58-
import { ProviderError, type ProviderRequest } from '@/providers/types'
59+
import { ProviderError, type ProviderRequest, type ProviderToolConfig } from '@/providers/types'
5960

6061
const { mockCheckExecutionUsageLimits } = billingUsageGateCacheMockFns
6162
const { mockExecuteTool } = toolsMockFns
@@ -203,8 +204,54 @@ describe('run usage limit', () => {
203204
expect(readTrustedExecutionCost(failure)).toEqual({ input: 1, output: 1, total: 2 })
204205
})
205206

207+
it("counts a resumed turn's journaled spend toward the limit once, however often the run reopens it", async () => {
208+
mockCheckExecutionUsageLimits.mockResolvedValue({
209+
isExceeded: false,
210+
payerUsage: { currentUsage: 1, limit: 5 },
211+
})
212+
const reopenTurn = () =>
213+
new AgentTurnSession(
214+
'turn-1',
215+
{ save: vi.fn() },
216+
{
217+
version: 1,
218+
steps: [
219+
{
220+
id: 'earlier-process-step',
221+
assistant: { role: 'assistant', content: 'Partial answer' },
222+
calls: [],
223+
results: [],
224+
usage: { input: 1, output: 1 },
225+
cost: { input: 1, output: 1, total: 2 },
226+
},
227+
],
228+
}
229+
)
230+
const run = startRun()
231+
mockExecuteRequest.mockImplementation(async (request: ProviderRequest) => {
232+
await prepareConversationGeneration(request, 'anthropic', { messages: request.messages })
233+
throw new Error('Model call sent')
234+
})
235+
const request = { model: 'model-a', messages: [{ role: 'user' as const, content: 'Continue' }] }
236+
237+
const attempt = () =>
238+
executeProviderRequest('anthropic', request, {
239+
executionContext: run,
240+
agentConversation: reopenTurn(),
241+
})
242+
243+
await expect(attempt()).rejects.toThrow('Model call sent')
244+
await expect(attempt()).rejects.toThrow('Model call sent')
245+
expect(run.unrecordedModelCost?.total).toBe(2)
246+
})
247+
206248
it('bills a retried pending tool call once, through the journal that records it', async () => {
207-
const tool = { id: 'search', name: 'search', description: 'search', params: {} }
249+
const tool: ProviderToolConfig = {
250+
id: 'search',
251+
description: 'search',
252+
params: {},
253+
parameters: { type: 'object', properties: {}, required: [] },
254+
}
208255
const session = new AgentTurnStateMachine(
209256
{ save: vi.fn() },
210257
{

‎apps/sim/providers/run-usage-limit.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isPaid } from '@/lib/billing/plan-helpers'
44
import { resolveUsageUpgradePayload } from '@/lib/billing/usage-upgrade'
55
import { markFailureKind } from '@/lib/core/errors/failure-log'
66
import { NonRetryableExecutionError } from '@/lib/execution/non-retryable-error'
7+
import type { ConversationUsageTotal } from '@/lib/memory/conversation-types'
78
import { attachTrustedExecutionCost } from '@/executor/utils/errors'
89
import type { ModelCost } from '@/providers/cost-policy'
910
import type { TokenUsageCost } from '@/providers/pricing'
@@ -26,6 +27,25 @@ export function addSettledCost(settledCost: TokenUsageCost, cost: TokenUsageCost
2627
settledCost.total += cost.total
2728
}
2829

30+
/**
31+
* Adds a resumed Agent turn's journaled model spend to its run's unrecorded total, once per turn.
32+
* Steps an earlier attempt journaled in another process were never counted by this run, while a
33+
* turn this run already opened (a fallback model, a block retry) holds steps it counted as they
34+
* settled. Journaled tool costs are left out, as settled ones are.
35+
*/
36+
export function recordJournaledModelCost(
37+
runtime: ProviderRuntimeContext,
38+
turnId: string | undefined,
39+
usage: ConversationUsageTotal
40+
): void {
41+
const unrecorded = runtime.executionContext?.unrecordedModelCost
42+
if (!unrecorded || !turnId) return
43+
unrecorded.countedTurnIds ??= new Set()
44+
if (unrecorded.countedTurnIds.has(turnId)) return
45+
unrecorded.countedTurnIds.add(turnId)
46+
unrecorded.total += usage.cost.total - usage.cost.toolCost
47+
}
48+
2949
/**
3050
* Adds a settled model step's billed cost to its request's and its run's running totals. Tool
3151
* costs reach only the request's: a workflow tool's child already spends against the run's.

0 commit comments

Comments
 (0)