mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-25 14:53:32 +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,
|
logAPIError,
|
||||||
logAPIQuery,
|
logAPIQuery,
|
||||||
logAPISuccessAndDuration,
|
logAPISuccessAndDuration,
|
||||||
|
normalizeUsage,
|
||||||
type NonNullableUsage,
|
type NonNullableUsage,
|
||||||
} from "./logging.js";
|
} from "./logging.js";
|
||||||
import {
|
import {
|
||||||
@ -2635,6 +2636,7 @@ async function* queryModel(
|
|||||||
const m: AssistantMessage = {
|
const m: AssistantMessage = {
|
||||||
message: {
|
message: {
|
||||||
...result,
|
...result,
|
||||||
|
usage: normalizeUsage(result.usage),
|
||||||
content: normalizeContentFromAPI(
|
content: normalizeContentFromAPI(
|
||||||
result.content,
|
result.content,
|
||||||
tools,
|
tools,
|
||||||
@ -2732,6 +2734,7 @@ async function* queryModel(
|
|||||||
const m: AssistantMessage = {
|
const m: AssistantMessage = {
|
||||||
message: {
|
message: {
|
||||||
...result,
|
...result,
|
||||||
|
usage: normalizeUsage(result.usage),
|
||||||
content: normalizeContentFromAPI(
|
content: normalizeContentFromAPI(
|
||||||
result.content,
|
result.content,
|
||||||
tools,
|
tools,
|
||||||
@ -2882,8 +2885,9 @@ async function* queryModel(
|
|||||||
// message_delta handler before any yield. Fallback pushes to newMessages
|
// message_delta handler before any yield. Fallback pushes to newMessages
|
||||||
// then yields, so tracking must be here to survive .return() at the yield.
|
// then yields, so tracking must be here to survive .return() at the yield.
|
||||||
if (fallbackMessage) {
|
if (fallbackMessage) {
|
||||||
const fallbackUsage = fallbackMessage.message.usage;
|
const fallbackUsage = normalizeUsage(fallbackMessage.message.usage);
|
||||||
usage = updateUsage(EMPTY_USAGE, fallbackUsage);
|
fallbackMessage.message.usage = fallbackUsage;
|
||||||
|
usage = fallbackUsage;
|
||||||
stopReason = fallbackMessage.message.stop_reason;
|
stopReason = fallbackMessage.message.stop_reason;
|
||||||
const fallbackCost = calculateUSDCost(resolvedModel, fallbackUsage);
|
const fallbackCost = calculateUSDCost(resolvedModel, fallbackUsage);
|
||||||
costUSD += addToTotalSessionCost(
|
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'
|
import type { NonNullableUsage } from '../../entrypoints/sdk/sdkUtilityTypes.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -20,3 +21,42 @@ export const EMPTY_USAGE: Readonly<NonNullableUsage> = {
|
|||||||
iterations: [],
|
iterations: [],
|
||||||
speed: 'standard',
|
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,
|
logEvent,
|
||||||
} from '../analytics/index.js'
|
} from '../analytics/index.js'
|
||||||
import { sanitizeToolNameForAnalytics } from '../analytics/metadata.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 { classifyAPIError } from './errors.js'
|
||||||
import { extractConnectionErrorDetails } from './errorUtils.js'
|
import { extractConnectionErrorDetails } from './errorUtils.js'
|
||||||
|
|
||||||
export type { NonNullableUsage }
|
export type { NonNullableUsage }
|
||||||
export { EMPTY_USAGE }
|
export { EMPTY_USAGE, normalizeUsage }
|
||||||
|
|
||||||
// Strategy used for global prompt caching
|
// Strategy used for global prompt caching
|
||||||
export type GlobalCacheStrategy = 'tool_based' | 'system_prompt' | 'none'
|
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 type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../services/analytics/metadata.js'
|
||||||
import { getAPIMetadata } from '../services/api/claude.js'
|
import { getAPIMetadata } from '../services/api/claude.js'
|
||||||
import { getAnthropicClient } from '../services/api/client.js'
|
import { getAnthropicClient } from '../services/api/client.js'
|
||||||
|
import { normalizeUsage } from '../services/api/emptyUsage.js'
|
||||||
import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js'
|
import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js'
|
||||||
import { computeFingerprint } from './fingerprint.js'
|
import { computeFingerprint } from './fingerprint.js'
|
||||||
import { normalizeModelStringForAPI } from './model/model.js'
|
import { normalizeModelStringForAPI } from './model/model.js'
|
||||||
@ -192,6 +193,7 @@ export async function sideQuery(opts: SideQueryOptions): Promise<BetaMessage> {
|
|||||||
|
|
||||||
const requestId =
|
const requestId =
|
||||||
(response as { _request_id?: string | null })._request_id ?? undefined
|
(response as { _request_id?: string | null })._request_id ?? undefined
|
||||||
|
response.usage = normalizeUsage(response.usage)
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const lastCompletion = getLastApiCompletionTimestamp()
|
const lastCompletion = getLastApiCompletionTimestamp()
|
||||||
logEvent('tengu_api_success', {
|
logEvent('tengu_api_success', {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user