From 4aff5a3cd6fcf1fcb863323450a0c2e21bc1357a Mon Sep 17 00:00:00 2001 From: uchouT Date: Sun, 31 May 2026 22:25:33 +0800 Subject: [PATCH] feat: builtin tools support --- packages/core/src/index.ts | 1 + .../session/src/__tests__/anthropic.test.ts | 64 +++++++ .../src/__tests__/openai-compatible.test.ts | 158 ++++++++++++++++++ packages/session/src/adapters/anthropic.ts | 81 ++++++++- .../session/src/adapters/openai-compatible.ts | 113 ++++++++++--- packages/session/src/index.ts | 3 +- packages/session/src/types/llm.ts | 34 +++- 7 files changed, 424 insertions(+), 30 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 80de8bf..448e5c8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -157,6 +157,7 @@ export { SessionArchivedError, NotImplementedError } from '@stello-ai/session'; export type { // LLM 适配器 LLMAdapter, LLMResult, LLMChunk, LLMCompleteOptions, Message, + ClientToolDefinition, ProviderToolDefinition, ProviderToolProvider, ProviderToolEvent, ClaudeModel, ClaudeOptions, GPTModel, GPTOptions, OpenAICompatibleOptions, diff --git a/packages/session/src/__tests__/anthropic.test.ts b/packages/session/src/__tests__/anthropic.test.ts index c00b81a..0dd8132 100644 --- a/packages/session/src/__tests__/anthropic.test.ts +++ b/packages/session/src/__tests__/anthropic.test.ts @@ -213,6 +213,70 @@ describe('createAnthropicAdapter complete() max_tokens', () => { undefined, ) }) + + it('将 providerTools 原样透传给 Anthropic tools 数组', async () => { + const adapter = createAnthropicAdapter({ + apiKey: 'k', + model: 'm', + maxContextTokens: 200_000, + providerTools: [{ + id: 'anthropic_web_search', + provider: 'anthropic', + spec: { type: 'web_search_20250305', name: 'web_search', max_uses: 3 }, + }], + }) + + await adapter.complete([{ role: 'user', content: 'latest news' }], { + tools: [{ name: 'client_tool', description: 'client', inputSchema: { type: 'object' } }], + }) + + expect(messagesCreate).toHaveBeenCalledWith( + expect.objectContaining({ + tools: [ + { name: 'client_tool', description: 'client', input_schema: { type: 'object' } }, + { type: 'web_search_20250305', name: 'web_search', max_uses: 3 }, + ], + }), + undefined, + ) + }) + + it('Anthropic server-side tool blocks 不会变成客户端 toolCalls,并保留 providerToolEvents', async () => { + messagesCreate.mockResolvedValueOnce({ + content: [ + { type: 'server_tool_use', id: 'srv_1', name: 'web_search', input: { query: 'OpenAI news' } }, + { type: 'web_search_tool_result', tool_use_id: 'srv_1', content: [{ type: 'web_search_result', title: 'Example', url: 'https://example.com' }] }, + { type: 'text', text: 'answer' }, + ], + usage: { input_tokens: 10, output_tokens: 5 }, + }) + + const adapter = createAnthropicAdapter({ + apiKey: 'k', + model: 'm', + maxContextTokens: 200_000, + }) + + const result = await adapter.complete([{ role: 'user', content: 'latest news' }]) + + expect(result.content).toBe('answer') + expect(result.toolCalls).toBeUndefined() + expect(result.providerToolEvents).toEqual([ + { + id: 'srv_1', + type: 'server_tool_use', + name: 'web_search', + input: { query: 'OpenAI news' }, + raw: { type: 'server_tool_use', id: 'srv_1', name: 'web_search', input: { query: 'OpenAI news' } }, + }, + { + id: 'srv_1', + type: 'web_search_tool_result', + results: [{ type: 'web_search_result', title: 'Example', url: 'https://example.com' }], + raw: { type: 'web_search_tool_result', tool_use_id: 'srv_1', content: [{ type: 'web_search_result', title: 'Example', url: 'https://example.com' }] }, + }, + ]) + }) }) describe('createAnthropicAdapter stream() max_tokens', () => { diff --git a/packages/session/src/__tests__/openai-compatible.test.ts b/packages/session/src/__tests__/openai-compatible.test.ts index 6b7644d..5cc2c2e 100644 --- a/packages/session/src/__tests__/openai-compatible.test.ts +++ b/packages/session/src/__tests__/openai-compatible.test.ts @@ -189,6 +189,164 @@ describe('createOpenAICompatibleAdapter', () => { ], }) }) + + it('将 providerTools 原样透传给 OpenAI-compatible tools 数组', async () => { + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.stepfun.com/v1', + model: 'step-3.7-flash', + maxContextTokens: 128_000, + }) + + await adapter.complete([{ role: 'user', content: '今天有什么新闻?' }], { + tools: [{ name: 'client_search', description: 'client search', inputSchema: { type: 'object' } }], + providerTools: [{ + id: 'stepfun_web_search', + provider: 'openai-compatible', + spec: { + type: 'web_search', + function: { description: '搜索互联网实时信息' }, + }, + }], + }) + + expect(createCompletion).toHaveBeenCalledWith( + expect.objectContaining({ + tool_choice: 'auto', + tools: [ + { + type: 'function', + function: { + name: 'client_search', + description: 'client search', + parameters: { type: 'object' }, + }, + }, + { + type: 'web_search', + function: { description: '搜索互联网实时信息' }, + }, + ], + }), + undefined, + ) + }) + + it('StepFun web_search tool_calls 不会变成客户端 toolCalls,并保留 providerToolEvents', async () => { + createCompletion.mockResolvedValueOnce({ + choices: [{ + message: { + content: '上海中心大厦', + tool_calls: [{ + id: 'call_search_1', + type: 'web_search', + function: { + name: 'step_websearch', + arguments: '{"keyword":"上海最高的楼"}', + results: [{ index: 0, url: 'https://example.com', title: '上海最高的楼' }], + }, + }], + }, + }], + usage: { prompt_tokens: 10, completion_tokens: 4 }, + }) + + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.stepfun.com/v1', + model: 'step-3.7-flash', + maxContextTokens: 128_000, + }) + + const result = await adapter.complete([{ role: 'user', content: '上海最高的楼?' }], { + providerTools: [{ + id: 'stepfun_web_search', + provider: 'openai-compatible', + spec: { type: 'web_search', function: { description: '搜索互联网实时信息' } }, + }], + }) + + expect(result.toolCalls).toEqual([]) + expect(result.providerToolEvents).toEqual([{ + id: 'call_search_1', + type: 'web_search', + name: 'step_websearch', + input: { keyword: '上海最高的楼' }, + results: [{ index: 0, url: 'https://example.com', title: '上海最高的楼' }], + raw: { + id: 'call_search_1', + type: 'web_search', + function: { + name: 'step_websearch', + arguments: '{"keyword":"上海最高的楼"}', + results: [{ index: 0, url: 'https://example.com', title: '上海最高的楼' }], + }, + }, + }]) + }) + + it('stream() 忽略 provider tool delta 的客户端执行通道,并下发 providerToolEvents', async () => { + createCompletion.mockResolvedValueOnce((async function* () { + yield { + choices: [{ + delta: { + tool_calls: [{ + index: 0, + id: 'call_search_1', + type: 'web_search', + function: { + name: 'step_websearch', + arguments: '{"keyword":"上海最高的楼"}', + results: [{ index: 0, url: 'https://example.com', title: '上海最高的楼' }], + }, + }], + }, + }], + } + yield { choices: [{ delta: { content: '上海中心大厦' } }] } + })()) + + const adapter = createOpenAICompatibleAdapter({ + apiKey: 'test-key', + baseURL: 'https://api.stepfun.com/v1', + model: 'step-3.7-flash', + maxContextTokens: 128_000, + }) + + if (!adapter.stream) throw new Error('adapter.stream is required') + + const chunks = [] + for await (const chunk of adapter.stream([{ role: 'user', content: '上海最高的楼?' }], { + providerTools: [{ + id: 'stepfun_web_search', + provider: 'openai-compatible', + spec: { type: 'web_search', function: { description: '搜索互联网实时信息' } }, + }], + })) { + chunks.push(chunk) + } + + expect(chunks.flatMap((chunk) => chunk.toolCallDeltas ?? [])).toEqual([]) + expect(chunks.flatMap((chunk) => chunk.providerToolEvents ?? [])).toEqual([{ + id: 'call_search_1', + type: 'web_search', + name: 'step_websearch', + input: { keyword: '上海最高的楼' }, + results: [{ index: 0, url: 'https://example.com', title: '上海最高的楼' }], + raw: { + index: 0, + id: 'call_search_1', + type: 'web_search', + function: { + name: 'step_websearch', + arguments: '{"keyword":"上海最高的楼"}', + results: [{ index: 0, url: 'https://example.com', title: '上海最高的楼' }], + }, + }, + }]) + expect(chunks.map((chunk) => chunk.delta).join('')).toBe('上海中心大厦') + }) + it('StepFun 3.7 多模态能力不绑定固定 baseURL', async () => { const adapter = createOpenAICompatibleAdapter({ apiKey: 'test-key', diff --git a/packages/session/src/adapters/anthropic.ts b/packages/session/src/adapters/anthropic.ts index edabc74..147dc0f 100644 --- a/packages/session/src/adapters/anthropic.ts +++ b/packages/session/src/adapters/anthropic.ts @@ -7,7 +7,25 @@ import type { Tool, ContentBlock, } from '@anthropic-ai/sdk/resources/messages/messages' -import type { LLMAdapter, LLMResult, LLMChunk, Message, ToolCall, LLMCompleteOptions } from '../types/llm.js' +import type { + LLMAdapter, + LLMResult, + LLMChunk, + Message, + ToolCall, + LLMCompleteOptions, + ProviderToolDefinition, + ProviderToolEvent, +} from '../types/llm.js' + +type AnthropicProviderBlock = { + type: string + id?: string + tool_use_id?: string + name?: string + input?: unknown + content?: unknown +} & Record /** Anthropic 原生协议的配置选项 */ export interface AnthropicAdapterOptions { @@ -24,6 +42,8 @@ export interface AnthropicAdapterOptions { * 在中途被截断,引发上层 JSON 解析失败。建议按模型上限设置。 */ maxOutputTokens?: number + /** Provider-hosted tools to send with every request for this adapter. */ + providerTools?: ProviderToolDefinition[] } /** 将 Stello 内部 Message 转换为 Anthropic MessageParam 格式 */ @@ -106,6 +126,27 @@ function toAnthropicTools( })) } +function isAnthropicProviderTool(tool: ProviderToolDefinition): boolean { + return tool.provider === 'anthropic' +} + +function buildProviderTools( + adapterTools: ProviderToolDefinition[] | undefined, + requestTools: ProviderToolDefinition[] | undefined, +): Record[] { + return [...(adapterTools ?? []), ...(requestTools ?? [])] + .filter(isAnthropicProviderTool) + .map((tool) => tool.spec) +} + +function buildRequestTools(completeOptions: LLMCompleteOptions | undefined, adapterTools: ProviderToolDefinition[] | undefined): Tool[] { + const clientTools = completeOptions?.tools && completeOptions.tools.length > 0 + ? toAnthropicTools(completeOptions.tools) + : [] + const providerTools = buildProviderTools(adapterTools, completeOptions?.providerTools) + return [...clientTools, ...providerTools] as Tool[] +} + /** 从 Anthropic response content blocks 中提取 tool calls */ function extractToolCalls(content: ContentBlock[]): ToolCall[] { return content @@ -125,6 +166,27 @@ function extractText(content: ContentBlock[]): string | null { return texts.length > 0 ? texts.join('') : null } +function toProviderToolEvent(block: AnthropicProviderBlock): ProviderToolEvent | null { + if (block.type === 'text' || block.type === 'tool_use') return null + const event: ProviderToolEvent = { + type: block.type, + raw: block, + } + const id = block.id ?? block.tool_use_id + if (id) event.id = id + if (block.name) event.name = block.name + if ('input' in block) event.input = block.input + if ('content' in block) event.results = block.content + return event +} + +function extractProviderToolEvents(content: ContentBlock[]): ProviderToolEvent[] { + return content.flatMap((block) => { + const event = toProviderToolEvent(block as AnthropicProviderBlock) + return event ? [event] : [] + }) +} + /** 创建基于 Anthropic 原生协议的 LLMAdapter */ export function createAnthropicAdapter(options: AnthropicAdapterOptions): LLMAdapter { const client = new Anthropic({ @@ -141,6 +203,7 @@ export function createAnthropicAdapter(options: AnthropicAdapterOptions): LLMAda const system = systemMessages.length > 0 ? systemMessages.map((m) => m.content).join('\n\n') : undefined + const requestTools = buildRequestTools(completeOptions, options.providerTools) const response = await client.messages.create( { @@ -148,19 +211,19 @@ export function createAnthropicAdapter(options: AnthropicAdapterOptions): LLMAda max_tokens: completeOptions?.maxTokens ?? options.maxOutputTokens ?? 4096, ...(completeOptions?.temperature !== undefined && { temperature: completeOptions.temperature }), ...(system && { system }), - ...(completeOptions?.tools && completeOptions.tools.length > 0 - ? { tools: toAnthropicTools(completeOptions.tools) } - : {}), + ...(requestTools.length > 0 ? { tools: requestTools } : {}), messages: toAnthropicMessages(nonSystemMessages), }, completeOptions?.signal ? { signal: completeOptions.signal } : undefined, ) const toolCalls = extractToolCalls(response.content) + const providerToolEvents = extractProviderToolEvents(response.content) return { content: extractText(response.content), ...(toolCalls.length > 0 ? { toolCalls } : {}), + ...(providerToolEvents.length > 0 ? { providerToolEvents } : {}), usage: { promptTokens: response.usage.input_tokens, completionTokens: response.usage.output_tokens, @@ -175,6 +238,7 @@ export function createAnthropicAdapter(options: AnthropicAdapterOptions): LLMAda const system = systemMessages.length > 0 ? systemMessages.map((m) => m.content).join('\n\n') : undefined + const requestTools = buildRequestTools(completeOptions, options.providerTools) const stream = client.messages.stream( { @@ -182,9 +246,7 @@ export function createAnthropicAdapter(options: AnthropicAdapterOptions): LLMAda max_tokens: completeOptions?.maxTokens ?? options.maxOutputTokens ?? 4096, ...(completeOptions?.temperature !== undefined && { temperature: completeOptions.temperature }), ...(system && { system }), - ...(completeOptions?.tools && completeOptions.tools.length > 0 - ? { tools: toAnthropicTools(completeOptions.tools) } - : {}), + ...(requestTools.length > 0 ? { tools: requestTools } : {}), messages: toAnthropicMessages(nonSystemMessages), }, completeOptions?.signal ? { signal: completeOptions.signal } : undefined, @@ -205,6 +267,11 @@ export function createAnthropicAdapter(options: AnthropicAdapterOptions): LLMAda name: event.content_block.name, }], } + } else { + const providerEvent = toProviderToolEvent(event.content_block as AnthropicProviderBlock) + if (providerEvent) { + yield { delta: '', providerToolEvents: [providerEvent] } + } } } else if (event.type === 'content_block_delta') { if (event.delta.type === 'text_delta') { diff --git a/packages/session/src/adapters/openai-compatible.ts b/packages/session/src/adapters/openai-compatible.ts index e109da0..8b509ed 100644 --- a/packages/session/src/adapters/openai-compatible.ts +++ b/packages/session/src/adapters/openai-compatible.ts @@ -1,11 +1,26 @@ import OpenAI from 'openai' import type { ChatCompletion, ChatCompletionChunk } from 'openai/resources/chat/completions' import type { Stream } from 'openai/streaming' -import type { ContentPart, LLMAdapter, LLMResult, Message, LLMCompleteOptions } from '../types/llm.js' +import type { + ContentPart, + LLMAdapter, + LLMResult, + Message, + LLMCompleteOptions, + ProviderToolDefinition, + ProviderToolEvent, +} from '../types/llm.js' -type ChatToolCallDelta = NonNullable< - NonNullable[number] -> +type RawOpenAIToolCall = { + index?: number + id?: string + type?: string + function?: { + name?: string + arguments?: string + results?: unknown + } +} & Record /** OpenAI 兼容协议的配置选项 */ export interface OpenAICompatibleOptions { @@ -23,6 +38,8 @@ export interface OpenAICompatibleOptions { * 在中途被截断,引发上层 JSON 解析失败。 */ maxOutputTokens?: number + /** Provider-hosted tools to send with every request for this adapter. */ + providerTools?: ProviderToolDefinition[] /** 将 KitKit 托管的多模态文件转成模型服务可访问的 URL。 */ resolveMediaUrl?: (source: Extract['source'], { type: 'kitkit_file' }>) => string | Promise } @@ -49,6 +66,45 @@ function isStepFun37Flash(options: OpenAICompatibleOptions): boolean { return options.model === 'step-3.7-flash' } +function isOpenAICompatibleProviderTool(tool: ProviderToolDefinition): boolean { + return tool.provider === 'openai' || tool.provider === 'openai-compatible' +} + +function buildProviderTools( + adapterTools: ProviderToolDefinition[] | undefined, + requestTools: ProviderToolDefinition[] | undefined, +): Record[] { + return [...(adapterTools ?? []), ...(requestTools ?? [])] + .filter(isOpenAICompatibleProviderTool) + .map((tool) => tool.spec) +} + +function parseProviderToolArguments(value: string | undefined): unknown { + if (!value) return undefined + try { + return JSON.parse(value) + } catch { + return value + } +} + +function isProviderToolCall(call: RawOpenAIToolCall): boolean { + return typeof call.type === 'string' && call.type !== 'function' +} + +function toProviderToolEvent(call: RawOpenAIToolCall): ProviderToolEvent { + const event: ProviderToolEvent = { + ...(call.id ? { id: call.id } : {}), + type: call.type ?? 'provider_tool', + ...(call.function?.name ? { name: call.function.name } : {}), + raw: call, + } + const input = parseProviderToolArguments(call.function?.arguments) + if (input !== undefined) event.input = input + if (call.function && 'results' in call.function) event.results = call.function.results + return event +} + function escapeDocumentAttribute(value: string): string { return value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') } @@ -137,22 +193,22 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): async function buildParams(messages: Message[], completeOptions?: LLMCompleteOptions) { const normalizedMessages = mergeConsecutiveSystemMessages(messages) const allowMultimodal = isStepFun37Flash(options) + const clientTools = completeOptions?.tools?.map((tool) => ({ + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, + }, + })) ?? [] + const providerTools = buildProviderTools(options.providerTools, completeOptions?.providerTools) + const requestTools = [...clientTools, ...providerTools] return { model: options.model, max_tokens: completeOptions?.maxTokens ?? options.maxOutputTokens ?? 4096, ...(completeOptions?.temperature !== undefined && { temperature: completeOptions.temperature }), - ...(completeOptions?.tools - ? { - tools: completeOptions.tools.map((tool) => ({ - type: 'function' as const, - function: { - name: tool.name, - description: tool.description, - parameters: tool.inputSchema, - }, - })), - } - : {}), + ...(requestTools.length > 0 ? { tools: requestTools } : {}), + ...(providerTools.length > 0 ? { tool_choice: 'auto' as const } : {}), messages: await Promise.all(normalizedMessages.map(async (m) => ({ role: m.role as 'system' | 'user' | 'assistant' | 'tool', content: await toOpenAIContent(m, allowMultimodal, options), @@ -194,18 +250,24 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): const reasoningContent = typeof rawMessage?.reasoning_content === 'string' ? rawMessage.reasoning_content : null + const rawToolCalls = (choice?.message?.tool_calls ?? []) as RawOpenAIToolCall[] + const providerToolEvents = rawToolCalls + .filter(isProviderToolCall) + .map(toProviderToolEvent) return { content: choice?.message?.content ?? null, ...(reasoningContent ? { reasoningContent } : {}), - toolCalls: (choice?.message?.tool_calls ?? []).flatMap((call) => { + toolCalls: rawToolCalls.flatMap((call) => { + if (isProviderToolCall(call)) return [] if (!('function' in call) || !call.function) return [] return [{ - id: call.id, + id: call.id ?? 'unknown_tool_call', name: call.function.name ?? 'unknown_tool', input: call.function.arguments ? JSON.parse(call.function.arguments) as Record : {}, }] }), + ...(providerToolEvents.length > 0 ? { providerToolEvents } : {}), usage: response.usage ? { promptTokens: response.usage.prompt_tokens, @@ -231,14 +293,23 @@ export function createOpenAICompatibleAdapter(options: OpenAICompatibleOptions): const reasoningDelta = typeof rawDelta?.reasoning_content === 'string' ? rawDelta.reasoning_content : undefined - const toolCallDeltas = (chunk.choices[0]?.delta?.tool_calls ?? []).map((call: ChatToolCallDelta) => ({ + const rawToolCalls = (chunk.choices[0]?.delta?.tool_calls ?? []) as RawOpenAIToolCall[] + const providerToolEvents = rawToolCalls + .filter(isProviderToolCall) + .map(toProviderToolEvent) + const toolCallDeltas = rawToolCalls.filter((call) => !isProviderToolCall(call)).map((call) => ({ index: call.index ?? 0, id: call.id, name: call.function?.name, input: call.function?.arguments, })) - if (delta || reasoningDelta || toolCallDeltas.length > 0) { - yield { delta, ...(reasoningDelta ? { reasoningDelta } : {}), toolCallDeltas } + if (delta || reasoningDelta || toolCallDeltas.length > 0 || providerToolEvents.length > 0) { + yield { + delta, + ...(reasoningDelta ? { reasoningDelta } : {}), + ...(toolCallDeltas.length > 0 ? { toolCallDeltas } : {}), + ...(providerToolEvents.length > 0 ? { providerToolEvents } : {}), + } } } }, diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 382fd3a..7642939 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -3,7 +3,8 @@ export type { SessionMeta, SessionMetaUpdate, SessionFilter, ForkOptions, ForkCo export type { SessionStorage, ListRecordsOptions, CompressionCacheSnapshot } from './types/storage.js' export type { Message, ContentPart, TextPart, ImagePart, VideoPart, FilePart, AudioPart, MediaSource, - ToolCall, LLMCompleteOptions, LLMResult, LLMChunk, LLMAdapter, + ToolCall, ClientToolDefinition, ProviderToolDefinition, ProviderToolProvider, ProviderToolEvent, + LLMCompleteOptions, LLMResult, LLMChunk, LLMAdapter, } from './types/llm.js' export type { Session, diff --git a/packages/session/src/types/llm.ts b/packages/session/src/types/llm.ts index 7e45afa..72cbb08 100644 --- a/packages/session/src/types/llm.ts +++ b/packages/session/src/types/llm.ts @@ -82,6 +82,32 @@ export interface ToolCall { input: Record } +/** 客户端执行的 function-calling tool 定义。 */ +export interface ClientToolDefinition { + name: string + description: string + inputSchema: Record +} + +export type ProviderToolProvider = 'openai' | 'openai-compatible' | 'anthropic' + +/** Provider 执行的内置 tool 原生描述符。Stello 只透传,不本地执行。 */ +export interface ProviderToolDefinition { + id: string + provider: ProviderToolProvider + spec: Record +} + +/** Provider 内置 tool 的事件 / 结果。由 adapter 从 provider 响应中提取。 */ +export interface ProviderToolEvent { + id?: string + type: string + name?: string + input?: unknown + results?: unknown + raw: unknown +} + /** LLM complete 的选项 */ export interface LLMCompleteOptions { /** 最大生成 token 数 */ @@ -89,7 +115,9 @@ export interface LLMCompleteOptions { /** 温度参数 */ temperature?: number /** 可用工具列表的 schema(JSON Schema 格式) */ - tools?: Array<{ name: string; description: string; inputSchema: Record }> + tools?: ClientToolDefinition[] + /** Provider 执行的内置 tool 原生描述符。 */ + providerTools?: ProviderToolDefinition[] /** * AbortSignal — adapter 应在 abort 时中断 LLM 调用并以 AbortError reject。 * 不支持取消的 adapter 可忽略此字段(best-effort 语义)。 @@ -103,6 +131,8 @@ export interface LLMResult { /** 推理模型的思考内容,多轮对话时需回传给 API */ reasoningContent?: string | null toolCalls?: ToolCall[] + /** Provider 内置 tool 事件 / 结果,不进入客户端 tool loop。 */ + providerToolEvents?: ProviderToolEvent[] usage?: { promptTokens: number completionTokens: number @@ -122,6 +152,8 @@ export interface LLMChunk { name?: string input?: string }> + /** Provider 内置 tool 事件 / 结果,不进入客户端 tool loop。 */ + providerToolEvents?: ProviderToolEvent[] } /**