diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index c71d0103..93ae1502 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -243,6 +243,7 @@ import { logAPIError, logAPIQuery, logAPISuccessAndDuration, + normalizeUsage, type NonNullableUsage, } from "./logging.js"; import { @@ -2635,6 +2636,7 @@ async function* queryModel( const m: AssistantMessage = { message: { ...result, + usage: normalizeUsage(result.usage), content: normalizeContentFromAPI( result.content, tools, @@ -2732,6 +2734,7 @@ async function* queryModel( const m: AssistantMessage = { message: { ...result, + usage: normalizeUsage(result.usage), content: normalizeContentFromAPI( result.content, tools, @@ -2882,8 +2885,9 @@ async function* queryModel( // message_delta handler before any yield. Fallback pushes to newMessages // then yields, so tracking must be here to survive .return() at the yield. if (fallbackMessage) { - const fallbackUsage = fallbackMessage.message.usage; - usage = updateUsage(EMPTY_USAGE, fallbackUsage); + const fallbackUsage = normalizeUsage(fallbackMessage.message.usage); + fallbackMessage.message.usage = fallbackUsage; + usage = fallbackUsage; stopReason = fallbackMessage.message.stop_reason; const fallbackCost = calculateUSDCost(resolvedModel, fallbackUsage); costUSD += addToTotalSessionCost( diff --git a/src/services/api/emptyUsage.test.ts b/src/services/api/emptyUsage.test.ts new file mode 100644 index 00000000..144548ad --- /dev/null +++ b/src/services/api/emptyUsage.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test' +import { calculateUSDCost } from '../../utils/modelCost.js' +import { EMPTY_USAGE, normalizeUsage } from './emptyUsage.js' + +describe('normalizeUsage', () => { + test('fills missing provider usage with zero-valued usage', () => { + expect(normalizeUsage(undefined)).toEqual(EMPTY_USAGE) + }) + + test('preserves reported token fields while defaulting missing nested fields', () => { + expect( + normalizeUsage({ + input_tokens: 12, + output_tokens: 5, + cache_read_input_tokens: 3, + }), + ).toEqual({ + ...EMPTY_USAGE, + input_tokens: 12, + output_tokens: 5, + cache_read_input_tokens: 3, + }) + }) + + test('keeps cost calculation safe for provider responses without usage', () => { + expect(() => + calculateUSDCost('anthropic/claude-opus-4.7', normalizeUsage(undefined)), + ).not.toThrow() + }) +}) diff --git a/src/services/api/emptyUsage.ts b/src/services/api/emptyUsage.ts index ad8c25ff..a1b16a3b 100644 --- a/src/services/api/emptyUsage.ts +++ b/src/services/api/emptyUsage.ts @@ -1,3 +1,4 @@ +import type { BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import type { NonNullableUsage } from '../../entrypoints/sdk/sdkUtilityTypes.js' /** @@ -20,3 +21,42 @@ export const EMPTY_USAGE: Readonly = { iterations: [], speed: 'standard', } + +/** + * Some Anthropic-compatible providers omit usage in successful fallback + * responses. Normalize at the provider boundary so transcript writes and cost + * tracking can stay total-order safe instead of crashing on missing token fields. + */ +export function normalizeUsage( + usage: Partial | null | undefined, +): NonNullableUsage { + return { + input_tokens: usage?.input_tokens ?? EMPTY_USAGE.input_tokens, + cache_creation_input_tokens: + usage?.cache_creation_input_tokens ?? + EMPTY_USAGE.cache_creation_input_tokens, + cache_read_input_tokens: + usage?.cache_read_input_tokens ?? EMPTY_USAGE.cache_read_input_tokens, + output_tokens: usage?.output_tokens ?? EMPTY_USAGE.output_tokens, + server_tool_use: { + web_search_requests: + usage?.server_tool_use?.web_search_requests ?? + EMPTY_USAGE.server_tool_use.web_search_requests, + web_fetch_requests: + usage?.server_tool_use?.web_fetch_requests ?? + EMPTY_USAGE.server_tool_use.web_fetch_requests, + }, + service_tier: usage?.service_tier ?? EMPTY_USAGE.service_tier, + cache_creation: { + ephemeral_1h_input_tokens: + usage?.cache_creation?.ephemeral_1h_input_tokens ?? + EMPTY_USAGE.cache_creation.ephemeral_1h_input_tokens, + ephemeral_5m_input_tokens: + usage?.cache_creation?.ephemeral_5m_input_tokens ?? + EMPTY_USAGE.cache_creation.ephemeral_5m_input_tokens, + }, + inference_geo: usage?.inference_geo ?? EMPTY_USAGE.inference_geo, + iterations: usage?.iterations ?? EMPTY_USAGE.iterations, + speed: usage?.speed ?? EMPTY_USAGE.speed, + } +} diff --git a/src/services/api/logging.ts b/src/services/api/logging.ts index a411c12c..769d4b9d 100644 --- a/src/services/api/logging.ts +++ b/src/services/api/logging.ts @@ -35,12 +35,12 @@ import { logEvent, } from '../analytics/index.js' import { sanitizeToolNameForAnalytics } from '../analytics/metadata.js' -import { EMPTY_USAGE } from './emptyUsage.js' +import { EMPTY_USAGE, normalizeUsage } from './emptyUsage.js' import { classifyAPIError } from './errors.js' import { extractConnectionErrorDetails } from './errorUtils.js' export type { NonNullableUsage } -export { EMPTY_USAGE } +export { EMPTY_USAGE, normalizeUsage } // Strategy used for global prompt caching export type GlobalCacheStrategy = 'tool_based' | 'system_prompt' | 'none' diff --git a/src/utils/sideQuery.ts b/src/utils/sideQuery.ts index e01e5b26..4c560286 100644 --- a/src/utils/sideQuery.ts +++ b/src/utils/sideQuery.ts @@ -14,6 +14,7 @@ import { logEvent } from '../services/analytics/index.js' import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../services/analytics/metadata.js' import { getAPIMetadata } from '../services/api/claude.js' import { getAnthropicClient } from '../services/api/client.js' +import { normalizeUsage } from '../services/api/emptyUsage.js' import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js' import { computeFingerprint } from './fingerprint.js' import { normalizeModelStringForAPI } from './model/model.js' @@ -192,6 +193,7 @@ export async function sideQuery(opts: SideQueryOptions): Promise { const requestId = (response as { _request_id?: string | null })._request_id ?? undefined + response.usage = normalizeUsage(response.usage) const now = Date.now() const lastCompletion = getLastApiCompletionTimestamp() logEvent('tengu_api_success', {