import { convertToModelMessages, stepCountIs, streamText, type LanguageModel, type ModelMessage, type UIMessage, } from "ai"; import { DEFAULT_MODEL_ID, getModel, getModelContextLimit, LMSTUDIO_DEFAULT_BASE_URL, MAX_AGENT_STEPS, providerNeedsKey, selectSystemPrompt, type ModelId, type ProviderId, } from "../config"; import type { ProviderKeys } from "./keyring"; import { proxyFetch } from "./proxyFetch"; import { buildTools, type ToolContext } from "../tools/tools"; import { compactModelMessages } from "./compact"; const TOOL_LABELS: Record) => string> = { read_file: (i) => `Reading ${shortPath(i.path)}`, list_directory: (i) => `Listing ${shortPath(i.path)}`, grep: (i) => `Grepping ${ellipsize(String(i.pattern ?? ""), 40)}`, glob: (i) => `Globbing ${ellipsize(String(i.pattern ?? ""), 40)}`, edit: (i) => `Editing ${shortPath(i.path)}`, multi_edit: (i) => `Editing ${shortPath(i.path)}`, write_file: (i) => `Writing ${shortPath(i.path)}`, create_directory: (i) => `Creating ${shortPath(i.path)}`, bash_run: (i) => `Running ${ellipsize(String(i.command ?? ""), 60)}`, bash_background: (i) => `Spawning ${ellipsize(String(i.command ?? ""), 60)}`, bash_logs: () => `Reading logs`, bash_list: () => `Listing background processes`, bash_kill: () => `Stopping background process`, suggest_command: (i) => `Suggesting ${ellipsize(String(i.command ?? ""), 60)}`, todo_write: (i) => `Updating plan (${Array.isArray(i.todos) ? i.todos.length : 0} items)`, run_subagent: (i) => `Spawning ${String(i.type ?? "subagent")} subagent`, }; function shortPath(p: unknown): string { if (typeof p !== "string") return ""; const i = p.lastIndexOf("/"); return i === -1 ? p : p.slice(i + 1); } function ellipsize(s: string, max: number): string { return s.length > max ? `${s.slice(0, max - 1)}…` : s; } export type BuildModelOptions = { modelIdOverride?: string; lmstudioBaseURL?: string; openaiCompatibleBaseURL?: string; }; const modelCache = new Map(); export async function buildLanguageModel( provider: ProviderId, keys: ProviderKeys, resolvedModelId: string, options: BuildModelOptions = {}, ): Promise { if (providerNeedsKey(provider) && !keys[provider]) { throw new Error( `No API key configured for ${provider}. Open Settings → AI to add one.`, ); } const key = keys[provider] ?? ""; const lmstudioURL = options.lmstudioBaseURL ?? LMSTUDIO_DEFAULT_BASE_URL; const compatURL = options.openaiCompatibleBaseURL ?? ""; const cacheKey = `${provider} ${key} ${resolvedModelId} ${lmstudioURL} ${compatURL}`; const hit = modelCache.get(cacheKey); if (hit) return hit; let built: LanguageModel; switch (provider) { case "openai": { const { createOpenAI } = await import("@ai-sdk/openai"); built = createOpenAI({ apiKey: key })(resolvedModelId); break; } case "anthropic": { const { createAnthropic } = await import("@ai-sdk/anthropic"); built = createAnthropic({ apiKey: key })(resolvedModelId); break; } case "google": { const { createGoogleGenerativeAI } = await import("@ai-sdk/google"); built = createGoogleGenerativeAI({ apiKey: key })(resolvedModelId); break; } case "xai": { const { createXai } = await import("@ai-sdk/xai"); built = createXai({ apiKey: key })(resolvedModelId); break; } case "cerebras": { const { createCerebras } = await import("@ai-sdk/cerebras"); built = createCerebras({ apiKey: key })(resolvedModelId); break; } case "deepseek": { const { createOpenAICompatible } = await import( "@ai-sdk/openai-compatible" ); built = createOpenAICompatible({ name: "deepseek", baseURL: "https://api.deepseek.com", apiKey: key, })(resolvedModelId); break; } case "groq": { const { createGroq } = await import("@ai-sdk/groq"); built = createGroq({ apiKey: key })(resolvedModelId); break; } case "openrouter": { const { createOpenAICompatible } = await import( "@ai-sdk/openai-compatible" ); built = createOpenAICompatible({ name: "openrouter", baseURL: "https://openrouter.ai/api/v1", apiKey: key, headers: { "HTTP-Referer": "https://terax.ai", "X-Title": "Terax", }, })(resolvedModelId); break; } case "openai-compatible": { if (!compatURL) { throw new Error( "OpenAI-compatible provider has no base URL. Set it in Settings → Models.", ); } const { createOpenAICompatible } = await import( "@ai-sdk/openai-compatible" ); built = createOpenAICompatible({ name: "openai-compatible", baseURL: compatURL, apiKey: key || undefined, fetch: proxyFetch, })(resolvedModelId); break; } case "lmstudio": { const { createOpenAICompatible } = await import( "@ai-sdk/openai-compatible" ); built = createOpenAICompatible({ name: "lmstudio", baseURL: lmstudioURL, fetch: proxyFetch, })(resolvedModelId); break; } default: { const _exhaustive: never = provider; throw new Error(`Unsupported provider: ${_exhaustive as ProviderId}`); } } modelCache.set(cacheKey, built); return built; } function buildModel( modelId: ModelId, keys: ProviderKeys, lmstudioBaseURL?: string, lmstudioModelId?: string, openaiCompatibleBaseURL?: string, openaiCompatibleModelId?: string, ): Promise { const m = getModel(modelId); let resolvedId: string = m.id; if (m.id === "lmstudio-local") { if (!lmstudioModelId?.trim()) { throw new Error( "LM Studio: no model id set. Open Settings → Models and enter the model id loaded in LM Studio.", ); } resolvedId = lmstudioModelId.trim(); } else if (m.id === "openai-compatible-custom") { if (!openaiCompatibleModelId?.trim()) { throw new Error( "OpenAI-compatible: no model id set. Open Settings → Models.", ); } resolvedId = openaiCompatibleModelId.trim(); } return buildLanguageModel(m.provider, keys, resolvedId, { lmstudioBaseURL, openaiCompatibleBaseURL, }); } const PLAN_MODE_PROMPT = `## PLAN MODE — ACTIVE Mutating tools (write_file, edit, multi_edit, create_directory) will queue their changes for the user to review as a single diff. Do NOT execute bash_run or bash_background while plan mode is active — restrict yourself to reads (read_file, grep, glob, list_directory) and the queued mutations. After queueing the full set of edits, stop and return a brief summary; do not continue acting until the user has accepted/rejected.`; function buildStableSystem( modelId: ModelId, persona: { name: string; instructions: string } | null, customInstructions: string | undefined, projectMemory: string | null, ): string { const base = selectSystemPrompt(getModel(modelId).id); const personaBlock = persona?.instructions.trim() ? `\n\n## ACTIVE AGENT — ${persona.name}\n${persona.instructions.trim()}` : ""; const customBlock = customInstructions?.trim() ? `\n\n## USER CUSTOM INSTRUCTIONS — follow unless they conflict with safety rules above\n${customInstructions.trim()}` : ""; const memoryBlock = projectMemory && projectMemory.trim().length > 0 ? `\n\n## PROJECT — TERAX.md\n${projectMemory.trim()}` : ""; return `${base}${memoryBlock}${personaBlock}${customBlock}`; } // OpenAI / Gemini / DeepSeek apply prefix caching automatically; only // Anthropic needs explicit breakpoints. Mark the stable system prefix and // the rotating conversation tail. function applyCacheBreakpoints( messages: ModelMessage[], provider: ProviderId, ): ModelMessage[] { if (provider !== "anthropic" || messages.length === 0) return messages; const marker = { anthropic: { cacheControl: { type: "ephemeral" as const } } }; const withMarker = (m: ModelMessage): ModelMessage => ({ ...m, providerOptions: { ...(m.providerOptions ?? {}), ...marker }, }); const out = messages.slice(); out[0] = withMarker(out[0]); const lastIdx = out.length - 1; if (lastIdx > 0) out[lastIdx] = withMarker(out[lastIdx]); return out; } export type AgentUsage = { inputTokens: number; outputTokens: number; cachedInputTokens: number; }; const EMPTY_USAGE: AgentUsage = { inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, }; export type RunAgentOptions = { keys: ProviderKeys; modelId?: ModelId; customInstructions?: string; agentPersona?: { name: string; instructions: string } | null; toolContext: ToolContext; onStep?: (step: string | null) => void; onUsage?: (delta: AgentUsage) => void; lmstudioBaseURL?: string; lmstudioModelId?: string; openaiCompatibleBaseURL?: string; openaiCompatibleModelId?: string; planMode?: boolean; projectMemory?: string | null; envBlock?: string | null; uiMessages: UIMessage[]; abortSignal?: AbortSignal; }; export async function runAgentStream(opts: RunAgentOptions) { const modelId = opts.modelId ?? DEFAULT_MODEL_ID; const model = await buildModel( modelId, opts.keys, opts.lmstudioBaseURL, opts.lmstudioModelId, opts.openaiCompatibleBaseURL, opts.openaiCompatibleModelId, ); const provider = getModel(modelId).provider; const stableSystem = buildStableSystem( modelId, opts.agentPersona ?? null, opts.customInstructions, opts.projectMemory ?? null, ); const history = await convertToModelMessages(opts.uiMessages); const compactedHistory = compactModelMessages( history, getModelContextLimit(getModel(modelId).id), ); const messages: ModelMessage[] = [ { role: "system", content: stableSystem }, ]; if (opts.envBlock?.trim()) { messages.push({ role: "system", content: opts.envBlock }); } if (opts.planMode) { messages.push({ role: "system", content: PLAN_MODE_PROMPT }); } messages.push(...compactedHistory); const finalMessages = applyCacheBreakpoints(messages, provider); return streamText({ model, messages: finalMessages, tools: buildTools(opts.toolContext), stopWhen: stepCountIs(MAX_AGENT_STEPS), abortSignal: opts.abortSignal, onStepFinish: (step) => { if (opts.onStep) { const last = step.toolCalls?.[step.toolCalls.length - 1]; if (last) { const label = TOOL_LABELS[last.toolName]; opts.onStep( label ? label((last.input ?? {}) as Record) : `Calling ${last.toolName}`, ); } else if (step.text) { opts.onStep("Writing"); } } if (opts.onUsage && step.usage) { const u = step.usage; opts.onUsage({ inputTokens: u.inputTokens ?? 0, outputTokens: u.outputTokens ?? 0, cachedInputTokens: u.inputTokenDetails?.cacheReadTokens ?? 0, }); } }, onFinish: () => { opts.onStep?.(null); }, }); } export { EMPTY_USAGE };