claude-code-best/src/services/api/gemini/index.ts
claude-code-best c80e593212
feature: langfuse thinking 及 文本edit的问题修复( #371); 省略 diff 以减少内存峰值 (#376)
* feat: langfuse tracing 增加 thinking 参数记录

在 recordLLMObservation 中添加 thinking 配置(type/budgetTokens),
所有 provider(claude/gemini/openai)及 tokenEstimation、sideQuery
调用处同步传递 thinking 信息,便于 Langfuse 面板观察 thinking 使用情况。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: langfuse tracing 兼容 budget_tokens snake_case 格式

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: 统一传递完整 thinking 配置而非仅 thinkingType

Langfuse 追踪直接传递整个 thinking 对象(含 type 和 budget_tokens),
Analytics 日志同步补充 thinkingBudgetTokens 字段,logAPIQuery 改为
接收 ThinkingConfig 类型参数。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat: 省略旧消息的代码 diff 展示,仅保留最新消息的完整 diff

* fix: Edit 工具增加 Tab/空格规范化匹配,修复中文和缩进文件编辑失败

Read 工具输出将 Tab 渲染为空格,用户复制后 Edit 工具无法匹配。
在 findActualString 中增加 Tab→空格规范化回退匹配,并精确映射回原始文件位置。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: README 添加安装/更新失败的解决方案提示

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 17:06:33 +08:00

216 lines
7.5 KiB
TypeScript

import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import { randomUUID } from 'crypto'
import type {
AssistantMessage,
Message,
StreamEvent,
SystemAPIErrorMessage,
} from '../../../types/message.js'
import { getEmptyToolPermissionContext, type Tools } from '../../../Tool.js'
import { toolToAPISchema } from '../../../utils/api.js'
import { logForDebugging } from '../../../utils/debug.js'
import {
createAssistantAPIErrorMessage,
normalizeContentFromAPI,
normalizeMessagesForAPI,
} from '../../../utils/messages.js'
import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
import type { ThinkingConfig } from '../../../utils/thinking.js'
import type { Options } from '../claude.js'
import { recordLLMObservation } from '../../../services/langfuse/tracing.js'
import { convertMessagesToLangfuse, convertOutputToLangfuse, convertToolsToLangfuse } from '../../../services/langfuse/convert.js'
import { streamGeminiGenerateContent } from './client.js'
import { anthropicMessagesToGemini, resolveGeminiModel, adaptGeminiStreamToAnthropic, anthropicToolsToGemini, anthropicToolChoiceToGemini, GEMINI_THOUGHT_SIGNATURE_FIELD } from '@ant/model-provider'
export async function* queryModelGemini(
messages: Message[],
systemPrompt: SystemPrompt,
tools: Tools,
signal: AbortSignal,
options: Options,
thinkingConfig: ThinkingConfig,
): AsyncGenerator<
StreamEvent | AssistantMessage | SystemAPIErrorMessage,
void
> {
try {
const geminiModel = resolveGeminiModel(options.model)
const messagesForAPI = normalizeMessagesForAPI(messages, tools)
const toolSchemas = await Promise.all(
tools.map(tool =>
toolToAPISchema(tool, {
getToolPermissionContext: options.getToolPermissionContext,
tools,
agents: options.agents,
allowedAgentTypes: options.allowedAgentTypes,
model: options.model,
}),
),
)
const standardTools = toolSchemas.filter(
(t): t is BetaToolUnion & { type: string } => {
const anyTool = t as unknown as Record<string, unknown>
return (
anyTool.type !== 'advisor_20260301' &&
anyTool.type !== 'computer_20250124'
)
},
)
const { contents, systemInstruction } = anthropicMessagesToGemini(
messagesForAPI,
systemPrompt,
)
const geminiTools = anthropicToolsToGemini(standardTools)
const toolChoice = anthropicToolChoiceToGemini(options.toolChoice)
const stream = streamGeminiGenerateContent({
model: geminiModel,
signal,
fetchOverride: options.fetchOverride as typeof fetch | undefined,
body: {
contents,
...(systemInstruction && { systemInstruction }),
...(geminiTools.length > 0 && { tools: geminiTools }),
...(toolChoice && {
toolConfig: {
functionCallingConfig: toolChoice,
},
}),
generationConfig: {
...(options.temperatureOverride !== undefined && {
temperature: options.temperatureOverride,
}),
...(thinkingConfig.type !== 'disabled' && {
thinkingConfig: {
includeThoughts: true,
...(thinkingConfig.type === 'enabled' && {
thinkingBudget: thinkingConfig.budgetTokens,
}),
},
}),
},
},
})
logForDebugging(
`[Gemini] Calling model=${geminiModel}, messages=${contents.length}, tools=${geminiTools.length}`,
)
const adaptedStream = adaptGeminiStreamToAnthropic(stream, geminiModel)
const contentBlocks: Record<number, any> = {}
const collectedMessages: AssistantMessage[] = []
let partialMessage: any
let ttftMs = 0
const start = Date.now()
for await (const event of adaptedStream) {
switch (event.type) {
case 'message_start':
partialMessage = (event as any).message
ttftMs = Date.now() - start
break
case 'content_block_start': {
const idx = (event as any).index
const cb = (event as any).content_block
if (cb.type === 'tool_use') {
contentBlocks[idx] = { ...cb, input: '' }
} else if (cb.type === 'text') {
contentBlocks[idx] = { ...cb, text: '' }
} else if (cb.type === 'thinking') {
contentBlocks[idx] = { ...cb, thinking: '', signature: '' }
} else {
contentBlocks[idx] = { ...cb }
}
break
}
case 'content_block_delta': {
const idx = (event as any).index
const delta = (event as any).delta
const block = contentBlocks[idx]
if (!block) break
if (delta.type === 'text_delta') {
block.text = (block.text || '') + delta.text
} else if (delta.type === 'input_json_delta') {
block.input = (block.input || '') + delta.partial_json
} else if (delta.type === 'thinking_delta') {
block.thinking = (block.thinking || '') + delta.thinking
} else if (delta.type === 'signature_delta') {
if (block.type === 'thinking') {
block.signature = delta.signature
} else {
block[GEMINI_THOUGHT_SIGNATURE_FIELD] = delta.signature
}
}
break
}
case 'content_block_stop': {
const idx = (event as any).index
const block = contentBlocks[idx]
if (!block || !partialMessage) break
const message: AssistantMessage = {
message: {
...partialMessage,
content: normalizeContentFromAPI([block], tools, options.agentId),
},
requestId: undefined,
type: 'assistant',
uuid: randomUUID(),
timestamp: new Date().toISOString(),
}
collectedMessages.push(message)
yield message
break
}
case 'message_delta':
case 'message_stop':
break
}
yield {
type: 'stream_event',
event,
...(event.type === 'message_start' ? { ttftMs } : undefined),
} as StreamEvent
}
// Record LLM observation in Langfuse (no-op if not configured)
recordLLMObservation(options.langfuseTrace ?? null, {
model: geminiModel,
provider: 'gemini',
input: convertMessagesToLangfuse(messagesForAPI, systemPrompt),
output: convertOutputToLangfuse(collectedMessages),
usage: {
input_tokens: 0,
output_tokens: 0,
},
startTime: new Date(start),
endTime: new Date(),
completionStartTime: ttftMs > 0 ? new Date(start + ttftMs) : undefined,
tools: convertToolsToLangfuse(toolSchemas as unknown[]),
thinking:
thinkingConfig.type !== 'disabled'
? {
type: thinkingConfig.type,
...(thinkingConfig.type === 'enabled' && {
budgetTokens: thinkingConfig.budgetTokens,
}),
}
: undefined,
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
logForDebugging(`[Gemini] Error: ${errorMessage}`, { level: 'error' })
yield createAssistantAPIErrorMessage({
content: `API Error: ${errorMessage}`,
apiError: 'api_error',
error: (error instanceof Error ? error : new Error(String(error))) as unknown as SDKAssistantMessageError,
})
}
}