|
@@ -1,6 +1,18 @@
|
|
|
-import OpenAI from "openai";
|
|
|
|
|
-import Anthropic from "@anthropic-ai/sdk";
|
|
|
|
|
import type { LLMConfig } from "../models/project.js";
|
|
import type { LLMConfig } from "../models/project.js";
|
|
|
|
|
+import {
|
|
|
|
|
+ streamSimple as piStreamSimple,
|
|
|
|
|
+ stream as piStream,
|
|
|
|
|
+} from "@mariozechner/pi-ai";
|
|
|
|
|
+import type {
|
|
|
|
|
+ Api as PiApi,
|
|
|
|
|
+ Model as PiModel,
|
|
|
|
|
+ Context as PiContext,
|
|
|
|
|
+ AssistantMessageEvent,
|
|
|
|
|
+ Tool as PiTool,
|
|
|
|
|
+ TextContent as PiTextContent,
|
|
|
|
|
+ ToolCall as PiToolCall,
|
|
|
|
|
+} from "@mariozechner/pi-ai";
|
|
|
|
|
+import { resolveServicePreset } from "./service-presets.js";
|
|
|
|
|
|
|
|
// === Streaming Monitor Types ===
|
|
// === Streaming Monitor Types ===
|
|
|
|
|
|
|
@@ -73,8 +85,8 @@ export interface LLMClient {
|
|
|
readonly provider: "openai" | "anthropic";
|
|
readonly provider: "openai" | "anthropic";
|
|
|
readonly apiFormat: "chat" | "responses";
|
|
readonly apiFormat: "chat" | "responses";
|
|
|
readonly stream: boolean;
|
|
readonly stream: boolean;
|
|
|
- readonly _openai?: OpenAI;
|
|
|
|
|
- readonly _anthropic?: Anthropic;
|
|
|
|
|
|
|
+ readonly _piModel?: PiModel<PiApi>;
|
|
|
|
|
+ readonly _apiKey?: string;
|
|
|
readonly defaults: {
|
|
readonly defaults: {
|
|
|
readonly temperature: number;
|
|
readonly temperature: number;
|
|
|
readonly maxTokens: number;
|
|
readonly maxTokens: number;
|
|
@@ -123,28 +135,34 @@ export function createLLMClient(config: LLMConfig): LLMClient {
|
|
|
const apiFormat = config.apiFormat ?? "chat";
|
|
const apiFormat = config.apiFormat ?? "chat";
|
|
|
const stream = config.stream ?? true;
|
|
const stream = config.stream ?? true;
|
|
|
|
|
|
|
|
- if (config.provider === "anthropic") {
|
|
|
|
|
- // Anthropic SDK appends /v1/ internally — strip if user included it
|
|
|
|
|
- const baseURL = config.baseUrl.replace(/\/v1\/?$/, "");
|
|
|
|
|
- return {
|
|
|
|
|
- provider: "anthropic",
|
|
|
|
|
- apiFormat,
|
|
|
|
|
- stream,
|
|
|
|
|
- _anthropic: new Anthropic({ apiKey: config.apiKey, baseURL }),
|
|
|
|
|
- defaults,
|
|
|
|
|
- };
|
|
|
|
|
- }
|
|
|
|
|
- // openai or custom — both use OpenAI SDK
|
|
|
|
|
|
|
+ // --- Build pi-ai Model object ---
|
|
|
|
|
+ const serviceName = config.service ?? "custom";
|
|
|
|
|
+ const preset = resolveServicePreset(serviceName);
|
|
|
|
|
+ const piApi = (preset?.api ?? "openai-completions") as PiApi;
|
|
|
|
|
+ const baseUrl = config.baseUrl || preset?.baseUrl || "";
|
|
|
const extraHeaders = config.headers ?? parseEnvHeaders();
|
|
const extraHeaders = config.headers ?? parseEnvHeaders();
|
|
|
|
|
+
|
|
|
|
|
+ const piModel: PiModel<PiApi> = {
|
|
|
|
|
+ id: config.model,
|
|
|
|
|
+ name: config.model,
|
|
|
|
|
+ api: piApi,
|
|
|
|
|
+ provider: serviceName,
|
|
|
|
|
+ baseUrl,
|
|
|
|
|
+ reasoning: (config.thinkingBudget ?? 0) > 0,
|
|
|
|
|
+ input: ["text"] as ("text" | "image")[],
|
|
|
|
|
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
|
|
|
+ contextWindow: 128_000,
|
|
|
|
|
+ maxTokens: config.maxTokens ?? 8192,
|
|
|
|
|
+ ...(extraHeaders ? { headers: extraHeaders } : {}),
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const provider = config.provider === "anthropic" ? "anthropic" : "openai";
|
|
|
return {
|
|
return {
|
|
|
- provider: "openai",
|
|
|
|
|
|
|
+ provider,
|
|
|
apiFormat,
|
|
apiFormat,
|
|
|
stream,
|
|
stream,
|
|
|
- _openai: new OpenAI({
|
|
|
|
|
- apiKey: config.apiKey,
|
|
|
|
|
- baseURL: config.baseUrl,
|
|
|
|
|
- ...(extraHeaders ? { defaultHeaders: extraHeaders } : {}),
|
|
|
|
|
- }),
|
|
|
|
|
|
|
+ _piModel: piModel,
|
|
|
|
|
+ _apiKey: config.apiKey,
|
|
|
defaults,
|
|
defaults,
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
@@ -273,23 +291,6 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
|
|
|
return error instanceof Error ? error : new Error(msg);
|
|
return error instanceof Error ? error : new Error(msg);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function wrapStreamRequiredError(
|
|
|
|
|
- streamError: unknown,
|
|
|
|
|
- syncError: unknown,
|
|
|
|
|
- context?: { readonly baseUrl?: string; readonly model?: string },
|
|
|
|
|
-): Error {
|
|
|
|
|
- const ctxLine = context
|
|
|
|
|
- ? `\n (baseUrl: ${context.baseUrl}, model: ${context.model})`
|
|
|
|
|
- : "";
|
|
|
|
|
- return new Error(
|
|
|
|
|
- `API 提供方要求使用流式请求(stream:true),不能回退到同步模式。` +
|
|
|
|
|
- `\n 这次失败不是模型名错误,而是前一次流式请求先失败了,随后同步回退又被提供方拒绝。` +
|
|
|
|
|
- `\n 建议:保持 stream:true,并检查该提供方/代理的 SSE 流是否稳定。` +
|
|
|
|
|
- `\n 原始流式错误:${String(streamError)}` +
|
|
|
|
|
- `\n 同步回退错误:${String(syncError)}${ctxLine}`,
|
|
|
|
|
- );
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
// === Simple Chat (used by all agents via BaseAgent.chat()) ===
|
|
// === Simple Chat (used by all agents via BaseAgent.chat()) ===
|
|
|
|
|
|
|
|
export async function chatCompletion(
|
|
export async function chatCompletion(
|
|
@@ -316,22 +317,10 @@ export async function chatCompletion(
|
|
|
};
|
|
};
|
|
|
const onStreamProgress = options?.onStreamProgress;
|
|
const onStreamProgress = options?.onStreamProgress;
|
|
|
const onTextDelta = options?.onTextDelta;
|
|
const onTextDelta = options?.onTextDelta;
|
|
|
- const errorCtx = { baseUrl: client._openai?.baseURL ?? "(anthropic)", model };
|
|
|
|
|
|
|
+ const errorCtx = { baseUrl: client._piModel?.baseUrl ?? "(unknown)", model };
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- if (client.provider === "anthropic") {
|
|
|
|
|
- return client.stream
|
|
|
|
|
- ? await chatCompletionAnthropic(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onStreamProgress, onTextDelta)
|
|
|
|
|
- : await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget, onTextDelta);
|
|
|
|
|
- }
|
|
|
|
|
- if (client.apiFormat === "responses") {
|
|
|
|
|
- return client.stream
|
|
|
|
|
- ? await chatCompletionOpenAIResponses(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
|
|
|
|
|
- : await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
|
|
|
|
|
- }
|
|
|
|
|
- return client.stream
|
|
|
|
|
- ? await chatCompletionOpenAIChat(client._openai!, model, messages, resolved, options?.webSearch, onStreamProgress, onTextDelta)
|
|
|
|
|
- : await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch, onTextDelta);
|
|
|
|
|
|
|
+ return await chatCompletionViaPiAi(client, model, messages, resolved, onStreamProgress, onTextDelta);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
// Stream interrupted but partial content is usable — return truncated response
|
|
// Stream interrupted but partial content is usable — return truncated response
|
|
|
if (error instanceof PartialResponseError) {
|
|
if (error instanceof PartialResponseError) {
|
|
@@ -340,59 +329,10 @@ export async function chatCompletion(
|
|
|
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- // Auto-fallback: if streaming failed, retry with sync (many proxies don't support SSE)
|
|
|
|
|
- if (client.stream) {
|
|
|
|
|
- const isStreamRelated = isLikelyStreamError(error);
|
|
|
|
|
- if (isStreamRelated) {
|
|
|
|
|
- try {
|
|
|
|
|
- if (client.provider === "anthropic") {
|
|
|
|
|
- return await chatCompletionAnthropicSync(client._anthropic!, model, messages, resolved, client.defaults.thinkingBudget);
|
|
|
|
|
- }
|
|
|
|
|
- if (client.apiFormat === "responses") {
|
|
|
|
|
- return await chatCompletionOpenAIResponsesSync(client._openai!, model, messages, resolved, options?.webSearch);
|
|
|
|
|
- }
|
|
|
|
|
- return await chatCompletionOpenAIChatSync(client._openai!, model, messages, resolved, options?.webSearch);
|
|
|
|
|
- } catch (syncError) {
|
|
|
|
|
- if (isStreamRequiredError(syncError)) {
|
|
|
|
|
- throw wrapStreamRequiredError(error, syncError, errorCtx);
|
|
|
|
|
- }
|
|
|
|
|
- throw wrapLLMError(syncError, errorCtx);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
throw wrapLLMError(error, errorCtx);
|
|
throw wrapLLMError(error, errorCtx);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function isLikelyStreamError(error: unknown): boolean {
|
|
|
|
|
- const msg = String(error).toLowerCase();
|
|
|
|
|
- // Common indicators that streaming specifically is the problem:
|
|
|
|
|
- // - SSE parse errors, chunked transfer issues, content-type mismatches
|
|
|
|
|
- // - Some proxies return 400/415 when stream=true
|
|
|
|
|
- // - "stream" mentioned in error, or generic network errors during streaming
|
|
|
|
|
- return (
|
|
|
|
|
- msg.includes("stream") ||
|
|
|
|
|
- msg.includes("text/event-stream") ||
|
|
|
|
|
- msg.includes("chunked") ||
|
|
|
|
|
- msg.includes("unexpected end") ||
|
|
|
|
|
- msg.includes("premature close") ||
|
|
|
|
|
- msg.includes("terminated") ||
|
|
|
|
|
- msg.includes("econnreset") ||
|
|
|
|
|
- (msg.includes("400") && !msg.includes("content"))
|
|
|
|
|
- );
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function isStreamRequiredError(error: unknown): boolean {
|
|
|
|
|
- const msg = String(error).toLowerCase();
|
|
|
|
|
- return (
|
|
|
|
|
- msg.includes("stream must be set to true") ||
|
|
|
|
|
- (msg.includes("stream") && msg.includes("must be set to true")) ||
|
|
|
|
|
- (msg.includes("stream") && msg.includes("required"))
|
|
|
|
|
- );
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
// === Tool-calling Chat (used by agent loop) ===
|
|
// === Tool-calling Chat (used by agent loop) ===
|
|
|
|
|
|
|
|
export async function chatWithTools(
|
|
export async function chatWithTools(
|
|
@@ -413,460 +353,158 @@ export async function chatWithTools(
|
|
|
),
|
|
),
|
|
|
maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
|
|
maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
|
|
|
};
|
|
};
|
|
|
- // Tool-calling always uses streaming (only used by agent loop, not by writer/auditor)
|
|
|
|
|
- if (client.provider === "anthropic") {
|
|
|
|
|
- return await chatWithToolsAnthropic(client._anthropic!, model, messages, tools, resolved, client.defaults.thinkingBudget);
|
|
|
|
|
- }
|
|
|
|
|
- if (client.apiFormat === "responses") {
|
|
|
|
|
- return await chatWithToolsOpenAIResponses(client._openai!, model, messages, tools, resolved);
|
|
|
|
|
- }
|
|
|
|
|
- return await chatWithToolsOpenAIChat(client._openai!, model, messages, tools, resolved);
|
|
|
|
|
|
|
+ return await chatWithToolsViaPiAi(client, model, messages, tools, resolved);
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
throw wrapLLMError(error);
|
|
throw wrapLLMError(error);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// === OpenAI Chat Completions API Implementation (default) ===
|
|
|
|
|
-
|
|
|
|
|
-async function chatCompletionOpenAIChat(
|
|
|
|
|
- client: OpenAI,
|
|
|
|
|
- model: string,
|
|
|
|
|
- messages: ReadonlyArray<LLMMessage>,
|
|
|
|
|
- options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
|
|
|
|
|
- webSearch?: boolean,
|
|
|
|
|
- onStreamProgress?: OnStreamProgress,
|
|
|
|
|
- onTextDelta?: (text: string) => void,
|
|
|
|
|
-): Promise<LLMResponse> {
|
|
|
|
|
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
- const createParams: any = {
|
|
|
|
|
- model,
|
|
|
|
|
- messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
|
|
|
- temperature: options.temperature,
|
|
|
|
|
- max_tokens: options.maxTokens,
|
|
|
|
|
- stream: true,
|
|
|
|
|
- ...(webSearch ? { web_search_options: { search_context_size: "medium" as const } } : {}),
|
|
|
|
|
- ...stripReservedKeys(options.extra),
|
|
|
|
|
- };
|
|
|
|
|
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
- const stream = await client.chat.completions.create(createParams) as any;
|
|
|
|
|
-
|
|
|
|
|
- const chunks: string[] = [];
|
|
|
|
|
- let inputTokens = 0;
|
|
|
|
|
- let outputTokens = 0;
|
|
|
|
|
- const monitor = createStreamMonitor(onStreamProgress);
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- for await (const chunk of stream) {
|
|
|
|
|
- const delta = chunk.choices[0]?.delta?.content;
|
|
|
|
|
- if (delta) {
|
|
|
|
|
- chunks.push(delta);
|
|
|
|
|
- monitor.onChunk(delta);
|
|
|
|
|
- onTextDelta?.(delta);
|
|
|
|
|
- }
|
|
|
|
|
- if (chunk.usage) {
|
|
|
|
|
- inputTokens = chunk.usage.prompt_tokens ?? 0;
|
|
|
|
|
- outputTokens = chunk.usage.completion_tokens ?? 0;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- } catch (streamError) {
|
|
|
|
|
- monitor.stop();
|
|
|
|
|
- const partial = chunks.join("");
|
|
|
|
|
- if (partial.length >= MIN_SALVAGEABLE_CHARS) {
|
|
|
|
|
- throw new PartialResponseError(partial, streamError);
|
|
|
|
|
- }
|
|
|
|
|
- throw streamError;
|
|
|
|
|
- } finally {
|
|
|
|
|
- monitor.stop();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const content = chunks.join("");
|
|
|
|
|
- if (!content) throw new Error("LLM returned empty response from stream");
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- content,
|
|
|
|
|
- usage: {
|
|
|
|
|
- promptTokens: inputTokens,
|
|
|
|
|
- completionTokens: outputTokens,
|
|
|
|
|
- totalTokens: inputTokens + outputTokens,
|
|
|
|
|
- },
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-async function chatCompletionOpenAIChatSync(
|
|
|
|
|
- client: OpenAI,
|
|
|
|
|
- model: string,
|
|
|
|
|
- messages: ReadonlyArray<LLMMessage>,
|
|
|
|
|
- options: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
|
|
|
|
|
- _webSearch?: boolean,
|
|
|
|
|
- onTextDelta?: (text: string) => void,
|
|
|
|
|
-): Promise<LLMResponse> {
|
|
|
|
|
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
|
|
|
- const syncParams: any = {
|
|
|
|
|
- model,
|
|
|
|
|
- messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
|
|
|
- temperature: options.temperature,
|
|
|
|
|
- max_tokens: options.maxTokens,
|
|
|
|
|
- stream: false,
|
|
|
|
|
- ...stripReservedKeys(options.extra),
|
|
|
|
|
- };
|
|
|
|
|
- const response = await client.chat.completions.create(syncParams);
|
|
|
|
|
-
|
|
|
|
|
- const content = response.choices[0]?.message?.content ?? "";
|
|
|
|
|
- if (!content) throw new Error("LLM returned empty response");
|
|
|
|
|
- onTextDelta?.(content);
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- content,
|
|
|
|
|
- usage: {
|
|
|
|
|
- promptTokens: response.usage?.prompt_tokens ?? 0,
|
|
|
|
|
- completionTokens: response.usage?.completion_tokens ?? 0,
|
|
|
|
|
- totalTokens: response.usage?.total_tokens ?? 0,
|
|
|
|
|
- },
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-async function chatWithToolsOpenAIChat(
|
|
|
|
|
- client: OpenAI,
|
|
|
|
|
- model: string,
|
|
|
|
|
- messages: ReadonlyArray<AgentMessage>,
|
|
|
|
|
- tools: ReadonlyArray<ToolDefinition>,
|
|
|
|
|
- options: { readonly temperature: number; readonly maxTokens: number },
|
|
|
|
|
-): Promise<ChatWithToolsResult> {
|
|
|
|
|
- const openaiMessages = agentMessagesToOpenAIChat(messages);
|
|
|
|
|
- const openaiTools: OpenAI.Chat.Completions.ChatCompletionTool[] = tools.map((t) => ({
|
|
|
|
|
- type: "function" as const,
|
|
|
|
|
- function: {
|
|
|
|
|
- name: t.name,
|
|
|
|
|
- description: t.description,
|
|
|
|
|
- parameters: t.parameters,
|
|
|
|
|
- },
|
|
|
|
|
- }));
|
|
|
|
|
-
|
|
|
|
|
- const stream = await client.chat.completions.create({
|
|
|
|
|
- model,
|
|
|
|
|
- messages: openaiMessages,
|
|
|
|
|
- tools: openaiTools,
|
|
|
|
|
- temperature: options.temperature,
|
|
|
|
|
- max_tokens: options.maxTokens,
|
|
|
|
|
- stream: true,
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- let content = "";
|
|
|
|
|
- const toolCallMap = new Map<number, { id: string; name: string; arguments: string }>();
|
|
|
|
|
-
|
|
|
|
|
- for await (const chunk of stream) {
|
|
|
|
|
- const delta = chunk.choices[0]?.delta;
|
|
|
|
|
- if (delta?.content) content += delta.content;
|
|
|
|
|
- if (delta?.tool_calls) {
|
|
|
|
|
- for (const tc of delta.tool_calls) {
|
|
|
|
|
- const existing = toolCallMap.get(tc.index);
|
|
|
|
|
- if (existing) {
|
|
|
|
|
- existing.arguments += tc.function?.arguments ?? "";
|
|
|
|
|
- } else {
|
|
|
|
|
- toolCallMap.set(tc.index, {
|
|
|
|
|
- id: tc.id ?? "",
|
|
|
|
|
- name: tc.function?.name ?? "",
|
|
|
|
|
- arguments: tc.function?.arguments ?? "",
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
|
|
+// === pi-ai Unified Implementation ===
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Build a pi-ai Model<Api> for a specific per-call model name.
|
|
|
|
|
+ * The base template comes from client._piModel (created in createLLMClient);
|
|
|
|
|
+ * we override .id / .name when the caller passes a different model string
|
|
|
|
|
+ * (e.g. agent overrides).
|
|
|
|
|
+ */
|
|
|
|
|
+function resolvePiModel(client: LLMClient, model: string): PiModel<PiApi> {
|
|
|
|
|
+ const base = client._piModel!;
|
|
|
|
|
+ if (base.id === model) return base;
|
|
|
|
|
+ return { ...base, id: model, name: model };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Convert inkos LLMMessage[] to pi-ai Context. */
|
|
|
|
|
+function toPiContext(messages: ReadonlyArray<LLMMessage>): PiContext {
|
|
|
|
|
+ const systemParts = messages.filter((m) => m.role === "system").map((m) => m.content);
|
|
|
|
|
+ const systemPrompt = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
|
|
|
|
|
+ const piMessages = messages
|
|
|
|
|
+ .filter((m) => m.role !== "system")
|
|
|
|
|
+ .map((m) => {
|
|
|
|
|
+ if (m.role === "user") {
|
|
|
|
|
+ return { role: "user" as const, content: m.content, timestamp: Date.now() };
|
|
|
}
|
|
}
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const toolCalls: ToolCall[] = [...toolCallMap.values()];
|
|
|
|
|
- return { content, toolCalls };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function agentMessagesToOpenAIChat(
|
|
|
|
|
- messages: ReadonlyArray<AgentMessage>,
|
|
|
|
|
-): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
|
|
|
|
|
- const result: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [];
|
|
|
|
|
-
|
|
|
|
|
- for (const msg of messages) {
|
|
|
|
|
- if (msg.role === "system") {
|
|
|
|
|
- result.push({ role: "system", content: msg.content });
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
- if (msg.role === "user") {
|
|
|
|
|
- result.push({ role: "user", content: msg.content });
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
- if (msg.role === "assistant") {
|
|
|
|
|
- const assistantMsg: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam = {
|
|
|
|
|
- role: "assistant",
|
|
|
|
|
- content: msg.content ?? null,
|
|
|
|
|
|
|
+ // assistant
|
|
|
|
|
+ return {
|
|
|
|
|
+ role: "assistant" as const,
|
|
|
|
|
+ content: [{ type: "text" as const, text: m.content }],
|
|
|
|
|
+ api: "openai-completions" as PiApi,
|
|
|
|
|
+ provider: "openai",
|
|
|
|
|
+ model: "",
|
|
|
|
|
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
|
|
|
+ stopReason: "stop" as const,
|
|
|
|
|
+ timestamp: Date.now(),
|
|
|
};
|
|
};
|
|
|
- if (msg.toolCalls && msg.toolCalls.length > 0) {
|
|
|
|
|
- assistantMsg.tool_calls = msg.toolCalls.map((tc) => ({
|
|
|
|
|
- id: tc.id,
|
|
|
|
|
- type: "function" as const,
|
|
|
|
|
- function: { name: tc.name, arguments: tc.arguments },
|
|
|
|
|
- }));
|
|
|
|
|
- }
|
|
|
|
|
- result.push(assistantMsg);
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
- if (msg.role === "tool") {
|
|
|
|
|
- result.push({
|
|
|
|
|
- role: "tool",
|
|
|
|
|
- tool_call_id: msg.toolCallId,
|
|
|
|
|
- content: msg.content,
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return result;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// === OpenAI Responses API Implementation (optional) ===
|
|
|
|
|
-
|
|
|
|
|
-async function chatCompletionOpenAIResponses(
|
|
|
|
|
- client: OpenAI,
|
|
|
|
|
- model: string,
|
|
|
|
|
- messages: ReadonlyArray<LLMMessage>,
|
|
|
|
|
- options: { readonly temperature: number; readonly maxTokens: number },
|
|
|
|
|
- webSearch?: boolean,
|
|
|
|
|
- onStreamProgress?: OnStreamProgress,
|
|
|
|
|
- onTextDelta?: (text: string) => void,
|
|
|
|
|
-): Promise<LLMResponse> {
|
|
|
|
|
- const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
|
|
|
|
|
- role: m.role as "system" | "user" | "assistant",
|
|
|
|
|
- content: m.content,
|
|
|
|
|
- }));
|
|
|
|
|
-
|
|
|
|
|
- const tools: OpenAI.Responses.Tool[] | undefined = webSearch
|
|
|
|
|
- ? [{ type: "web_search_preview" as const }]
|
|
|
|
|
- : undefined;
|
|
|
|
|
-
|
|
|
|
|
- const stream = await client.responses.create({
|
|
|
|
|
- model,
|
|
|
|
|
- input,
|
|
|
|
|
- temperature: options.temperature,
|
|
|
|
|
- max_output_tokens: options.maxTokens,
|
|
|
|
|
- stream: true,
|
|
|
|
|
- ...(tools ? { tools } : {}),
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const chunks: string[] = [];
|
|
|
|
|
- let inputTokens = 0;
|
|
|
|
|
- let outputTokens = 0;
|
|
|
|
|
- const monitor = createStreamMonitor(onStreamProgress);
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- for await (const event of stream) {
|
|
|
|
|
- if (event.type === "response.output_text.delta") {
|
|
|
|
|
- chunks.push(event.delta);
|
|
|
|
|
- monitor.onChunk(event.delta);
|
|
|
|
|
- onTextDelta?.(event.delta);
|
|
|
|
|
- }
|
|
|
|
|
- if (event.type === "response.completed") {
|
|
|
|
|
- inputTokens = event.response.usage?.input_tokens ?? 0;
|
|
|
|
|
- outputTokens = event.response.usage?.output_tokens ?? 0;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- } catch (streamError) {
|
|
|
|
|
- monitor.stop();
|
|
|
|
|
- const partial = chunks.join("");
|
|
|
|
|
- if (partial.length >= MIN_SALVAGEABLE_CHARS) {
|
|
|
|
|
- throw new PartialResponseError(partial, streamError);
|
|
|
|
|
- }
|
|
|
|
|
- throw streamError;
|
|
|
|
|
- } finally {
|
|
|
|
|
- monitor.stop();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const content = chunks.join("");
|
|
|
|
|
- if (!content) throw new Error("LLM returned empty response from stream");
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- content,
|
|
|
|
|
- usage: {
|
|
|
|
|
- promptTokens: inputTokens,
|
|
|
|
|
- completionTokens: outputTokens,
|
|
|
|
|
- totalTokens: inputTokens + outputTokens,
|
|
|
|
|
- },
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-async function chatCompletionOpenAIResponsesSync(
|
|
|
|
|
- client: OpenAI,
|
|
|
|
|
- model: string,
|
|
|
|
|
- messages: ReadonlyArray<LLMMessage>,
|
|
|
|
|
- options: { readonly temperature: number; readonly maxTokens: number },
|
|
|
|
|
- _webSearch?: boolean,
|
|
|
|
|
- onTextDelta?: (text: string) => void,
|
|
|
|
|
-): Promise<LLMResponse> {
|
|
|
|
|
- const input: OpenAI.Responses.ResponseInputItem[] = messages.map((m) => ({
|
|
|
|
|
- role: m.role as "system" | "user" | "assistant",
|
|
|
|
|
- content: m.content,
|
|
|
|
|
- }));
|
|
|
|
|
-
|
|
|
|
|
- const response = await client.responses.create({
|
|
|
|
|
- model,
|
|
|
|
|
- input,
|
|
|
|
|
- temperature: options.temperature,
|
|
|
|
|
- max_output_tokens: options.maxTokens,
|
|
|
|
|
- stream: false,
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const content = response.output
|
|
|
|
|
- .filter((item): item is OpenAI.Responses.ResponseOutputMessage => item.type === "message")
|
|
|
|
|
- .flatMap((item) => item.content)
|
|
|
|
|
- .filter((block): block is OpenAI.Responses.ResponseOutputText => block.type === "output_text")
|
|
|
|
|
- .map((block) => block.text)
|
|
|
|
|
- .join("");
|
|
|
|
|
-
|
|
|
|
|
- if (!content) throw new Error("LLM returned empty response");
|
|
|
|
|
- onTextDelta?.(content);
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- content,
|
|
|
|
|
- usage: {
|
|
|
|
|
- promptTokens: response.usage?.input_tokens ?? 0,
|
|
|
|
|
- completionTokens: response.usage?.output_tokens ?? 0,
|
|
|
|
|
- totalTokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0),
|
|
|
|
|
- },
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ });
|
|
|
|
|
+ return { systemPrompt, messages: piMessages };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function chatWithToolsOpenAIResponses(
|
|
|
|
|
- client: OpenAI,
|
|
|
|
|
- model: string,
|
|
|
|
|
- messages: ReadonlyArray<AgentMessage>,
|
|
|
|
|
- tools: ReadonlyArray<ToolDefinition>,
|
|
|
|
|
- options: { readonly temperature: number; readonly maxTokens: number },
|
|
|
|
|
-): Promise<ChatWithToolsResult> {
|
|
|
|
|
- const input = agentMessagesToResponsesInput(messages);
|
|
|
|
|
- const responsesTools: OpenAI.Responses.Tool[] = tools.map((t) => ({
|
|
|
|
|
- type: "function" as const,
|
|
|
|
|
- name: t.name,
|
|
|
|
|
- description: t.description,
|
|
|
|
|
- parameters: t.parameters as OpenAI.Responses.FunctionTool["parameters"],
|
|
|
|
|
- strict: false,
|
|
|
|
|
- }));
|
|
|
|
|
-
|
|
|
|
|
- const stream = await client.responses.create({
|
|
|
|
|
- model,
|
|
|
|
|
- input,
|
|
|
|
|
- tools: responsesTools,
|
|
|
|
|
- temperature: options.temperature,
|
|
|
|
|
- max_output_tokens: options.maxTokens,
|
|
|
|
|
- stream: true,
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- let content = "";
|
|
|
|
|
- const toolCalls: ToolCall[] = [];
|
|
|
|
|
-
|
|
|
|
|
- for await (const event of stream) {
|
|
|
|
|
- if (event.type === "response.output_text.delta") {
|
|
|
|
|
- content += event.delta;
|
|
|
|
|
- }
|
|
|
|
|
- if (event.type === "response.output_item.done" && event.item.type === "function_call") {
|
|
|
|
|
- toolCalls.push({
|
|
|
|
|
- id: event.item.call_id,
|
|
|
|
|
- name: event.item.name,
|
|
|
|
|
- arguments: event.item.arguments,
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return { content, toolCalls };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function agentMessagesToResponsesInput(
|
|
|
|
|
- messages: ReadonlyArray<AgentMessage>,
|
|
|
|
|
-): OpenAI.Responses.ResponseInputItem[] {
|
|
|
|
|
- const result: OpenAI.Responses.ResponseInputItem[] = [];
|
|
|
|
|
-
|
|
|
|
|
|
|
+/** Convert inkos AgentMessage[] to pi-ai Context (with tool calls/results). */
|
|
|
|
|
+function agentMessagesToPiContext(messages: ReadonlyArray<AgentMessage>): PiContext {
|
|
|
|
|
+ const systemParts = messages.filter((m) => m.role === "system").map((m) => (m as { content: string }).content);
|
|
|
|
|
+ const systemPrompt = systemParts.length > 0 ? systemParts.join("\n\n") : undefined;
|
|
|
|
|
+ const piMessages: PiContext["messages"] = [];
|
|
|
for (const msg of messages) {
|
|
for (const msg of messages) {
|
|
|
- if (msg.role === "system") {
|
|
|
|
|
- result.push({ role: "system", content: msg.content });
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (msg.role === "system") continue;
|
|
|
if (msg.role === "user") {
|
|
if (msg.role === "user") {
|
|
|
- result.push({ role: "user", content: msg.content });
|
|
|
|
|
|
|
+ piMessages.push({ role: "user", content: msg.content, timestamp: Date.now() });
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
if (msg.role === "assistant") {
|
|
if (msg.role === "assistant") {
|
|
|
- if (msg.content) {
|
|
|
|
|
- result.push({ role: "assistant", content: msg.content });
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ const content: (PiTextContent | PiToolCall)[] = [];
|
|
|
|
|
+ if (msg.content) content.push({ type: "text", text: msg.content });
|
|
|
if (msg.toolCalls) {
|
|
if (msg.toolCalls) {
|
|
|
for (const tc of msg.toolCalls) {
|
|
for (const tc of msg.toolCalls) {
|
|
|
- result.push({
|
|
|
|
|
- type: "function_call" as const,
|
|
|
|
|
- call_id: tc.id,
|
|
|
|
|
|
|
+ content.push({
|
|
|
|
|
+ type: "toolCall",
|
|
|
|
|
+ id: tc.id,
|
|
|
name: tc.name,
|
|
name: tc.name,
|
|
|
- arguments: tc.arguments,
|
|
|
|
|
|
|
+ arguments: JSON.parse(tc.arguments),
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ if (content.length === 0) content.push({ type: "text", text: "" });
|
|
|
|
|
+ piMessages.push({
|
|
|
|
|
+ role: "assistant",
|
|
|
|
|
+ content,
|
|
|
|
|
+ api: "openai-completions" as PiApi,
|
|
|
|
|
+ provider: "openai",
|
|
|
|
|
+ model: "",
|
|
|
|
|
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
|
|
|
+ stopReason: "stop",
|
|
|
|
|
+ timestamp: Date.now(),
|
|
|
|
|
+ });
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
if (msg.role === "tool") {
|
|
if (msg.role === "tool") {
|
|
|
- result.push({
|
|
|
|
|
- type: "function_call_output" as const,
|
|
|
|
|
- call_id: msg.toolCallId,
|
|
|
|
|
- output: msg.content,
|
|
|
|
|
|
|
+ piMessages.push({
|
|
|
|
|
+ role: "toolResult",
|
|
|
|
|
+ toolCallId: msg.toolCallId,
|
|
|
|
|
+ toolName: "",
|
|
|
|
|
+ content: [{ type: "text", text: msg.content }],
|
|
|
|
|
+ isError: false,
|
|
|
|
|
+ timestamp: Date.now(),
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- return result;
|
|
|
|
|
|
|
+ return { systemPrompt, messages: piMessages };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// === Anthropic Implementation ===
|
|
|
|
|
|
|
+/** Convert inkos ToolDefinition[] to pi-ai Tool[]. */
|
|
|
|
|
+function toPiTools(tools: ReadonlyArray<ToolDefinition>): PiTool[] {
|
|
|
|
|
+ return tools.map((t) => ({
|
|
|
|
|
+ name: t.name,
|
|
|
|
|
+ description: t.description,
|
|
|
|
|
+ parameters: t.parameters as PiTool["parameters"],
|
|
|
|
|
+ }));
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
-async function chatCompletionAnthropic(
|
|
|
|
|
- client: Anthropic,
|
|
|
|
|
|
|
+async function chatCompletionViaPiAi(
|
|
|
|
|
+ client: LLMClient,
|
|
|
model: string,
|
|
model: string,
|
|
|
messages: ReadonlyArray<LLMMessage>,
|
|
messages: ReadonlyArray<LLMMessage>,
|
|
|
- options: { readonly temperature: number; readonly maxTokens: number },
|
|
|
|
|
- thinkingBudget: number = 0,
|
|
|
|
|
|
|
+ resolved: { readonly temperature: number; readonly maxTokens: number; readonly extra: Record<string, unknown> },
|
|
|
onStreamProgress?: OnStreamProgress,
|
|
onStreamProgress?: OnStreamProgress,
|
|
|
onTextDelta?: (text: string) => void,
|
|
onTextDelta?: (text: string) => void,
|
|
|
): Promise<LLMResponse> {
|
|
): Promise<LLMResponse> {
|
|
|
- const systemText = messages
|
|
|
|
|
- .filter((m) => m.role === "system")
|
|
|
|
|
- .map((m) => m.content)
|
|
|
|
|
- .join("\n\n");
|
|
|
|
|
- const nonSystem = messages.filter((m) => m.role !== "system");
|
|
|
|
|
-
|
|
|
|
|
- const stream = await client.messages.create({
|
|
|
|
|
- model,
|
|
|
|
|
- ...(systemText ? { system: systemText } : {}),
|
|
|
|
|
- messages: nonSystem.map((m) => ({
|
|
|
|
|
- role: m.role as "user" | "assistant",
|
|
|
|
|
- content: m.content,
|
|
|
|
|
- })),
|
|
|
|
|
- ...(thinkingBudget > 0
|
|
|
|
|
- ? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
|
|
|
|
|
- : { temperature: options.temperature }),
|
|
|
|
|
- max_tokens: options.maxTokens,
|
|
|
|
|
- stream: true,
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const piModel = resolvePiModel(client, model);
|
|
|
|
|
+ const context = toPiContext(messages);
|
|
|
|
|
+ const streamOpts = {
|
|
|
|
|
+ temperature: resolved.temperature,
|
|
|
|
|
+ maxTokens: resolved.maxTokens,
|
|
|
|
|
+ apiKey: client._apiKey,
|
|
|
|
|
+ headers: piModel.headers,
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
|
|
+ const eventStream = piStreamSimple(piModel, context, streamOpts);
|
|
|
const chunks: string[] = [];
|
|
const chunks: string[] = [];
|
|
|
|
|
+ const monitor = createStreamMonitor(onStreamProgress);
|
|
|
let inputTokens = 0;
|
|
let inputTokens = 0;
|
|
|
let outputTokens = 0;
|
|
let outputTokens = 0;
|
|
|
- const monitor = createStreamMonitor(onStreamProgress);
|
|
|
|
|
|
|
|
|
|
try {
|
|
try {
|
|
|
- for await (const event of stream) {
|
|
|
|
|
- if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
|
|
|
|
|
- chunks.push(event.delta.text);
|
|
|
|
|
- monitor.onChunk(event.delta.text);
|
|
|
|
|
- onTextDelta?.(event.delta.text);
|
|
|
|
|
- }
|
|
|
|
|
- if (event.type === "message_start") {
|
|
|
|
|
- inputTokens = event.message.usage?.input_tokens ?? 0;
|
|
|
|
|
|
|
+ for await (const event of eventStream) {
|
|
|
|
|
+ if (event.type === "text_delta") {
|
|
|
|
|
+ chunks.push(event.delta);
|
|
|
|
|
+ monitor.onChunk(event.delta);
|
|
|
|
|
+ onTextDelta?.(event.delta);
|
|
|
}
|
|
}
|
|
|
- if (event.type === "message_delta") {
|
|
|
|
|
- outputTokens = ((event as unknown as { usage?: { output_tokens?: number } }).usage?.output_tokens) ?? 0;
|
|
|
|
|
|
|
+ if (event.type === "done" || event.type === "error") {
|
|
|
|
|
+ const msg = event.type === "done" ? event.message : event.error;
|
|
|
|
|
+ inputTokens = msg.usage.input;
|
|
|
|
|
+ outputTokens = msg.usage.output;
|
|
|
|
|
+ if (event.type === "error" && msg.errorMessage) {
|
|
|
|
|
+ // Check if we have partial content worth salvaging
|
|
|
|
|
+ const partial = chunks.join("");
|
|
|
|
|
+ if (partial.length >= MIN_SALVAGEABLE_CHARS) {
|
|
|
|
|
+ throw new PartialResponseError(partial, new Error(msg.errorMessage));
|
|
|
|
|
+ }
|
|
|
|
|
+ throw new Error(msg.errorMessage);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
} catch (streamError) {
|
|
} catch (streamError) {
|
|
|
monitor.stop();
|
|
monitor.stop();
|
|
|
|
|
+ if (streamError instanceof PartialResponseError) throw streamError;
|
|
|
const partial = chunks.join("");
|
|
const partial = chunks.join("");
|
|
|
if (partial.length >= MIN_SALVAGEABLE_CHARS) {
|
|
if (partial.length >= MIN_SALVAGEABLE_CHARS) {
|
|
|
throw new PartialResponseError(partial, streamError);
|
|
throw new PartialResponseError(partial, streamError);
|
|
@@ -877,7 +515,11 @@ async function chatCompletionAnthropic(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const content = chunks.join("");
|
|
const content = chunks.join("");
|
|
|
- if (!content) throw new Error("LLM returned empty response from stream");
|
|
|
|
|
|
|
+ if (!content) {
|
|
|
|
|
+ const diag = `usage=${inputTokens}+${outputTokens}`;
|
|
|
|
|
+ console.warn(`[inkos] LLM 流式响应无文本内容 (${diag})`);
|
|
|
|
|
+ throw new Error(`LLM returned empty response from stream (${diag})`);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
content,
|
|
content,
|
|
@@ -889,167 +531,42 @@ async function chatCompletionAnthropic(
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function chatCompletionAnthropicSync(
|
|
|
|
|
- client: Anthropic,
|
|
|
|
|
- model: string,
|
|
|
|
|
- messages: ReadonlyArray<LLMMessage>,
|
|
|
|
|
- options: { readonly temperature: number; readonly maxTokens: number },
|
|
|
|
|
- thinkingBudget: number = 0,
|
|
|
|
|
- onTextDelta?: (text: string) => void,
|
|
|
|
|
-): Promise<LLMResponse> {
|
|
|
|
|
- const systemText = messages
|
|
|
|
|
- .filter((m) => m.role === "system")
|
|
|
|
|
- .map((m) => m.content)
|
|
|
|
|
- .join("\n\n");
|
|
|
|
|
- const nonSystem = messages.filter((m) => m.role !== "system");
|
|
|
|
|
-
|
|
|
|
|
- const response = await client.messages.create({
|
|
|
|
|
- model,
|
|
|
|
|
- ...(systemText ? { system: systemText } : {}),
|
|
|
|
|
- messages: nonSystem.map((m) => ({
|
|
|
|
|
- role: m.role as "user" | "assistant",
|
|
|
|
|
- content: m.content,
|
|
|
|
|
- })),
|
|
|
|
|
- ...(thinkingBudget > 0
|
|
|
|
|
- ? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
|
|
|
|
|
- : { temperature: options.temperature }),
|
|
|
|
|
- max_tokens: options.maxTokens,
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- const content = response.content
|
|
|
|
|
- .filter((block): block is Anthropic.Messages.TextBlock => block.type === "text")
|
|
|
|
|
- .map((block) => block.text)
|
|
|
|
|
- .join("");
|
|
|
|
|
-
|
|
|
|
|
- if (!content) throw new Error("LLM returned empty response");
|
|
|
|
|
- onTextDelta?.(content);
|
|
|
|
|
-
|
|
|
|
|
- return {
|
|
|
|
|
- content,
|
|
|
|
|
- usage: {
|
|
|
|
|
- promptTokens: response.usage?.input_tokens ?? 0,
|
|
|
|
|
- completionTokens: response.usage?.output_tokens ?? 0,
|
|
|
|
|
- totalTokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0),
|
|
|
|
|
- },
|
|
|
|
|
- };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-async function chatWithToolsAnthropic(
|
|
|
|
|
- client: Anthropic,
|
|
|
|
|
|
|
+async function chatWithToolsViaPiAi(
|
|
|
|
|
+ client: LLMClient,
|
|
|
model: string,
|
|
model: string,
|
|
|
messages: ReadonlyArray<AgentMessage>,
|
|
messages: ReadonlyArray<AgentMessage>,
|
|
|
tools: ReadonlyArray<ToolDefinition>,
|
|
tools: ReadonlyArray<ToolDefinition>,
|
|
|
- options: { readonly temperature: number; readonly maxTokens: number },
|
|
|
|
|
- thinkingBudget: number = 0,
|
|
|
|
|
|
|
+ resolved: { readonly temperature: number; readonly maxTokens: number },
|
|
|
): Promise<ChatWithToolsResult> {
|
|
): Promise<ChatWithToolsResult> {
|
|
|
- const systemText = messages
|
|
|
|
|
- .filter((m) => m.role === "system")
|
|
|
|
|
- .map((m) => (m as { content: string }).content)
|
|
|
|
|
- .join("\n\n");
|
|
|
|
|
- const nonSystem = messages.filter((m) => m.role !== "system");
|
|
|
|
|
-
|
|
|
|
|
- const anthropicMessages = agentMessagesToAnthropic(nonSystem);
|
|
|
|
|
- const anthropicTools = tools.map((t) => ({
|
|
|
|
|
- name: t.name,
|
|
|
|
|
- description: t.description,
|
|
|
|
|
- input_schema: t.parameters as Anthropic.Messages.Tool.InputSchema,
|
|
|
|
|
- }));
|
|
|
|
|
-
|
|
|
|
|
- const stream = await client.messages.create({
|
|
|
|
|
- model,
|
|
|
|
|
- ...(systemText ? { system: systemText } : {}),
|
|
|
|
|
- messages: anthropicMessages,
|
|
|
|
|
- tools: anthropicTools,
|
|
|
|
|
- ...(thinkingBudget > 0
|
|
|
|
|
- ? { thinking: { type: "enabled" as const, budget_tokens: thinkingBudget } }
|
|
|
|
|
- : { temperature: options.temperature }),
|
|
|
|
|
- max_tokens: options.maxTokens,
|
|
|
|
|
- stream: true,
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const piModel = resolvePiModel(client, model);
|
|
|
|
|
+ const context = agentMessagesToPiContext(messages);
|
|
|
|
|
+ context.tools = toPiTools(tools);
|
|
|
|
|
+ const streamOpts = {
|
|
|
|
|
+ temperature: resolved.temperature,
|
|
|
|
|
+ maxTokens: resolved.maxTokens,
|
|
|
|
|
+ apiKey: client._apiKey,
|
|
|
|
|
+ headers: piModel.headers,
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
|
|
+ const eventStream = piStream(piModel, context, streamOpts);
|
|
|
let content = "";
|
|
let content = "";
|
|
|
const toolCalls: ToolCall[] = [];
|
|
const toolCalls: ToolCall[] = [];
|
|
|
- let currentBlock: { id: string; name: string; input: string } | null = null;
|
|
|
|
|
|
|
|
|
|
- for await (const event of stream) {
|
|
|
|
|
- if (event.type === "content_block_start" && event.content_block.type === "tool_use") {
|
|
|
|
|
- currentBlock = {
|
|
|
|
|
- id: event.content_block.id,
|
|
|
|
|
- name: event.content_block.name,
|
|
|
|
|
- input: "",
|
|
|
|
|
- };
|
|
|
|
|
- }
|
|
|
|
|
- if (event.type === "content_block_delta") {
|
|
|
|
|
- if (event.delta.type === "text_delta") {
|
|
|
|
|
- content += event.delta.text;
|
|
|
|
|
- }
|
|
|
|
|
- if (event.delta.type === "input_json_delta" && currentBlock) {
|
|
|
|
|
- currentBlock.input += event.delta.partial_json;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ for await (const event of eventStream) {
|
|
|
|
|
+ if (event.type === "text_delta") {
|
|
|
|
|
+ content += event.delta;
|
|
|
}
|
|
}
|
|
|
- if (event.type === "content_block_stop" && currentBlock) {
|
|
|
|
|
|
|
+ if (event.type === "toolcall_end") {
|
|
|
toolCalls.push({
|
|
toolCalls.push({
|
|
|
- id: currentBlock.id,
|
|
|
|
|
- name: currentBlock.name,
|
|
|
|
|
- arguments: currentBlock.input,
|
|
|
|
|
|
|
+ id: event.toolCall.id,
|
|
|
|
|
+ name: event.toolCall.name,
|
|
|
|
|
+ arguments: JSON.stringify(event.toolCall.arguments),
|
|
|
});
|
|
});
|
|
|
- currentBlock = null;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- return { content, toolCalls };
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-function agentMessagesToAnthropic(
|
|
|
|
|
- messages: ReadonlyArray<AgentMessage>,
|
|
|
|
|
-): Anthropic.Messages.MessageParam[] {
|
|
|
|
|
- const result: Anthropic.Messages.MessageParam[] = [];
|
|
|
|
|
-
|
|
|
|
|
- for (const msg of messages) {
|
|
|
|
|
- if (msg.role === "system") continue;
|
|
|
|
|
-
|
|
|
|
|
- if (msg.role === "user") {
|
|
|
|
|
- result.push({ role: "user", content: msg.content });
|
|
|
|
|
- continue;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (msg.role === "assistant") {
|
|
|
|
|
- const blocks: Anthropic.Messages.ContentBlockParam[] = [];
|
|
|
|
|
- if (msg.content) {
|
|
|
|
|
- blocks.push({ type: "text", text: msg.content });
|
|
|
|
|
- }
|
|
|
|
|
- if (msg.toolCalls) {
|
|
|
|
|
- for (const tc of msg.toolCalls) {
|
|
|
|
|
- blocks.push({
|
|
|
|
|
- type: "tool_use",
|
|
|
|
|
- id: tc.id,
|
|
|
|
|
- name: tc.name,
|
|
|
|
|
- input: JSON.parse(tc.arguments),
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- if (blocks.length === 0) {
|
|
|
|
|
- blocks.push({ type: "text", text: "" });
|
|
|
|
|
- }
|
|
|
|
|
- result.push({ role: "assistant", content: blocks });
|
|
|
|
|
- continue;
|
|
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- if (msg.role === "tool") {
|
|
|
|
|
- const toolResult: Anthropic.Messages.ToolResultBlockParam = {
|
|
|
|
|
- type: "tool_result",
|
|
|
|
|
- tool_use_id: msg.toolCallId,
|
|
|
|
|
- content: msg.content,
|
|
|
|
|
- };
|
|
|
|
|
- // Merge consecutive tool results into one user message (Anthropic requires alternating roles)
|
|
|
|
|
- const prev = result[result.length - 1];
|
|
|
|
|
- if (prev && prev.role === "user" && Array.isArray(prev.content)) {
|
|
|
|
|
- (prev.content as Anthropic.Messages.ToolResultBlockParam[]).push(toolResult);
|
|
|
|
|
- } else {
|
|
|
|
|
- result.push({ role: "user", content: [toolResult] });
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ if (event.type === "error" && event.error.errorMessage) {
|
|
|
|
|
+ throw new Error(event.error.errorMessage);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return result;
|
|
|
|
|
|
|
+ return { content, toolCalls };
|
|
|
}
|
|
}
|