/** * AgentGuard(TM) Spend: local Advisor LLM client. * * All provider calls go from the customer terminal to the configured provider. * No AgentGuard service is contacted for prompts or completions. * * Patent notice: Protected by U.S. patent-pending technology * (App. Nos. 63/983,615; 63/983,621; 63/983,843; 63/984,626; * 64/071,781; 64/071,789). */ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; export type AdvisorProvider = 'openrouter' | 'openai' | 'anthropic' | 'compatible' | 'mock'; export interface AdvisorChatMessage { role: 'system' | 'user' | 'assistant'; content: string; } export interface AdvisorClientOptions { provider?: AdvisorProvider; apiKey?: string; baseUrl?: string; model?: string; timeoutMs?: number; fetchImpl?: FetchLike; } export interface AdvisorClient { provider: AdvisorProvider; model: string; baseUrl: string; streamChat(messages: AdvisorChatMessage[], signal?: AbortSignalLike): AsyncIterable; } type FetchLike = (url: string, init: Record) => Promise; type AbortSignalLike = { aborted?: boolean; addEventListener?: (type: 'abort', listener: () => void, options?: { once?: boolean }) => void; }; type AbortControllerLike = { signal: AbortSignalLike; abort: () => void; }; const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'; const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1'; const DEFAULT_MODEL = 'openai/gpt-4o-mini'; export function resolveAdvisorApiKey(provider: AdvisorProvider = 'openrouter', explicit?: string): string | null { if (explicit?.trim()) return explicit.trim(); if (provider === 'openai' && process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY; if (provider === 'anthropic' && process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY; if ((provider === 'openrouter' || provider === 'compatible') && process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY; if (process.env.AGENTGUARD_ADVISOR_API_KEY) return process.env.AGENTGUARD_ADVISOR_API_KEY; try { const key = fs.readFileSync(path.join(agentguardHome(), 'openrouter-key'), 'utf8').trim(); return key.length > 0 ? key : null; } catch { return null; } } export function createAdvisorClient(options: AdvisorClientOptions = {}): AdvisorClient { const provider = options.provider ?? providerFromBaseUrl(options.baseUrl) ?? 'openrouter'; const model = options.model ?? (provider === 'anthropic' ? 'claude-sonnet-4-6' : DEFAULT_MODEL); const baseUrl = normalizeBaseUrl(options.baseUrl ?? defaultBaseUrl(provider)); const fetchImpl = options.fetchImpl ?? globalFetch; const apiKey = resolveAdvisorApiKey(provider, options.apiKey); return { provider, model, baseUrl, async *streamChat(messages: AdvisorChatMessage[], signal?: AbortSignalLike): AsyncIterable { if (provider === 'mock') { yield 'Mock advisor response.'; return; } if (!apiKey) throw new Error('No Advisor API key configured'); if (provider === 'anthropic') { yield* streamAnthropic({ fetchImpl, baseUrl, model, apiKey, messages, signal, timeoutMs: options.timeoutMs }); } else { yield* streamOpenAICompatible({ fetchImpl, baseUrl, model, apiKey, messages, signal, timeoutMs: options.timeoutMs }); } }, }; } async function* streamOpenAICompatible(args: { fetchImpl: FetchLike; baseUrl: string; model: string; apiKey: string; messages: AdvisorChatMessage[]; signal?: AbortSignalLike; timeoutMs?: number; }): AsyncIterable { const controller = createAbortController(); const timer = setTimeout(() => controller.abort(), args.timeoutMs ?? 120_000); const signal = mergeSignal(args.signal, controller.signal); try { const response = await args.fetchImpl(`${args.baseUrl}/chat/completions`, { method: 'POST', headers: { authorization: `Bearer ${args.apiKey}`, 'content-type': 'application/json', accept: 'text/event-stream', }, body: JSON.stringify({ model: args.model, messages: args.messages, stream: true }), signal, }); if (!response.ok) throw new Error(`Advisor provider HTTP ${response.status}`); yield* parseSseResponse(response, (json) => json?.choices?.[0]?.delta?.content ?? json?.choices?.[0]?.message?.content ?? ''); } finally { clearTimeout(timer); } } async function* streamAnthropic(args: { fetchImpl: FetchLike; baseUrl: string; model: string; apiKey: string; messages: AdvisorChatMessage[]; signal?: AbortSignalLike; timeoutMs?: number; }): AsyncIterable { const controller = createAbortController(); const timer = setTimeout(() => controller.abort(), args.timeoutMs ?? 120_000); const signal = mergeSignal(args.signal, controller.signal); try { const system = args.messages.find((m) => m.role === 'system')?.content ?? ''; const messages = args.messages.filter((m) => m.role !== 'system').map((m) => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content })); const response = await args.fetchImpl(`${args.baseUrl}/messages`, { method: 'POST', headers: { 'x-api-key': args.apiKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json', accept: 'text/event-stream', }, body: JSON.stringify({ model: args.model, system, messages, max_tokens: 1200, stream: true }), signal, }); if (!response.ok) throw new Error(`Advisor provider HTTP ${response.status}`); yield* parseSseResponse(response, (json) => json?.delta?.text ?? json?.content_block?.text ?? ''); } finally { clearTimeout(timer); } } export async function* parseSseResponse(response: any, pickText: (json: any) => string): AsyncIterable { let buffer = ''; for await (const chunk of responseBodyIterable(response)) { buffer += chunk; const parts = buffer.split('\n\n'); buffer = parts.pop() ?? ''; for (const part of parts) { const dataLines = part.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()); for (const data of dataLines) { if (!data || data === '[DONE]') continue; try { const token = pickText(JSON.parse(data)); if (token) yield token; } catch { continue; } } } } } async function* responseBodyIterable(response: any): AsyncIterable { const body = response.body; if (!body) return; if (typeof body[Symbol.asyncIterator] === 'function') { for await (const chunk of body) yield chunkToString(chunk); return; } if (typeof body.getReader === 'function') { const reader = body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; yield decoder.decode(value, { stream: true }); } } } function chunkToString(chunk: unknown): string { if (typeof chunk === 'string') return chunk; if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString('utf8'); return String(chunk ?? ''); } function defaultBaseUrl(provider: AdvisorProvider): string { if (provider === 'openai') return DEFAULT_OPENAI_BASE_URL; if (provider === 'anthropic') return DEFAULT_ANTHROPIC_BASE_URL; return DEFAULT_OPENROUTER_BASE_URL; } function normalizeBaseUrl(value: string): string { return value.replace(/\/+$/, ''); } function providerFromBaseUrl(baseUrl?: string): AdvisorProvider | null { if (!baseUrl) return null; if (baseUrl.includes('anthropic.com')) return 'anthropic'; if (baseUrl.includes('openai.com')) return 'openai'; if (baseUrl.includes('openrouter.ai')) return 'openrouter'; return 'compatible'; } function agentguardHome(): string { return process.env.AGENTGUARD_HOME || path.join(os.homedir(), '.agentguard'); } function globalFetch(url: string, init: Record): Promise { const fetchImpl = (globalThis as { fetch?: FetchLike }).fetch; if (typeof fetchImpl !== 'function') throw new Error('Global fetch is not available. Use Node 20 or newer.'); return fetchImpl(url, init); } function createAbortController(): AbortControllerLike { const Controller = (globalThis as { AbortController?: new () => AbortControllerLike }).AbortController; if (Controller) return new Controller(); const signal: AbortSignalLike = { aborted: false, addEventListener: () => undefined }; return { signal, abort: () => { signal.aborted = true; } }; } function mergeSignal(a: AbortSignalLike | undefined, b: AbortSignalLike): AbortSignalLike { if (!a) return b; if (a.aborted) return a; const controller = createAbortController(); const abort = () => controller.abort(); a.addEventListener?.('abort', abort, { once: true }); b.addEventListener?.('abort', abort, { once: true }); return controller.signal; }