UNPKG

naisys

Version:

NAISYS - Autonomous AI agent runner with built-in context management and cost tracking

235 lines 9.65 kB
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