| 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, (input: Record<string, unknown>) => 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<string, LanguageModel>(); |
|
|
| export async function buildLanguageModel( |
| provider: ProviderId, |
| keys: ProviderKeys, |
| resolvedModelId: string, |
| options: BuildModelOptions = {}, |
| ): Promise<LanguageModel> { |
| 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<LanguageModel> { |
| 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}`; |
| } |
|
|
| |
| |
| |
| 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<string, unknown>) |
| : `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 }; |
|
|