From c45406054baf8f5acf2b64974f456a1c9e537236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 10 May 2026 10:47:45 +0800 Subject: [PATCH] fix: tolerate missing provider usage in fallback responses Some Anthropic-compatible providers can return successful responses with missing or partial usage metadata, especially after falling back to non-streaming requests. Normalize usage at the provider response boundary so transcript writes, side queries, and cost tracking continue without treating token accounting as required response content. Constraint: Third-party Anthropic-compatible providers do not always return complete usage fields. Rejected: Add provider-specific handling | this is a protocol compatibility issue across proxies and gateways Confidence: high Scope-risk: moderate Directive: Keep provider response normalization vendor-neutral; do not branch on provider names for missing usage fields. Tested: bun test src/services/api/emptyUsage.test.ts src/utils/__tests__/thinking.test.ts src/server/__tests__/title-service.test.ts src/server/__tests__/conversations.test.ts Tested: bun run check:server Tested: bun run check:persistence-upgrade Tested: git diff --check Not-tested: Live Shengsuan Cloud provider replay --- src/services/api/claude.ts | 8 ++++-- src/services/api/emptyUsage.test.ts | 30 ++++++++++++++++++++++ src/services/api/emptyUsage.ts | 40 +++++++++++++++++++++++++++++ src/services/api/logging.ts | 4 +-- src/utils/sideQuery.ts | 2 ++ 5 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 src/services/api/emptyUsage.test.ts 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', {