naisys
Version:
NAISYS - Autonomous AI agent runner with built-in context management and cost tracking
235 lines • 9.65 kB
JavaScript
import Anthropic from "@anthropic-ai/sdk";
import { LlmApiType, } from "@naisys/common";
import { extractDesktopActions, prepareComputerUse, } from "../../computer-use/vendors/anthropic-computer-use.js";
/** Anthropic's computer-use beta rejects requests that include computer tool
* output alongside more than one standalone image input. To stay under that
* limit, we disallow adding standalone images (via ns-look, ns-desktop
* screenshot, etc.) whenever the agent is running with the computer tool
* active. The model should request screenshots through the computer tool. */
export function getImageContextBlockReason(model, controlDesktop) {
if (model.apiType === LlmApiType.Anthropic &&
model.supportsComputerUse &&
controlDesktop) {
return "Error: Cannot add images to context while the computer tool is active (Anthropic rejects >1 image alongside computer output). Open the image on the desktop and request a screenshot via the computer tool instead.";
}
return undefined;
}
const clientCache = new Map();
function getClient(apiKey, baseURL) {
const cacheKey = `${baseURL || ""}|${apiKey}`;
let client = clientCache.get(cacheKey);
if (!client) {
client = new Anthropic({ apiKey, baseURL });
clientCache.set(cacheKey, client);
}
return client;
}
function toAnthropicThinkingBudget(level, maxTokens) {
if (!level || level === "none") {
return undefined;
}
const maxBudget = Math.max(1024, maxTokens - 512);
const budget = level === "low"
? 1024
: level === "medium"
? Math.floor(maxTokens / 2)
: level === "high"
? Math.floor(maxTokens * 0.75)
: maxBudget;
return Math.min(maxBudget, Math.max(1024, budget));
}
export async function sendWithAnthropic(deps, modelKey, systemMessage, context, source, apiKey, abortSignal) {
const { modelService, costTracker, tools, useToolsForLlmConsoleResponses, desktopConfig, } = deps;
const model = modelService.getLlmModel(modelKey);
if (!apiKey) {
throw `Error, set ${model.apiKeyVar} variable`;
}
const anthropic = getClient(apiKey, model.baseUrl);
const useConsoleTools = source === "console" &&
useToolsForLlmConsoleResponses &&
model.supportsToolUse === true;
const createParams = {
model: model.versionName,
max_tokens: 4096, // Blows up on anything higher
messages: [
{
role: "user",
content: systemMessage,
},
{
role: "assistant",
content: context.length === 0
? [
{
type: "text",
text: "Understood",
cache_control: { type: "ephemeral" },
},
]
: "Understood",
},
...context.map((msg) => {
return {
role: msg.role == "assistant" ? "assistant" : "user",
content: formatContentForAnthropic(msg.content, msg.cachePoint),
};
}),
],
};
const thinkingBudget = toAnthropicThinkingBudget(model.reasoningLevel, createParams.max_tokens);
if (thinkingBudget !== undefined) {
createParams.thinking = {
type: "enabled",
budget_tokens: thinkingBudget,
};
}
// Build tools array — console and desktop tools can coexist
if (useConsoleTools) {
createParams.tools = [tools.consoleToolAnthropic];
if (thinkingBudget !== undefined) {
createParams.tool_choice = { type: "auto" };
}
else {
createParams.tool_choice = {
type: "tool",
name: tools.consoleToolAnthropic.name,
};
}
}
// Computer use: add tool, scale dimensions
let desktopBetaFlag = "";
if (desktopConfig) {
const setup = prepareComputerUse(desktopConfig, model.versionName);
desktopBetaFlag = setup.betaFlag;
// computerTool is a Beta tool (not in the non-beta ToolUnion) but we
// route through the beta endpoint below — cast to appease the shared
// createParams shape.
const computerTool = setup.computerTool;
if (createParams.tools) {
createParams.tools.push(computerTool);
createParams.tool_choice = { type: "auto" };
}
else {
createParams.tools = [computerTool];
}
}
// Use beta endpoint when computer use tool is present, otherwise normal.
// The beta endpoint uses BetaMessageCreateParams/BetaMessage which are
// structurally compatible with the non-beta equivalents for our usage;
// cast here rather than duplicating the request construction.
const msgResponse = desktopConfig
? (await anthropic.beta.messages.create({ ...createParams, betas: [desktopBetaFlag] }, { signal: abortSignal }))
: await anthropic.messages.create(createParams, { signal: abortSignal });
// Record token usage
if (!msgResponse.usage) {
throw "Error, no usage data returned from Anthropic API.";
}
const inputTokens = msgResponse.usage.input_tokens;
const outputTokens = msgResponse.usage.output_tokens;
const cacheCreationTokens = msgResponse.usage.cache_creation_input_tokens || 0;
const cacheReadTokens = msgResponse.usage.cache_read_input_tokens || 0;
const messagesTokenCount = inputTokens + cacheCreationTokens + cacheReadTokens;
costTracker.recordTokens(source, model.key, inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens);
// Extract desktop actions; coords stay in scaled-pixel space (API space)
const desktopActions = desktopConfig
? extractDesktopActions(msgResponse.content)
: [];
// Extract console commands (submit_commands tool_use blocks)
const consoleCommands = useConsoleTools
? tools.getCommandsFromAnthropicToolUse(msgResponse.content)
: undefined;
// Extract text blocks
const textParts = msgResponse.content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text);
// Desktop actions present — they take priority for the response flow.
// Console commands (if any) are folded into the text so the model sees them
// in context and can re-issue after the desktop actions complete.
if (desktopActions.length > 0) {
const allText = [...textParts, ...(consoleCommands || [])];
return { responses: allText, messagesTokenCount, desktopActions };
}
if (consoleCommands) {
return { responses: consoleCommands, messagesTokenCount };
}
return {
responses: textParts.length > 0 ? textParts : [""],
messagesTokenCount,
};
}
function formatContentForAnthropic(content, cachePoint) {
if (typeof content === "string") {
if (cachePoint) {
return [
{
type: "text",
text: content,
cache_control: { type: "ephemeral" },
},
];
}
return content;
}
// ContentBlock[] — map to Anthropic content blocks
const blocks = content.map((block, index) => {
const isLast = index === content.length - 1;
if (block.type === "text") {
const textBlock = { type: "text", text: block.text };
if (cachePoint && isLast) {
textBlock.cache_control = { type: "ephemeral" };
}
return textBlock;
}
if (block.type === "audio") {
throw new Error("Anthropic does not support audio input. Use an OpenAI or Google model for audio.");
}
if (block.type === "tool_use") {
// Unwrap the standardized { actions: [...] } back to a single action for Anthropic
const input = block.name === "computer"
? block.input.actions[0]
: block.input;
return {
type: "tool_use",
id: block.id,
name: block.name,
input,
};
}
if (block.type === "tool_result") {
return {
type: "tool_result",
tool_use_id: block.toolUseId,
...(block.isError ? { is_error: true } : {}),
content: block.resultContent.map((c) => {
if (c.type === "image") {
return {
type: "image",
source: {
type: "base64",
media_type: c.mimeType,
data: c.base64,
},
};
}
return { type: "text", text: c.text };
}),
};
}
// image block — cast mimeType because our ImageBlock uses `string` while
// Anthropic narrows to a literal union of supported media types
const imageBlock = {
type: "image",
source: {
type: "base64",
media_type: block.mimeType,
data: block.base64,
},
};
if (cachePoint && isLast) {
imageBlock.cache_control = { type: "ephemeral" };
}
return imageBlock;
});
return blocks;
}
//# sourceMappingURL=anthropic.js.map