naisys
Version:
NAISYS - Autonomous AI agent runner with built-in context management and cost tracking
108 lines • 4.25 kB
JavaScript
import { isDefined } from "@naisys/common";
import OpenAI from "openai";
const clientCache = new Map();
function getClient(apiKey, baseURL) {
const cacheKey = `${baseURL || ""}|${apiKey}`;
let client = clientCache.get(cacheKey);
if (!client) {
client = new OpenAI({ baseURL, apiKey });
clientCache.set(cacheKey, client);
}
return client;
}
function toOpenAiReasoningEffort(level) {
if (!level)
return undefined;
return level === "max" ? "xhigh" : level;
}
export async function sendWithOpenAiCompatible(deps, modelKey, systemMessage, context, source, apiKey, abortSignal) {
const { modelService, costTracker, tools, useToolsForLlmConsoleResponses } = deps;
const model = modelService.getLlmModel(modelKey);
// API key can be blank like in cases of local LLMs
const openAI = getClient(apiKey || "", model.baseUrl);
const reasoningEffort = toOpenAiReasoningEffort(model.reasoningLevel);
const useConsoleTools = source === "console" &&
useToolsForLlmConsoleResponses &&
model.supportsToolUse === true;
const chatRequest = {
model: model.versionName,
stream: false,
reasoning_effort: reasoningEffort,
messages: [
{
role: "system",
content: systemMessage,
},
...context.map((m) => ({
content: formatContentForOpenAI(m.content),
role: m.role,
})),
],
};
if (useConsoleTools) {
chatRequest.tools = [tools.consoleToolOpenAI];
// Only one tool is ever offered, so "required" forces that tool while
// staying compatible with endpoints that reject the object form of
// tool_choice (e.g. some local OpenAI-compatible servers).
chatRequest.tool_choice = "required";
}
const chatResponse = await openAI.chat.completions.create(chatRequest, {
signal: abortSignal,
});
// Record token usage
if (!chatResponse.usage) {
throw "Error, no usage data returned from OpenAI API.";
}
const inputTokens = chatResponse.usage.prompt_tokens || 0;
const outputTokens = chatResponse.usage.completion_tokens || 0;
// Excludes output_tokens because it contains reasoning tokens that don't persist in context;
// the actual response text is estimated locally by contextManager.getTokenCount()
const messagesTokenCount = inputTokens;
const cacheReadTokens = chatResponse.usage.prompt_tokens_details?.cached_tokens || 0;
// Remove cached tokens so we only bill fresh tokens at the full input rate.
const nonCachedPromptTokens = Math.max(0, inputTokens - cacheReadTokens);
costTracker.recordTokens(source, model.key, nonCachedPromptTokens, outputTokens, 0, // OpenAI doesn't report cache write tokens separately - it's automatic
cacheReadTokens);
if (chatRequest.tools) {
const commandsFromTool = tools.getCommandsFromOpenAiToolUse(chatResponse.choices.at(0)?.message?.tool_calls);
if (commandsFromTool) {
return { responses: commandsFromTool, messagesTokenCount };
}
}
return {
responses: [chatResponse.choices[0].message.content || ""],
messagesTokenCount,
};
}
function formatContentForOpenAI(content) {
if (typeof content === "string") {
return content;
}
return content
.map((block) => {
if (block.type === "text") {
return { type: "text", text: block.text };
}
if (block.type === "image") {
return {
type: "image_url",
image_url: {
url: `data:${block.mimeType};base64,${block.base64}`,
},
};
}
// tool_use/tool_result: convert to text fallback
if (block.type === "tool_use") {
return {
type: "text",
text: `ns-tool desktop-action ${JSON.stringify(block.input)}`,
};
}
if (block.type === "tool_result") {
return { type: "text", text: "[Desktop screenshot]" };
}
return null;
})
.filter(isDefined);
}
//# sourceMappingURL=openai-compatible.js.map