fix: report AI usage before tools run and price self usage consistently

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-08-18 17:01:10 +02:00
parent 9dbb5e237d
commit ea78c51174
6 changed files with 36 additions and 12 deletions
@@ -10,8 +10,7 @@
import UserInfoSettings from './settings/UserInfoSettings.svelte'
import AIUserSettings from './settings/AIUserSettings.svelte'
import AiUsagePanel from './workspaceSettings/AiUsagePanel.svelte'
import { copilotInfo } from '$lib/aiStore'
import { workspaceStore } from '$lib/stores'
import { copilotInfo, copilotWorkspace } from '$lib/aiStore'
import {
getDarkModeVariant,
setDarkModeVariant,
@@ -108,9 +107,13 @@
<AIUserSettings />
</div>
</div>
{#if $workspaceStore}
<!-- Keyed on the workspace `copilotInfo` reflects, not on `$workspaceStore`:
a session acting on another workspace loads that workspace's AI config
while navigation stays put, and pricing usage from one workspace with
another's rates would silently misstate it. -->
{#if $copilotWorkspace}
<AiUsagePanel
workspace={$workspaceStore}
workspace={$copilotWorkspace}
modelPricing={$copilotInfo.modelPricing ?? {}}
scope="self"
/>
@@ -193,7 +193,7 @@ export async function parseAnthropicCompletion(
tools: Tool<any>[],
helpers: any,
abortController?: AbortController,
options?: { workspace?: string }
options?: { workspace?: string; onTokenUsage?: (usage: ChatTokenUsage) => void }
): Promise<ParsedCompletionResult> {
let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = []
let error = null
@@ -415,6 +415,7 @@ export async function parseAnthropicCompletion(
const finalMessage = await completion.finalMessage()
const tokenUsage = anthropicUsageToChatTokenUsage(finalMessage.usage)
options?.onTokenUsage?.(tokenUsage)
// Process tool calls if any
if (toolCallsToProcess.length > 0) {
@@ -338,11 +338,18 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
// actually served it rather than to whatever is selected when the loop ends.
let iterationModel: ReasoningProviderModel | undefined
const trackUsage = (usage: ChatTokenUsage | null | undefined) => {
tokenUsage = addChatTokenUsage(tokenUsage, usage)
// Reported as the provider's usage arrives, not when the parser returns: a parser
// waits on tool execution, which can wait on a person, and a tab closed in that
// gap would drop a response that was already billed. Accounting for the turn's
// own totals stays on the return path, where every parser reports uniformly.
const reportUsage = (usage: ChatTokenUsage | null | undefined) => {
if (usage && iterationModel) {
config.onUsage?.(usage, iterationModel)
}
}
const trackUsage = (usage: ChatTokenUsage | null | undefined) => {
tokenUsage = addChatTokenUsage(tokenUsage, usage)
// Some providers/paths report no usage (prompt 0); keep the last real one.
if (usage && usage.prompt > 0) {
lastIterationUsage = usage
@@ -399,7 +406,14 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
...(pendingUserMessage ? [pendingUserMessage] : [])
]
const toolDefs = tools.map((t) => t.def)
const parseOptions = { workspace, provider: modelProvider.provider }
// Report each response as its usage arrives rather than after the parser
// returns: a parser waits on tool execution, which can wait on a person, and
// a tab closed in that gap would drop a response the provider already billed.
const parseOptions = {
workspace,
provider: modelProvider.provider,
onTokenUsage: reportUsage
}
if (isOpenAI) {
const reasoningSummaryCacheKey = getReasoningSummaryCacheKey(workspace, modelProvider)
@@ -392,7 +392,7 @@ export async function parseOpenAIResponsesCompletion(
addedMessages: ChatCompletionMessageParam[],
tools: Tool<any>[],
helpers: any,
options?: { workspace?: string }
options?: { workspace?: string; onTokenUsage?: (usage: ChatTokenUsage) => void }
): Promise<ParsedCompletionResult> {
let toolCallsToProcess: ChatCompletionMessageFunctionToolCall[] = []
let error: OpenAIError | ResponseErrorEvent | null = null
@@ -566,6 +566,7 @@ export async function parseOpenAIResponsesCompletion(
const finalResponse = await runner.finalResponse()
const tokenUsage = openAIResponsesUsageToChatTokenUsage(finalResponse.usage)
options?.onTokenUsage?.(tokenUsage)
for (const item of finalResponse.output ?? []) {
if (item.type === 'web_search_call' && !surfacedWebSearchCalls.has(item.id)) {
+6 -1
View File
@@ -1162,7 +1162,11 @@ export async function parseOpenAICompletion(
tools: Tool<any>[],
helpers: any,
_abortController?: AbortController, // unused, for signature compatibility with parseAnthropicCompletion
options?: { workspace?: string; provider?: string }
options?: {
workspace?: string
provider?: string
onTokenUsage?: (usage: ChatTokenUsage) => void
}
): Promise<{ shouldContinue: boolean; tokenUsage: ChatTokenUsage }> {
const finalToolCalls: Record<number, ChatCompletionChunk.Choice.Delta.ToolCall> = {}
// The tool call currently receiving argument deltas; when the stream moves on
@@ -1312,6 +1316,7 @@ export async function parseOpenAICompletion(
callbacks.onMessageEnd()
options?.onTokenUsage?.(tokenUsage)
// Stream over: every parsed call is queued until its turn in processToolCall.
for (const toolCall of Object.values(finalToolCalls)) {
if (toolCall.id) {
@@ -96,8 +96,8 @@ const MODEL_PRICES: [name: string, price: PriceEntry | null][] = [
// one, so the write rate never applies. The -mini/-nano entries must precede
// the family entry, which would otherwise claim them.
// Revisions past gpt-5 are priced separately by OpenAI and are not tracked here.
// `strictVariants` does not cover these: a version bump is digits, not a name
// segment, so `gpt-5-6` would otherwise still match `gpt-5`.
// The matcher's revision guard already keeps them off the family rate; these
// entries stay so a revision the guard admits still resolves to no rate.
['gpt-5.6', null],
['gpt-5.5', null],
['gpt-5.4', null],