import { ChatCompletionMessageToolCall } from "openai/resources"; import { ChatCompletionCreateParamsNonStreaming, ChatCompletionCreateParamsStreaming, ChatCompletionMessageParam, ChatCompletionFunctionTool, ChatCompletionChunk, } from "openai/resources.js"; import { OpenAIClient } from "../utils/openaiClient.js"; import { logger } from "../utils/globalLogger.js"; import { addOnceAbortListener } from "../utils/abortUtils.js"; import type { GatewayConfig, ModelConfig } from "../types/index.js"; import { ConfigurationError, CONFIG_ERRORS } from "../types/index.js"; import { transformMessagesForExplicitCache, extendUsageWithCacheMetrics, type ClaudeUsage, type ClaudeChatCompletionContentPartText, } from "../utils/cacheControlUtils.js"; import { supportsPromptCaching } from "../utils/modelCapabilities.js"; import * as os from "os"; import * as fs from "fs"; import * as path from "path"; import { COMPACT_MESSAGES_SYSTEM_PROMPT, WEB_CONTENT_SYSTEM_PROMPT, BTW_SYSTEM_PROMPT, type SystemPromptBlock, } from "../prompts/index.js"; import { GOAL_EVALUATION_SYSTEM_PROMPT } from "../constants/goalPrompts.js"; /** * Interface for debug data saved during 400 errors */ interface DebugData { originalMessages: ChatCompletionMessageParam[]; timestamp: string; model: string; workdir: string; sessionId?: string; gatewayConfig: { baseURL?: string; defaultHeaders?: Record; }; processedMessages?: ChatCompletionMessageParam[]; createParams?: | ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming; tools?: ChatCompletionFunctionTool[]; } /** * Interface for error data saved during 400 errors */ interface ErrorData { error: { message?: string; status?: number; type?: string; code?: string; body?: unknown; stack?: string; }; timestamp: string; } /** * Use parametersChunk as compact param for better performance * Instead of parsing JSON, we use the raw chunk for efficient streaming */ /** * OpenAI model configuration type, based on OpenAI parameters but excluding messages */ type OpenAIModelConfig = Omit< ChatCompletionCreateParamsNonStreaming, "messages" >; // Global rate limiter state for 1 QPS let nextAllowedTime = 0; const MIN_INTERVAL = 1000; // 1 second for 1 QPS /** * Resets the rate limiter state. Primarily used for testing. */ export function resetRateLimiter(): void { nextAllowedTime = 0; } /** * Acquires a slot for an AI request, ensuring 1 QPS limit. * @param abortSignal Optional abort signal to cancel waiting */ async function acquireSlot(abortSignal?: AbortSignal): Promise { if (abortSignal?.aborted) { throw new Error("Request was aborted"); } const now = Date.now(); const waitTime = Math.max(0, nextAllowedTime - now); // Reserve the slot synchronously to ensure global ordering nextAllowedTime = Math.max(now, nextAllowedTime) + MIN_INTERVAL; if (waitTime > 0) { await new Promise((resolve, reject) => { const timeout = setTimeout(() => { cleanup(); resolve(); }, waitTime); const cleanup = abortSignal ? addOnceAbortListener(abortSignal, () => { clearTimeout(timeout); reject(new Error("Request was aborted")); }) : () => {}; }); } } /** * Get specific configuration parameters based on model name * @param modelName Model name * @param baseConfig Base configuration * @returns Configured model parameters */ function getModelConfig( modelName: string, baseConfig: Partial = {}, ): OpenAIModelConfig { const config: OpenAIModelConfig = { model: modelName, stream: false, ...baseConfig, }; // Handle parameter exclusion: if a parameter is explicitly set to null, remove it. // This allows users to "unset" default parameters like temperature for models that don't support them. for (const key in config) { if (config[key as keyof OpenAIModelConfig] === null) { delete config[key as keyof OpenAIModelConfig]; } } return config; } export interface CallAgentOptions { // Resolved configuration gatewayConfig: GatewayConfig; modelConfig: ModelConfig; // Existing parameters (preserved) messages: ChatCompletionMessageParam[]; sessionId?: string; abortSignal?: AbortSignal; workdir: string; // Current working directory tools?: ChatCompletionFunctionTool[]; // Tool configuration model?: string; // Custom model systemPrompt?: string | SystemPromptBlock[]; // Custom system prompt (string or structured blocks) maxTokens?: number; // Maximum output tokens toolChoice?: | "auto" | "none" | "required" | { type: "function"; function: { name: string } }; // Force tool selection // NEW: Streaming callbacks onContentUpdate?: (content: string) => void; onToolUpdate?: (toolCall: { id: string; name: string; parameters: string; parametersChunk?: string; stage?: "start" | "streaming" | "running" | "end"; }) => void; onReasoningUpdate?: (content: string) => void; } export interface CallAgentResult { content?: string; tool_calls?: ChatCompletionMessageToolCall[]; reasoning_content?: string; usage?: ClaudeUsage; finish_reason?: | "stop" | "length" | "tool_calls" | "content_filter" | "function_call" | null; response_headers?: Record; additionalFields?: Record; } function validateModelConfig( modelConfig: ModelConfig, ): asserts modelConfig is ModelConfig & { model: string; fastModel: string } { if (!modelConfig.model) { throw new ConfigurationError(CONFIG_ERRORS.MISSING_MODEL, "model", { constructor: undefined, environment: process.env.WAVE_MODEL, }); } if (!modelConfig.fastModel) { throw new ConfigurationError( CONFIG_ERRORS.MISSING_FAST_MODEL, "fastModel", { constructor: undefined, environment: process.env.WAVE_FAST_MODEL, }, ); } } export async function callAgent( options: CallAgentOptions, ): Promise { const { gatewayConfig, modelConfig, messages, abortSignal, workdir, tools, model, systemPrompt, onContentUpdate, onToolUpdate, onReasoningUpdate, } = options; // Validate model config at call time validateModelConfig(modelConfig); // Apply global 1 QPS rate limit if ( process.env.NODE_ENV !== "test" || modelConfig.model === "rate-limit-test" ) { await acquireSlot(abortSignal); } // Declare variables outside try block for error handling access let openaiMessages: ChatCompletionMessageParam[] | undefined; let createParams: | ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming | undefined; let processedTools: ChatCompletionFunctionTool[] | undefined; try { // Create OpenAI client with injected configuration const openai = new OpenAIClient({ apiKey: gatewayConfig.apiKey, baseURL: gatewayConfig.baseURL, defaultHeaders: gatewayConfig.defaultHeaders, fetchOptions: gatewayConfig.fetchOptions, fetch: gatewayConfig.fetch, }); // Determine model early (needed for system prompt construction) const resolvedMaxTokens = options.maxTokens ?? modelConfig.maxTokens; // Build system message content let systemMessage: ChatCompletionMessageParam; if (Array.isArray(systemPrompt)) { if (supportsPromptCaching(modelConfig.capabilities)) { // For Claude models, map blocks to content parts with cache_control on cacheable blocks const contentParts: ClaudeChatCompletionContentPartText[] = systemPrompt.map((block) => { const part: ClaudeChatCompletionContentPartText = { type: "text", text: block.text, }; if (block.cacheable) { part.cache_control = { type: "ephemeral" }; } return part; }); systemMessage = { role: "system", content: contentParts, } as ChatCompletionMessageParam; } else { // For non-Claude models, join blocks into a single string systemMessage = { role: "system", content: systemPrompt.map((b) => b.text).join("\n\n"), }; } } else { systemMessage = { role: "system", content: systemPrompt || "", }; } // ChatCompletionMessageParam[] is already in OpenAI format, add system prompt to the beginning openaiMessages = [systemMessage, ...messages]; processedTools = tools; if (supportsPromptCaching(modelConfig.capabilities)) { openaiMessages = transformMessagesForExplicitCache( openaiMessages, modelConfig.capabilities, ); } const openaiModelConfig = getModelConfig(model || modelConfig.model, { max_tokens: resolvedMaxTokens, ...(modelConfig.options || {}), }); // Determine if streaming is needed const isStreaming = !!( onContentUpdate || onToolUpdate || onReasoningUpdate ); // Prepare API call parameters createParams = { ...openaiModelConfig, messages: openaiMessages, stream: isStreaming, } as | ChatCompletionCreateParamsNonStreaming | ChatCompletionCreateParamsStreaming; // Only add tools if they exist if (processedTools && processedTools.length > 0) { createParams.tools = processedTools; } // Add tool_choice if specified if (options.toolChoice) { createParams.tool_choice = options.toolChoice; } if (isStreaming) { // Handle streaming response const { data: stream, response } = await openai.chat.completions .create(createParams as ChatCompletionCreateParamsStreaming, { signal: abortSignal, }) .withResponse(); // Extract response headers const responseHeaders: Record = {}; (response.headers as Headers).forEach((value: string, key: string) => { responseHeaders[key] = value; }); return await processStreamingResponse( stream, onContentUpdate, onToolUpdate, onReasoningUpdate, abortSignal, responseHeaders, ); } else { // Handle non-streaming response const { data: response, response: rawResponse } = await openai.chat.completions .create(createParams as ChatCompletionCreateParamsNonStreaming, { signal: abortSignal, }) .withResponse(); // Extract response headers const responseHeaders: Record = {}; (rawResponse.headers as Headers).forEach((value: string, key: string) => { responseHeaders[key] = value; }); const finalMessage = response.choices[0]?.message; const finishReason = response.choices[0]?.finish_reason || null; let totalUsage = response.usage ? { prompt_tokens: response.usage.prompt_tokens, completion_tokens: response.usage.completion_tokens, total_tokens: response.usage.total_tokens, } : undefined; // Extend usage with cache metrics (Claude top-level + OpenAI prompt_tokens_details) if (totalUsage && response.usage) { totalUsage = extendUsageWithCacheMetrics( totalUsage, response.usage as Partial, ); } const result: CallAgentResult = {}; if (finalMessage) { const { content: finalContent, tool_calls: finalToolCalls, reasoning_content: finalReasoningContent, ...otherFields } = finalMessage as unknown as { content?: string; tool_calls?: ChatCompletionMessageToolCall[]; reasoning_content?: string; [key: string]: unknown; }; if (typeof finalContent === "string" && finalContent.length > 0) { result.content = finalContent; } if (typeof finalReasoningContent === "string") { result.reasoning_content = finalReasoningContent; } if (Array.isArray(finalToolCalls) && finalToolCalls.length > 0) { result.tool_calls = finalToolCalls; } if (Object.keys(otherFields).length > 0) { const additionalFields: Record = {}; for (const [key, value] of Object.entries(otherFields)) { if (value !== undefined && key !== "role") { additionalFields[key] = value; } } if (Object.keys(additionalFields).length > 0) { result.additionalFields = additionalFields; } } } if (totalUsage) { result.usage = totalUsage; } if (finishReason) { result.finish_reason = finishReason; } if (Object.keys(responseHeaders).length > 0) { result.response_headers = responseHeaders; } return result; } } catch (error) { if ((error as Error).name === "AbortError") { logger.info("OpenAI request aborted"); throw new Error("Request was aborted"); } // Check if it's a 400 error and save messages to temp directory if ( error && typeof error === "object" && "status" in error && error.status === 400 ) { try { // Create temp directory for error debugging const tempDir = fs.mkdtempSync( path.join(os.tmpdir(), "callAgent-400-error-"), ); const messagesFile = path.join(tempDir, "messages.json"); const errorFile = path.join(tempDir, "error.json"); // Save complete messages to temp file const debugData: DebugData = { originalMessages: messages, timestamp: new Date().toISOString(), model: model || modelConfig.model, workdir, sessionId: options.sessionId, gatewayConfig: { baseURL: gatewayConfig.baseURL, // Don't include apiKey for security defaultHeaders: gatewayConfig.defaultHeaders, }, }; // Add processed messages if they exist if (typeof openaiMessages !== "undefined") { debugData.processedMessages = openaiMessages; } // Add create params if they exist if (typeof createParams !== "undefined") { debugData.createParams = createParams; } // Add tools if they exist if (processedTools) { debugData.tools = processedTools; } fs.writeFileSync(messagesFile, JSON.stringify(debugData, null, 2)); // Save error details const errorData: ErrorData = { error: { message: error && typeof error === "object" && "message" in error ? String(error.message) : undefined, status: error && typeof error === "object" && "status" in error ? Number(error.status) : undefined, type: error && typeof error === "object" && "type" in error ? String(error.type) : undefined, code: error && typeof error === "object" && "code" in error ? String(error.code) : undefined, body: error && typeof error === "object" && "body" in error ? error.body : undefined, stack: error && typeof error === "object" && "stack" in error ? String(error.stack) : undefined, }, timestamp: new Date().toISOString(), }; fs.writeFileSync(errorFile, JSON.stringify(errorData, null, 2)); logger.error( "callAgent 400 error occurred. Debug files saved to:", tempDir, ); logger.error("Messages file:", messagesFile); logger.error("Error file:", errorFile); logger.error("Error details:", error); } catch (saveError) { logger.error("Failed to save 400 error debug files:", saveError); } } logger.error("Failed to call OpenAI:", error); throw error; } } /** * Process streaming response from OpenAI API * @param stream Async iterator of chat completion chunks * @param onContentUpdate Callback for content updates * @param onToolUpdate Callback for tool updates * @param abortSignal Optional abort signal * @param responseHeaders Response headers from the initial request * @returns Final result with accumulated content and tool calls */ async function processStreamingResponse( stream: AsyncIterable, onContentUpdate?: (content: string) => void, onToolUpdate?: (toolCall: { id: string; name: string; parameters: string; parametersChunk?: string; stage?: "start" | "streaming" | "running" | "end"; }) => void, onReasoningUpdate?: (content: string) => void, abortSignal?: AbortSignal, responseHeaders?: Record, ): Promise { let accumulatedContent = ""; let accumulatedReasoningContent = ""; let hasReasoningContent = false; const toolCalls: { id: string; type: "function"; function: { name: string; arguments: string; }; }[] = []; const additionalDeltaFields: Record = {}; let usage: CallAgentResult["usage"] = undefined; let finishReason: CallAgentResult["finish_reason"] = null; try { for await (const chunk of stream) { // Check for abort signal if (abortSignal?.aborted) { throw new Error("Request was aborted"); } // Check for usage information in any chunk if (chunk.usage) { let chunkUsage = { prompt_tokens: chunk.usage.prompt_tokens, completion_tokens: chunk.usage.completion_tokens, total_tokens: chunk.usage.total_tokens, }; // Extend usage with cache metrics (Claude top-level + OpenAI prompt_tokens_details) chunkUsage = extendUsageWithCacheMetrics( chunkUsage, chunk.usage as Partial, ); usage = chunkUsage; } // Check for finish_reason in the choice const choice = chunk.choices?.[0]; if (choice?.finish_reason) { finishReason = choice.finish_reason; } const delta = choice?.delta; if (!delta) { continue; } const { content, tool_calls: toolCallUpdates, reasoning_content, ...deltaMetadata } = delta as unknown as { content?: string; tool_calls?: ChatCompletionChunk.Choice.Delta.ToolCall[]; reasoning_content?: string; [key: string]: unknown; }; if (Object.keys(deltaMetadata).length > 0) { Object.assign(additionalDeltaFields, deltaMetadata); } if (typeof content === "string" && content.length > 0) { // Note: OpenAI API already handles UTF-8 character boundaries correctly in streaming, // ensuring that delta.content always contains complete UTF-8 strings accumulatedContent += content; if (onContentUpdate) { onContentUpdate(accumulatedContent); } } if (typeof reasoning_content === "string") { hasReasoningContent = true; if (reasoning_content.length > 0) { accumulatedReasoningContent += reasoning_content; if (onReasoningUpdate) { onReasoningUpdate(accumulatedReasoningContent); } } } if (Array.isArray(toolCallUpdates)) { for (const rawToolCall of toolCallUpdates) { const toolCallDelta = rawToolCall as ChatCompletionChunk.Choice.Delta.ToolCall; if (!toolCallDelta.function) { continue; } const functionDelta = toolCallDelta.function; let existingCall; let isNew = false; if (toolCallDelta.id) { existingCall = toolCalls.find((t) => t.id === toolCallDelta.id); if (!existingCall) { existingCall = { id: toolCallDelta.id, type: "function" as const, function: { name: functionDelta.name || "", arguments: "", }, }; toolCalls.push(existingCall); isNew = true; } } else { existingCall = toolCalls[toolCalls.length - 1]; } if (!existingCall) { continue; } if (functionDelta.name) { existingCall.function.name = functionDelta.name; } // Emit start stage when a new tool call is created and we have the tool name if (onToolUpdate && isNew && existingCall.function.name) { onToolUpdate({ id: existingCall.id, name: existingCall.function.name, parameters: "", // Empty parameters for start stage parametersChunk: "", // Empty chunk for start stage stage: "start", // New tool call triggers start stage }); isNew = false; // Prevent duplicate start emissions } if (functionDelta.arguments) { existingCall.function.arguments += functionDelta.arguments; } // Emit streaming updates for all chunks with actual content (including first chunk) if ( onToolUpdate && existingCall.function.name && functionDelta.arguments && functionDelta.arguments.length > 0 // Only emit streaming for chunks with actual content ) { onToolUpdate({ id: existingCall.id, name: existingCall.function.name, parameters: existingCall.function.arguments, parametersChunk: functionDelta.arguments, stage: "streaming", }); } } } } } catch (error) { if ((error as Error).message === "Request was aborted") { throw error; } throw error; } // Prepare final result const result: CallAgentResult = {}; if (accumulatedContent) { result.content = accumulatedContent.trim(); } if (hasReasoningContent) { result.reasoning_content = accumulatedReasoningContent.trim(); } if (toolCalls.length > 0) { result.tool_calls = toolCalls; } if (usage) { result.usage = usage; } if (finishReason) { result.finish_reason = finishReason; } if (responseHeaders && Object.keys(responseHeaders).length > 0) { result.response_headers = responseHeaders; } if (Object.keys(additionalDeltaFields).length > 0) { result.additionalFields = {}; for (const [key, value] of Object.entries(additionalDeltaFields)) { if (value !== undefined && key !== "role") { result.additionalFields[key] = value; } } if (Object.keys(result.additionalFields).length === 0) { delete result.additionalFields; } } return result; } export interface CompactMessagesOptions { // Resolved configuration gatewayConfig: GatewayConfig; modelConfig: ModelConfig; // Existing parameters messages: ChatCompletionMessageParam[]; abortSignal?: AbortSignal; model?: string; customInstructions?: string; } export interface CompactMessagesResult { content: string; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; } export async function compactMessages( options: CompactMessagesOptions, ): Promise { const { gatewayConfig, modelConfig, messages, abortSignal } = options; // Validate model config at call time validateModelConfig(modelConfig); // Apply global 1 QPS rate limit if ( process.env.NODE_ENV !== "test" || modelConfig.model === "rate-limit-test" ) { await acquireSlot(abortSignal); } // Strip images from messages before compact API call to reduce token usage const cleanedMessages = messages.map((msg) => { // Handle user/assistant messages with array content if (Array.isArray(msg.content)) { const textParts = msg.content.filter( (part) => part.type === "text", ) as import("openai/resources.js").ChatCompletionContentPartText[]; const text = textParts.map((p) => p.text).join("\n"); return { ...msg, content: text || "(empty message)" }; } return msg; }); // Create OpenAI client with injected configuration const openai = new OpenAIClient({ apiKey: gatewayConfig.apiKey, baseURL: gatewayConfig.baseURL, defaultHeaders: gatewayConfig.defaultHeaders, fetchOptions: gatewayConfig.fetchOptions, fetch: gatewayConfig.fetch, }); // When a fast model override is provided, use the fast model's options // (if configured); otherwise fall back to the agent model's options. const activeExtraParams = options.model ? modelConfig.fastModelOptions || {} : modelConfig.options || {}; const openaiModelConfig = getModelConfig(options.model || modelConfig.model, { temperature: 0.1, max_tokens: 8192, ...activeExtraParams, }); try { const response = await openai.chat.completions.create( { ...openaiModelConfig, messages: [ { role: "system", content: COMPACT_MESSAGES_SYSTEM_PROMPT, }, ...cleanedMessages, { role: "user", content: options.customInstructions ? `Please create a detailed summary of the conversation so far. Pay special attention to these instructions: ${options.customInstructions}` : `Please create a detailed summary of the conversation so far.`, }, ], }, { signal: abortSignal, }, ); const content = response.choices[0]?.message?.content?.trim(); if (!content) { throw new Error( "Failed to compact conversation history: Empty response from AI", ); } const usage = response.usage ? { prompt_tokens: response.usage.prompt_tokens, completion_tokens: response.usage.completion_tokens, total_tokens: response.usage.total_tokens, } : undefined; return { content, usage, }; } catch (error) { if ((error as Error).name === "AbortError") { logger.info("Compaction request was aborted"); throw new Error("Compaction request was aborted"); } logger.error("Failed to compact messages:", error); throw error; } } export interface ProcessWebContentOptions { // Resolved configuration gatewayConfig: GatewayConfig; modelConfig: ModelConfig; // Parameters content: string; prompt: string; abortSignal?: AbortSignal; model?: string; } export interface ProcessWebContentResult { content: string; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; } export async function processWebContent( options: ProcessWebContentOptions, ): Promise { const { gatewayConfig, modelConfig, content, prompt, abortSignal } = options; // Validate model config at call time validateModelConfig(modelConfig); // Apply global 1 QPS rate limit if ( process.env.NODE_ENV !== "test" || modelConfig.model === "rate-limit-test" ) { await acquireSlot(abortSignal); } // Create OpenAI client with injected configuration const openai = new OpenAIClient({ apiKey: gatewayConfig.apiKey, baseURL: gatewayConfig.baseURL, defaultHeaders: gatewayConfig.defaultHeaders, fetchOptions: gatewayConfig.fetchOptions, fetch: gatewayConfig.fetch, }); // When a fast model override is provided, use the fast model's options // (if configured); otherwise fall back to the agent model's options. const activeExtraParams = options.model ? modelConfig.fastModelOptions || {} : modelConfig.options || {}; const openaiModelConfig = getModelConfig(options.model || modelConfig.model, { temperature: 0.1, max_tokens: 4096, ...activeExtraParams, }); try { const response = await openai.chat.completions.create( { ...openaiModelConfig, messages: [ { role: "system", content: WEB_CONTENT_SYSTEM_PROMPT, }, { role: "user", content: `Web Content:\n\n${content}\n\nUser Prompt: ${prompt}`, }, ], }, { signal: abortSignal, }, ); const result = response.choices[0]?.message?.content?.trim(); if (!result) { throw new Error("Failed to process web content: Empty response from AI"); } const usage = response.usage ? { prompt_tokens: response.usage.prompt_tokens, completion_tokens: response.usage.completion_tokens, total_tokens: response.usage.total_tokens, } : undefined; return { content: result, usage, }; } catch (error) { if ((error as Error).name === "AbortError") { logger.info("Web content processing request was aborted"); throw new Error("Web content processing request was aborted"); } logger.error("Failed to process web content:", error); throw error; } } export interface BtwOptions { // Resolved configuration gatewayConfig: GatewayConfig; modelConfig: ModelConfig; // Parameters messages: ChatCompletionMessageParam[]; question: string; abortSignal?: AbortSignal; model?: string; } export interface BtwResult { content: string; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; } export async function btw(options: BtwOptions): Promise { const { gatewayConfig, modelConfig, messages, question, abortSignal } = options; // Validate model config at call time validateModelConfig(modelConfig); // Apply global 1 QPS rate limit if ( process.env.NODE_ENV !== "test" || modelConfig.model === "rate-limit-test" ) { await acquireSlot(abortSignal); } // Create OpenAI client with injected configuration const openai = new OpenAIClient({ apiKey: gatewayConfig.apiKey, baseURL: gatewayConfig.baseURL, defaultHeaders: gatewayConfig.defaultHeaders, fetchOptions: gatewayConfig.fetchOptions, fetch: gatewayConfig.fetch, }); const openaiModelConfig = getModelConfig(options.model || modelConfig.model, { temperature: 0.1, max_tokens: 4096, ...(modelConfig.options || {}), }); try { const response = await openai.chat.completions.create( { ...openaiModelConfig, messages: [ { role: "system", content: BTW_SYSTEM_PROMPT, }, ...messages, { role: "user", content: question, }, ], }, { signal: abortSignal, }, ); const result = response.choices[0]?.message?.content?.trim(); if (!result) { throw new Error( "Failed to process side question: Empty response from AI", ); } const usage = response.usage ? { prompt_tokens: response.usage.prompt_tokens, completion_tokens: response.usage.completion_tokens, total_tokens: response.usage.total_tokens, } : undefined; return { content: result, usage, }; } catch (error) { if ((error as Error).name === "AbortError") { logger.info("Side question request was aborted"); throw new Error("Side question request was aborted"); } logger.error("Failed to process side question:", error); throw error; } } export interface EvaluateGoalOptions { gatewayConfig: GatewayConfig; modelConfig: ModelConfig; model: string; goalCondition: string; messages: ChatCompletionMessageParam[]; abortSignal?: AbortSignal; } export interface EvaluateGoalResult { content: string; usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number; }; } export async function evaluateGoal( options: EvaluateGoalOptions, ): Promise { const { gatewayConfig, modelConfig, model, goalCondition, messages, abortSignal, } = options; // Create OpenAI client with injected configuration (no rate limiter — bypasses 1 QPS) const openai = new OpenAIClient({ apiKey: gatewayConfig.apiKey, baseURL: gatewayConfig.baseURL, defaultHeaders: gatewayConfig.defaultHeaders, fetchOptions: gatewayConfig.fetchOptions, fetch: gatewayConfig.fetch, }); const openaiModelConfig = getModelConfig(model, { temperature: 0, max_tokens: 200, ...(modelConfig.fastModelOptions || {}), }); // Strip images from messages to reduce token usage (same as compact) const cleanedMessages = messages.map((msg) => { if (Array.isArray(msg.content)) { const textParts = msg.content.filter( (part) => part.type === "text", ) as import("openai/resources.js").ChatCompletionContentPartText[]; const text = textParts.map((p) => p.text).join("\n"); return { ...msg, content: text || "(empty message)" }; } return msg; }); try { const response = await openai.chat.completions.create( { ...openaiModelConfig, messages: [ { role: "system", content: GOAL_EVALUATION_SYSTEM_PROMPT, }, ...cleanedMessages, { role: "user", content: `Goal condition: ${goalCondition}\n\nHas this goal been achieved based on the conversation above?`, }, ], }, { signal: abortSignal, }, ); const result = response.choices[0]?.message?.content?.trim(); if (!result) { throw new Error("Goal evaluation returned empty response"); } const usage = response.usage ? { prompt_tokens: response.usage.prompt_tokens, completion_tokens: response.usage.completion_tokens, total_tokens: response.usage.total_tokens, } : undefined; return { content: result, usage }; } catch (error) { if ((error as Error).name === "AbortError") { logger.info("Goal evaluation was aborted"); throw new Error("Goal evaluation was aborted"); } logger.error("Goal evaluation failed:", error); throw error; } }