Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 297b053a91 |
@@ -56,7 +56,7 @@ Chat upload limits:
|
||||
```
|
||||
|
||||
Behavior notes:
|
||||
- Lists Sybil-managed chat tools that can be enabled for `openai` and `xai` chat completions.
|
||||
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, and `xai` chat completions.
|
||||
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
|
||||
|
||||
## Active Runs
|
||||
@@ -291,13 +291,14 @@ Behavior notes:
|
||||
- Images are forwarded inline to providers as multimodal image parts. Use PNG or JPEG for cross-provider compatibility.
|
||||
- Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission.
|
||||
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
|
||||
- For `anthropic`, backend calls Anthropic's Messages API and enables internal tool use with Anthropic `tool_use`/`tool_result` content blocks.
|
||||
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
|
||||
- For `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
|
||||
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
|
||||
- For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
|
||||
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
|
||||
- Available Sybil-managed tool calls for `openai` and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||
- Available Sybil-managed tool calls for `openai`, `anthropic`, and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
|
||||
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
|
||||
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
|
||||
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
|
||||
@@ -315,7 +316,6 @@ Behavior notes:
|
||||
- `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
|
||||
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
|
||||
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
|
||||
- `anthropic` currently runs without server-managed tool calls.
|
||||
|
||||
## Searches
|
||||
|
||||
|
||||
@@ -171,19 +171,20 @@ Terminal tool-call event:
|
||||
## Provider Streaming Behavior
|
||||
|
||||
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
|
||||
- `anthropic`: backend uses Anthropic's Messages API and may execute the same internal tools with `tool_use`/`tool_result` content blocks before producing final text.
|
||||
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
|
||||
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients.
|
||||
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
|
||||
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
|
||||
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
|
||||
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
|
||||
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
|
||||
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`, and emits normalized `tool_call` SSE events when Anthropic `tool_use` blocks are executed. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
|
||||
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
|
||||
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
|
||||
- `shell_exec` is available only when `CHAT_SHELL_TOOL_ENABLED=true`. It uses the same devbox SSH configuration, starts in `CHAT_CODEX_REMOTE_WORKDIR`, and runs non-interactive shell commands there with SSH stdin closed, not inside the Sybil server container.
|
||||
- `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100.
|
||||
|
||||
Tool-enabled streaming notes (`openai`/`xai`):
|
||||
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`):
|
||||
- Stream still emits standard `meta`, `delta`, `done|error` events.
|
||||
- Stream may emit `tool_call` events while tool calls are executed.
|
||||
- `delta` events carry assistant text and are emitted incrementally for normal text rounds. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls.
|
||||
|
||||
@@ -4,20 +4,14 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { convert as htmlToText } from "html-to-text";
|
||||
import type OpenAI from "openai";
|
||||
import { z } from "zod";
|
||||
import { buildBrowserLikeNavigationHeaders } from "../browser-fetch-headers.js";
|
||||
import { env } from "../env.js";
|
||||
import { exaClient } from "../search/exa.js";
|
||||
import { searchSearxng } from "../search/searxng.js";
|
||||
import {
|
||||
buildOpenAIConversationMessage,
|
||||
buildOpenAIResponsesInputMessage,
|
||||
buildSystemPromptAugmentationMessage,
|
||||
} from "./message-content.js";
|
||||
import type { ChatMessage } from "./types.js";
|
||||
|
||||
const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
||||
export const MAX_TOOL_ROUNDS = env.CHAT_MAX_TOOL_ROUNDS;
|
||||
const DEFAULT_WEB_RESULTS = 5;
|
||||
const MAX_WEB_RESULTS = 10;
|
||||
const DEFAULT_FETCH_MAX_CHARACTERS = 12_000;
|
||||
@@ -30,7 +24,7 @@ const MAX_SHELL_COMMAND_CHARACTERS = 20_000;
|
||||
const DEFAULT_SHELL_MAX_OUTPUT_CHARACTERS = 24_000;
|
||||
const MAX_SHELL_MAX_OUTPUT_CHARACTERS = 80_000;
|
||||
const REMOTE_EXEC_MAX_BUFFER_BYTES = 1_000_000;
|
||||
const MAX_DANGLING_TOOL_INTENT_RETRIES = 1;
|
||||
export const MAX_DANGLING_TOOL_INTENT_RETRIES = 1;
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -220,7 +214,7 @@ function getEnabledToolSet(params: Pick<ToolAwareCompletionParams, "enabledTools
|
||||
return new Set(normalizeEnabledChatTools(params.enabledTools));
|
||||
}
|
||||
|
||||
function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||
export function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||
const enabled = getEnabledToolSet(params);
|
||||
return CHAT_TOOLS.filter((tool) => {
|
||||
const name = getToolName(tool);
|
||||
@@ -228,19 +222,6 @@ function getEnabledChatTools(params: Pick<ToolAwareCompletionParams, "enabledToo
|
||||
});
|
||||
}
|
||||
|
||||
function toResponsesChatTools(tools: any[]) {
|
||||
return tools.map((tool) => {
|
||||
if (tool?.type !== "function") return tool;
|
||||
return {
|
||||
type: "function",
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
strict: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const CHAT_TOOL_SYSTEM_PROMPT =
|
||||
"You can use tools to gather up-to-date web information when needed. " +
|
||||
"Use web_search for discovery and recent facts, and fetch_url to read the full content of a specific page. " +
|
||||
@@ -254,18 +235,18 @@ export const CHAT_TOOL_SYSTEM_PROMPT =
|
||||
: "") +
|
||||
"Do not fabricate tool outputs; reason only from provided tool results.";
|
||||
|
||||
type ToolRunOutcome = {
|
||||
export type ToolRunOutcome = {
|
||||
ok: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type ToolAwareUsage = {
|
||||
export type ToolAwareUsage = {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
|
||||
type ToolAwareCompletionResult = {
|
||||
export type ToolAwareCompletionResult = {
|
||||
text: string;
|
||||
usage?: ToolAwareUsage;
|
||||
raw: unknown;
|
||||
@@ -277,8 +258,8 @@ export type ToolAwareStreamingEvent =
|
||||
| { type: "tool_call"; event: ToolExecutionEvent }
|
||||
| { type: "done"; result: ToolAwareCompletionResult };
|
||||
|
||||
type ToolAwareCompletionParams = {
|
||||
client: OpenAI;
|
||||
export type ToolAwareCompletionParams = {
|
||||
client: any;
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
enabledTools?: string[];
|
||||
@@ -440,7 +421,7 @@ function extractHtmlTitle(html: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||
export function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enabledTools">) {
|
||||
const enabled = getEnabledToolSet(params);
|
||||
return (
|
||||
"You can use tools to gather up-to-date web information when needed. " +
|
||||
@@ -458,22 +439,6 @@ function buildChatToolSystemPrompt(params: Pick<ToolAwareCompletionParams, "enab
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeIncomingMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||
const normalized = messages.map((message) => buildOpenAIConversationMessage(message));
|
||||
|
||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||
}
|
||||
|
||||
function normalizePlainIncomingMessages(messages: ChatMessage[], userLocation?: string) {
|
||||
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildOpenAIConversationMessage(message))];
|
||||
}
|
||||
|
||||
function normalizeIncomingResponsesInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||
const normalized = messages.map((message) => buildOpenAIResponsesInputMessage(message));
|
||||
|
||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||
}
|
||||
|
||||
async function runExaWebSearchTool(args: WebSearchArgs): Promise<ToolRunOutcome> {
|
||||
const exa = exaClient();
|
||||
const response = await exa.search(args.query, {
|
||||
@@ -842,7 +807,7 @@ async function executeTool(name: string, args: unknown): Promise<ToolRunOutcome>
|
||||
return { ok: false, error: `Unknown tool: ${name}` };
|
||||
}
|
||||
|
||||
function parseToolArgs(raw: unknown) {
|
||||
export function parseToolArgs(raw: unknown) {
|
||||
if (typeof raw !== "string") return {};
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
@@ -871,7 +836,7 @@ function buildEventArgs(name: string, args: Record<string, unknown>) {
|
||||
return args;
|
||||
}
|
||||
|
||||
function looksLikeDanglingToolIntent(text: string) {
|
||||
export function looksLikeDanglingToolIntent(text: string) {
|
||||
const normalized = text
|
||||
.toLowerCase()
|
||||
.replace(/[`*_>#-]/g, " ")
|
||||
@@ -887,7 +852,7 @@ function looksLikeDanglingToolIntent(text: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function appendDanglingToolIntentCorrection(conversation: any[], text: string) {
|
||||
export function appendDanglingToolIntentCorrection(conversation: any[], text: string) {
|
||||
conversation.push({ role: "assistant", content: text });
|
||||
conversation.push({
|
||||
role: "system",
|
||||
@@ -896,7 +861,7 @@ function appendDanglingToolIntentCorrection(conversation: any[], text: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
export function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
if (!usage) return false;
|
||||
acc.inputTokens += usage.prompt_tokens ?? 0;
|
||||
acc.outputTokens += usage.completion_tokens ?? 0;
|
||||
@@ -904,79 +869,19 @@ function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeResponsesUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
if (!usage) return false;
|
||||
acc.inputTokens += usage.input_tokens ?? 0;
|
||||
acc.outputTokens += usage.output_tokens ?? 0;
|
||||
acc.totalTokens += usage.total_tokens ?? 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getResponseOutputItems(response: any) {
|
||||
return Array.isArray(response?.output) ? response.output : [];
|
||||
}
|
||||
|
||||
function extractResponsesText(response: any, fallback = "") {
|
||||
if (typeof response?.output_text === "string") return response.output_text;
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const item of getResponseOutputItems(response)) {
|
||||
if (item?.type !== "message" || !Array.isArray(item.content)) continue;
|
||||
for (const content of item.content) {
|
||||
if (content?.type === "output_text" && typeof content.text === "string") {
|
||||
parts.push(content.text);
|
||||
} else if (content?.type === "refusal" && typeof content.refusal === "string") {
|
||||
parts.push(content.refusal);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("") || fallback;
|
||||
}
|
||||
|
||||
function extractChatCompletionContent(message: any) {
|
||||
if (typeof message?.content === "string") return message.content;
|
||||
if (!Array.isArray(message?.content)) return "";
|
||||
|
||||
return message.content
|
||||
.map((part: any) => {
|
||||
if (typeof part === "string") return part;
|
||||
if (typeof part?.text === "string") return part.text;
|
||||
if (typeof part?.content === "string") return part.content;
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function getUnstreamedText(finalText: string, streamedText: string) {
|
||||
export function getUnstreamedText(finalText: string, streamedText: string) {
|
||||
if (!finalText) return "";
|
||||
if (!streamedText) return finalText;
|
||||
return finalText.startsWith(streamedText) ? finalText.slice(streamedText.length) : "";
|
||||
}
|
||||
|
||||
function getResponseFailureMessage(response: any) {
|
||||
if (response?.status !== "failed" && response?.status !== "incomplete") return null;
|
||||
const errorMessage = typeof response?.error?.message === "string" ? response.error.message : null;
|
||||
const incompleteReason = typeof response?.incomplete_details?.reason === "string" ? response.incomplete_details.reason : null;
|
||||
return errorMessage ?? (incompleteReason ? `Response incomplete: ${incompleteReason}` : `Response ${response.status}.`);
|
||||
}
|
||||
|
||||
function normalizeResponsesToolCalls(outputItems: any[], round: number): NormalizedToolCall[] {
|
||||
return outputItems
|
||||
.filter((item) => item?.type === "function_call")
|
||||
.map((call: any, index: number) => ({
|
||||
id: call.call_id ?? call.id ?? `tool_call_${round}_${index}`,
|
||||
name: call.name ?? "unknown_tool",
|
||||
arguments: call.arguments ?? "{}",
|
||||
}));
|
||||
}
|
||||
|
||||
type NormalizedToolCall = {
|
||||
export type NormalizedToolCall = {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
|
||||
function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToolCall[] {
|
||||
export function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToolCall[] {
|
||||
return toolCalls.map((call: any, index: number) => ({
|
||||
id: call?.id ?? `tool_call_${round}_${index}`,
|
||||
name: call?.function?.name ?? "unknown_tool",
|
||||
@@ -984,7 +889,7 @@ function normalizeModelToolCalls(toolCalls: any[], round: number): NormalizedToo
|
||||
}));
|
||||
}
|
||||
|
||||
type PreparedToolCallExecution = {
|
||||
export type PreparedToolCallExecution = {
|
||||
startedAtMs: number;
|
||||
startedAt: string;
|
||||
parsedArgs: Record<string, unknown>;
|
||||
@@ -992,7 +897,7 @@ type PreparedToolCallExecution = {
|
||||
parseError?: unknown;
|
||||
};
|
||||
|
||||
function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecutionEvent; execution: PreparedToolCallExecution } {
|
||||
export function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecutionEvent; execution: PreparedToolCallExecution } {
|
||||
const startedAtMs = Date.now();
|
||||
const startedAt = new Date(startedAtMs).toISOString();
|
||||
let parsedArgs: Record<string, unknown> = {};
|
||||
@@ -1024,7 +929,7 @@ function prepareToolCallExecution(call: NormalizedToolCall): { event: ToolExecut
|
||||
};
|
||||
}
|
||||
|
||||
async function executeToolCallAndBuildEvent(
|
||||
export async function executeToolCallAndBuildEvent(
|
||||
call: NormalizedToolCall,
|
||||
execution: PreparedToolCallExecution,
|
||||
params: ToolAwareCompletionParams
|
||||
@@ -1068,488 +973,3 @@ async function executeToolCallAndBuildEvent(
|
||||
|
||||
return { event, toolResult };
|
||||
}
|
||||
|
||||
export async function runToolAwareOpenAIChat(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const response = await params.client.responses.create({
|
||||
model: params.model,
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: toResponsesChatTools(enabledTools),
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||
store: true,
|
||||
} as any);
|
||||
rawResponses.push(response);
|
||||
sawUsage = mergeResponsesUsage(usageAcc, response?.usage) || sawUsage;
|
||||
|
||||
const failureMessage = getResponseFailureMessage(response);
|
||||
if (failureMessage) {
|
||||
throw new Error(failureMessage);
|
||||
}
|
||||
|
||||
const outputItems = getResponseOutputItems(response);
|
||||
const normalizedToolCalls = normalizeResponsesToolCalls(outputItems, round);
|
||||
if (!normalizedToolCalls.length) {
|
||||
const text = extractResponsesText(response);
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(input, text);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
input.push(...outputItems);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: call.id,
|
||||
output: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "responses" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runToolAwareChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const completion = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: enabledTools,
|
||||
tool_choice: "auto",
|
||||
} as any);
|
||||
rawResponses.push(completion);
|
||||
sawUsage = mergeUsage(usageAcc, completion?.usage) || sawUsage;
|
||||
|
||||
const message = completion?.choices?.[0]?.message;
|
||||
if (!message) {
|
||||
return {
|
||||
text: "",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, missingMessage: true },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
if (!toolCalls.length) {
|
||||
const text = typeof message.content === "string" ? message.content : "";
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(conversation, text);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedToolCalls = normalizeModelToolCalls(toolCalls, round);
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
|
||||
const assistantToolCallMessage: any = {
|
||||
role: "assistant",
|
||||
tool_calls: normalizedToolCalls.map((call) => ({
|
||||
id: call.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
},
|
||||
})),
|
||||
};
|
||||
if (typeof message.content === "string" && message.content.length) {
|
||||
assistantToolCallMessage.content = message.content;
|
||||
}
|
||||
conversation.push(assistantToolCallMessage);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
|
||||
conversation.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPlainChatCompletions(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const completion = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
} as any);
|
||||
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
const sawUsage = mergeUsage(usageAcc, completion?.usage);
|
||||
const message = completion?.choices?.[0]?.message;
|
||||
|
||||
return {
|
||||
text: extractChatCompletionContent(message),
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { response: completion, api: "chat.completions" },
|
||||
toolEvents: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function* runToolAwareOpenAIChatStream(
|
||||
params: ToolAwareCompletionParams
|
||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const input: any[] = normalizeIncomingResponsesInput(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const stream = await params.client.responses.create({
|
||||
model: params.model,
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: toResponsesChatTools(enabledTools),
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
// Tool loops pass response output items back as input; reasoning items need persistence.
|
||||
store: true,
|
||||
stream: true,
|
||||
} as any);
|
||||
|
||||
let roundText = "";
|
||||
let streamedRoundText = "";
|
||||
let roundHasToolCalls = false;
|
||||
let canStreamRoundText = false;
|
||||
let completedResponse: any | null = null;
|
||||
const completedOutputItems: any[] = [];
|
||||
|
||||
for await (const event of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(event);
|
||||
|
||||
if (event?.type === "response.output_text.delta" && typeof event.delta === "string") {
|
||||
roundText += event.delta;
|
||||
if (canStreamRoundText && !roundHasToolCalls && event.delta.length) {
|
||||
streamedRoundText += event.delta;
|
||||
yield { type: "delta", text: event.delta };
|
||||
}
|
||||
} else if (event?.type === "response.output_item.added" && event.item) {
|
||||
if (event.item.type === "function_call") {
|
||||
roundHasToolCalls = true;
|
||||
canStreamRoundText = false;
|
||||
} else if (event.item.type === "message" && !roundHasToolCalls) {
|
||||
canStreamRoundText = true;
|
||||
}
|
||||
} else if (event?.type === "response.output_item.done" && event.item) {
|
||||
completedOutputItems[event.output_index ?? completedOutputItems.length] = event.item;
|
||||
if (event.item.type === "function_call") {
|
||||
roundHasToolCalls = true;
|
||||
canStreamRoundText = false;
|
||||
}
|
||||
} else if (event?.type === "response.completed") {
|
||||
completedResponse = event.response;
|
||||
sawUsage = mergeResponsesUsage(usageAcc, event.response?.usage) || sawUsage;
|
||||
} else if (event?.type === "response.failed" || event?.type === "response.incomplete") {
|
||||
completedResponse = event.response;
|
||||
sawUsage = mergeResponsesUsage(usageAcc, event.response?.usage) || sawUsage;
|
||||
} else if (event?.type === "error") {
|
||||
throw new Error(event.message ?? "OpenAI Responses stream failed.");
|
||||
}
|
||||
}
|
||||
|
||||
const failureMessage = getResponseFailureMessage(completedResponse);
|
||||
if (failureMessage) {
|
||||
throw new Error(failureMessage);
|
||||
}
|
||||
|
||||
const outputItems = getResponseOutputItems(completedResponse);
|
||||
const responseOutputItems = outputItems.length ? outputItems : completedOutputItems.filter(Boolean);
|
||||
const normalizedToolCalls = normalizeResponsesToolCalls(responseOutputItems, round);
|
||||
if (!normalizedToolCalls.length) {
|
||||
const text = extractResponsesText(completedResponse, roundText);
|
||||
if (
|
||||
!streamedRoundText &&
|
||||
danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES &&
|
||||
looksLikeDanglingToolIntent(text)
|
||||
) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(input, text);
|
||||
continue;
|
||||
}
|
||||
const unstreamedText = getUnstreamedText(text, streamedRoundText);
|
||||
if (unstreamedText) {
|
||||
yield { type: "delta", text: unstreamedText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
input.push(...responseOutputItems);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: call.id,
|
||||
output: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "responses" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function* runToolAwareChatCompletionsStream(
|
||||
params: ToolAwareCompletionParams
|
||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const conversation: any[] = normalizeIncomingMessages(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const stream = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: enabledTools,
|
||||
tool_choice: "auto",
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
} as any);
|
||||
|
||||
let roundText = "";
|
||||
let streamedRoundText = "";
|
||||
let roundHasToolCalls = false;
|
||||
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
|
||||
|
||||
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(chunk);
|
||||
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||
|
||||
const choice = chunk?.choices?.[0];
|
||||
const deltaText = choice?.delta?.content ?? "";
|
||||
if (typeof deltaText === "string" && deltaText.length) {
|
||||
roundText += deltaText;
|
||||
if (!roundHasToolCalls) {
|
||||
streamedRoundText += deltaText;
|
||||
yield { type: "delta", text: deltaText };
|
||||
}
|
||||
}
|
||||
|
||||
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
|
||||
if (deltaToolCalls.length) {
|
||||
roundHasToolCalls = true;
|
||||
}
|
||||
for (const toolCall of deltaToolCalls) {
|
||||
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
|
||||
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
|
||||
if (typeof toolCall?.id === "string" && toolCall.id.length) {
|
||||
entry.id = toolCall.id;
|
||||
}
|
||||
if (typeof toolCall?.function?.name === "string" && toolCall.function.name.length) {
|
||||
entry.name = toolCall.function.name;
|
||||
}
|
||||
if (typeof toolCall?.function?.arguments === "string" && toolCall.function.arguments.length) {
|
||||
entry.arguments += toolCall.function.arguments;
|
||||
}
|
||||
roundToolCalls.set(idx, entry);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedToolCalls: NormalizedToolCall[] = [...roundToolCalls.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([_, call], index) => ({
|
||||
id: call.id ?? `tool_call_${round}_${index}`,
|
||||
name: call.name ?? "unknown_tool",
|
||||
arguments: call.arguments || "{}",
|
||||
}));
|
||||
|
||||
if (!normalizedToolCalls.length) {
|
||||
if (
|
||||
!streamedRoundText &&
|
||||
danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES &&
|
||||
looksLikeDanglingToolIntent(roundText)
|
||||
) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(conversation, roundText);
|
||||
continue;
|
||||
}
|
||||
const unstreamedText = getUnstreamedText(roundText, streamedRoundText);
|
||||
if (unstreamedText) {
|
||||
yield { type: "delta", text: unstreamedText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: roundText,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
const assistantToolCallMessage: any = {
|
||||
role: "assistant",
|
||||
tool_calls: normalizedToolCalls.map((call) => ({
|
||||
id: call.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
},
|
||||
})),
|
||||
};
|
||||
if (roundText) {
|
||||
assistantToolCallMessage.content = roundText;
|
||||
}
|
||||
conversation.push(assistantToolCallMessage);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
conversation.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function* runPlainChatCompletionsStream(
|
||||
params: ToolAwareCompletionParams
|
||||
): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const rawResponses: unknown[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let text = "";
|
||||
|
||||
const stream = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: normalizePlainIncomingMessages(params.messages, params.userLocation),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
stream: true,
|
||||
} as any);
|
||||
|
||||
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(chunk);
|
||||
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||
|
||||
const deltaText = chunk?.choices?.[0]?.delta?.content ?? "";
|
||||
if (typeof deltaText === "string" && deltaText.length) {
|
||||
text += deltaText;
|
||||
yield { type: "delta", text: deltaText };
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, api: "chat.completions" },
|
||||
toolEvents: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,21 +18,21 @@ function escapeAttribute(value: string) {
|
||||
return value.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function getImageAttachments(message: ChatMessage) {
|
||||
export function getImageAttachments(message: ChatMessage) {
|
||||
return (message.attachments ?? []).filter((attachment): attachment is ChatImageAttachment => attachment.kind === "image");
|
||||
}
|
||||
|
||||
function getTextAttachments(message: ChatMessage) {
|
||||
export function getTextAttachments(message: ChatMessage) {
|
||||
return (message.attachments ?? []).filter((attachment): attachment is ChatTextAttachment => attachment.kind === "text");
|
||||
}
|
||||
|
||||
function buildImageSummaryText(attachments: ChatImageAttachment[]) {
|
||||
export function buildImageSummaryText(attachments: ChatImageAttachment[]) {
|
||||
if (!attachments.length) return null;
|
||||
const label = attachments.length === 1 ? "Attached image" : "Attached images";
|
||||
return `${label}: ${attachments.map((attachment) => attachment.filename).join(", ")}.`;
|
||||
}
|
||||
|
||||
function buildTextAttachmentPrompt(attachment: ChatTextAttachment) {
|
||||
export function buildTextAttachmentPrompt(attachment: ChatTextAttachment) {
|
||||
const truncationNote = attachment.truncated ? ' truncated="true"' : "";
|
||||
return [
|
||||
`Attached text file: ${attachment.filename}${attachment.truncated ? " (content truncated)" : ""}`,
|
||||
@@ -42,83 +42,7 @@ function buildTextAttachmentPrompt(attachment: ChatTextAttachment) {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function toOpenAIContent(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
if (!imageAttachments.length && !textAttachments.length) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const attachment of imageAttachments) {
|
||||
parts.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: attachment.dataUrl,
|
||||
detail: "auto",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
parts.push({ type: "text", text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
parts.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
parts.push({ type: "text", text: message.content });
|
||||
}
|
||||
|
||||
if (parts.length === 1 && parts[0]?.type === "text" && typeof parts[0].text === "string") {
|
||||
return parts[0].text;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function toOpenAIResponsesContent(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
if (!imageAttachments.length && !textAttachments.length) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const attachment of imageAttachments) {
|
||||
parts.push({
|
||||
type: "input_image",
|
||||
image_url: attachment.dataUrl,
|
||||
detail: "auto",
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
parts.push({ type: "input_text", text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
parts.push({ type: "input_text", text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
parts.push({ type: "input_text", text: message.content });
|
||||
}
|
||||
|
||||
if (parts.length === 1 && parts[0]?.type === "input_text" && typeof parts[0].text === "string") {
|
||||
return parts[0].text;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseImageDataUrl(attachment: ChatImageAttachment) {
|
||||
export function parseImageDataUrl(attachment: ChatImageAttachment) {
|
||||
const match = attachment.dataUrl.match(/^data:(image\/(?:png|jpeg));base64,([a-z0-9+/=\s]+)$/i);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid image attachment data URL for '${attachment.filename}'.`);
|
||||
@@ -135,83 +59,6 @@ function parseImageDataUrl(attachment: ChatImageAttachment) {
|
||||
};
|
||||
}
|
||||
|
||||
function toAnthropicContent(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
if (!imageAttachments.length && !textAttachments.length) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const blocks: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const attachment of imageAttachments) {
|
||||
const source = parseImageDataUrl(attachment);
|
||||
blocks.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: source.mediaType,
|
||||
data: source.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
blocks.push({ type: "text", text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
blocks.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
blocks.push({ type: "text", text: message.content });
|
||||
}
|
||||
|
||||
if (blocks.length === 1 && blocks[0]?.type === "text" && typeof blocks[0].text === "string") {
|
||||
return blocks[0].text;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function buildOpenAIConversationMessage(message: ChatMessage) {
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool output (${name}):\n${message.content}`,
|
||||
};
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = {
|
||||
role: message.role,
|
||||
content: toOpenAIContent(message),
|
||||
};
|
||||
|
||||
if (message.name && (message.role === "assistant" || message.role === "user")) {
|
||||
out.name = message.name;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildOpenAIResponsesInputMessage(message: ChatMessage) {
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool output (${name}):\n${message.content}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role,
|
||||
content: toOpenAIResponsesContent(message),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSystemPromptAugmentationMessage(userLocation?: string) {
|
||||
return {
|
||||
role: "system",
|
||||
@@ -219,34 +66,12 @@ export function buildSystemPromptAugmentationMessage(userLocation?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
const ANTHROPIC_NO_SERVER_TOOLS_PROMPT =
|
||||
"This Anthropic backend path does not have server-managed tool calls. Do not claim to run shell commands, Codex tasks, web searches, or fetch URLs. If the user asks for tool execution, explain that they should switch to OpenAI or xAI in this app for tool-enabled chat.";
|
||||
|
||||
export function getAnthropicSystemPrompt(messages: ChatMessage[], userLocation?: string) {
|
||||
return [ANTHROPIC_NO_SERVER_TOOLS_PROMPT, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
|
||||
export function buildTopLevelSystemPrompt(messages: ChatMessage[], userLocation?: string, toolSystemPrompt?: string) {
|
||||
return [toolSystemPrompt, buildSystemPromptAugmentation(userLocation), messages.find((message) => message.role === "system")?.content]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
export function buildAnthropicConversationMessage(message: ChatMessage) {
|
||||
if (message.role === "system") {
|
||||
throw new Error("System messages must be handled separately for Anthropic.");
|
||||
}
|
||||
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool output (${name}):\n${message.content}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role === "assistant" ? "assistant" : "user",
|
||||
content: toAnthropicContent(message),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildComparableAttachments(input: unknown): ChatAttachment[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { env } from "../env.js";
|
||||
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||
import {
|
||||
fetchProviderCatalogModels,
|
||||
getProviderCatalogFallbackModels,
|
||||
listModelCatalogProviders,
|
||||
} from "./provider-adapters.js";
|
||||
import type { Provider } from "./types.js";
|
||||
|
||||
export type ProviderModelSnapshot = {
|
||||
@@ -11,35 +14,13 @@ export type ProviderModelSnapshot = {
|
||||
|
||||
export type ModelCatalogSnapshot = Partial<Record<Provider, ProviderModelSnapshot>>;
|
||||
|
||||
const baseProviders: Provider[] = ["openai", "anthropic", "xai"];
|
||||
const MODEL_FETCH_TIMEOUT_MS = 15000;
|
||||
const MODEL_CATALOG_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const modelCatalog: ModelCatalogSnapshot = {
|
||||
openai: { models: [], loadedAt: null, error: null },
|
||||
anthropic: { models: [], loadedAt: null, error: null },
|
||||
xai: { models: [], loadedAt: null, error: null },
|
||||
};
|
||||
const modelCatalog: ModelCatalogSnapshot = {};
|
||||
|
||||
let catalogRefreshPromise: Promise<void> | null = null;
|
||||
|
||||
function getCatalogProviders(): Provider[] {
|
||||
return isHermesAgentConfigured() ? [...baseProviders, "hermes-agent"] : baseProviders;
|
||||
}
|
||||
|
||||
function uniqSorted(models: string[]) {
|
||||
return [...new Set(models.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function isLikelyOpenAIResponsesModel(model: string) {
|
||||
const id = model.toLowerCase();
|
||||
if (id.includes("embedding") || id.includes("moderation")) return false;
|
||||
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
||||
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||
if (id.includes("search") || id.includes("computer-use")) return false;
|
||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string) {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
try {
|
||||
@@ -56,31 +37,9 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProviderModels(provider: Provider) {
|
||||
if (provider === "openai") {
|
||||
const page = await openaiClient().models.list();
|
||||
return uniqSorted(page.data.map((model) => model.id).filter(isLikelyOpenAIResponsesModel));
|
||||
}
|
||||
|
||||
if (provider === "anthropic") {
|
||||
const page = await anthropicClient().models.list({ limit: 200 });
|
||||
return uniqSorted(page.data.map((model) => model.id));
|
||||
}
|
||||
|
||||
if (provider === "xai") {
|
||||
const page = await xaiClient().models.list();
|
||||
return uniqSorted(page.data.map((model) => model.id));
|
||||
}
|
||||
|
||||
const page = await hermesAgentClient().models.list();
|
||||
const models = page.data.map((model) => model.id);
|
||||
if (env.HERMES_AGENT_MODEL) models.push(env.HERMES_AGENT_MODEL);
|
||||
return uniqSorted(models);
|
||||
}
|
||||
|
||||
async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLogger) {
|
||||
try {
|
||||
const models = await withTimeout(fetchProviderModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
|
||||
const models = await withTimeout(fetchProviderCatalogModels(provider), MODEL_FETCH_TIMEOUT_MS, `${provider} model fetch`);
|
||||
modelCatalog[provider] = {
|
||||
models,
|
||||
loadedAt: new Date().toISOString(),
|
||||
@@ -90,7 +49,7 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? String(err);
|
||||
const previous = modelCatalog[provider];
|
||||
const fallbackModels = provider === "hermes-agent" && env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
|
||||
const fallbackModels = getProviderCatalogFallbackModels(provider);
|
||||
modelCatalog[provider] = {
|
||||
models: previous?.models.length ? previous.models : fallbackModels,
|
||||
loadedAt: previous?.loadedAt ?? null,
|
||||
@@ -103,7 +62,7 @@ async function refreshProviderModels(provider: Provider, logger?: FastifyBaseLog
|
||||
export async function refreshModelCatalog(logger?: FastifyBaseLogger) {
|
||||
if (catalogRefreshPromise) return catalogRefreshPromise;
|
||||
|
||||
catalogRefreshPromise = Promise.all(getCatalogProviders().map((provider) => refreshProviderModels(provider, logger)))
|
||||
catalogRefreshPromise = Promise.all(listModelCatalogProviders().map((provider) => refreshProviderModels(provider, logger)))
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
catalogRefreshPromise = null;
|
||||
@@ -129,7 +88,7 @@ export function startModelCatalogRefreshLoop(logger?: FastifyBaseLogger) {
|
||||
|
||||
export function getModelCatalogSnapshot(): ModelCatalogSnapshot {
|
||||
const snapshot: ModelCatalogSnapshot = {};
|
||||
for (const provider of getCatalogProviders()) {
|
||||
for (const provider of listModelCatalogProviders()) {
|
||||
const entry = modelCatalog[provider] ?? { models: [], loadedAt: null, error: null };
|
||||
snapshot[provider] = {
|
||||
models: [...entry.models],
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { prisma } from "../db.js";
|
||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import { buildToolLogMessageData, normalizeEnabledChatTools, runPlainChatCompletions, runToolAwareChatCompletions, runToolAwareOpenAIChat } from "./chat-tools.js";
|
||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||
import { buildToolLogMessageData } from "./chat-tools.js";
|
||||
import { getProviderChatAdapter } from "./provider-adapters.js";
|
||||
import { toPrismaProvider } from "./provider-ids.js";
|
||||
import type { MultiplexRequest, MultiplexResponse, Provider } from "./types.js";
|
||||
|
||||
@@ -47,15 +46,11 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
let usage: MultiplexResponse["usage"] | undefined;
|
||||
let raw: unknown;
|
||||
let toolMessages: ReturnType<typeof buildToolLogMessageData>[] = [];
|
||||
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
||||
|
||||
if (req.provider === "openai" && enabledTools.length > 0) {
|
||||
const client = openaiClient();
|
||||
const r = await runToolAwareOpenAIChat({
|
||||
client,
|
||||
const adapter = getProviderChatAdapter(req.provider);
|
||||
const r = await adapter.complete({
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
enabledTools: req.enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
@@ -69,75 +64,6 @@ export async function runMultiplex(req: MultiplexRequest): Promise<MultiplexResp
|
||||
outText = r.text;
|
||||
usage = r.usage;
|
||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||
} else if (req.provider === "xai" && enabledTools.length > 0) {
|
||||
const client = xaiClient();
|
||||
const r = await runToolAwareChatCompletions({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
chatId,
|
||||
},
|
||||
});
|
||||
raw = r.raw;
|
||||
outText = r.text;
|
||||
usage = r.usage;
|
||||
toolMessages = r.toolEvents.map((event) => buildToolLogMessageData(call.chatId, event));
|
||||
} else if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||
const r = await runPlainChatCompletions({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
chatId,
|
||||
},
|
||||
});
|
||||
raw = r.raw;
|
||||
outText = r.text;
|
||||
usage = r.usage;
|
||||
} else if (req.provider === "anthropic") {
|
||||
const client = anthropicClient();
|
||||
|
||||
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||
|
||||
const r = await client.messages.create({
|
||||
model: req.model,
|
||||
system,
|
||||
max_tokens: req.maxTokens ?? 1024,
|
||||
temperature: req.temperature,
|
||||
messages: msgs as any,
|
||||
});
|
||||
raw = r;
|
||||
outText = r.content
|
||||
.map((c: any) => (c.type === "text" ? c.text : ""))
|
||||
.join("")
|
||||
.trim();
|
||||
|
||||
// Anthropic usage (SDK typing varies by version)
|
||||
const ru: any = (r as any).usage;
|
||||
if (ru) {
|
||||
usage = {
|
||||
inputTokens: ru.input_tokens,
|
||||
outputTokens: ru.output_tokens,
|
||||
totalTokens: (ru.input_tokens ?? 0) + (ru.output_tokens ?? 0),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
throw new Error(`unknown provider: ${req.provider}`);
|
||||
}
|
||||
|
||||
const latencyMs = Math.round(performance.now() - t0);
|
||||
|
||||
|
||||
386
server/src/llm/protocols/chat-completions-api.ts
Normal file
386
server/src/llm/protocols/chat-completions-api.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import {
|
||||
appendDanglingToolIntentCorrection,
|
||||
buildChatToolSystemPrompt,
|
||||
executeToolCallAndBuildEvent,
|
||||
getEnabledChatTools,
|
||||
getUnstreamedText,
|
||||
looksLikeDanglingToolIntent,
|
||||
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||
MAX_TOOL_ROUNDS,
|
||||
mergeUsage,
|
||||
normalizeModelToolCalls,
|
||||
prepareToolCallExecution,
|
||||
type NormalizedToolCall,
|
||||
type ToolAwareCompletionParams,
|
||||
type ToolAwareCompletionResult,
|
||||
type ToolAwareStreamingEvent,
|
||||
type ToolExecutionEvent,
|
||||
} from "../chat-tools.js";
|
||||
import {
|
||||
buildImageSummaryText,
|
||||
buildSystemPromptAugmentationMessage,
|
||||
buildTextAttachmentPrompt,
|
||||
getImageAttachments,
|
||||
getTextAttachments,
|
||||
} from "../message-content.js";
|
||||
import type { ChatMessage } from "../types.js";
|
||||
|
||||
function toContentParts(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
if (!imageAttachments.length && !textAttachments.length) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
for (const attachment of imageAttachments) {
|
||||
parts.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: attachment.dataUrl,
|
||||
detail: "auto",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
parts.push({ type: "text", text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
parts.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
parts.push({ type: "text", text: message.content });
|
||||
}
|
||||
|
||||
if (parts.length === 1 && parts[0]?.type === "text" && typeof parts[0].text === "string") {
|
||||
return parts[0].text;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildConversationMessage(message: ChatMessage) {
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool output (${name}):\n${message.content}`,
|
||||
};
|
||||
}
|
||||
|
||||
const out: Record<string, unknown> = {
|
||||
role: message.role,
|
||||
content: toContentParts(message),
|
||||
};
|
||||
|
||||
if (message.name && (message.role === "assistant" || message.role === "user")) {
|
||||
out.name = message.name;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeMessages(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||
const normalized = messages.map((message) => buildConversationMessage(message));
|
||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||
}
|
||||
|
||||
function normalizePlainMessages(messages: ChatMessage[], userLocation?: string) {
|
||||
return [buildSystemPromptAugmentationMessage(userLocation), ...messages.map((message) => buildConversationMessage(message))];
|
||||
}
|
||||
|
||||
function extractContent(message: any) {
|
||||
if (typeof message?.content === "string") return message.content;
|
||||
if (!Array.isArray(message?.content)) return "";
|
||||
|
||||
return message.content
|
||||
.map((part: any) => {
|
||||
if (typeof part === "string") return part;
|
||||
if (typeof part?.text === "string") return part.text;
|
||||
if (typeof part?.content === "string") return part.content;
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
export async function completeWithChatCompletionsApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
if (!enabledTools.length) {
|
||||
const completion = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: normalizePlainMessages(params.messages, params.userLocation),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
} as any);
|
||||
|
||||
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
const sawUsage = mergeUsage(usageAcc, completion?.usage);
|
||||
const message = completion?.choices?.[0]?.message;
|
||||
|
||||
return {
|
||||
text: extractContent(message),
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { response: completion, api: "chat.completions" },
|
||||
toolEvents: [],
|
||||
};
|
||||
}
|
||||
|
||||
const conversation: any[] = normalizeMessages(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const completion = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: enabledTools,
|
||||
tool_choice: "auto",
|
||||
} as any);
|
||||
rawResponses.push(completion);
|
||||
sawUsage = mergeUsage(usageAcc, completion?.usage) || sawUsage;
|
||||
|
||||
const message = completion?.choices?.[0]?.message;
|
||||
if (!message) {
|
||||
return {
|
||||
text: "",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, missingMessage: true },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
if (!toolCalls.length) {
|
||||
const text = typeof message.content === "string" ? message.content : "";
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(conversation, text);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedToolCalls = normalizeModelToolCalls(toolCalls, round);
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
|
||||
const assistantToolCallMessage: any = {
|
||||
role: "assistant",
|
||||
tool_calls: normalizedToolCalls.map((call) => ({
|
||||
id: call.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
},
|
||||
})),
|
||||
};
|
||||
if (typeof message.content === "string" && message.content.length) {
|
||||
assistantToolCallMessage.content = message.content;
|
||||
}
|
||||
conversation.push(assistantToolCallMessage);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
|
||||
conversation.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function* streamWithChatCompletionsApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
if (!enabledTools.length) {
|
||||
const rawResponses: unknown[] = [];
|
||||
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let text = "";
|
||||
|
||||
const stream = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: normalizePlainMessages(params.messages, params.userLocation),
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
stream: true,
|
||||
} as any);
|
||||
|
||||
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(chunk);
|
||||
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||
|
||||
const deltaText = chunk?.choices?.[0]?.delta?.content ?? "";
|
||||
if (typeof deltaText === "string" && deltaText.length) {
|
||||
text += deltaText;
|
||||
yield { type: "delta", text: deltaText };
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, api: "chat.completions" },
|
||||
toolEvents: [],
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation: any[] = normalizeMessages(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<NonNullable<ToolAwareCompletionResult["usage"]>> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const stream = await params.client.chat.completions.create({
|
||||
model: params.model,
|
||||
messages: conversation,
|
||||
temperature: params.temperature,
|
||||
max_tokens: params.maxTokens,
|
||||
tools: enabledTools,
|
||||
tool_choice: "auto",
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
} as any);
|
||||
|
||||
let roundText = "";
|
||||
let streamedRoundText = "";
|
||||
let roundHasToolCalls = false;
|
||||
const roundToolCalls = new Map<number, { id?: string; name?: string; arguments: string }>();
|
||||
|
||||
for await (const chunk of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(chunk);
|
||||
sawUsage = mergeUsage(usageAcc, chunk?.usage) || sawUsage;
|
||||
|
||||
const choice = chunk?.choices?.[0];
|
||||
const deltaText = choice?.delta?.content ?? "";
|
||||
if (typeof deltaText === "string" && deltaText.length) {
|
||||
roundText += deltaText;
|
||||
if (!roundHasToolCalls) {
|
||||
streamedRoundText += deltaText;
|
||||
yield { type: "delta", text: deltaText };
|
||||
}
|
||||
}
|
||||
|
||||
const deltaToolCalls = Array.isArray(choice?.delta?.tool_calls) ? choice.delta.tool_calls : [];
|
||||
if (deltaToolCalls.length) {
|
||||
roundHasToolCalls = true;
|
||||
}
|
||||
for (const toolCall of deltaToolCalls) {
|
||||
const idx = typeof toolCall?.index === "number" ? toolCall.index : 0;
|
||||
const entry = roundToolCalls.get(idx) ?? { arguments: "" };
|
||||
if (typeof toolCall?.id === "string" && toolCall.id.length) {
|
||||
entry.id = toolCall.id;
|
||||
}
|
||||
if (typeof toolCall?.function?.name === "string" && toolCall.function.name.length) {
|
||||
entry.name = toolCall.function.name;
|
||||
}
|
||||
if (typeof toolCall?.function?.arguments === "string" && toolCall.function.arguments.length) {
|
||||
entry.arguments += toolCall.function.arguments;
|
||||
}
|
||||
roundToolCalls.set(idx, entry);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedToolCalls: NormalizedToolCall[] = [...roundToolCalls.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([_, call], index) => ({
|
||||
id: call.id ?? `tool_call_${round}_${index}`,
|
||||
name: call.name ?? "unknown_tool",
|
||||
arguments: call.arguments || "{}",
|
||||
}));
|
||||
|
||||
if (!normalizedToolCalls.length) {
|
||||
if (!streamedRoundText && danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(conversation, roundText);
|
||||
continue;
|
||||
}
|
||||
const unstreamedText = getUnstreamedText(roundText, streamedRoundText);
|
||||
if (unstreamedText) {
|
||||
yield { type: "delta", text: unstreamedText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: roundText,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
const assistantToolCallMessage: any = {
|
||||
role: "assistant",
|
||||
tool_calls: normalizedToolCalls.map((call) => ({
|
||||
id: call.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: call.arguments,
|
||||
},
|
||||
})),
|
||||
};
|
||||
if (roundText) {
|
||||
assistantToolCallMessage.content = roundText;
|
||||
}
|
||||
conversation.push(assistantToolCallMessage);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
conversation.push({
|
||||
role: "tool",
|
||||
tool_call_id: call.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
470
server/src/llm/protocols/messages-api.ts
Normal file
470
server/src/llm/protocols/messages-api.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
import {
|
||||
buildChatToolSystemPrompt,
|
||||
executeToolCallAndBuildEvent,
|
||||
getEnabledChatTools,
|
||||
looksLikeDanglingToolIntent,
|
||||
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||
MAX_TOOL_ROUNDS,
|
||||
parseToolArgs,
|
||||
prepareToolCallExecution,
|
||||
type NormalizedToolCall,
|
||||
type ToolAwareCompletionParams,
|
||||
type ToolAwareCompletionResult,
|
||||
type ToolAwareStreamingEvent,
|
||||
type ToolAwareUsage,
|
||||
type ToolExecutionEvent,
|
||||
type ToolRunOutcome,
|
||||
} from "../chat-tools.js";
|
||||
import {
|
||||
buildImageSummaryText,
|
||||
buildTextAttachmentPrompt,
|
||||
buildTopLevelSystemPrompt,
|
||||
getImageAttachments,
|
||||
getTextAttachments,
|
||||
parseImageDataUrl,
|
||||
} from "../message-content.js";
|
||||
import type { ChatMessage } from "../types.js";
|
||||
|
||||
const INTERNAL_CORRECTION =
|
||||
"Internal correction: the previous assistant message claimed it would run a tool, but no tool call was made. If the task needs an available tool, call it now. Otherwise provide the final answer directly without saying you will run a tool.";
|
||||
|
||||
function toTools(tools: any[]) {
|
||||
return tools
|
||||
.map((tool) => {
|
||||
if (tool?.type !== "function") return null;
|
||||
return {
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
input_schema: tool.function.parameters,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function toContentBlocks(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
if (!imageAttachments.length && !textAttachments.length) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const blocks: Array<Record<string, unknown>> = [];
|
||||
for (const attachment of imageAttachments) {
|
||||
const source = parseImageDataUrl(attachment);
|
||||
blocks.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: source.mediaType,
|
||||
data: source.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
blocks.push({ type: "text", text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
blocks.push({ type: "text", text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
blocks.push({ type: "text", text: message.content });
|
||||
}
|
||||
|
||||
if (blocks.length === 1 && blocks[0]?.type === "text" && typeof blocks[0].text === "string") {
|
||||
return blocks[0].text;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function buildConversationMessage(message: ChatMessage) {
|
||||
if (message.role === "system") {
|
||||
throw new Error("System messages must be handled separately for top-level-system protocols.");
|
||||
}
|
||||
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool output (${name}):\n${message.content}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role === "assistant" ? "assistant" : "user",
|
||||
content: toContentBlocks(message),
|
||||
};
|
||||
}
|
||||
|
||||
function buildBaseMessages(params: ToolAwareCompletionParams) {
|
||||
return params.messages.filter((message) => message.role !== "system").map((message) => buildConversationMessage(message));
|
||||
}
|
||||
|
||||
function stringifyToolInput(input: unknown) {
|
||||
if (typeof input === "string") return input;
|
||||
try {
|
||||
return JSON.stringify(input ?? {});
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToolCalls(content: any[], round: number): NormalizedToolCall[] {
|
||||
return content
|
||||
.filter((item) => item?.type === "tool_use")
|
||||
.map((call: any, index: number) => ({
|
||||
id: call?.id ?? `tool_call_${round}_${index}`,
|
||||
name: call?.name ?? "unknown_tool",
|
||||
arguments: stringifyToolInput(call?.input),
|
||||
}));
|
||||
}
|
||||
|
||||
function extractText(response: any) {
|
||||
if (!Array.isArray(response?.content)) return "";
|
||||
return response.content
|
||||
.map((content: any) => (content?.type === "text" && typeof content.text === "string" ? content.text : ""))
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildToolResultBlock(call: NormalizedToolCall, toolResult: ToolRunOutcome) {
|
||||
return {
|
||||
type: "tool_result",
|
||||
tool_use_id: call.id,
|
||||
content: JSON.stringify(toolResult),
|
||||
is_error: !toolResult.ok,
|
||||
};
|
||||
}
|
||||
|
||||
function appendCorrection(conversation: any[], text: string) {
|
||||
conversation.push({ role: "assistant", content: text });
|
||||
conversation.push({
|
||||
role: "user",
|
||||
content: INTERNAL_CORRECTION,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
if (!usage) return false;
|
||||
const inputTokens = usage.input_tokens ?? 0;
|
||||
const outputTokens = usage.output_tokens ?? 0;
|
||||
acc.inputTokens += inputTokens;
|
||||
acc.outputTokens += outputTokens;
|
||||
acc.totalTokens += inputTokens + outputTokens;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function completeWithMessagesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
if (!enabledTools.length) {
|
||||
const response = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
temperature: params.temperature,
|
||||
messages: buildBaseMessages(params),
|
||||
} as any);
|
||||
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
const sawUsage = mergeUsage(usageAcc, response?.usage);
|
||||
|
||||
return {
|
||||
text: extractText(response),
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { response, api: "messages" },
|
||||
toolEvents: [],
|
||||
};
|
||||
}
|
||||
|
||||
const conversation: any[] = buildBaseMessages(params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const response = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
temperature: params.temperature,
|
||||
messages: conversation,
|
||||
tools: toTools(enabledTools),
|
||||
tool_choice: { type: "auto" },
|
||||
} as any);
|
||||
rawResponses.push(response);
|
||||
sawUsage = mergeUsage(usageAcc, response?.usage) || sawUsage;
|
||||
|
||||
const content = Array.isArray(response?.content) ? response.content : [];
|
||||
const normalizedToolCalls = normalizeToolCalls(content, round);
|
||||
if (!normalizedToolCalls.length) {
|
||||
const text = extractText(response);
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendCorrection(conversation, text);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "messages" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
conversation.push({
|
||||
role: "assistant",
|
||||
content,
|
||||
});
|
||||
|
||||
const toolResultBlocks: any[] = [];
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
toolResultBlocks.push(buildToolResultBlock(call, toolResult));
|
||||
}
|
||||
|
||||
conversation.push({
|
||||
role: "user",
|
||||
content: toolResultBlocks,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "messages" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function* streamWithMessagesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
if (!enabledTools.length) {
|
||||
const rawResponses: unknown[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let roundInputTokens = 0;
|
||||
let roundOutputTokens = 0;
|
||||
let text = "";
|
||||
|
||||
const stream = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation),
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
temperature: params.temperature,
|
||||
messages: buildBaseMessages(params),
|
||||
stream: true,
|
||||
} as any);
|
||||
|
||||
for await (const ev of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(ev);
|
||||
if (ev?.type === "message_start" && ev?.message?.usage) {
|
||||
roundInputTokens = ev.message.usage.input_tokens ?? roundInputTokens;
|
||||
sawUsage = true;
|
||||
}
|
||||
if (ev?.type === "content_block_delta" && ev?.delta?.type === "text_delta") {
|
||||
const delta = ev.delta.text ?? "";
|
||||
if (delta) {
|
||||
text += delta;
|
||||
yield { type: "delta", text: delta };
|
||||
}
|
||||
}
|
||||
if (ev?.type === "message_delta" && ev.usage) {
|
||||
roundInputTokens = ev.usage.input_tokens ?? roundInputTokens;
|
||||
roundOutputTokens = ev.usage.output_tokens ?? roundOutputTokens;
|
||||
sawUsage = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (sawUsage) {
|
||||
usageAcc.inputTokens += roundInputTokens;
|
||||
usageAcc.outputTokens += roundOutputTokens;
|
||||
usageAcc.totalTokens += roundInputTokens + roundOutputTokens;
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: 0, api: "messages" },
|
||||
toolEvents: [],
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation: any[] = buildBaseMessages(params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const stream = await params.client.messages.create({
|
||||
model: params.model,
|
||||
system: buildTopLevelSystemPrompt(params.messages, params.userLocation, buildChatToolSystemPrompt(params)),
|
||||
max_tokens: params.maxTokens ?? 1024,
|
||||
temperature: params.temperature,
|
||||
messages: conversation,
|
||||
tools: toTools(enabledTools),
|
||||
tool_choice: { type: "auto" },
|
||||
stream: true,
|
||||
} as any);
|
||||
|
||||
const contentByIndex = new Map<number, any>();
|
||||
const toolArgumentByIndex = new Map<number, string>();
|
||||
let roundText = "";
|
||||
let roundHasToolCalls = false;
|
||||
let roundInputTokens = 0;
|
||||
let roundOutputTokens = 0;
|
||||
let sawRoundUsage = false;
|
||||
|
||||
for await (const ev of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(ev);
|
||||
|
||||
if (ev?.type === "message_start" && ev?.message?.usage) {
|
||||
roundInputTokens = ev.message.usage.input_tokens ?? roundInputTokens;
|
||||
sawRoundUsage = true;
|
||||
}
|
||||
|
||||
if (ev?.type === "content_block_start" && typeof ev.index === "number") {
|
||||
const block = ev.content_block ?? {};
|
||||
if (block.type === "tool_use") {
|
||||
roundHasToolCalls = true;
|
||||
contentByIndex.set(ev.index, {
|
||||
type: "tool_use",
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
input: block.input ?? {},
|
||||
});
|
||||
toolArgumentByIndex.set(ev.index, "");
|
||||
} else if (block.type === "text") {
|
||||
contentByIndex.set(ev.index, {
|
||||
type: "text",
|
||||
text: typeof block.text === "string" ? block.text : "",
|
||||
});
|
||||
} else if (block.type) {
|
||||
contentByIndex.set(ev.index, block);
|
||||
}
|
||||
}
|
||||
|
||||
if (ev?.type === "content_block_delta" && typeof ev.index === "number") {
|
||||
if (ev.delta?.type === "text_delta") {
|
||||
const delta = typeof ev.delta.text === "string" ? ev.delta.text : "";
|
||||
if (delta) {
|
||||
const block = contentByIndex.get(ev.index) ?? { type: "text", text: "" };
|
||||
if (block.type === "text") {
|
||||
block.text = `${typeof block.text === "string" ? block.text : ""}${delta}`;
|
||||
contentByIndex.set(ev.index, block);
|
||||
}
|
||||
roundText += delta;
|
||||
}
|
||||
} else if (ev.delta?.type === "input_json_delta") {
|
||||
roundHasToolCalls = true;
|
||||
const partialJson = typeof ev.delta.partial_json === "string" ? ev.delta.partial_json : "";
|
||||
toolArgumentByIndex.set(ev.index, `${toolArgumentByIndex.get(ev.index) ?? ""}${partialJson}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (ev?.type === "content_block_stop" && typeof ev.index === "number") {
|
||||
const block = contentByIndex.get(ev.index);
|
||||
if (block?.type === "tool_use") {
|
||||
const rawArguments = toolArgumentByIndex.get(ev.index) || stringifyToolInput(block.input);
|
||||
try {
|
||||
block.input = parseToolArgs(rawArguments);
|
||||
} catch {
|
||||
block.input = {};
|
||||
}
|
||||
contentByIndex.set(ev.index, block);
|
||||
}
|
||||
}
|
||||
|
||||
if (ev?.type === "message_delta" && ev.usage) {
|
||||
roundInputTokens = ev.usage.input_tokens ?? roundInputTokens;
|
||||
roundOutputTokens = ev.usage.output_tokens ?? roundOutputTokens;
|
||||
sawRoundUsage = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (sawRoundUsage) {
|
||||
usageAcc.inputTokens += roundInputTokens;
|
||||
usageAcc.outputTokens += roundOutputTokens;
|
||||
usageAcc.totalTokens += roundInputTokens + roundOutputTokens;
|
||||
sawUsage = true;
|
||||
}
|
||||
|
||||
const indexedContent = [...contentByIndex.entries()].sort((a, b) => a[0] - b[0]);
|
||||
const assistantContent = indexedContent.map(([, block]) => block);
|
||||
const normalizedToolCalls: NormalizedToolCall[] = indexedContent
|
||||
.filter(([, block]) => block?.type === "tool_use")
|
||||
.map(([index, block], callIndex) => ({
|
||||
id: block.id ?? `tool_call_${round}_${callIndex}`,
|
||||
name: block.name ?? "unknown_tool",
|
||||
arguments: toolArgumentByIndex.get(index) || stringifyToolInput(block.input),
|
||||
}));
|
||||
|
||||
if (!normalizedToolCalls.length) {
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(roundText)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendCorrection(conversation, roundText);
|
||||
continue;
|
||||
}
|
||||
if (roundText) {
|
||||
yield { type: "delta", text: roundText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: roundText,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "messages" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
conversation.push({
|
||||
role: "assistant",
|
||||
content: assistantContent,
|
||||
});
|
||||
|
||||
const toolResultBlocks: any[] = [];
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
toolResultBlocks.push(buildToolResultBlock(call, toolResult));
|
||||
}
|
||||
|
||||
conversation.push({
|
||||
role: "user",
|
||||
content: toolResultBlocks,
|
||||
});
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "messages" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
332
server/src/llm/protocols/responses-api.ts
Normal file
332
server/src/llm/protocols/responses-api.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
appendDanglingToolIntentCorrection,
|
||||
buildChatToolSystemPrompt,
|
||||
executeToolCallAndBuildEvent,
|
||||
getEnabledChatTools,
|
||||
getUnstreamedText,
|
||||
looksLikeDanglingToolIntent,
|
||||
MAX_DANGLING_TOOL_INTENT_RETRIES,
|
||||
MAX_TOOL_ROUNDS,
|
||||
prepareToolCallExecution,
|
||||
type NormalizedToolCall,
|
||||
type ToolAwareCompletionParams,
|
||||
type ToolAwareCompletionResult,
|
||||
type ToolAwareStreamingEvent,
|
||||
type ToolAwareUsage,
|
||||
type ToolExecutionEvent,
|
||||
} from "../chat-tools.js";
|
||||
import {
|
||||
buildImageSummaryText,
|
||||
buildSystemPromptAugmentationMessage,
|
||||
buildTextAttachmentPrompt,
|
||||
getImageAttachments,
|
||||
getTextAttachments,
|
||||
} from "../message-content.js";
|
||||
import type { ChatMessage } from "../types.js";
|
||||
|
||||
function toResponsesTools(tools: any[]) {
|
||||
return tools.map((tool) => {
|
||||
if (tool?.type !== "function") return tool;
|
||||
return {
|
||||
type: "function",
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
strict: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function toContentParts(message: ChatMessage) {
|
||||
const imageAttachments = getImageAttachments(message);
|
||||
const textAttachments = getTextAttachments(message);
|
||||
if (!imageAttachments.length && !textAttachments.length) {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const parts: Array<Record<string, unknown>> = [];
|
||||
for (const attachment of imageAttachments) {
|
||||
parts.push({
|
||||
type: "input_image",
|
||||
image_url: attachment.dataUrl,
|
||||
detail: "auto",
|
||||
});
|
||||
}
|
||||
|
||||
const imageSummary = buildImageSummaryText(imageAttachments);
|
||||
if (imageSummary) {
|
||||
parts.push({ type: "input_text", text: imageSummary });
|
||||
}
|
||||
|
||||
for (const attachment of textAttachments) {
|
||||
parts.push({ type: "input_text", text: buildTextAttachmentPrompt(attachment) });
|
||||
}
|
||||
|
||||
if (message.content.trim()) {
|
||||
parts.push({ type: "input_text", text: message.content });
|
||||
}
|
||||
|
||||
if (parts.length === 1 && parts[0]?.type === "input_text" && typeof parts[0].text === "string") {
|
||||
return parts[0].text;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildInputMessage(message: ChatMessage) {
|
||||
if (message.role === "tool") {
|
||||
const name = message.name?.trim() || "tool";
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool output (${name}):\n${message.content}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role,
|
||||
content: toContentParts(message),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeInput(messages: ChatMessage[], userLocation?: string, params: Pick<ToolAwareCompletionParams, "enabledTools"> = {}) {
|
||||
const normalized = messages.map((message) => buildInputMessage(message));
|
||||
return [{ role: "system", content: buildChatToolSystemPrompt(params) }, buildSystemPromptAugmentationMessage(userLocation), ...normalized];
|
||||
}
|
||||
|
||||
function mergeUsage(acc: Required<ToolAwareUsage>, usage: any) {
|
||||
if (!usage) return false;
|
||||
acc.inputTokens += usage.input_tokens ?? 0;
|
||||
acc.outputTokens += usage.output_tokens ?? 0;
|
||||
acc.totalTokens += usage.total_tokens ?? 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getOutputItems(response: any) {
|
||||
return Array.isArray(response?.output) ? response.output : [];
|
||||
}
|
||||
|
||||
function extractText(response: any, fallback = "") {
|
||||
if (typeof response?.output_text === "string") return response.output_text;
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const item of getOutputItems(response)) {
|
||||
if (item?.type !== "message" || !Array.isArray(item.content)) continue;
|
||||
for (const content of item.content) {
|
||||
if (content?.type === "output_text" && typeof content.text === "string") {
|
||||
parts.push(content.text);
|
||||
} else if (content?.type === "refusal" && typeof content.refusal === "string") {
|
||||
parts.push(content.refusal);
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join("") || fallback;
|
||||
}
|
||||
|
||||
function getFailureMessage(response: any) {
|
||||
if (response?.status !== "failed" && response?.status !== "incomplete") return null;
|
||||
const errorMessage = typeof response?.error?.message === "string" ? response.error.message : null;
|
||||
const incompleteReason = typeof response?.incomplete_details?.reason === "string" ? response.incomplete_details.reason : null;
|
||||
return errorMessage ?? (incompleteReason ? `Response incomplete: ${incompleteReason}` : `Response ${response.status}.`);
|
||||
}
|
||||
|
||||
function normalizeToolCalls(outputItems: any[], round: number): NormalizedToolCall[] {
|
||||
return outputItems
|
||||
.filter((item) => item?.type === "function_call")
|
||||
.map((call: any, index: number) => ({
|
||||
id: call.call_id ?? call.id ?? `tool_call_${round}_${index}`,
|
||||
name: call.name ?? "unknown_tool",
|
||||
arguments: call.arguments ?? "{}",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function completeWithResponsesApi(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const input: any[] = normalizeInput(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const response = await params.client.responses.create({
|
||||
model: params.model,
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: toResponsesTools(enabledTools),
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
store: true,
|
||||
} as any);
|
||||
rawResponses.push(response);
|
||||
sawUsage = mergeUsage(usageAcc, response?.usage) || sawUsage;
|
||||
|
||||
const failureMessage = getFailureMessage(response);
|
||||
if (failureMessage) {
|
||||
throw new Error(failureMessage);
|
||||
}
|
||||
|
||||
const outputItems = getOutputItems(response);
|
||||
const normalizedToolCalls = normalizeToolCalls(outputItems, round);
|
||||
if (!normalizedToolCalls.length) {
|
||||
const text = extractText(response);
|
||||
if (danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(input, text);
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
input.push(...outputItems);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { execution } = prepareToolCallExecution(call);
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: call.id,
|
||||
output: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "responses" },
|
||||
toolEvents,
|
||||
};
|
||||
}
|
||||
|
||||
export async function* streamWithResponsesApi(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent> {
|
||||
const enabledTools = getEnabledChatTools(params);
|
||||
const input: any[] = normalizeInput(params.messages, params.userLocation, params);
|
||||
const rawResponses: unknown[] = [];
|
||||
const toolEvents: ToolExecutionEvent[] = [];
|
||||
const usageAcc: Required<ToolAwareUsage> = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
||||
let sawUsage = false;
|
||||
let totalToolCalls = 0;
|
||||
let danglingToolIntentRetries = 0;
|
||||
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
||||
const stream = await params.client.responses.create({
|
||||
model: params.model,
|
||||
input,
|
||||
temperature: params.temperature,
|
||||
max_output_tokens: params.maxTokens,
|
||||
tools: toResponsesTools(enabledTools),
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
store: true,
|
||||
stream: true,
|
||||
} as any);
|
||||
|
||||
let roundText = "";
|
||||
let streamedRoundText = "";
|
||||
let roundHasToolCalls = false;
|
||||
let canStreamRoundText = false;
|
||||
let completedResponse: any | null = null;
|
||||
const completedOutputItems: any[] = [];
|
||||
|
||||
for await (const event of stream as any as AsyncIterable<any>) {
|
||||
rawResponses.push(event);
|
||||
|
||||
if (event?.type === "response.output_text.delta" && typeof event.delta === "string") {
|
||||
roundText += event.delta;
|
||||
if (canStreamRoundText && !roundHasToolCalls && event.delta.length) {
|
||||
streamedRoundText += event.delta;
|
||||
yield { type: "delta", text: event.delta };
|
||||
}
|
||||
} else if (event?.type === "response.output_item.added" && event.item) {
|
||||
if (event.item.type === "function_call") {
|
||||
roundHasToolCalls = true;
|
||||
canStreamRoundText = false;
|
||||
} else if (event.item.type === "message" && !roundHasToolCalls) {
|
||||
canStreamRoundText = true;
|
||||
}
|
||||
} else if (event?.type === "response.output_item.done" && event.item) {
|
||||
completedOutputItems[event.output_index ?? completedOutputItems.length] = event.item;
|
||||
if (event.item.type === "function_call") {
|
||||
roundHasToolCalls = true;
|
||||
canStreamRoundText = false;
|
||||
}
|
||||
} else if (event?.type === "response.completed") {
|
||||
completedResponse = event.response;
|
||||
sawUsage = mergeUsage(usageAcc, event.response?.usage) || sawUsage;
|
||||
} else if (event?.type === "response.failed" || event?.type === "response.incomplete") {
|
||||
completedResponse = event.response;
|
||||
sawUsage = mergeUsage(usageAcc, event.response?.usage) || sawUsage;
|
||||
} else if (event?.type === "error") {
|
||||
throw new Error(event.message ?? "Responses stream failed.");
|
||||
}
|
||||
}
|
||||
|
||||
const failureMessage = getFailureMessage(completedResponse);
|
||||
if (failureMessage) {
|
||||
throw new Error(failureMessage);
|
||||
}
|
||||
|
||||
const outputItems = getOutputItems(completedResponse);
|
||||
const responseOutputItems = outputItems.length ? outputItems : completedOutputItems.filter(Boolean);
|
||||
const normalizedToolCalls = normalizeToolCalls(responseOutputItems, round);
|
||||
if (!normalizedToolCalls.length) {
|
||||
const text = extractText(completedResponse, roundText);
|
||||
if (!streamedRoundText && danglingToolIntentRetries < MAX_DANGLING_TOOL_INTENT_RETRIES && looksLikeDanglingToolIntent(text)) {
|
||||
danglingToolIntentRetries += 1;
|
||||
appendDanglingToolIntentCorrection(input, text);
|
||||
continue;
|
||||
}
|
||||
const unstreamedText = getUnstreamedText(text, streamedRoundText);
|
||||
if (unstreamedText) {
|
||||
yield { type: "delta", text: unstreamedText };
|
||||
}
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text,
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, api: "responses" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
totalToolCalls += normalizedToolCalls.length;
|
||||
input.push(...responseOutputItems);
|
||||
|
||||
for (const call of normalizedToolCalls) {
|
||||
const { event: initiatedEvent, execution } = prepareToolCallExecution(call);
|
||||
yield { type: "tool_call", event: initiatedEvent };
|
||||
const { event, toolResult } = await executeToolCallAndBuildEvent(call, execution, params);
|
||||
toolEvents.push(event);
|
||||
yield { type: "tool_call", event };
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: call.id,
|
||||
output: JSON.stringify(toolResult),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "done",
|
||||
result: {
|
||||
text: "I reached the tool-call limit while gathering information. Please narrow the request and try again.",
|
||||
usage: sawUsage ? usageAcc : undefined,
|
||||
raw: { streamed: true, responses: rawResponses, toolCallsUsed: totalToolCalls, toolCallLimitReached: true, api: "responses" },
|
||||
toolEvents,
|
||||
},
|
||||
};
|
||||
}
|
||||
217
server/src/llm/provider-adapters.ts
Normal file
217
server/src/llm/provider-adapters.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
normalizeEnabledChatTools,
|
||||
type ToolAwareCompletionParams,
|
||||
type ToolAwareCompletionResult,
|
||||
type ToolAwareStreamingEvent,
|
||||
} from "./chat-tools.js";
|
||||
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "./protocols/chat-completions-api.js";
|
||||
import { completeWithMessagesApi, streamWithMessagesApi } from "./protocols/messages-api.js";
|
||||
import { completeWithResponsesApi, streamWithResponsesApi } from "./protocols/responses-api.js";
|
||||
import { env } from "../env.js";
|
||||
import { anthropicClient, hermesAgentClient, isHermesAgentConfigured, openaiClient, xaiClient } from "./providers.js";
|
||||
import type { ChatMessage, Provider } from "./types.js";
|
||||
|
||||
type ProviderAdapterParams = {
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
enabledTools?: string[];
|
||||
userLocation?: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
logContext?: ToolAwareCompletionParams["logContext"];
|
||||
};
|
||||
|
||||
export type ProviderChatAdapter = {
|
||||
provider: Provider;
|
||||
complete(params: ProviderAdapterParams): Promise<ToolAwareCompletionResult>;
|
||||
stream(params: ProviderAdapterParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||
};
|
||||
|
||||
type ChatProtocolId = "chat-completions" | "messages" | "responses";
|
||||
|
||||
type ChatProtocol = {
|
||||
id: ChatProtocolId;
|
||||
complete(params: ToolAwareCompletionParams): Promise<ToolAwareCompletionResult>;
|
||||
stream(params: ToolAwareCompletionParams): AsyncGenerator<ToolAwareStreamingEvent>;
|
||||
};
|
||||
|
||||
type ModelCatalogSpec = {
|
||||
enabled?: () => boolean;
|
||||
fetchModels(client: any): Promise<string[]>;
|
||||
fallbackModels?: () => string[];
|
||||
};
|
||||
|
||||
type ProviderBackendSpec = {
|
||||
createClient: () => any;
|
||||
plainProtocol: ChatProtocol;
|
||||
toolProtocol?: ChatProtocol;
|
||||
managedTools?: boolean;
|
||||
modelCatalog?: ModelCatalogSpec;
|
||||
};
|
||||
|
||||
const chatCompletionsProtocol: ChatProtocol = {
|
||||
id: "chat-completions",
|
||||
complete: completeWithChatCompletionsApi,
|
||||
stream: streamWithChatCompletionsApi,
|
||||
};
|
||||
|
||||
const messagesProtocol: ChatProtocol = {
|
||||
id: "messages",
|
||||
complete: completeWithMessagesApi,
|
||||
stream: streamWithMessagesApi,
|
||||
};
|
||||
|
||||
const responsesProtocol: ChatProtocol = {
|
||||
id: "responses",
|
||||
complete: completeWithResponsesApi,
|
||||
stream: streamWithResponsesApi,
|
||||
};
|
||||
|
||||
function uniqSorted(values: string[]) {
|
||||
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function modelIdsFromListResponse(page: any) {
|
||||
return Array.isArray(page?.data)
|
||||
? page.data.map((model: any) => model?.id).filter((id: unknown): id is string => typeof id === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function isLikelyResponsesApiModel(model: string) {
|
||||
const id = model.toLowerCase();
|
||||
if (id.includes("embedding") || id.includes("moderation")) return false;
|
||||
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
||||
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||
if (id.includes("search") || id.includes("computer-use")) return false;
|
||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||
}
|
||||
|
||||
function withClient(params: ProviderAdapterParams, client: any, enabledTools?: string[]): ToolAwareCompletionParams {
|
||||
return {
|
||||
client,
|
||||
model: params.model,
|
||||
messages: params.messages,
|
||||
enabledTools,
|
||||
userLocation: params.userLocation,
|
||||
temperature: params.temperature,
|
||||
maxTokens: params.maxTokens,
|
||||
logContext: params.logContext,
|
||||
};
|
||||
}
|
||||
|
||||
function selectChatProtocol(spec: ProviderBackendSpec, params: Pick<ProviderAdapterParams, "enabledTools">) {
|
||||
const enabledTools = normalizeEnabledChatTools(params.enabledTools);
|
||||
const useManagedTools = spec.managedTools === true && spec.toolProtocol && enabledTools.length > 0;
|
||||
return {
|
||||
protocol: useManagedTools ? spec.toolProtocol! : spec.plainProtocol,
|
||||
enabledTools: useManagedTools ? enabledTools : [],
|
||||
managedTools: Boolean(useManagedTools),
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderChatAdapter(provider: Provider, spec: ProviderBackendSpec): ProviderChatAdapter {
|
||||
return {
|
||||
provider,
|
||||
complete(params) {
|
||||
const selected = selectChatProtocol(spec, params);
|
||||
return selected.protocol.complete(withClient(params, spec.createClient(), selected.enabledTools));
|
||||
},
|
||||
stream(params) {
|
||||
const selected = selectChatProtocol(spec, params);
|
||||
return selected.protocol.stream(withClient(params, spec.createClient(), selected.enabledTools));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const backendSpecs: Record<Provider, ProviderBackendSpec> = {
|
||||
openai: {
|
||||
createClient: openaiClient,
|
||||
plainProtocol: chatCompletionsProtocol,
|
||||
toolProtocol: responsesProtocol,
|
||||
managedTools: true,
|
||||
modelCatalog: {
|
||||
async fetchModels(client) {
|
||||
const page = await client.models.list();
|
||||
return modelIdsFromListResponse(page).filter(isLikelyResponsesApiModel);
|
||||
},
|
||||
},
|
||||
},
|
||||
anthropic: {
|
||||
createClient: anthropicClient,
|
||||
plainProtocol: messagesProtocol,
|
||||
toolProtocol: messagesProtocol,
|
||||
managedTools: true,
|
||||
modelCatalog: {
|
||||
async fetchModels(client) {
|
||||
const page = await client.models.list({ limit: 200 });
|
||||
return modelIdsFromListResponse(page);
|
||||
},
|
||||
},
|
||||
},
|
||||
xai: {
|
||||
createClient: xaiClient,
|
||||
plainProtocol: chatCompletionsProtocol,
|
||||
toolProtocol: chatCompletionsProtocol,
|
||||
managedTools: true,
|
||||
modelCatalog: {
|
||||
async fetchModels(client) {
|
||||
const page = await client.models.list();
|
||||
return modelIdsFromListResponse(page);
|
||||
},
|
||||
},
|
||||
},
|
||||
"hermes-agent": {
|
||||
createClient: hermesAgentClient,
|
||||
plainProtocol: chatCompletionsProtocol,
|
||||
managedTools: false,
|
||||
modelCatalog: {
|
||||
enabled: isHermesAgentConfigured,
|
||||
async fetchModels(client) {
|
||||
const page = await client.models.list();
|
||||
const models = modelIdsFromListResponse(page);
|
||||
if (env.HERMES_AGENT_MODEL) models.push(env.HERMES_AGENT_MODEL);
|
||||
return models;
|
||||
},
|
||||
fallbackModels() {
|
||||
return env.HERMES_AGENT_MODEL ? [env.HERMES_AGENT_MODEL] : [];
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const providerChatAdapters: Record<Provider, ProviderChatAdapter> = Object.fromEntries(
|
||||
Object.entries(backendSpecs).map(([provider, spec]) => [provider, createProviderChatAdapter(provider as Provider, spec)])
|
||||
) as Record<Provider, ProviderChatAdapter>;
|
||||
|
||||
export function getProviderChatAdapter(provider: Provider) {
|
||||
return providerChatAdapters[provider];
|
||||
}
|
||||
|
||||
export function describeProviderChatBackend(provider: Provider, enabledTools?: string[]) {
|
||||
const selected = selectChatProtocol(backendSpecs[provider], { enabledTools });
|
||||
return {
|
||||
provider,
|
||||
protocol: selected.protocol.id,
|
||||
managedTools: selected.managedTools,
|
||||
enabledTools: selected.enabledTools,
|
||||
};
|
||||
}
|
||||
|
||||
export function listModelCatalogProviders(): Provider[] {
|
||||
return (Object.entries(backendSpecs) as [Provider, ProviderBackendSpec][])
|
||||
.filter(([, spec]) => {
|
||||
const catalog = spec.modelCatalog;
|
||||
return catalog !== undefined && catalog.enabled?.() !== false;
|
||||
})
|
||||
.map(([provider]) => provider);
|
||||
}
|
||||
|
||||
export async function fetchProviderCatalogModels(provider: Provider) {
|
||||
const spec = backendSpecs[provider].modelCatalog;
|
||||
if (!spec) return [];
|
||||
return uniqSorted(await spec.fetchModels(backendSpecs[provider].createClient()));
|
||||
}
|
||||
|
||||
export function getProviderCatalogFallbackModels(provider: Provider) {
|
||||
return uniqSorted(backendSpecs[provider].modelCatalog?.fallbackModels?.() ?? []);
|
||||
}
|
||||
@@ -2,15 +2,28 @@ import type { Provider } from "./types.js";
|
||||
|
||||
type PrismaProvider = Exclude<Provider, "hermes-agent"> | "hermes_agent";
|
||||
|
||||
const apiToPrismaProvider = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
xai: "xai",
|
||||
"hermes-agent": "hermes_agent",
|
||||
} as const satisfies Record<Provider, PrismaProvider>;
|
||||
|
||||
const prismaToApiProvider = {
|
||||
openai: "openai",
|
||||
anthropic: "anthropic",
|
||||
xai: "xai",
|
||||
hermes_agent: "hermes-agent",
|
||||
"hermes-agent": "hermes-agent",
|
||||
} as const satisfies Record<PrismaProvider | "hermes-agent", Provider>;
|
||||
|
||||
export function toPrismaProvider(provider: Provider): PrismaProvider {
|
||||
return provider === "hermes-agent" ? "hermes_agent" : provider;
|
||||
return apiToPrismaProvider[provider];
|
||||
}
|
||||
|
||||
export function fromPrismaProvider(provider: unknown): Provider | null {
|
||||
if (provider === null || provider === undefined) return null;
|
||||
if (provider === "hermes_agent" || provider === "hermes-agent") return "hermes-agent";
|
||||
if (provider === "openai" || provider === "anthropic" || provider === "xai") return provider;
|
||||
return null;
|
||||
return prismaToApiProvider[provider as keyof typeof prismaToApiProvider] ?? null;
|
||||
}
|
||||
|
||||
export function serializeProviderFields<T extends Record<string, any>>(value: T): T {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { prisma } from "../db.js";
|
||||
import { anthropicClient, hermesAgentClient, openaiClient, xaiClient } from "./providers.js";
|
||||
import {
|
||||
buildToolLogMessageData,
|
||||
normalizeEnabledChatTools,
|
||||
runPlainChatCompletionsStream,
|
||||
runToolAwareChatCompletionsStream,
|
||||
runToolAwareOpenAIChatStream,
|
||||
type ToolExecutionEvent,
|
||||
} from "./chat-tools.js";
|
||||
import { buildAnthropicConversationMessage, getAnthropicSystemPrompt } from "./message-content.js";
|
||||
import { getProviderChatAdapter } from "./provider-adapters.js";
|
||||
import { toPrismaProvider } from "./provider-ids.js";
|
||||
import type { MultiplexRequest, Provider } from "./types.js";
|
||||
|
||||
@@ -75,44 +70,11 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
let raw: unknown = { streamed: true };
|
||||
|
||||
try {
|
||||
if (req.provider === "openai" || req.provider === "xai" || req.provider === "hermes-agent") {
|
||||
const client = req.provider === "openai" ? openaiClient() : req.provider === "xai" ? xaiClient() : hermesAgentClient();
|
||||
const enabledTools = normalizeEnabledChatTools(req.enabledTools);
|
||||
const streamEvents =
|
||||
req.provider === "openai" && enabledTools.length > 0
|
||||
? runToolAwareOpenAIChatStream({
|
||||
client,
|
||||
const adapter = getProviderChatAdapter(req.provider);
|
||||
const streamEvents = adapter.stream({
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
chatId: chatId ?? undefined,
|
||||
},
|
||||
})
|
||||
: req.provider === "hermes-agent" || enabledTools.length === 0
|
||||
? runPlainChatCompletionsStream({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
logContext: {
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
chatId: chatId ?? undefined,
|
||||
},
|
||||
})
|
||||
: runToolAwareChatCompletionsStream({
|
||||
client,
|
||||
model: req.model,
|
||||
messages: req.messages,
|
||||
enabledTools,
|
||||
enabledTools: req.enabledTools,
|
||||
userLocation: req.userLocation,
|
||||
temperature: req.temperature,
|
||||
maxTokens: req.maxTokens,
|
||||
@@ -122,6 +84,7 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
chatId: chatId ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
for await (const ev of streamEvents) {
|
||||
if (ev.type === "delta") {
|
||||
text += ev.text;
|
||||
@@ -150,45 +113,6 @@ export async function* runMultiplexStream(req: MultiplexRequest): AsyncGenerator
|
||||
usage = ev.result.usage;
|
||||
text = ev.result.text;
|
||||
}
|
||||
} else if (req.provider === "anthropic") {
|
||||
const client = anthropicClient();
|
||||
|
||||
const system = getAnthropicSystemPrompt(req.messages, req.userLocation);
|
||||
const msgs = req.messages.filter((message) => message.role !== "system").map((message) => buildAnthropicConversationMessage(message));
|
||||
|
||||
const stream = await client.messages.create({
|
||||
model: req.model,
|
||||
system,
|
||||
max_tokens: req.maxTokens ?? 1024,
|
||||
temperature: req.temperature,
|
||||
messages: msgs as any,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
for await (const ev of stream as any as AsyncIterable<any>) {
|
||||
// Anthropic streaming events include content_block_delta with text_delta
|
||||
if (ev?.type === "content_block_delta" && ev?.delta?.type === "text_delta") {
|
||||
const delta = ev.delta.text ?? "";
|
||||
if (delta) {
|
||||
text += delta;
|
||||
yield { type: "delta", text: delta };
|
||||
}
|
||||
}
|
||||
// capture usage if present on message_delta
|
||||
if (ev?.type === "message_delta" && ev?.usage) {
|
||||
usage = {
|
||||
inputTokens: ev.usage.input_tokens,
|
||||
outputTokens: ev.usage.output_tokens,
|
||||
totalTokens:
|
||||
(ev.usage.input_tokens ?? 0) + (ev.usage.output_tokens ?? 0),
|
||||
};
|
||||
}
|
||||
// some streams end with message_stop
|
||||
}
|
||||
raw = { streamed: true, provider: "anthropic" };
|
||||
} else {
|
||||
throw new Error(`unknown provider: ${req.provider}`);
|
||||
}
|
||||
|
||||
const latencyMs = Math.round(performance.now() - t0);
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
runPlainChatCompletionsStream,
|
||||
runToolAwareChatCompletions,
|
||||
runToolAwareChatCompletionsStream,
|
||||
runToolAwareOpenAIChatStream,
|
||||
type ToolAwareStreamingEvent,
|
||||
} from "../src/llm/chat-tools.js";
|
||||
import { type ToolAwareStreamingEvent } from "../src/llm/chat-tools.js";
|
||||
import { completeWithChatCompletionsApi, streamWithChatCompletionsApi } from "../src/llm/protocols/chat-completions-api.js";
|
||||
import { completeWithMessagesApi, streamWithMessagesApi } from "../src/llm/protocols/messages-api.js";
|
||||
import { streamWithResponsesApi } from "../src/llm/protocols/responses-api.js";
|
||||
|
||||
async function* streamFrom(events: any[]) {
|
||||
for (const event of events) {
|
||||
@@ -23,7 +20,7 @@ async function collectEvents(iterable: AsyncIterable<ToolAwareStreamingEvent>) {
|
||||
return events;
|
||||
}
|
||||
|
||||
test("OpenAI Responses stream emits text deltas as they arrive", async () => {
|
||||
test("Responses API stream emits text deltas as they arrive", async () => {
|
||||
const outputMessage = {
|
||||
id: "msg_1",
|
||||
type: "message",
|
||||
@@ -53,7 +50,7 @@ test("OpenAI Responses stream emits text deltas as they arrive", async () => {
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
runToolAwareOpenAIChatStream({
|
||||
streamWithResponsesApi({
|
||||
client: client as any,
|
||||
model: "gpt-test",
|
||||
messages: [{ role: "user", content: "Say hello" }],
|
||||
@@ -71,7 +68,7 @@ test("OpenAI Responses stream emits text deltas as they arrive", async () => {
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Hello");
|
||||
});
|
||||
|
||||
test("OpenAI-compatible Chat Completions stream emits text deltas as they arrive", async () => {
|
||||
test("Chat Completions API stream emits text deltas as they arrive", async () => {
|
||||
const client = {
|
||||
chat: {
|
||||
completions: {
|
||||
@@ -90,7 +87,7 @@ test("OpenAI-compatible Chat Completions stream emits text deltas as they arrive
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
runToolAwareChatCompletionsStream({
|
||||
streamWithChatCompletionsApi({
|
||||
client: client as any,
|
||||
model: "grok-test",
|
||||
messages: [{ role: "user", content: "Say hello" }],
|
||||
@@ -125,10 +122,11 @@ test("plain Chat Completions stream does not send Sybil-managed tools", async ()
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
runPlainChatCompletionsStream({
|
||||
streamWithChatCompletionsApi({
|
||||
client: client as any,
|
||||
model: "hermes-agent",
|
||||
messages: [{ role: "user", content: "Say hi" }],
|
||||
enabledTools: [],
|
||||
})
|
||||
);
|
||||
|
||||
@@ -189,7 +187,7 @@ test("fetch_url sends browser-like navigation headers", async () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runToolAwareChatCompletions({
|
||||
const result = await completeWithChatCompletionsApi({
|
||||
client: client as any,
|
||||
model: "grok-test",
|
||||
messages: [{ role: "user", content: "Fetch CPI PDF" }],
|
||||
@@ -215,7 +213,81 @@ test("fetch_url sends browser-like navigation headers", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenAI-compatible Chat Completions stream emits initiated and terminal tool call updates", async () => {
|
||||
test("Messages API executes tool_use blocks and sends tool_result follow-up", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const fetchCalls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
fetchCalls.push({ input, init });
|
||||
return new Response("<!doctype html><title>Example</title><main>Tool result body</main>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const requestBodies: any[] = [];
|
||||
const client = {
|
||||
messages: {
|
||||
create: async (body: any) => {
|
||||
requestBodies.push(body);
|
||||
if (requestBodies.length === 1) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "fetch_url",
|
||||
input: { url: "https://example.com/article" },
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 3, output_tokens: 2 },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: "Fetched" }],
|
||||
usage: { input_tokens: 5, output_tokens: 1 },
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await completeWithMessagesApi({
|
||||
client: client as any,
|
||||
model: "claude-test",
|
||||
messages: [{ role: "user", content: "Fetch the article" }],
|
||||
});
|
||||
|
||||
assert.equal(result.text, "Fetched");
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(String(fetchCalls[0]?.input), "https://example.com/article");
|
||||
assert.equal(requestBodies.length, 2);
|
||||
assert.equal(requestBodies[0]?.model, "claude-test");
|
||||
assert.equal(requestBodies[0]?.tool_choice?.type, "auto");
|
||||
const fetchTool = requestBodies[0]?.tools?.find((tool: any) => tool.name === "fetch_url");
|
||||
assert.equal(fetchTool?.input_schema?.type, "object");
|
||||
assert.equal(fetchTool?.input_schema?.properties?.url?.type, "string");
|
||||
|
||||
const secondMessages = requestBodies[1]?.messages ?? [];
|
||||
assert.equal(secondMessages.at(-2)?.role, "assistant");
|
||||
assert.equal(secondMessages.at(-2)?.content?.[0]?.type, "tool_use");
|
||||
assert.equal(secondMessages.at(-1)?.role, "user");
|
||||
const toolResult = secondMessages.at(-1)?.content?.[0];
|
||||
assert.equal(toolResult?.type, "tool_result");
|
||||
assert.equal(toolResult?.tool_use_id, "toolu_1");
|
||||
assert.equal(toolResult?.is_error, false);
|
||||
assert.equal(JSON.parse(toolResult?.content ?? "{}").ok, true);
|
||||
assert.equal(result.toolEvents[0]?.toolCallId, "toolu_1");
|
||||
assert.equal(result.toolEvents[0]?.status, "completed");
|
||||
assert.equal(result.usage?.inputTokens, 8);
|
||||
assert.equal(result.usage?.outputTokens, 3);
|
||||
assert.equal(result.usage?.totalTokens, 11);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("Chat Completions API stream emits initiated and terminal tool call updates", async () => {
|
||||
let requestCount = 0;
|
||||
const client = {
|
||||
chat: {
|
||||
@@ -256,7 +328,7 @@ test("OpenAI-compatible Chat Completions stream emits initiated and terminal too
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
runToolAwareChatCompletionsStream({
|
||||
streamWithChatCompletionsApi({
|
||||
client: client as any,
|
||||
model: "grok-test",
|
||||
messages: [{ role: "user", content: "Use a tool" }],
|
||||
@@ -280,3 +352,122 @@ test("OpenAI-compatible Chat Completions stream emits initiated and terminal too
|
||||
assert.equal(typeof toolEvents[1]?.durationMs, "number");
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||
});
|
||||
|
||||
test("Messages API stream emits initiated and terminal tool call updates", async () => {
|
||||
let requestCount = 0;
|
||||
const requestBodies: any[] = [];
|
||||
const client = {
|
||||
messages: {
|
||||
create: async (body: any) => {
|
||||
requestCount += 1;
|
||||
requestBodies.push(body);
|
||||
if (requestCount === 1) {
|
||||
return streamFrom([
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: { input_tokens: 3, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "I'll check that." },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "unknown_tool",
|
||||
input: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 1,
|
||||
delta: { type: "input_json_delta", partial_json: "{\"query\":\"current weather\"}" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "tool_use", stop_sequence: null },
|
||||
usage: { output_tokens: 2 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
]);
|
||||
}
|
||||
|
||||
return streamFrom([
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: { input_tokens: 4, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "Done" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: null },
|
||||
usage: { output_tokens: 1 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
]);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const events = await collectEvents(
|
||||
streamWithMessagesApi({
|
||||
client: client as any,
|
||||
model: "claude-test",
|
||||
messages: [{ role: "user", content: "Use a tool" }],
|
||||
})
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.type),
|
||||
["tool_call", "tool_call", "delta", "done"]
|
||||
);
|
||||
assert.equal(requestBodies[0]?.stream, true);
|
||||
assert.equal(requestBodies[0]?.tools?.some((tool: any) => tool.name === "fetch_url"), true);
|
||||
|
||||
const secondMessages = requestBodies[1]?.messages ?? [];
|
||||
assert.equal(secondMessages.at(-2)?.role, "assistant");
|
||||
assert.equal(secondMessages.at(-2)?.content?.[0]?.type, "text");
|
||||
assert.equal(secondMessages.at(-2)?.content?.[0]?.text, "I'll check that.");
|
||||
assert.equal(secondMessages.at(-2)?.content?.[1]?.type, "tool_use");
|
||||
assert.deepEqual(secondMessages.at(-2)?.content?.[1]?.input, { query: "current weather" });
|
||||
const toolResult = secondMessages.at(-1)?.content?.[0];
|
||||
assert.equal(toolResult?.type, "tool_result");
|
||||
assert.equal(toolResult?.tool_use_id, "toolu_1");
|
||||
assert.equal(toolResult?.is_error, true);
|
||||
assert.match(JSON.parse(toolResult?.content ?? "{}").error ?? "", /Unknown tool: unknown_tool/);
|
||||
|
||||
const toolEvents = events.flatMap((event) => (event.type === "tool_call" ? [event.event] : []));
|
||||
assert.equal(toolEvents[0]?.toolCallId, "toolu_1");
|
||||
assert.equal(toolEvents[0]?.status, "initiated");
|
||||
assert.equal(toolEvents[1]?.toolCallId, "toolu_1");
|
||||
assert.equal(toolEvents[1]?.status, "failed");
|
||||
assert.match(toolEvents[1]?.error ?? "", /Unknown tool: unknown_tool/);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.text : null, "Done");
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.usage?.inputTokens : null, 7);
|
||||
assert.equal(events.at(-1)?.type === "done" ? events.at(-1)?.result.usage?.outputTokens : null, 3);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildSystemPromptAugmentation, getAnthropicSystemPrompt } from "../src/llm/message-content.js";
|
||||
import { buildSystemPromptAugmentation, buildTopLevelSystemPrompt } from "../src/llm/message-content.js";
|
||||
|
||||
test("system prompt augmentation includes date and default location", () => {
|
||||
const prompt = buildSystemPromptAugmentation(undefined, new Date("2026-05-24T15:30:00Z"));
|
||||
@@ -14,8 +14,8 @@ test("system prompt augmentation uses provided user location", () => {
|
||||
assert.equal(prompt, "Current date: 2026-05-24.\nUser location: New York, NY.");
|
||||
});
|
||||
|
||||
test("Anthropic system prompt includes runtime context with existing system messages", () => {
|
||||
const prompt = getAnthropicSystemPrompt(
|
||||
test("top-level system prompt includes runtime context with existing system messages", () => {
|
||||
const prompt = buildTopLevelSystemPrompt(
|
||||
[{ role: "system", content: "Use concise answers." }],
|
||||
"Los Angeles, CA"
|
||||
);
|
||||
|
||||
36
server/tests/provider-adapters.test.ts
Normal file
36
server/tests/provider-adapters.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
|
||||
|
||||
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
||||
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
||||
provider: "openai",
|
||||
protocol: "chat-completions",
|
||||
managedTools: false,
|
||||
enabledTools: [],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("openai", ["web_search"]), {
|
||||
provider: "openai",
|
||||
protocol: "responses",
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("anthropic", ["web_search"]), {
|
||||
provider: "anthropic",
|
||||
protocol: "messages",
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("xai", ["web_search"]), {
|
||||
provider: "xai",
|
||||
protocol: "chat-completions",
|
||||
managedTools: true,
|
||||
enabledTools: ["web_search"],
|
||||
});
|
||||
assert.deepEqual(describeProviderChatBackend("hermes-agent", ["web_search"]), {
|
||||
provider: "hermes-agent",
|
||||
protocol: "chat-completions",
|
||||
managedTools: false,
|
||||
enabledTools: [],
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user