@agentguard-run/spend 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.es-419.md +28 -0
  3. package/README.md +28 -0
  4. package/README.pt-BR.md +28 -0
  5. package/dist/advisor/anomaly.d.ts +26 -0
  6. package/dist/advisor/anomaly.d.ts.map +1 -0
  7. package/dist/advisor/anomaly.js +119 -0
  8. package/dist/advisor/anomaly.js.map +1 -0
  9. package/dist/advisor/conversation.d.ts +75 -0
  10. package/dist/advisor/conversation.d.ts.map +1 -0
  11. package/dist/advisor/conversation.js +264 -0
  12. package/dist/advisor/conversation.js.map +1 -0
  13. package/dist/advisor/forecast.d.ts +19 -0
  14. package/dist/advisor/forecast.d.ts.map +1 -0
  15. package/dist/advisor/forecast.js +57 -0
  16. package/dist/advisor/forecast.js.map +1 -0
  17. package/dist/advisor/llm-client.d.ts +41 -0
  18. package/dist/advisor/llm-client.d.ts.map +1 -0
  19. package/dist/advisor/llm-client.js +248 -0
  20. package/dist/advisor/llm-client.js.map +1 -0
  21. package/dist/advisor/output.d.ts +41 -0
  22. package/dist/advisor/output.d.ts.map +1 -0
  23. package/dist/advisor/output.js +202 -0
  24. package/dist/advisor/output.js.map +1 -0
  25. package/dist/advisor/posture.d.ts +26 -0
  26. package/dist/advisor/posture.d.ts.map +1 -0
  27. package/dist/advisor/posture.js +99 -0
  28. package/dist/advisor/posture.js.map +1 -0
  29. package/dist/advisor/system-prompt.d.ts +20 -0
  30. package/dist/advisor/system-prompt.d.ts.map +1 -0
  31. package/dist/advisor/system-prompt.js +190 -0
  32. package/dist/advisor/system-prompt.js.map +1 -0
  33. package/dist/cli/advisor.d.ts +5 -0
  34. package/dist/cli/advisor.d.ts.map +1 -0
  35. package/dist/cli/advisor.js +270 -0
  36. package/dist/cli/advisor.js.map +1 -0
  37. package/dist/cli/main.d.ts.map +1 -1
  38. package/dist/cli/main.js +6 -0
  39. package/dist/cli/main.js.map +1 -1
  40. package/dist/index.d.ts +4 -1
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +15 -2
  43. package/dist/index.js.map +1 -1
  44. package/dist/telemetry.js +1 -1
  45. package/package.json +9 -2
  46. package/src/advisor/anomaly.ts +98 -0
  47. package/src/advisor/conversation.ts +289 -0
  48. package/src/advisor/forecast.ts +64 -0
  49. package/src/advisor/llm-client.ts +247 -0
  50. package/src/advisor/output.ts +201 -0
  51. package/src/advisor/posture.ts +111 -0
  52. package/src/advisor/system-prompt.ts +195 -0
  53. package/src/cli/advisor.ts +262 -0
@@ -0,0 +1,289 @@
1
+ /**
2
+ * AgentGuard(TM) Spend: Advisor conversation state.
3
+ *
4
+ * Patent notice: Protected by U.S. patent-pending technology
5
+ * (App. Nos. 63/983,615; 63/983,621; 63/983,843; 63/984,626;
6
+ * 64/071,781; 64/071,789).
7
+ */
8
+
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import type { CapabilityTier, SpendCap, SpendPolicy } from '../types';
12
+ import { applyPostureCapability, normalizePosture, postureProfile, suggestPostureForVertical, type GovernancePosture } from './posture';
13
+
14
+ export type AdvisorQuestionId = 'building' | 'posture' | 'scale' | 'tasks' | 'budget' | 'confirm';
15
+
16
+ export interface AdvisorQuestion {
17
+ id: AdvisorQuestionId;
18
+ prompt: string;
19
+ }
20
+
21
+ export interface AdvisorAnswers {
22
+ building?: string;
23
+ scale?: string;
24
+ tasks?: string;
25
+ budget?: string;
26
+ confirm?: string;
27
+ language?: 'ts' | 'py';
28
+ posture?: GovernancePosture;
29
+ }
30
+
31
+ export interface AdvisorBusinessProfile {
32
+ vertical: string;
33
+ tenantId: string;
34
+ teamSize: number;
35
+ monthlyVolume: number;
36
+ tasks: string[];
37
+ monthlyBudgetCents: number;
38
+ requiredCapability: CapabilityTier;
39
+ primaryModel: string;
40
+ fallbackModel: string;
41
+ perCallCapCents: number;
42
+ perDayCapCents: number;
43
+ perMonthCapCents: number;
44
+ scopeLabel: string;
45
+ language: 'ts' | 'py';
46
+ posture: GovernancePosture;
47
+ }
48
+
49
+ export interface ProjectedSavingsRow {
50
+ label: string;
51
+ beforeCents: number;
52
+ afterCents: number;
53
+ savingsCents: number;
54
+ }
55
+
56
+ export interface ProjectedSavings {
57
+ rows: ProjectedSavingsRow[];
58
+ monthlyBeforeCents: number;
59
+ monthlyAfterCents: number;
60
+ monthlySavingsCents: number;
61
+ savingsPercent: number;
62
+ }
63
+
64
+ export const ADVISOR_QUESTIONS: AdvisorQuestion[] = [
65
+ { id: 'building', prompt: 'What are you building or running? Include your business type and the agent workflow.' },
66
+ { id: 'posture', prompt: 'Confirm or change governance posture: Velocity / Standard / Compliance.' },
67
+ { id: 'scale', prompt: 'How big is the team and roughly how many AI calls, tickets, orders, encounters, matters, or jobs happen per month?' },
68
+ { id: 'tasks', prompt: 'What are the top 3 AI tasks this agent will perform?' },
69
+ { id: 'budget', prompt: 'What monthly AI budget or per-task ceiling should this stay under?' },
70
+ { id: 'confirm', prompt: 'Confirm the setup or list any refinements before I write policy.yaml and quickstart code.' },
71
+ ];
72
+
73
+ const VERTICAL_HINTS: Array<{ vertical: string; needles: string[]; scopeLabel: string; capability: CapabilityTier; primary: string; fallback: string; perCall: number }> = [
74
+ { vertical: 'law-firm', needles: ['law', 'legal', 'attorney', 'paralegal', 'contract', 'matter', 'discovery'], scopeLabel: 'matter', capability: 'data_write', primary: 'anthropic/claude-sonnet-4-6', fallback: 'anthropic/claude-haiku-4-5', perCall: 300 },
75
+ { vertical: 'healthcare', needles: ['health', 'medical', 'dental', 'clinic', 'patient', 'chart', 'hipaa', 'encounter'], scopeLabel: 'encounter', capability: 'data_write', primary: 'anthropic.claude-sonnet-4-v1:0', fallback: 'amazon.nova-lite-v1:0', perCall: 300 },
76
+ { vertical: 'ecommerce', needles: ['shopify', 'commerce', 'store', 'order', 'refund', 'chargeback', 'fraud', 'returns'], scopeLabel: 'order', capability: 'payment_initiate', primary: 'openai/gpt-5-mini', fallback: 'openai/gpt-4o-mini', perCall: 25 },
77
+ { vertical: 'accounting', needles: ['accounting', 'bookkeeping', 'tax', 'ledger', 'receipt', 'close', 'audit', 'sox'], scopeLabel: 'engagement', capability: 'data_write', primary: 'anthropic/claude-sonnet-4-6', fallback: 'anthropic/claude-haiku-4-5', perCall: 150 },
78
+ { vertical: 'software', needles: ['software', 'code', 'developer', 'github', 'repo', 'pull request', 'pr'], scopeLabel: 'repo', capability: 'read_only', primary: 'google/gemini-3-flash-preview', fallback: 'openai/gpt-4o-mini', perCall: 10 },
79
+ { vertical: 'real-estate', needles: ['real estate', 'broker', 'listing', 'mortgage', 'tenant', 'lease', 'property'], scopeLabel: 'transaction', capability: 'data_write', primary: 'openai/gpt-5-mini', fallback: 'openai/gpt-4o-mini', perCall: 50 },
80
+ { vertical: 'marketing', needles: ['marketing', 'agency', 'content', 'campaign', 'brand'], scopeLabel: 'campaign', capability: 'data_write', primary: 'openai/gpt-5-mini', fallback: 'openai/gpt-4o-mini', perCall: 25 },
81
+ { vertical: 'local-services', needles: ['salon', 'gym', 'restaurant', 'landscaping', 'construction', 'photography', 'fitness', 'pet'], scopeLabel: 'job', capability: 'data_write', primary: 'openai/gpt-5-mini', fallback: 'openai/gpt-4o-mini', perCall: 25 },
82
+ ];
83
+
84
+ export class AdvisorConversation {
85
+ private answers: AdvisorAnswers = {};
86
+ private index = 0;
87
+
88
+ constructor(initial?: AdvisorAnswers) {
89
+ if (initial) this.answers = { ...initial };
90
+ this.index = firstMissingIndex(this.answers);
91
+ }
92
+
93
+ currentQuestion(): AdvisorQuestion | null {
94
+ const question = ADVISOR_QUESTIONS[this.index] ?? null;
95
+ if (question?.id !== 'posture') return question;
96
+ const vertical = detectVertical(this.answers.building ?? '');
97
+ const suggested = suggestPostureForVertical(vertical);
98
+ return {
99
+ id: 'posture',
100
+ prompt: `Based on ${vertical}, we suggest ${suggested} governance posture. Confirm or change: Velocity / Standard / Compliance.`,
101
+ };
102
+ }
103
+
104
+ answer(value: string): void {
105
+ const question = this.currentQuestion();
106
+ if (!question) return;
107
+ if (question.id === 'posture') {
108
+ this.answers.posture = normalizePosture(value) ?? suggestedPostureForText(this.answers.building ?? '');
109
+ return;
110
+ }
111
+ this.answers[question.id] = value.trim();
112
+ }
113
+
114
+ next(): AdvisorQuestion | null {
115
+ this.index = Math.min(this.index + 1, ADVISOR_QUESTIONS.length);
116
+ return this.currentQuestion();
117
+ }
118
+
119
+ back(): AdvisorQuestion | null {
120
+ this.index = Math.max(0, this.index - 1);
121
+ return this.currentQuestion();
122
+ }
123
+
124
+ isComplete(): boolean {
125
+ return ADVISOR_QUESTIONS.every((question) => Boolean(this.answers[question.id]?.trim()));
126
+ }
127
+
128
+ snapshot(): AdvisorAnswers {
129
+ return { ...this.answers };
130
+ }
131
+
132
+ setPosture(posture: GovernancePosture): void {
133
+ this.answers.posture = posture;
134
+ }
135
+
136
+ profile(cwd = process.cwd()): AdvisorBusinessProfile {
137
+ return buildBusinessProfile(this.answers, cwd);
138
+ }
139
+ }
140
+
141
+ export function detectVertical(text: string): string {
142
+ const joined = text.toLowerCase();
143
+ return (VERTICAL_HINTS.find((candidate) => candidate.needles.some((needle) => joined.includes(needle))) ?? VERTICAL_HINTS[0]!).vertical;
144
+ }
145
+
146
+ export function suggestedPostureForText(text: string): GovernancePosture {
147
+ return suggestPostureForVertical(detectVertical(text));
148
+ }
149
+
150
+
151
+ export function buildBusinessProfile(answers: AdvisorAnswers, cwd = process.cwd()): AdvisorBusinessProfile {
152
+ const joined = `${answers.building ?? ''} ${answers.scale ?? ''} ${answers.tasks ?? ''}`.toLowerCase();
153
+ const hint = VERTICAL_HINTS.find((candidate) => candidate.needles.some((needle) => joined.includes(needle))) ?? VERTICAL_HINTS[0]!;
154
+ const posture = answers.posture ?? suggestPostureForVertical(hint.vertical);
155
+ const scale = parseScale(answers.scale ?? '');
156
+ const budgetCents = parseBudgetCents(answers.budget ?? '', Math.max(4900, Math.ceil(scale.monthlyVolume * hint.perCall * 0.35)));
157
+ const tasks = parseTasks(answers.tasks ?? answers.building ?? hint.vertical);
158
+ const perMonthCapCents = Math.max(1000, budgetCents);
159
+ const perDayCapCents = Math.max(hint.perCall * 10, Math.ceil(perMonthCapCents / 20));
160
+ return {
161
+ vertical: hint.vertical,
162
+ tenantId: tenantIdFromBusiness(answers.building ?? hint.vertical),
163
+ teamSize: scale.teamSize,
164
+ monthlyVolume: scale.monthlyVolume,
165
+ tasks,
166
+ monthlyBudgetCents: perMonthCapCents,
167
+ requiredCapability: capabilityFor(joined, hint.capability, posture),
168
+ primaryModel: hint.primary,
169
+ fallbackModel: hint.fallback,
170
+ perCallCapCents: hint.perCall,
171
+ perDayCapCents,
172
+ perMonthCapCents,
173
+ scopeLabel: hint.scopeLabel,
174
+ language: answers.language ?? detectProjectLanguage(cwd),
175
+ posture,
176
+ };
177
+ }
178
+
179
+ export function buildPolicyFromProfile(profile: AdvisorBusinessProfile): SpendPolicy {
180
+ const posture = postureProfile(profile.posture);
181
+ const conservativeBlock = profile.posture === 'compliance' && profile.requiredCapability !== 'read_only';
182
+ const perCallAmount = profile.posture === 'velocity'
183
+ ? Math.max(1, Math.ceil(profile.perCallCapCents * 0.6))
184
+ : profile.perCallCapCents;
185
+ const caps: SpendCap[] = [
186
+ conservativeBlock
187
+ ? {
188
+ amountCents: perCallAmount,
189
+ window: 'per_call',
190
+ action: 'block',
191
+ reason: 'Per-call regulated capability budget reached',
192
+ }
193
+ : {
194
+ amountCents: perCallAmount,
195
+ window: 'per_call',
196
+ action: 'downgrade',
197
+ downgradeTo: profile.fallbackModel,
198
+ reason: 'Per-call budget reached, route to fallback model',
199
+ },
200
+ {
201
+ amountCents: profile.perDayCapCents,
202
+ window: 'per_day',
203
+ action: 'block',
204
+ reason: 'Daily budget reached',
205
+ },
206
+ {
207
+ amountCents: profile.perMonthCapCents,
208
+ window: 'per_month',
209
+ action: 'block',
210
+ reason: 'Monthly budget reached',
211
+ },
212
+ ];
213
+ return {
214
+ id: `advisor-${profile.vertical}-v1`,
215
+ name: `Advisor generated ${profile.vertical} policy`,
216
+ scope: { tenantId: profile.tenantId },
217
+ caps,
218
+ mode: posture.defaultMode,
219
+ requiredCapability: profile.requiredCapability,
220
+ version: 1,
221
+ effectiveFrom: new Date().toISOString(),
222
+ };
223
+ }
224
+
225
+ export function projectedSavings(profile: AdvisorBusinessProfile): ProjectedSavings {
226
+ const heavyPerCall = Math.max(profile.perCallCapCents * 4, 100);
227
+ const routedPerCall = Math.max(1, Math.ceil(profile.perCallCapCents * 0.45));
228
+ const monthlyBeforeCents = profile.monthlyVolume * heavyPerCall;
229
+ const monthlyAfterCents = Math.min(profile.perMonthCapCents, profile.monthlyVolume * routedPerCall);
230
+ const monthlySavingsCents = Math.max(0, monthlyBeforeCents - monthlyAfterCents);
231
+ return {
232
+ rows: [
233
+ { label: 'All traffic on premium model', beforeCents: monthlyBeforeCents, afterCents: 0, savingsCents: 0 },
234
+ { label: 'Task routed with AgentGuard caps', beforeCents: 0, afterCents: monthlyAfterCents, savingsCents: monthlySavingsCents },
235
+ ],
236
+ monthlyBeforeCents,
237
+ monthlyAfterCents,
238
+ monthlySavingsCents,
239
+ savingsPercent: monthlyBeforeCents > 0 ? Math.round((monthlySavingsCents / monthlyBeforeCents) * 100) : 0,
240
+ };
241
+ }
242
+
243
+ export function formatCents(cents: number): string {
244
+ return `$${(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
245
+ }
246
+
247
+ export function detectProjectLanguage(cwd: string): 'ts' | 'py' {
248
+ try {
249
+ if (fs.existsSync(path.join(cwd, 'pyproject.toml')) || fs.existsSync(path.join(cwd, 'requirements.txt'))) return 'py';
250
+ if (fs.existsSync(path.join(cwd, 'package.json'))) return 'ts';
251
+ } catch {
252
+ return 'ts';
253
+ }
254
+ return 'ts';
255
+ }
256
+
257
+ function firstMissingIndex(answers: AdvisorAnswers): number {
258
+ const index = ADVISOR_QUESTIONS.findIndex((question) => !answers[question.id]?.trim());
259
+ return index === -1 ? ADVISOR_QUESTIONS.length : index;
260
+ }
261
+
262
+ function parseScale(value: string): { teamSize: number; monthlyVolume: number } {
263
+ const numbers = (value.match(/\d[\d,]*/g) ?? []).map((part) => Number(part.replace(/,/g, ''))).filter(Number.isFinite);
264
+ const teamSize = Math.max(1, numbers[0] ?? 3);
265
+ const monthlyVolume = Math.max(25, numbers[1] ?? numbers[0] ?? 500);
266
+ return { teamSize, monthlyVolume };
267
+ }
268
+
269
+ function parseBudgetCents(value: string, fallback: number): number {
270
+ const money = value.match(/\$?\s*(\d[\d,]*(?:\.\d{1,2})?)/);
271
+ if (!money) return fallback;
272
+ const amount = Number(money[1]!.replace(/,/g, ''));
273
+ if (!Number.isFinite(amount) || amount <= 0) return fallback;
274
+ return Math.round(amount * 100);
275
+ }
276
+
277
+ function parseTasks(value: string): string[] {
278
+ const parts = value.split(/,|\n|;| and /i).map((part) => part.trim()).filter(Boolean);
279
+ return parts.length > 0 ? parts.slice(0, 3) : ['review requests', 'route models', 'write audit receipts'];
280
+ }
281
+
282
+ function tenantIdFromBusiness(value: string): string {
283
+ const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
284
+ return cleaned || 'local-business';
285
+ }
286
+
287
+ function capabilityFor(text: string, fallback: CapabilityTier, posture: GovernancePosture): CapabilityTier {
288
+ return applyPostureCapability(posture, text, fallback);
289
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * AgentGuard(TM) Spend: local Advisor forecast skeleton.
3
+ *
4
+ * Reads local decision logs only. No network calls are made.
5
+ *
6
+ * Patent notice: Protected by U.S. patent-pending technology
7
+ * (App. Nos. 63/983,615; 63/983,621; 63/983,843; 63/984,626;
8
+ * 64/071,781; 64/071,789).
9
+ */
10
+
11
+ import type { AdvisorSpendPoint } from './anomaly';
12
+
13
+ export interface AdvisorForecast {
14
+ daysObserved: number;
15
+ monthEndCents: number;
16
+ capCents: number | null;
17
+ overCap: boolean;
18
+ message: string;
19
+ }
20
+
21
+ export function forecastMonthEnd(points: AdvisorSpendPoint[], capCents: number | null = null, now = new Date()): AdvisorForecast {
22
+ const daily = lastThirtyDaily(points, now);
23
+ if (daily.length === 0) {
24
+ return { daysObserved: 0, monthEndCents: 0, capCents, overCap: false, message: 'No local spend history found.' };
25
+ }
26
+ const slope = linearSlope(daily.map((row, index) => ({ x: index + 1, y: row.cents })));
27
+ const monthDays = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
28
+ const elapsed = now.getDate();
29
+ const observedTotal = daily.reduce((sum, row) => sum + row.cents, 0);
30
+ const avg = observedTotal / Math.max(1, daily.length);
31
+ const projectedRemaining = Math.max(0, monthDays - elapsed) * Math.max(0, avg + slope);
32
+ const monthEndCents = Math.round(observedTotal + projectedRemaining);
33
+ const overCap = capCents !== null && monthEndCents > capCents;
34
+ return {
35
+ daysObserved: daily.length,
36
+ monthEndCents,
37
+ capCents,
38
+ overCap,
39
+ message: overCap ? 'Projected spend is above cap. Consider a lower fallback model or a tighter per_day cap.' : 'Projected spend is within the current cap.',
40
+ };
41
+ }
42
+
43
+ function lastThirtyDaily(points: AdvisorSpendPoint[], now: Date): Array<{ day: string; cents: number }> {
44
+ const cutoff = now.getTime() - 30 * 24 * 60 * 60 * 1000;
45
+ const buckets = new Map<string, number>();
46
+ for (const point of points) {
47
+ const t = Date.parse(point.ts);
48
+ if (!Number.isFinite(t) || t < cutoff) continue;
49
+ const day = new Date(t).toISOString().slice(0, 10);
50
+ buckets.set(day, (buckets.get(day) ?? 0) + point.cents);
51
+ }
52
+ return [...buckets.entries()].map(([day, cents]) => ({ day, cents })).sort((a, b) => a.day.localeCompare(b.day));
53
+ }
54
+
55
+ function linearSlope(points: Array<{ x: number; y: number }>): number {
56
+ if (points.length < 2) return 0;
57
+ const n = points.length;
58
+ const sx = points.reduce((sum, point) => sum + point.x, 0);
59
+ const sy = points.reduce((sum, point) => sum + point.y, 0);
60
+ const sxx = points.reduce((sum, point) => sum + point.x * point.x, 0);
61
+ const sxy = points.reduce((sum, point) => sum + point.x * point.y, 0);
62
+ const denom = n * sxx - sx * sx;
63
+ return denom === 0 ? 0 : (n * sxy - sx * sy) / denom;
64
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * AgentGuard(TM) Spend: local Advisor LLM client.
3
+ *
4
+ * All provider calls go from the customer terminal to the configured provider.
5
+ * No AgentGuard service is contacted for prompts or completions.
6
+ *
7
+ * Patent notice: Protected by U.S. patent-pending technology
8
+ * (App. Nos. 63/983,615; 63/983,621; 63/983,843; 63/984,626;
9
+ * 64/071,781; 64/071,789).
10
+ */
11
+
12
+ import * as fs from 'fs';
13
+ import * as os from 'os';
14
+ import * as path from 'path';
15
+
16
+ export type AdvisorProvider = 'openrouter' | 'openai' | 'anthropic' | 'compatible' | 'mock';
17
+
18
+ export interface AdvisorChatMessage {
19
+ role: 'system' | 'user' | 'assistant';
20
+ content: string;
21
+ }
22
+
23
+ export interface AdvisorClientOptions {
24
+ provider?: AdvisorProvider;
25
+ apiKey?: string;
26
+ baseUrl?: string;
27
+ model?: string;
28
+ timeoutMs?: number;
29
+ fetchImpl?: FetchLike;
30
+ }
31
+
32
+ export interface AdvisorClient {
33
+ provider: AdvisorProvider;
34
+ model: string;
35
+ baseUrl: string;
36
+ streamChat(messages: AdvisorChatMessage[], signal?: AbortSignalLike): AsyncIterable<string>;
37
+ }
38
+
39
+ type FetchLike = (url: string, init: Record<string, unknown>) => Promise<any>;
40
+
41
+ type AbortSignalLike = {
42
+ aborted?: boolean;
43
+ addEventListener?: (type: 'abort', listener: () => void, options?: { once?: boolean }) => void;
44
+ };
45
+
46
+ type AbortControllerLike = {
47
+ signal: AbortSignalLike;
48
+ abort: () => void;
49
+ };
50
+
51
+ const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
52
+ const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1';
53
+ const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1';
54
+ const DEFAULT_MODEL = 'openai/gpt-4o-mini';
55
+
56
+ export function resolveAdvisorApiKey(provider: AdvisorProvider = 'openrouter', explicit?: string): string | null {
57
+ if (explicit?.trim()) return explicit.trim();
58
+ if (provider === 'openai' && process.env.OPENAI_API_KEY) return process.env.OPENAI_API_KEY;
59
+ if (provider === 'anthropic' && process.env.ANTHROPIC_API_KEY) return process.env.ANTHROPIC_API_KEY;
60
+ if ((provider === 'openrouter' || provider === 'compatible') && process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
61
+ if (process.env.AGENTGUARD_ADVISOR_API_KEY) return process.env.AGENTGUARD_ADVISOR_API_KEY;
62
+ try {
63
+ const key = fs.readFileSync(path.join(agentguardHome(), 'openrouter-key'), 'utf8').trim();
64
+ return key.length > 0 ? key : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ export function createAdvisorClient(options: AdvisorClientOptions = {}): AdvisorClient {
71
+ const provider = options.provider ?? providerFromBaseUrl(options.baseUrl) ?? 'openrouter';
72
+ const model = options.model ?? (provider === 'anthropic' ? 'claude-sonnet-4-6' : DEFAULT_MODEL);
73
+ const baseUrl = normalizeBaseUrl(options.baseUrl ?? defaultBaseUrl(provider));
74
+ const fetchImpl = options.fetchImpl ?? globalFetch;
75
+ const apiKey = resolveAdvisorApiKey(provider, options.apiKey);
76
+
77
+ return {
78
+ provider,
79
+ model,
80
+ baseUrl,
81
+ async *streamChat(messages: AdvisorChatMessage[], signal?: AbortSignalLike): AsyncIterable<string> {
82
+ if (provider === 'mock') {
83
+ yield 'Mock advisor response.';
84
+ return;
85
+ }
86
+ if (!apiKey) throw new Error('No Advisor API key configured');
87
+ if (provider === 'anthropic') {
88
+ yield* streamAnthropic({ fetchImpl, baseUrl, model, apiKey, messages, signal, timeoutMs: options.timeoutMs });
89
+ } else {
90
+ yield* streamOpenAICompatible({ fetchImpl, baseUrl, model, apiKey, messages, signal, timeoutMs: options.timeoutMs });
91
+ }
92
+ },
93
+ };
94
+ }
95
+
96
+ async function* streamOpenAICompatible(args: {
97
+ fetchImpl: FetchLike;
98
+ baseUrl: string;
99
+ model: string;
100
+ apiKey: string;
101
+ messages: AdvisorChatMessage[];
102
+ signal?: AbortSignalLike;
103
+ timeoutMs?: number;
104
+ }): AsyncIterable<string> {
105
+ const controller = createAbortController();
106
+ const timer = setTimeout(() => controller.abort(), args.timeoutMs ?? 120_000);
107
+ const signal = mergeSignal(args.signal, controller.signal);
108
+ try {
109
+ const response = await args.fetchImpl(`${args.baseUrl}/chat/completions`, {
110
+ method: 'POST',
111
+ headers: {
112
+ authorization: `Bearer ${args.apiKey}`,
113
+ 'content-type': 'application/json',
114
+ accept: 'text/event-stream',
115
+ },
116
+ body: JSON.stringify({ model: args.model, messages: args.messages, stream: true }),
117
+ signal,
118
+ });
119
+ if (!response.ok) throw new Error(`Advisor provider HTTP ${response.status}`);
120
+ yield* parseSseResponse(response, (json) => json?.choices?.[0]?.delta?.content ?? json?.choices?.[0]?.message?.content ?? '');
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ }
125
+
126
+ async function* streamAnthropic(args: {
127
+ fetchImpl: FetchLike;
128
+ baseUrl: string;
129
+ model: string;
130
+ apiKey: string;
131
+ messages: AdvisorChatMessage[];
132
+ signal?: AbortSignalLike;
133
+ timeoutMs?: number;
134
+ }): AsyncIterable<string> {
135
+ const controller = createAbortController();
136
+ const timer = setTimeout(() => controller.abort(), args.timeoutMs ?? 120_000);
137
+ const signal = mergeSignal(args.signal, controller.signal);
138
+ try {
139
+ const system = args.messages.find((m) => m.role === 'system')?.content ?? '';
140
+ const messages = args.messages.filter((m) => m.role !== 'system').map((m) => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content }));
141
+ const response = await args.fetchImpl(`${args.baseUrl}/messages`, {
142
+ method: 'POST',
143
+ headers: {
144
+ 'x-api-key': args.apiKey,
145
+ 'anthropic-version': '2023-06-01',
146
+ 'content-type': 'application/json',
147
+ accept: 'text/event-stream',
148
+ },
149
+ body: JSON.stringify({ model: args.model, system, messages, max_tokens: 1200, stream: true }),
150
+ signal,
151
+ });
152
+ if (!response.ok) throw new Error(`Advisor provider HTTP ${response.status}`);
153
+ yield* parseSseResponse(response, (json) => json?.delta?.text ?? json?.content_block?.text ?? '');
154
+ } finally {
155
+ clearTimeout(timer);
156
+ }
157
+ }
158
+
159
+ export async function* parseSseResponse(response: any, pickText: (json: any) => string): AsyncIterable<string> {
160
+ let buffer = '';
161
+ for await (const chunk of responseBodyIterable(response)) {
162
+ buffer += chunk;
163
+ const parts = buffer.split('\n\n');
164
+ buffer = parts.pop() ?? '';
165
+ for (const part of parts) {
166
+ const dataLines = part.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim());
167
+ for (const data of dataLines) {
168
+ if (!data || data === '[DONE]') continue;
169
+ try {
170
+ const token = pickText(JSON.parse(data));
171
+ if (token) yield token;
172
+ } catch {
173
+ continue;
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }
179
+
180
+ async function* responseBodyIterable(response: any): AsyncIterable<string> {
181
+ const body = response.body;
182
+ if (!body) return;
183
+ if (typeof body[Symbol.asyncIterator] === 'function') {
184
+ for await (const chunk of body) yield chunkToString(chunk);
185
+ return;
186
+ }
187
+ if (typeof body.getReader === 'function') {
188
+ const reader = body.getReader();
189
+ const decoder = new TextDecoder();
190
+ while (true) {
191
+ const { done, value } = await reader.read();
192
+ if (done) break;
193
+ yield decoder.decode(value, { stream: true });
194
+ }
195
+ }
196
+ }
197
+
198
+ function chunkToString(chunk: unknown): string {
199
+ if (typeof chunk === 'string') return chunk;
200
+ if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString('utf8');
201
+ return String(chunk ?? '');
202
+ }
203
+
204
+ function defaultBaseUrl(provider: AdvisorProvider): string {
205
+ if (provider === 'openai') return DEFAULT_OPENAI_BASE_URL;
206
+ if (provider === 'anthropic') return DEFAULT_ANTHROPIC_BASE_URL;
207
+ return DEFAULT_OPENROUTER_BASE_URL;
208
+ }
209
+
210
+ function normalizeBaseUrl(value: string): string {
211
+ return value.replace(/\/+$/, '');
212
+ }
213
+
214
+ function providerFromBaseUrl(baseUrl?: string): AdvisorProvider | null {
215
+ if (!baseUrl) return null;
216
+ if (baseUrl.includes('anthropic.com')) return 'anthropic';
217
+ if (baseUrl.includes('openai.com')) return 'openai';
218
+ if (baseUrl.includes('openrouter.ai')) return 'openrouter';
219
+ return 'compatible';
220
+ }
221
+
222
+ function agentguardHome(): string {
223
+ return process.env.AGENTGUARD_HOME || path.join(os.homedir(), '.agentguard');
224
+ }
225
+
226
+ function globalFetch(url: string, init: Record<string, unknown>): Promise<any> {
227
+ const fetchImpl = (globalThis as { fetch?: FetchLike }).fetch;
228
+ if (typeof fetchImpl !== 'function') throw new Error('Global fetch is not available. Use Node 20 or newer.');
229
+ return fetchImpl(url, init);
230
+ }
231
+
232
+ function createAbortController(): AbortControllerLike {
233
+ const Controller = (globalThis as { AbortController?: new () => AbortControllerLike }).AbortController;
234
+ if (Controller) return new Controller();
235
+ const signal: AbortSignalLike = { aborted: false, addEventListener: () => undefined };
236
+ return { signal, abort: () => { signal.aborted = true; } };
237
+ }
238
+
239
+ function mergeSignal(a: AbortSignalLike | undefined, b: AbortSignalLike): AbortSignalLike {
240
+ if (!a) return b;
241
+ if (a.aborted) return a;
242
+ const controller = createAbortController();
243
+ const abort = () => controller.abort();
244
+ a.addEventListener?.('abort', abort, { once: true });
245
+ b.addEventListener?.('abort', abort, { once: true });
246
+ return controller.signal;
247
+ }