Files
claude-code/packages/@ant/model-provider/src/providers/gemini/streamAdapter.ts
Cepvor ecd3f9d791 fix: Gemini 适配器补全 usage 字段映射 (#1233)
* fix: Gemini 适配器补全 usage 字段映射

Gemini API 的 usageMetadata 包含 cachedContentTokenCount 字段,
但此前未映射到 Anthropic 格式的 cache_read_input_tokens,导致
cache_creation_input_tokens 和 cache_read_input_tokens 始终为 0。

同时 message_delta 事件此前只携带 output_tokens,缺失
input_tokens、cache_creation_input_tokens、cache_read_input_tokens,
导致下游从 message_delta 读取最终 token 计数时获取不完整数据。

修复:
- 新增 cachedContentTokenCount → cache_read_input_tokens 映射
- message_start 和 message_delta 携带完整四个 usage 字段
- cache_creation_input_tokens 保持为 0(Gemini API 无等价概念)

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>

* fix: 添加 cachedContentTokenCount 到 GeminiUsageMetadata 类型

streamAdapter.ts 使用 usage.cachedContentTokenCount 但该字段未
在 GeminiUsageMetadata 类型中声明。CodeRabbit 审查发现此问题。

Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>

---------

Co-authored-by: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-05-17 07:28:16 +08:00

249 lines
6.6 KiB
TypeScript

import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { randomUUID } from 'crypto'
import type { GeminiPart, GeminiStreamChunk } from './types.js'
export async function* adaptGeminiStreamToAnthropic(
stream: AsyncIterable<GeminiStreamChunk>,
model: string,
): AsyncGenerator<BetaRawMessageStreamEvent, void> {
const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}`
let started = false
let stopped = false
let nextContentIndex = 0
let openTextLikeBlock: { index: number; type: 'text' | 'thinking' } | null =
null
let sawToolUse = false
let finishReason: string | undefined
let inputTokens = 0
let outputTokens = 0
let cachedReadTokens = 0
for await (const chunk of stream) {
const usage = chunk.usageMetadata
if (usage) {
inputTokens = usage.promptTokenCount ?? inputTokens
outputTokens =
(usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0)
cachedReadTokens = usage.cachedContentTokenCount ?? cachedReadTokens
}
if (!started) {
started = true
yield {
type: 'message_start',
message: {
id: messageId,
type: 'message',
role: 'assistant',
content: [],
model,
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: inputTokens,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: cachedReadTokens,
},
},
} as unknown as BetaRawMessageStreamEvent
}
const candidate = chunk.candidates?.[0]
const parts = candidate?.content?.parts ?? []
for (const part of parts) {
if (part.functionCall) {
if (openTextLikeBlock) {
yield {
type: 'content_block_stop',
index: openTextLikeBlock.index,
} as BetaRawMessageStreamEvent
openTextLikeBlock = null
}
sawToolUse = true
const toolIndex = nextContentIndex++
const toolId = `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
yield {
type: 'content_block_start',
index: toolIndex,
content_block: {
type: 'tool_use',
id: toolId,
name: part.functionCall.name || '',
input: {},
},
} as BetaRawMessageStreamEvent
if (part.thoughtSignature) {
yield {
type: 'content_block_delta',
index: toolIndex,
delta: {
type: 'signature_delta',
signature: part.thoughtSignature,
},
} as BetaRawMessageStreamEvent
}
if (
part.functionCall.args &&
Object.keys(part.functionCall.args).length > 0
) {
yield {
type: 'content_block_delta',
index: toolIndex,
delta: {
type: 'input_json_delta',
partial_json: JSON.stringify(part.functionCall.args),
},
} as BetaRawMessageStreamEvent
}
yield {
type: 'content_block_stop',
index: toolIndex,
} as BetaRawMessageStreamEvent
continue
}
const textLikeType = getTextLikeBlockType(part)
if (textLikeType) {
if (!openTextLikeBlock || openTextLikeBlock.type !== textLikeType) {
if (openTextLikeBlock) {
yield {
type: 'content_block_stop',
index: openTextLikeBlock.index,
} as BetaRawMessageStreamEvent
}
openTextLikeBlock = {
index: nextContentIndex++,
type: textLikeType,
}
yield {
type: 'content_block_start',
index: openTextLikeBlock.index,
content_block:
textLikeType === 'thinking'
? {
type: 'thinking',
thinking: '',
signature: '',
}
: {
type: 'text',
text: '',
},
} as BetaRawMessageStreamEvent
}
if (part.text) {
yield {
type: 'content_block_delta',
index: openTextLikeBlock.index,
delta:
textLikeType === 'thinking'
? {
type: 'thinking_delta',
thinking: part.text,
}
: {
type: 'text_delta',
text: part.text,
},
} as BetaRawMessageStreamEvent
}
if (part.thoughtSignature) {
yield {
type: 'content_block_delta',
index: openTextLikeBlock.index,
delta: {
type: 'signature_delta',
signature: part.thoughtSignature,
},
} as BetaRawMessageStreamEvent
}
continue
}
if (part.thoughtSignature && openTextLikeBlock) {
yield {
type: 'content_block_delta',
index: openTextLikeBlock.index,
delta: {
type: 'signature_delta',
signature: part.thoughtSignature,
},
} as BetaRawMessageStreamEvent
}
}
if (candidate?.finishReason) {
finishReason = candidate.finishReason
}
}
if (!started) {
return
}
if (openTextLikeBlock) {
yield {
type: 'content_block_stop',
index: openTextLikeBlock.index,
} as BetaRawMessageStreamEvent
}
if (!stopped) {
yield {
type: 'message_delta',
delta: {
stop_reason: mapGeminiFinishReason(finishReason, sawToolUse),
stop_sequence: null,
},
usage: {
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: cachedReadTokens,
},
} as BetaRawMessageStreamEvent
yield {
type: 'message_stop',
} as BetaRawMessageStreamEvent
stopped = true
}
}
function getTextLikeBlockType(part: GeminiPart): 'text' | 'thinking' | null {
if (typeof part.text !== 'string') {
return null
}
return part.thought ? 'thinking' : 'text'
}
function mapGeminiFinishReason(
reason: string | undefined,
sawToolUse: boolean,
): string {
switch (reason) {
case 'MAX_TOKENS':
return 'max_tokens'
case 'STOP':
case 'FINISH_REASON_UNSPECIFIED':
case 'SAFETY':
case 'RECITATION':
case 'BLOCKLIST':
case 'PROHIBITED_CONTENT':
case 'SPII':
case 'MALFORMED_FUNCTION_CALL':
default:
return sawToolUse ? 'tool_use' : 'end_turn'
}
}