mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
065a80580b
commit
c45406054b
@ -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(
|
||||
|
||||
30
src/services/api/emptyUsage.test.ts
Normal file
30
src/services/api/emptyUsage.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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', {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user