provider.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. import type { LLMConfig } from "../models/project.js";
  2. import {
  3. streamSimple as piStreamSimple,
  4. stream as piStream,
  5. completeSimple as piCompleteSimple,
  6. complete as piComplete,
  7. } from "@mariozechner/pi-ai";
  8. import type {
  9. Api as PiApi,
  10. Model as PiModel,
  11. Context as PiContext,
  12. AssistantMessageEvent,
  13. Tool as PiTool,
  14. TextContent as PiTextContent,
  15. ToolCall as PiToolCall,
  16. } from "@mariozechner/pi-ai";
  17. import { resolveServicePreset } from "./service-presets.js";
  18. // === Streaming Monitor Types ===
  19. export interface StreamProgress {
  20. readonly elapsedMs: number;
  21. readonly totalChars: number;
  22. readonly chineseChars: number;
  23. readonly status: "streaming" | "done";
  24. }
  25. export type OnStreamProgress = (progress: StreamProgress) => void;
  26. export function createStreamMonitor(
  27. onProgress?: OnStreamProgress,
  28. intervalMs: number = 30000,
  29. ): { readonly onChunk: (text: string) => void; readonly stop: () => void } {
  30. let totalChars = 0;
  31. let chineseChars = 0;
  32. const startTime = Date.now();
  33. let timer: ReturnType<typeof setInterval> | undefined;
  34. if (onProgress) {
  35. timer = setInterval(() => {
  36. onProgress({
  37. elapsedMs: Date.now() - startTime,
  38. totalChars,
  39. chineseChars,
  40. status: "streaming",
  41. });
  42. }, intervalMs);
  43. }
  44. return {
  45. onChunk(text: string): void {
  46. totalChars += text.length;
  47. chineseChars += (text.match(/[\u4e00-\u9fff]/g) || []).length;
  48. },
  49. stop(): void {
  50. if (timer !== undefined) {
  51. clearInterval(timer);
  52. timer = undefined;
  53. }
  54. onProgress?.({
  55. elapsedMs: Date.now() - startTime,
  56. totalChars,
  57. chineseChars,
  58. status: "done",
  59. });
  60. },
  61. };
  62. }
  63. // === Shared Types ===
  64. export interface LLMResponse {
  65. readonly content: string;
  66. readonly usage: {
  67. readonly promptTokens: number;
  68. readonly completionTokens: number;
  69. readonly totalTokens: number;
  70. };
  71. }
  72. export interface LLMMessage {
  73. readonly role: "system" | "user" | "assistant";
  74. readonly content: string;
  75. }
  76. export interface LLMClient {
  77. readonly provider: "openai" | "anthropic";
  78. readonly apiFormat: "chat" | "responses";
  79. readonly stream: boolean;
  80. readonly _piModel?: PiModel<PiApi>;
  81. readonly _apiKey?: string;
  82. readonly defaults: {
  83. readonly temperature: number;
  84. readonly maxTokens: number;
  85. readonly maxTokensCap: number | null; // non-null only when user explicitly configured
  86. readonly thinkingBudget: number;
  87. readonly extra: Record<string, unknown>;
  88. };
  89. }
  90. // === Tool-calling Types ===
  91. export interface ToolDefinition {
  92. readonly name: string;
  93. readonly description: string;
  94. readonly parameters: Record<string, unknown>;
  95. }
  96. export interface ToolCall {
  97. readonly id: string;
  98. readonly name: string;
  99. readonly arguments: string;
  100. }
  101. export type AgentMessage =
  102. | { readonly role: "system"; readonly content: string }
  103. | { readonly role: "user"; readonly content: string }
  104. | { readonly role: "assistant"; readonly content: string | null; readonly toolCalls?: ReadonlyArray<ToolCall> }
  105. | { readonly role: "tool"; readonly toolCallId: string; readonly content: string };
  106. export interface ChatWithToolsResult {
  107. readonly content: string;
  108. readonly toolCalls: ReadonlyArray<ToolCall>;
  109. }
  110. // === Factory ===
  111. export function createLLMClient(config: LLMConfig): LLMClient {
  112. const defaults = {
  113. temperature: config.temperature ?? 0.7,
  114. maxTokens: config.maxTokens ?? 8192,
  115. maxTokensCap: config.maxTokens ?? null, // only cap when user explicitly set maxTokens
  116. thinkingBudget: config.thinkingBudget ?? 0,
  117. extra: config.extra ?? {},
  118. };
  119. const apiFormat = config.apiFormat ?? "chat";
  120. const stream = config.stream ?? true;
  121. // --- Build pi-ai Model object ---
  122. const serviceName = config.service ?? "custom";
  123. const preset = resolveServicePreset(serviceName);
  124. const piApi = resolvePiApi(serviceName, config.apiFormat, preset?.api) as PiApi;
  125. const baseUrl = config.baseUrl || preset?.baseUrl || "";
  126. const extraHeaders = config.headers ?? parseEnvHeaders();
  127. const piModel: PiModel<PiApi> = {
  128. id: config.model,
  129. name: config.model,
  130. api: piApi,
  131. provider: serviceName,
  132. baseUrl,
  133. reasoning: (config.thinkingBudget ?? 0) > 0,
  134. input: ["text"] as ("text" | "image")[],
  135. cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
  136. contextWindow: 128_000,
  137. maxTokens: config.maxTokens ?? 8192,
  138. ...(extraHeaders ? { headers: extraHeaders } : {}),
  139. };
  140. const provider = config.provider === "anthropic" ? "anthropic" : "openai";
  141. return {
  142. provider,
  143. apiFormat,
  144. stream,
  145. _piModel: piModel,
  146. _apiKey: config.apiKey,
  147. defaults,
  148. };
  149. }
  150. function resolvePiApi(
  151. serviceName: string,
  152. apiFormat: LLMConfig["apiFormat"] | undefined,
  153. presetApi: PiApi | undefined,
  154. ): PiApi {
  155. if (serviceName === "custom") {
  156. return apiFormat === "responses" ? "openai-responses" : "openai-completions";
  157. }
  158. return (presetApi ?? "openai-completions") as PiApi;
  159. }
  160. function parseEnvHeaders(): Record<string, string> | undefined {
  161. const raw = process.env.INKOS_LLM_HEADERS;
  162. if (!raw) return undefined;
  163. try {
  164. const parsed: unknown = JSON.parse(raw);
  165. if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
  166. return parsed as Record<string, string>;
  167. }
  168. } catch {
  169. // not JSON — treat as single "Key: Value" pair
  170. const idx = raw.indexOf(":");
  171. if (idx > 0) {
  172. return { [raw.slice(0, idx).trim()]: raw.slice(idx + 1).trim() };
  173. }
  174. }
  175. return undefined;
  176. }
  177. // === Partial Response (stream interrupted but usable content received) ===
  178. export class PartialResponseError extends Error {
  179. readonly partialContent: string;
  180. constructor(partialContent: string, cause: unknown) {
  181. super(`Stream interrupted after ${partialContent.length} chars: ${String(cause)}`);
  182. this.name = "PartialResponseError";
  183. this.partialContent = partialContent;
  184. }
  185. }
  186. /** Minimum chars to consider a partial response salvageable (Chinese ~2 chars/word → 500 chars ≈ 250 words) */
  187. const MIN_SALVAGEABLE_CHARS = 500;
  188. /** Keys managed by the provider layer — prevent extra from overriding them. */
  189. const RESERVED_KEYS = new Set(["max_tokens", "temperature", "model", "messages", "stream"]);
  190. function stripReservedKeys(extra: Record<string, unknown>): Record<string, unknown> {
  191. const result: Record<string, unknown> = {};
  192. for (const [key, value] of Object.entries(extra)) {
  193. if (!RESERVED_KEYS.has(key)) result[key] = value;
  194. }
  195. return result;
  196. }
  197. // === Fixed-Temperature Model Clamp ===
  198. //
  199. // 部分 thinking 模型(如 Moonshot kimi-k2.5、kimi-thinking-preview)强制要求
  200. // temperature === 1,其他值会被 API 直接 400 拒绝。为让这类模型能和 inkos
  201. // 已有的 per-call 温度调参(0.1 validator → 0.8 architect brainstorm)共存,
  202. // 在 provider 层统一夹制:命中名单就把传入的 temperature 强制改成 1,并对
  203. // 每个模型名打一次 warning 提示用户。
  204. function requiresFixedTemperature(model: string): boolean {
  205. const lower = model.toLowerCase();
  206. // kimi-k2.5 及其子变体(k2.5-preview 等),以及任何名字里带 "thinking" 的模型
  207. return lower.startsWith("kimi-k2.5") || lower.includes("thinking");
  208. }
  209. const warnedFixedTemperatureModels = new Set<string>();
  210. function clampTemperatureForModel(model: string, requested: number): number {
  211. if (!requiresFixedTemperature(model)) return requested;
  212. if (requested === 1) return 1;
  213. if (!warnedFixedTemperatureModels.has(model)) {
  214. warnedFixedTemperatureModels.add(model);
  215. console.warn(
  216. `[inkos] 模型 "${model}" 是 thinking 模型,强制 temperature=1(原请求值 ${requested})`,
  217. );
  218. }
  219. return 1;
  220. }
  221. // 仅测试用:清空 warning 去重集合。
  222. export function __resetFixedTemperatureWarnings(): void {
  223. warnedFixedTemperatureModels.clear();
  224. }
  225. // === Error Wrapping ===
  226. function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; readonly model?: string }): Error {
  227. const msg = String(error);
  228. const ctxLine = context
  229. ? `\n (baseUrl: ${context.baseUrl}, model: ${context.model})`
  230. : "";
  231. if (msg.includes("400")) {
  232. return new Error(
  233. `API 返回 400 (请求参数错误)。可能原因:\n` +
  234. ` 1. 模型名称不正确(检查 INKOS_LLM_MODEL)\n` +
  235. ` 2. 提供方不支持某些参数(如 max_tokens、stream)\n` +
  236. ` 3. 消息格式不兼容(部分提供方不支持 system role)\n` +
  237. ` 建议:检查提供方文档,确认该接口要求流式开启、流式关闭,还是根本不支持 stream${ctxLine}`,
  238. );
  239. }
  240. if (msg.includes("403")) {
  241. return new Error(
  242. `API 返回 403 (请求被拒绝)。可能原因:\n` +
  243. ` 1. API Key 无效或过期\n` +
  244. ` 2. API 提供方的内容审查拦截了请求(公益/免费 API 常见)\n` +
  245. ` 3. 账户余额不足\n` +
  246. ` 建议:用 inkos doctor 测试 API 连通性,或换一个不限制内容的 API 提供方${ctxLine}`,
  247. );
  248. }
  249. if (msg.includes("401")) {
  250. return new Error(
  251. `API 返回 401 (未授权)。请检查 .env 中的 INKOS_LLM_API_KEY 是否正确。${ctxLine}`,
  252. );
  253. }
  254. if (msg.includes("429")) {
  255. return new Error(
  256. `API 返回 429 (请求过多)。请稍后重试,或检查 API 配额。${ctxLine}`,
  257. );
  258. }
  259. if (msg.includes("Connection error") || msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND") || msg.includes("fetch failed")) {
  260. return new Error(
  261. `无法连接到 API 服务。可能原因:\n` +
  262. ` 1. baseUrl 地址不正确(当前:${context?.baseUrl ?? "未知"})\n` +
  263. ` 2. 网络不通或被防火墙拦截\n` +
  264. ` 3. API 服务暂时不可用\n` +
  265. ` 建议:检查 INKOS_LLM_BASE_URL 是否包含完整路径(如 /v1)`,
  266. );
  267. }
  268. return error instanceof Error ? error : new Error(msg);
  269. }
  270. // === Simple Chat (used by all agents via BaseAgent.chat()) ===
  271. export async function chatCompletion(
  272. client: LLMClient,
  273. model: string,
  274. messages: ReadonlyArray<LLMMessage>,
  275. options?: {
  276. readonly temperature?: number;
  277. readonly maxTokens?: number;
  278. readonly webSearch?: boolean;
  279. readonly onStreamProgress?: OnStreamProgress;
  280. readonly onTextDelta?: (text: string) => void;
  281. },
  282. ): Promise<LLMResponse> {
  283. const perCallMax = options?.maxTokens ?? client.defaults.maxTokens;
  284. const cap = client.defaults.maxTokensCap;
  285. const resolved = {
  286. temperature: clampTemperatureForModel(
  287. model,
  288. options?.temperature ?? client.defaults.temperature,
  289. ),
  290. maxTokens: cap !== null ? Math.min(perCallMax, cap) : perCallMax,
  291. extra: client.defaults.extra,
  292. };
  293. const onStreamProgress = options?.onStreamProgress;
  294. const onTextDelta = options?.onTextDelta;
  295. const errorCtx = { baseUrl: client._piModel?.baseUrl ?? "(unknown)", model };
  296. try {
  297. return await chatCompletionViaPiAi(client, model, messages, resolved, onStreamProgress, onTextDelta);
  298. } catch (error) {
  299. // Stream interrupted but partial content is usable — return truncated response
  300. if (error instanceof PartialResponseError) {
  301. return {
  302. content: error.partialContent,
  303. usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
  304. };
  305. }
  306. throw wrapLLMError(error, errorCtx);
  307. }
  308. }
  309. // === Tool-calling Chat (used by agent loop) ===
  310. export async function chatWithTools(
  311. client: LLMClient,
  312. model: string,
  313. messages: ReadonlyArray<AgentMessage>,
  314. tools: ReadonlyArray<ToolDefinition>,
  315. options?: {
  316. readonly temperature?: number;
  317. readonly maxTokens?: number;
  318. },
  319. ): Promise<ChatWithToolsResult> {
  320. try {
  321. const resolved = {
  322. temperature: clampTemperatureForModel(
  323. model,
  324. options?.temperature ?? client.defaults.temperature,
  325. ),
  326. maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
  327. };
  328. return await chatWithToolsViaPiAi(client, model, messages, tools, resolved);
  329. } catch (error) {
  330. throw wrapLLMError(error);
  331. }
  332. }
  333. // === pi-ai Unified Implementation ===
  334. /**
  335. * Build a pi-ai Model<Api> for a specific per-call model name.
  336. * The base template comes from client._piModel (created in createLLMClient);
  337. * we override .id / .name when the caller passes a different model string
  338. * (e.g. agent overrides).
  339. */
  340. function resolvePiModel(client: LLMClient, model: string): PiModel<PiApi> {
  341. const base = client._piModel!;
  342. if (base.id === model) return base;
  343. return { ...base, id: model, name: model };
  344. }
  345. /** Convert inkos LLMMessage[] to pi-ai Context. */
  346. function toPiContext(messages: ReadonlyArray<LLMMessage>): PiContext {
  347. const systemParts = messages.filter((m) => m.role === "system").map((m) => m.content);
  348. const systemPrompt = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
  349. const piMessages = messages
  350. .filter((m) => m.role !== "system")
  351. .map((m) => {
  352. if (m.role === "user") {
  353. return { role: "user" as const, content: m.content, timestamp: Date.now() };
  354. }
  355. // assistant
  356. return {
  357. role: "assistant" as const,
  358. content: [{ type: "text" as const, text: m.content }],
  359. api: "openai-completions" as PiApi,
  360. provider: "openai",
  361. model: "",
  362. usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
  363. stopReason: "stop" as const,
  364. timestamp: Date.now(),
  365. };
  366. });
  367. return { systemPrompt, messages: piMessages };
  368. }
  369. /** Convert inkos AgentMessage[] to pi-ai Context (with tool calls/results). */
  370. function agentMessagesToPiContext(messages: ReadonlyArray<AgentMessage>): PiContext {
  371. const systemParts = messages.filter((m) => m.role === "system").map((m) => (m as { content: string }).content);
  372. const systemPrompt = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
  373. const piMessages: PiContext["messages"] = [];
  374. for (const msg of messages) {
  375. if (msg.role === "system") continue;
  376. if (msg.role === "user") {
  377. piMessages.push({ role: "user", content: msg.content, timestamp: Date.now() });
  378. continue;
  379. }
  380. if (msg.role === "assistant") {
  381. const content: (PiTextContent | PiToolCall)[] = [];
  382. if (msg.content) content.push({ type: "text", text: msg.content });
  383. if (msg.toolCalls) {
  384. for (const tc of msg.toolCalls) {
  385. content.push({
  386. type: "toolCall",
  387. id: tc.id,
  388. name: tc.name,
  389. arguments: JSON.parse(tc.arguments),
  390. });
  391. }
  392. }
  393. if (content.length === 0) content.push({ type: "text", text: "" });
  394. piMessages.push({
  395. role: "assistant",
  396. content,
  397. api: "openai-completions" as PiApi,
  398. provider: "openai",
  399. model: "",
  400. usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
  401. stopReason: "stop",
  402. timestamp: Date.now(),
  403. });
  404. continue;
  405. }
  406. if (msg.role === "tool") {
  407. piMessages.push({
  408. role: "toolResult",
  409. toolCallId: msg.toolCallId,
  410. toolName: "",
  411. content: [{ type: "text", text: msg.content }],
  412. isError: false,
  413. timestamp: Date.now(),
  414. });
  415. }
  416. }
  417. return { systemPrompt, messages: piMessages };
  418. }
  419. /** Convert inkos ToolDefinition[] to pi-ai Tool[]. */
  420. function toPiTools(tools: ReadonlyArray<ToolDefinition>): PiTool[] {
  421. return tools.map((t) => ({
  422. name: t.name,
  423. description: t.description,
  424. parameters: t.parameters as PiTool["parameters"],
  425. }));
  426. }
  427. async function chatCompletionViaPiAi(
  428. client: LLMClient,
  429. model: string,
  430. messages: ReadonlyArray<LLMMessage>,
  431. resolved: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
  432. onStreamProgress?: OnStreamProgress,
  433. onTextDelta?: (text: string) => void,
  434. ): Promise<LLMResponse> {
  435. const piModel = resolvePiModel(client, model);
  436. const context = toPiContext(messages);
  437. const streamOpts = {
  438. temperature: resolved.temperature,
  439. maxTokens: resolved.maxTokens,
  440. apiKey: client._apiKey,
  441. headers: piModel.headers,
  442. };
  443. if (!client.stream) {
  444. const response = await piCompleteSimple(piModel, context, streamOpts);
  445. if (response.stopReason === "error" && response.errorMessage) {
  446. throw new Error(response.errorMessage);
  447. }
  448. const content = response.content
  449. .filter((block): block is { type: "text"; text: string } => block.type === "text")
  450. .map((block) => block.text)
  451. .join("");
  452. if (!content) {
  453. const diag = `usage=${response.usage.input}+${response.usage.output}`;
  454. console.warn(`[inkos] LLM 非流式响应无文本内容 (${diag})`);
  455. throw new Error(`LLM returned empty response (${diag})`);
  456. }
  457. return {
  458. content,
  459. usage: {
  460. promptTokens: response.usage.input,
  461. completionTokens: response.usage.output,
  462. totalTokens: response.usage.totalTokens,
  463. },
  464. };
  465. }
  466. const eventStream = piStreamSimple(piModel, context, streamOpts);
  467. const chunks: string[] = [];
  468. const monitor = createStreamMonitor(onStreamProgress);
  469. let inputTokens = 0;
  470. let outputTokens = 0;
  471. try {
  472. for await (const event of eventStream) {
  473. if (event.type === "text_delta") {
  474. chunks.push(event.delta);
  475. monitor.onChunk(event.delta);
  476. onTextDelta?.(event.delta);
  477. }
  478. if (event.type === "done" || event.type === "error") {
  479. const msg = event.type === "done" ? event.message : event.error;
  480. inputTokens = msg.usage.input;
  481. outputTokens = msg.usage.output;
  482. if (event.type === "error" && msg.errorMessage) {
  483. const partial = chunks.join("");
  484. if (partial.length >= MIN_SALVAGEABLE_CHARS) {
  485. throw new PartialResponseError(partial, new Error(msg.errorMessage));
  486. }
  487. throw new Error(msg.errorMessage);
  488. }
  489. }
  490. }
  491. } catch (streamError) {
  492. monitor.stop();
  493. if (streamError instanceof PartialResponseError) throw streamError;
  494. const partial = chunks.join("");
  495. if (partial.length >= MIN_SALVAGEABLE_CHARS) {
  496. throw new PartialResponseError(partial, streamError);
  497. }
  498. throw streamError;
  499. } finally {
  500. monitor.stop();
  501. }
  502. const content = chunks.join("");
  503. if (!content) {
  504. const diag = `usage=${inputTokens}+${outputTokens}`;
  505. console.warn(`[inkos] LLM 流式响应无文本内容 (${diag})`);
  506. throw new Error(`LLM returned empty response from stream (${diag})`);
  507. }
  508. return {
  509. content,
  510. usage: {
  511. promptTokens: inputTokens,
  512. completionTokens: outputTokens,
  513. totalTokens: inputTokens + outputTokens,
  514. },
  515. };
  516. }
  517. async function chatWithToolsViaPiAi(
  518. client: LLMClient,
  519. model: string,
  520. messages: ReadonlyArray<AgentMessage>,
  521. tools: ReadonlyArray<ToolDefinition>,
  522. resolved: { readonly temperature: number; readonly maxTokens: number },
  523. ): Promise<ChatWithToolsResult> {
  524. const piModel = resolvePiModel(client, model);
  525. const context = agentMessagesToPiContext(messages);
  526. context.tools = toPiTools(tools);
  527. const streamOpts = {
  528. temperature: resolved.temperature,
  529. maxTokens: resolved.maxTokens,
  530. apiKey: client._apiKey,
  531. headers: piModel.headers,
  532. };
  533. if (!client.stream) {
  534. const response = await piComplete(piModel, context, streamOpts);
  535. if (response.stopReason === "error" && response.errorMessage) {
  536. throw new Error(response.errorMessage);
  537. }
  538. const content = response.content
  539. .filter((block): block is { type: "text"; text: string } => block.type === "text")
  540. .map((block) => block.text)
  541. .join("");
  542. const toolCalls = response.content
  543. .filter((block): block is PiToolCall => block.type === "toolCall")
  544. .map((block) => ({
  545. id: block.id,
  546. name: block.name,
  547. arguments: JSON.stringify(block.arguments),
  548. }));
  549. return { content, toolCalls };
  550. }
  551. const eventStream = piStream(piModel, context, streamOpts);
  552. let content = "";
  553. const toolCalls: ToolCall[] = [];
  554. for await (const event of eventStream) {
  555. if (event.type === "text_delta") {
  556. content += event.delta;
  557. }
  558. if (event.type === "toolcall_end") {
  559. toolCalls.push({
  560. id: event.toolCall.id,
  561. name: event.toolCall.name,
  562. arguments: JSON.stringify(event.toolCall.arguments),
  563. });
  564. }
  565. if (event.type === "error" && event.error.errorMessage) {
  566. throw new Error(event.error.errorMessage);
  567. }
  568. }
  569. return { content, toolCalls };
  570. }