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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-10 10:47:45 +08:00
parent 065a80580b
commit c45406054b
5 changed files with 80 additions and 4 deletions

View File

@ -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(

View File

@ -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()
})
})

View File

@ -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<NonNullableUsage> = {
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<BetaUsage> | 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,
}
}

View File

@ -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'

View File

@ -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<BetaMessage> {
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', {