@agentguard-run/spend 0.2.2 → 0.4.0
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.
- package/CHANGELOG.md +14 -1
- package/LICENSE +1 -1
- package/README.es-419.md +37 -100
- package/README.md +58 -121
- package/README.pt-BR.md +37 -100
- package/dist/bindings/anthropic.d.ts +11 -0
- package/dist/bindings/anthropic.d.ts.map +1 -0
- package/dist/bindings/anthropic.js +116 -0
- package/dist/bindings/anthropic.js.map +1 -0
- package/dist/bindings/bedrock.d.ts +11 -0
- package/dist/bindings/bedrock.d.ts.map +1 -0
- package/dist/bindings/bedrock.js +177 -0
- package/dist/bindings/bedrock.js.map +1 -0
- package/dist/cli/auth.d.ts +7 -0
- package/dist/cli/auth.d.ts.map +1 -0
- package/dist/cli/auth.js +189 -0
- package/dist/cli/auth.js.map +1 -0
- package/dist/cli/coach.d.ts +5 -0
- package/dist/cli/coach.d.ts.map +1 -0
- package/dist/cli/coach.js +257 -0
- package/dist/cli/coach.js.map +1 -0
- package/dist/cli/colors.d.ts +8 -3
- package/dist/cli/colors.d.ts.map +1 -1
- package/dist/cli/colors.js +93 -4
- package/dist/cli/colors.js.map +1 -1
- package/dist/cli/demo.d.ts.map +1 -1
- package/dist/cli/demo.js +23 -2
- package/dist/cli/demo.js.map +1 -1
- package/dist/cli/main.d.ts +0 -6
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +42 -16
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/models.d.ts +18 -0
- package/dist/cli/models.d.ts.map +1 -0
- package/dist/cli/models.js +277 -0
- package/dist/cli/models.js.map +1 -0
- package/dist/cli/tips.d.ts +21 -0
- package/dist/cli/tips.d.ts.map +1 -0
- package/dist/cli/tips.js +191 -0
- package/dist/cli/tips.js.map +1 -0
- package/dist/cli/wizard.d.ts +27 -0
- package/dist/cli/wizard.d.ts.map +1 -0
- package/dist/cli/wizard.js +182 -0
- package/dist/cli/wizard.js.map +1 -0
- package/dist/coach/anomaly.d.ts +26 -0
- package/dist/coach/anomaly.d.ts.map +1 -0
- package/dist/coach/anomaly.js +119 -0
- package/dist/coach/anomaly.js.map +1 -0
- package/dist/coach/conversation.d.ts +69 -0
- package/dist/coach/conversation.d.ts.map +1 -0
- package/dist/coach/conversation.js +228 -0
- package/dist/coach/conversation.js.map +1 -0
- package/dist/coach/forecast.d.ts +19 -0
- package/dist/coach/forecast.d.ts.map +1 -0
- package/dist/coach/forecast.js +57 -0
- package/dist/coach/forecast.js.map +1 -0
- package/dist/coach/llm-client.d.ts +41 -0
- package/dist/coach/llm-client.d.ts.map +1 -0
- package/dist/coach/llm-client.js +248 -0
- package/dist/coach/llm-client.js.map +1 -0
- package/dist/coach/output.d.ts +41 -0
- package/dist/coach/output.d.ts.map +1 -0
- package/dist/coach/output.js +173 -0
- package/dist/coach/output.js.map +1 -0
- package/dist/coach/system-prompt.d.ts +20 -0
- package/dist/coach/system-prompt.d.ts.map +1 -0
- package/dist/coach/system-prompt.js +177 -0
- package/dist/coach/system-prompt.js.map +1 -0
- package/dist/cost-table.d.ts +11 -36
- package/dist/cost-table.d.ts.map +1 -1
- package/dist/cost-table.js +114 -45
- package/dist/cost-table.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -2
- package/dist/index.js.map +1 -1
- package/dist/openrouter-catalog.d.ts +56 -0
- package/dist/openrouter-catalog.d.ts.map +1 -0
- package/dist/openrouter-catalog.js +183 -0
- package/dist/openrouter-catalog.js.map +1 -0
- package/dist/spend-guard.d.ts +38 -55
- package/dist/spend-guard.d.ts.map +1 -1
- package/dist/spend-guard.js +268 -83
- package/dist/spend-guard.js.map +1 -1
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +52 -21
- package/dist/telemetry.js.map +1 -1
- package/dist/templates/index.d.ts +17 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +100 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/types.d.ts +18 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +39 -4
- package/src/bindings/anthropic.ts +142 -0
- package/src/bindings/bedrock.ts +200 -0
- package/src/cli/auth.ts +145 -0
- package/src/cli/coach.ts +249 -0
- package/src/cli/models.ts +236 -0
- package/src/cli/tips.ts +161 -0
- package/src/cli/wizard.ts +160 -0
- package/src/coach/anomaly.ts +98 -0
- package/src/coach/conversation.ts +248 -0
- package/src/coach/forecast.ts +64 -0
- package/src/coach/llm-client.ts +247 -0
- package/src/coach/output.ts +172 -0
- package/src/coach/system-prompt.ts +181 -0
- package/src/openrouter-catalog.ts +180 -0
- package/src/templates/agent-support.yaml +30 -0
- package/src/templates/chargeback-evidence.yaml +30 -0
- package/src/templates/code-scan.yaml +30 -0
- package/src/templates/index.ts +109 -0
- package/src/templates/payment-approval.yaml +30 -0
- package/src/templates/risk-review.yaml +30 -0
- package/tests/fixtures/openrouter-catalog.json +1 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentGuard(TM) Spend: Coach 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
|
+
|
|
13
|
+
export type CoachQuestionId = 'building' | 'scale' | 'tasks' | 'budget' | 'confirm';
|
|
14
|
+
|
|
15
|
+
export interface CoachQuestion {
|
|
16
|
+
id: CoachQuestionId;
|
|
17
|
+
prompt: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CoachAnswers {
|
|
21
|
+
building?: string;
|
|
22
|
+
scale?: string;
|
|
23
|
+
tasks?: string;
|
|
24
|
+
budget?: string;
|
|
25
|
+
confirm?: string;
|
|
26
|
+
language?: 'ts' | 'py';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CoachBusinessProfile {
|
|
30
|
+
vertical: string;
|
|
31
|
+
tenantId: string;
|
|
32
|
+
teamSize: number;
|
|
33
|
+
monthlyVolume: number;
|
|
34
|
+
tasks: string[];
|
|
35
|
+
monthlyBudgetCents: number;
|
|
36
|
+
requiredCapability: CapabilityTier;
|
|
37
|
+
primaryModel: string;
|
|
38
|
+
fallbackModel: string;
|
|
39
|
+
perCallCapCents: number;
|
|
40
|
+
perDayCapCents: number;
|
|
41
|
+
perMonthCapCents: number;
|
|
42
|
+
scopeLabel: string;
|
|
43
|
+
language: 'ts' | 'py';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ProjectedSavingsRow {
|
|
47
|
+
label: string;
|
|
48
|
+
beforeCents: number;
|
|
49
|
+
afterCents: number;
|
|
50
|
+
savingsCents: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ProjectedSavings {
|
|
54
|
+
rows: ProjectedSavingsRow[];
|
|
55
|
+
monthlyBeforeCents: number;
|
|
56
|
+
monthlyAfterCents: number;
|
|
57
|
+
monthlySavingsCents: number;
|
|
58
|
+
savingsPercent: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const COACH_QUESTIONS: CoachQuestion[] = [
|
|
62
|
+
{ id: 'building', prompt: 'What are you building or running? Include your business type and the agent workflow.' },
|
|
63
|
+
{ id: 'scale', prompt: 'How big is the team and roughly how many AI calls, tickets, orders, encounters, matters, or jobs happen per month?' },
|
|
64
|
+
{ id: 'tasks', prompt: 'What are the top 3 AI tasks this agent will perform?' },
|
|
65
|
+
{ id: 'budget', prompt: 'What monthly AI budget or per-task ceiling should this stay under?' },
|
|
66
|
+
{ id: 'confirm', prompt: 'Confirm the setup or list any refinements before I write policy.yaml and quickstart code.' },
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const VERTICAL_HINTS: Array<{ vertical: string; needles: string[]; scopeLabel: string; capability: CapabilityTier; primary: string; fallback: string; perCall: number }> = [
|
|
70
|
+
{ 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 },
|
|
71
|
+
{ 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 },
|
|
72
|
+
{ 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 },
|
|
73
|
+
{ 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 },
|
|
74
|
+
{ 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 },
|
|
75
|
+
{ 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 },
|
|
76
|
+
{ 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 },
|
|
77
|
+
{ 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 },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
export class CoachConversation {
|
|
81
|
+
private answers: CoachAnswers = {};
|
|
82
|
+
private index = 0;
|
|
83
|
+
|
|
84
|
+
constructor(initial?: CoachAnswers) {
|
|
85
|
+
if (initial) this.answers = { ...initial };
|
|
86
|
+
this.index = firstMissingIndex(this.answers);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
currentQuestion(): CoachQuestion | null {
|
|
90
|
+
return COACH_QUESTIONS[this.index] ?? null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
answer(value: string): void {
|
|
94
|
+
const question = this.currentQuestion();
|
|
95
|
+
if (!question) return;
|
|
96
|
+
this.answers[question.id] = value.trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
next(): CoachQuestion | null {
|
|
100
|
+
this.index = Math.min(this.index + 1, COACH_QUESTIONS.length);
|
|
101
|
+
return this.currentQuestion();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
back(): CoachQuestion | null {
|
|
105
|
+
this.index = Math.max(0, this.index - 1);
|
|
106
|
+
return this.currentQuestion();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
isComplete(): boolean {
|
|
110
|
+
return COACH_QUESTIONS.every((question) => Boolean(this.answers[question.id]?.trim()));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
snapshot(): CoachAnswers {
|
|
114
|
+
return { ...this.answers };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
profile(cwd = process.cwd()): CoachBusinessProfile {
|
|
118
|
+
return buildBusinessProfile(this.answers, cwd);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildBusinessProfile(answers: CoachAnswers, cwd = process.cwd()): CoachBusinessProfile {
|
|
123
|
+
const joined = `${answers.building ?? ''} ${answers.scale ?? ''} ${answers.tasks ?? ''}`.toLowerCase();
|
|
124
|
+
const hint = VERTICAL_HINTS.find((candidate) => candidate.needles.some((needle) => joined.includes(needle))) ?? VERTICAL_HINTS[0]!;
|
|
125
|
+
const scale = parseScale(answers.scale ?? '');
|
|
126
|
+
const budgetCents = parseBudgetCents(answers.budget ?? '', Math.max(4900, Math.ceil(scale.monthlyVolume * hint.perCall * 0.35)));
|
|
127
|
+
const tasks = parseTasks(answers.tasks ?? answers.building ?? hint.vertical);
|
|
128
|
+
const perMonthCapCents = Math.max(1000, budgetCents);
|
|
129
|
+
const perDayCapCents = Math.max(hint.perCall * 10, Math.ceil(perMonthCapCents / 20));
|
|
130
|
+
return {
|
|
131
|
+
vertical: hint.vertical,
|
|
132
|
+
tenantId: tenantIdFromBusiness(answers.building ?? hint.vertical),
|
|
133
|
+
teamSize: scale.teamSize,
|
|
134
|
+
monthlyVolume: scale.monthlyVolume,
|
|
135
|
+
tasks,
|
|
136
|
+
monthlyBudgetCents: perMonthCapCents,
|
|
137
|
+
requiredCapability: capabilityFor(joined, hint.capability),
|
|
138
|
+
primaryModel: hint.primary,
|
|
139
|
+
fallbackModel: hint.fallback,
|
|
140
|
+
perCallCapCents: hint.perCall,
|
|
141
|
+
perDayCapCents,
|
|
142
|
+
perMonthCapCents,
|
|
143
|
+
scopeLabel: hint.scopeLabel,
|
|
144
|
+
language: answers.language ?? detectProjectLanguage(cwd),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function buildPolicyFromProfile(profile: CoachBusinessProfile): SpendPolicy {
|
|
149
|
+
const caps: SpendCap[] = [
|
|
150
|
+
{
|
|
151
|
+
amountCents: profile.perCallCapCents,
|
|
152
|
+
window: 'per_call',
|
|
153
|
+
action: 'downgrade',
|
|
154
|
+
downgradeTo: profile.fallbackModel,
|
|
155
|
+
reason: 'Per-call budget reached, route to fallback model',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
amountCents: profile.perDayCapCents,
|
|
159
|
+
window: 'per_day',
|
|
160
|
+
action: 'block',
|
|
161
|
+
reason: 'Daily budget reached',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
amountCents: profile.perMonthCapCents,
|
|
165
|
+
window: 'per_month',
|
|
166
|
+
action: 'block',
|
|
167
|
+
reason: 'Monthly budget reached',
|
|
168
|
+
},
|
|
169
|
+
];
|
|
170
|
+
return {
|
|
171
|
+
id: `coach-${profile.vertical}-v1`,
|
|
172
|
+
name: `Coach generated ${profile.vertical} policy`,
|
|
173
|
+
scope: { tenantId: profile.tenantId },
|
|
174
|
+
caps,
|
|
175
|
+
mode: 'enforce',
|
|
176
|
+
requiredCapability: profile.requiredCapability,
|
|
177
|
+
version: 1,
|
|
178
|
+
effectiveFrom: new Date().toISOString(),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function projectedSavings(profile: CoachBusinessProfile): ProjectedSavings {
|
|
183
|
+
const heavyPerCall = Math.max(profile.perCallCapCents * 4, 100);
|
|
184
|
+
const routedPerCall = Math.max(1, Math.ceil(profile.perCallCapCents * 0.45));
|
|
185
|
+
const monthlyBeforeCents = profile.monthlyVolume * heavyPerCall;
|
|
186
|
+
const monthlyAfterCents = Math.min(profile.perMonthCapCents, profile.monthlyVolume * routedPerCall);
|
|
187
|
+
const monthlySavingsCents = Math.max(0, monthlyBeforeCents - monthlyAfterCents);
|
|
188
|
+
return {
|
|
189
|
+
rows: [
|
|
190
|
+
{ label: 'All traffic on premium model', beforeCents: monthlyBeforeCents, afterCents: 0, savingsCents: 0 },
|
|
191
|
+
{ label: 'Task routed with AgentGuard caps', beforeCents: 0, afterCents: monthlyAfterCents, savingsCents: monthlySavingsCents },
|
|
192
|
+
],
|
|
193
|
+
monthlyBeforeCents,
|
|
194
|
+
monthlyAfterCents,
|
|
195
|
+
monthlySavingsCents,
|
|
196
|
+
savingsPercent: monthlyBeforeCents > 0 ? Math.round((monthlySavingsCents / monthlyBeforeCents) * 100) : 0,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function formatCents(cents: number): string {
|
|
201
|
+
return `$${(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function detectProjectLanguage(cwd: string): 'ts' | 'py' {
|
|
205
|
+
try {
|
|
206
|
+
if (fs.existsSync(path.join(cwd, 'pyproject.toml')) || fs.existsSync(path.join(cwd, 'requirements.txt'))) return 'py';
|
|
207
|
+
if (fs.existsSync(path.join(cwd, 'package.json'))) return 'ts';
|
|
208
|
+
} catch {
|
|
209
|
+
return 'ts';
|
|
210
|
+
}
|
|
211
|
+
return 'ts';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function firstMissingIndex(answers: CoachAnswers): number {
|
|
215
|
+
const index = COACH_QUESTIONS.findIndex((question) => !answers[question.id]?.trim());
|
|
216
|
+
return index === -1 ? COACH_QUESTIONS.length : index;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseScale(value: string): { teamSize: number; monthlyVolume: number } {
|
|
220
|
+
const numbers = (value.match(/\d[\d,]*/g) ?? []).map((part) => Number(part.replace(/,/g, ''))).filter(Number.isFinite);
|
|
221
|
+
const teamSize = Math.max(1, numbers[0] ?? 3);
|
|
222
|
+
const monthlyVolume = Math.max(25, numbers[1] ?? numbers[0] ?? 500);
|
|
223
|
+
return { teamSize, monthlyVolume };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseBudgetCents(value: string, fallback: number): number {
|
|
227
|
+
const money = value.match(/\$?\s*(\d[\d,]*(?:\.\d{1,2})?)/);
|
|
228
|
+
if (!money) return fallback;
|
|
229
|
+
const amount = Number(money[1]!.replace(/,/g, ''));
|
|
230
|
+
if (!Number.isFinite(amount) || amount <= 0) return fallback;
|
|
231
|
+
return Math.round(amount * 100);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function parseTasks(value: string): string[] {
|
|
235
|
+
const parts = value.split(/,|\n|;| and /i).map((part) => part.trim()).filter(Boolean);
|
|
236
|
+
return parts.length > 0 ? parts.slice(0, 3) : ['review requests', 'route models', 'write audit receipts'];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function tenantIdFromBusiness(value: string): string {
|
|
240
|
+
const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
|
|
241
|
+
return cleaned || 'local-business';
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function capabilityFor(text: string, fallback: CapabilityTier): CapabilityTier {
|
|
245
|
+
if (/refund|payment|charge|dispute|money|invoice/.test(text)) return 'payment_initiate';
|
|
246
|
+
if (/write|update|ledger|chart|patient|health|phi|sox|legal|contract|tax|student|employment/.test(text)) return 'data_write';
|
|
247
|
+
return fallback;
|
|
248
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentGuard(TM) Spend: local Coach 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 { CoachSpendPoint } from './anomaly';
|
|
12
|
+
|
|
13
|
+
export interface CoachForecast {
|
|
14
|
+
daysObserved: number;
|
|
15
|
+
monthEndCents: number;
|
|
16
|
+
capCents: number | null;
|
|
17
|
+
overCap: boolean;
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function forecastMonthEnd(points: CoachSpendPoint[], capCents: number | null = null, now = new Date()): CoachForecast {
|
|
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: CoachSpendPoint[], 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 Coach 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 CoachProvider = 'openrouter' | 'openai' | 'anthropic' | 'compatible' | 'mock';
|
|
17
|
+
|
|
18
|
+
export interface CoachChatMessage {
|
|
19
|
+
role: 'system' | 'user' | 'assistant';
|
|
20
|
+
content: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CoachClientOptions {
|
|
24
|
+
provider?: CoachProvider;
|
|
25
|
+
apiKey?: string;
|
|
26
|
+
baseUrl?: string;
|
|
27
|
+
model?: string;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
fetchImpl?: FetchLike;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CoachClient {
|
|
33
|
+
provider: CoachProvider;
|
|
34
|
+
model: string;
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
streamChat(messages: CoachChatMessage[], 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 resolveCoachApiKey(provider: CoachProvider = '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_COACH_API_KEY) return process.env.AGENTGUARD_COACH_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 createCoachClient(options: CoachClientOptions = {}): CoachClient {
|
|
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 = resolveCoachApiKey(provider, options.apiKey);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
provider,
|
|
79
|
+
model,
|
|
80
|
+
baseUrl,
|
|
81
|
+
async *streamChat(messages: CoachChatMessage[], signal?: AbortSignalLike): AsyncIterable<string> {
|
|
82
|
+
if (provider === 'mock') {
|
|
83
|
+
yield 'Mock coach response.';
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (!apiKey) throw new Error('No Coach 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: CoachChatMessage[];
|
|
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(`Coach 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: CoachChatMessage[];
|
|
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(`Coach 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: CoachProvider): 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): CoachProvider | 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
|
+
}
|