@agentguard-run/spend 0.2.2 → 0.3.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 +9 -1
- package/LICENSE +1 -1
- package/README.es-419.md +28 -102
- package/README.md +50 -124
- package/README.pt-BR.md +28 -102
- 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/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 +36 -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/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 +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -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 +32 -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/models.ts +236 -0
- package/src/cli/tips.ts +161 -0
- package/src/cli/wizard.ts +160 -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,200 @@
|
|
|
1
|
+
/** Native AWS Bedrock binding for AgentGuard Spend. */
|
|
2
|
+
|
|
3
|
+
import type { CapabilityTier, CallContext, Provider, SpendPolicy, SpendScope } from '../types';
|
|
4
|
+
import {
|
|
5
|
+
AgentGuardBlockedError,
|
|
6
|
+
SpendGuard,
|
|
7
|
+
type SpendGuardConfig,
|
|
8
|
+
extractText,
|
|
9
|
+
usageCountsFromObject,
|
|
10
|
+
wrapUsageStream,
|
|
11
|
+
type UsageCounts,
|
|
12
|
+
} from '../spend-guard';
|
|
13
|
+
|
|
14
|
+
export interface BedrockBindingOptions {
|
|
15
|
+
policy: SpendPolicy;
|
|
16
|
+
scope: SpendScope;
|
|
17
|
+
capabilityClaim?: CapabilityTier;
|
|
18
|
+
config?: Omit<SpendGuardConfig, 'policy'>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type BedrockCommand = {
|
|
22
|
+
input?: Record<string, unknown>;
|
|
23
|
+
constructor?: { name?: string };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function withSpendGuardBedrock(
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
+
client: any,
|
|
29
|
+
opts: BedrockBindingOptions,
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
|
+
): any {
|
|
32
|
+
const guard = new SpendGuard({ policy: opts.policy, ...(opts.config ?? {}) });
|
|
33
|
+
const originalSend = client?.send?.bind(client);
|
|
34
|
+
if (!originalSend) throw new Error('withSpendGuardBedrock: expected client.send');
|
|
35
|
+
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
client.send = async (command: BedrockCommand, ...args: any[]) => {
|
|
38
|
+
if (!isInvokeModelCommand(command)) return originalSend(command, ...args);
|
|
39
|
+
|
|
40
|
+
const call = buildBedrockCall(command, guard, opts);
|
|
41
|
+
const { decision } = await guard.decide(call);
|
|
42
|
+
if (decision.action === 'block') throw new AgentGuardBlockedError(decision, opts.scope, opts.config?.locale);
|
|
43
|
+
|
|
44
|
+
if (decision.action === 'downgrade' && decision.modelResolved !== decision.modelRequested && command.input) {
|
|
45
|
+
command.input = { ...command.input, modelId: decision.modelResolved };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const response = await originalSend(command, ...args);
|
|
49
|
+
if (isStreamingCommand(command) && response?.body) {
|
|
50
|
+
return {
|
|
51
|
+
...response,
|
|
52
|
+
body: wrapBedrockStream(response.body, guard, decision.decisionId, call),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const usage = extractBedrockResponseUsage(response, call.model);
|
|
57
|
+
if (usage) await guard.settleStreamUsage(decision.decisionId, usage.inputTokens, usage.outputTokens);
|
|
58
|
+
return response;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
client.__agentguard = guard;
|
|
62
|
+
return client;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isInvokeModelCommand(command: BedrockCommand): boolean {
|
|
66
|
+
const name = command?.constructor?.name;
|
|
67
|
+
return name === 'InvokeModelCommand' || name === 'InvokeModelWithResponseStreamCommand';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isStreamingCommand(command: BedrockCommand): boolean {
|
|
71
|
+
return command?.constructor?.name === 'InvokeModelWithResponseStreamCommand';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildBedrockCall(command: BedrockCommand, guard: SpendGuard, opts: BedrockBindingOptions): CallContext {
|
|
75
|
+
const input = command.input ?? {};
|
|
76
|
+
const modelId = String(input.modelId ?? 'unknown');
|
|
77
|
+
const body = parseJsonBody(input.body);
|
|
78
|
+
const inputText = extractText(body);
|
|
79
|
+
return {
|
|
80
|
+
provider: 'bedrock' as Provider,
|
|
81
|
+
model: modelId,
|
|
82
|
+
inputTokens: guard.estimateTokens(inputText),
|
|
83
|
+
outputTokens: outputTokensFromBody(body),
|
|
84
|
+
scope: opts.scope,
|
|
85
|
+
capabilityClaim: opts.capabilityClaim,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function wrapBedrockStream(
|
|
90
|
+
stream: AsyncIterable<unknown>,
|
|
91
|
+
guard: SpendGuard,
|
|
92
|
+
decisionId: string,
|
|
93
|
+
call: CallContext,
|
|
94
|
+
): AsyncIterable<unknown> {
|
|
95
|
+
const tracker = new BedrockStreamUsageTracker(guard, call);
|
|
96
|
+
return wrapUsageStream(stream, {
|
|
97
|
+
onChunk: (event) => tracker.observe(event),
|
|
98
|
+
settle: (partial, reason) => {
|
|
99
|
+
const usage = tracker.usage();
|
|
100
|
+
return guard.settleStreamUsage(decisionId, usage.inputTokens, usage.outputTokens, { partial, reason });
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
class BedrockStreamUsageTracker {
|
|
106
|
+
private input: number | null = null;
|
|
107
|
+
private output: number | null = null;
|
|
108
|
+
private content = '';
|
|
109
|
+
|
|
110
|
+
constructor(private guard: SpendGuard, private call: CallContext) {}
|
|
111
|
+
|
|
112
|
+
observe(event: unknown): void {
|
|
113
|
+
const payload = bedrockEventPayload(event);
|
|
114
|
+
if (!payload) return;
|
|
115
|
+
const usage = usageFromBedrockPayload(payload);
|
|
116
|
+
if (usage) {
|
|
117
|
+
this.input = usage.inputTokens;
|
|
118
|
+
this.output = usage.outputTokens;
|
|
119
|
+
}
|
|
120
|
+
this.content += textFromBedrockPayload(payload);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
usage(): UsageCounts {
|
|
124
|
+
return {
|
|
125
|
+
inputTokens: this.input ?? this.call.inputTokens,
|
|
126
|
+
outputTokens: this.output ?? this.guard.estimateTokens(this.content),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function extractBedrockResponseUsage(response: unknown, modelId: string): UsageCounts | null {
|
|
132
|
+
const body = (response as { body?: unknown } | null)?.body;
|
|
133
|
+
const parsed = parseJsonBody(body);
|
|
134
|
+
const usage = usageFromBedrockPayload(parsed);
|
|
135
|
+
if (usage) return usage;
|
|
136
|
+
if (modelId.startsWith('amazon.nova.')) return usageCountsFromObject((parsed as Record<string, unknown> | null)?.usage);
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function usageFromBedrockPayload(payload: unknown): UsageCounts | null {
|
|
141
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
142
|
+
const obj = payload as Record<string, unknown>;
|
|
143
|
+
const direct = usageCountsFromObject(obj.usage);
|
|
144
|
+
if (direct) return direct;
|
|
145
|
+
|
|
146
|
+
const bedrockMetrics = obj['amazon-bedrock-invocationMetrics'];
|
|
147
|
+
if (bedrockMetrics && typeof bedrockMetrics === 'object') {
|
|
148
|
+
const metrics = bedrockMetrics as Record<string, unknown>;
|
|
149
|
+
const input = metrics.inputTokenCount;
|
|
150
|
+
const output = metrics.outputTokenCount;
|
|
151
|
+
if (Number.isSafeInteger(input) && Number.isSafeInteger(output)) {
|
|
152
|
+
return { inputTokens: input as number, outputTokens: output as number };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const nested = usageCountsFromObject((obj.message as Record<string, unknown> | undefined)?.usage);
|
|
157
|
+
if (nested) return nested;
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function textFromBedrockPayload(payload: unknown): string {
|
|
162
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
163
|
+
const obj = payload as Record<string, unknown>;
|
|
164
|
+
const delta = obj.delta as Record<string, unknown> | undefined;
|
|
165
|
+
if (typeof delta?.text === 'string') return delta.text;
|
|
166
|
+
const contentBlockDelta = obj.contentBlockDelta as Record<string, unknown> | undefined;
|
|
167
|
+
const nestedDelta = contentBlockDelta?.delta as Record<string, unknown> | undefined;
|
|
168
|
+
if (typeof nestedDelta?.text === 'string') return nestedDelta.text;
|
|
169
|
+
const output = obj.output as Record<string, unknown> | undefined;
|
|
170
|
+
if (output) return extractText(output);
|
|
171
|
+
return '';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function outputTokensFromBody(body: unknown): number {
|
|
175
|
+
if (!body || typeof body !== 'object') return 512;
|
|
176
|
+
const obj = body as Record<string, unknown>;
|
|
177
|
+
const value = obj.max_tokens ?? obj.maxTokens ?? obj.max_new_tokens ??
|
|
178
|
+
(obj.inferenceConfig as Record<string, unknown> | undefined)?.maxTokens;
|
|
179
|
+
return Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 512;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseJsonBody(body: unknown): unknown {
|
|
183
|
+
if (!body) return {};
|
|
184
|
+
try {
|
|
185
|
+
if (typeof body === 'string') return JSON.parse(body);
|
|
186
|
+
if (body instanceof Uint8Array) return JSON.parse(new TextDecoder().decode(body));
|
|
187
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(body)) return JSON.parse(body.toString('utf8'));
|
|
188
|
+
if (typeof body === 'object') return body;
|
|
189
|
+
} catch {
|
|
190
|
+
return {};
|
|
191
|
+
}
|
|
192
|
+
return {};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function bedrockEventPayload(event: unknown): unknown {
|
|
196
|
+
const obj = event as Record<string, unknown> | null;
|
|
197
|
+
const chunk = obj?.chunk as Record<string, unknown> | undefined;
|
|
198
|
+
const bytes = chunk?.bytes ?? chunk?.payload ?? obj?.payload;
|
|
199
|
+
return parseJsonBody(bytes);
|
|
200
|
+
}
|
package/src/cli/auth.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import * as crypto from 'crypto';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as http from 'http';
|
|
4
|
+
import * as https from 'https';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import * as readline from 'readline';
|
|
7
|
+
import { AGENTGUARD_SPEND_VERSION } from '../index';
|
|
8
|
+
import { agentguardHome, banner, dim, green, statusBar, yellow } from './colors';
|
|
9
|
+
|
|
10
|
+
const OPENROUTER_AUTH_URL = 'https://openrouter.ai/api/v1/auth/key';
|
|
11
|
+
|
|
12
|
+
export function openRouterKeyPath(): string {
|
|
13
|
+
return path.join(agentguardHome(), 'openrouter-key');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function keyFingerprint(key: string): string {
|
|
17
|
+
return crypto.createHash('sha256').update(key).digest('hex').slice(0, 16);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function validateOpenRouterKey(key: string, endpoint = process.env.AGENTGUARD_OPENROUTER_AUTH_URL || OPENROUTER_AUTH_URL): Promise<boolean> {
|
|
21
|
+
if (!key.trim()) return false;
|
|
22
|
+
if (endpoint.startsWith('mock://')) {
|
|
23
|
+
const expected = new URL(endpoint).searchParams.get('key');
|
|
24
|
+
return expected === key.trim();
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const status = await requestStatus(endpoint, key.trim());
|
|
28
|
+
return status >= 200 && status < 300;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readOpenRouterKey(): string | null {
|
|
35
|
+
if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
|
|
36
|
+
try {
|
|
37
|
+
const value = fs.readFileSync(openRouterKeyPath(), 'utf8').trim();
|
|
38
|
+
return value.length > 0 ? value : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function writeOpenRouterKey(key: string): void {
|
|
45
|
+
const filePath = openRouterKeyPath();
|
|
46
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
47
|
+
fs.writeFileSync(filePath, key.trim() + '\n', { mode: 0o600 });
|
|
48
|
+
try { fs.chmodSync(filePath, 0o600); } catch { return; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runAuth(argv: string[]): Promise<number> {
|
|
52
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
53
|
+
console.log('agentguard auth openrouter | status | clear openrouter');
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
const sub = argv[0] ?? 'status';
|
|
57
|
+
if (sub === 'status') return authStatus();
|
|
58
|
+
if (sub === 'clear' && argv[1] === 'openrouter') return clearOpenRouter();
|
|
59
|
+
if (sub === 'openrouter') return configureOpenRouter(argv);
|
|
60
|
+
console.error(`agentguard auth: unknown command '${argv.join(' ')}'`);
|
|
61
|
+
return 2;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function configureOpenRouter(argv: string[]): Promise<number> {
|
|
65
|
+
const flagKey = valueAfter(argv, '--key');
|
|
66
|
+
const key = flagKey ?? await promptForKey();
|
|
67
|
+
if (!key) {
|
|
68
|
+
console.error('No key provided.');
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
const ok = await validateOpenRouterKey(key);
|
|
72
|
+
if (!ok) {
|
|
73
|
+
console.error('OpenRouter key validation failed.');
|
|
74
|
+
return 1;
|
|
75
|
+
}
|
|
76
|
+
writeOpenRouterKey(key);
|
|
77
|
+
console.log('');
|
|
78
|
+
console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
|
|
79
|
+
console.log('');
|
|
80
|
+
console.log(` ${green('configured')} openrouter fingerprint ${keyFingerprint(key)}`);
|
|
81
|
+
console.log(` ${dim('saved')} ${openRouterKeyPath()}`);
|
|
82
|
+
console.log('');
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function authStatus(): number {
|
|
87
|
+
const key = readOpenRouterKey();
|
|
88
|
+
console.log('');
|
|
89
|
+
console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
|
|
90
|
+
console.log('');
|
|
91
|
+
if (key) {
|
|
92
|
+
console.log(` ${green('openrouter')} configured fingerprint ${keyFingerprint(key)}`);
|
|
93
|
+
} else {
|
|
94
|
+
console.log(` ${yellow('openrouter')} not configured`);
|
|
95
|
+
}
|
|
96
|
+
console.log('');
|
|
97
|
+
console.log(' ' + statusBar());
|
|
98
|
+
console.log('');
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function clearOpenRouter(): number {
|
|
103
|
+
try { fs.unlinkSync(openRouterKeyPath()); } catch { return 0; }
|
|
104
|
+
console.log('OpenRouter key removed.');
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function promptForKey(): Promise<string> {
|
|
109
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
rl.question('OpenRouter API key: ', (answer) => {
|
|
112
|
+
rl.close();
|
|
113
|
+
resolve(answer.trim());
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function requestStatus(endpoint: string, key: string): Promise<number> {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const url = new URL(endpoint);
|
|
121
|
+
const client = url.protocol === 'http:' ? http : https;
|
|
122
|
+
const req = client.request(
|
|
123
|
+
{
|
|
124
|
+
method: 'GET',
|
|
125
|
+
hostname: url.hostname,
|
|
126
|
+
port: url.port,
|
|
127
|
+
path: url.pathname + url.search,
|
|
128
|
+
headers: { authorization: `Bearer ${key}`, accept: 'application/json' },
|
|
129
|
+
timeout: 5000,
|
|
130
|
+
},
|
|
131
|
+
(res) => {
|
|
132
|
+
res.resume();
|
|
133
|
+
res.on('end', () => resolve(res.statusCode ?? 0));
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
req.on('error', reject);
|
|
137
|
+
req.on('timeout', () => req.destroy(new Error('OpenRouter auth validation timed out')));
|
|
138
|
+
req.end();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function valueAfter(argv: string[], flag: string): string | undefined {
|
|
143
|
+
const index = argv.indexOf(flag);
|
|
144
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
145
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as readline from 'readline';
|
|
4
|
+
import { AGENTGUARD_SPEND_VERSION } from '../index';
|
|
5
|
+
import { fetchCatalog, getCachedCatalog, syncPricingIntoCostTable, type OpenRouterModel } from '../openrouter-catalog';
|
|
6
|
+
import { getModelCost, knownModelCosts } from '../cost-table';
|
|
7
|
+
import { TASK_TEMPLATES, getTaskTemplate } from '../templates';
|
|
8
|
+
import { agentguardHome, banner, cyanBold, dim, green, statusBar, yellow } from './colors';
|
|
9
|
+
|
|
10
|
+
export interface ModelRow {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
inputCentsPerMtok: number | null;
|
|
14
|
+
outputCentsPerMtok: number | null;
|
|
15
|
+
capability: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function rowsFromCatalog(models: OpenRouterModel[]): ModelRow[] {
|
|
19
|
+
return models.map((model) => {
|
|
20
|
+
const input = Number(model.pricing?.prompt);
|
|
21
|
+
const output = Number(model.pricing?.completion);
|
|
22
|
+
return {
|
|
23
|
+
id: model.id,
|
|
24
|
+
name: model.name ?? model.id,
|
|
25
|
+
inputCentsPerMtok: Number.isFinite(input) ? input * 100 * 1_000_000 : null,
|
|
26
|
+
outputCentsPerMtok: Number.isFinite(output) ? output * 100 * 1_000_000 : null,
|
|
27
|
+
capability: capabilityForModel(model.id),
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function rowsFromCostTable(): ModelRow[] {
|
|
33
|
+
return Object.entries(knownModelCosts()).map(([id, cost]) => ({
|
|
34
|
+
id,
|
|
35
|
+
name: id,
|
|
36
|
+
inputCentsPerMtok: cost.inputCentsPerKtok * 1000,
|
|
37
|
+
outputCentsPerMtok: cost.outputCentsPerKtok * 1000,
|
|
38
|
+
capability: capabilityForModel(id),
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function filterRows(rows: ModelRow[], opts: { search?: string; capability?: string; task?: string }): ModelRow[] {
|
|
43
|
+
let out = rows;
|
|
44
|
+
if (opts.task) {
|
|
45
|
+
const template = getTaskTemplate(opts.task);
|
|
46
|
+
if (template) {
|
|
47
|
+
const allowed = new Set(template.allowedModels);
|
|
48
|
+
out = out.filter((row) => allowed.has(row.id) || row.capability === template.requiredCapability);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (opts.capability) out = out.filter((row) => row.capability === opts.capability);
|
|
52
|
+
if (opts.search) {
|
|
53
|
+
const needle = opts.search.toLowerCase();
|
|
54
|
+
out = out.filter((row) => row.id.toLowerCase().includes(needle) || row.name.toLowerCase().includes(needle));
|
|
55
|
+
}
|
|
56
|
+
return out.sort((a, b) => displayCost(a) - displayCost(b) || a.id.localeCompare(b.id));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function suggestedAssignments(task: string): Record<string, string[]> {
|
|
60
|
+
const template = getTaskTemplate(task);
|
|
61
|
+
if (!template) return {};
|
|
62
|
+
return {
|
|
63
|
+
primary: [template.primaryModel],
|
|
64
|
+
fallback: [template.fallbackModel],
|
|
65
|
+
allowed: template.allowedModels,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runModels(argv: string[]): Promise<number> {
|
|
70
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
71
|
+
console.log('agentguard models [--sync-pricing] [--search <term>] [--capability <tier>] [--task <template>] [--json]');
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sync = argv.includes('--sync-pricing');
|
|
76
|
+
const json = argv.includes('--json');
|
|
77
|
+
const search = argValue(argv, '--search');
|
|
78
|
+
const capability = argValue(argv, '--capability');
|
|
79
|
+
const task = argValue(argv, '--task');
|
|
80
|
+
|
|
81
|
+
if (sync) {
|
|
82
|
+
const result = await syncPricingIntoCostTable({ force: true });
|
|
83
|
+
if (json) {
|
|
84
|
+
console.log(JSON.stringify(result, null, 2));
|
|
85
|
+
} else {
|
|
86
|
+
console.log('');
|
|
87
|
+
console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
|
|
88
|
+
console.log('');
|
|
89
|
+
console.log(` ${green('applied')} ${result.count} OpenRouter pricing overrides`);
|
|
90
|
+
console.log(` ${dim('saved')} ${agentguardHome()}/cost-overrides.json`);
|
|
91
|
+
console.log('');
|
|
92
|
+
}
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rows = await loadRows();
|
|
97
|
+
const filtered = filterRows(rows, { search, capability, task });
|
|
98
|
+
if (json) {
|
|
99
|
+
console.log(JSON.stringify({ models: filtered, assignments: task ? suggestedAssignments(task) : undefined }, null, 2));
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (process.stdin.isTTY && !search && !capability && !task) {
|
|
104
|
+
const selected = await interactivePick(filtered);
|
|
105
|
+
if (selected.length > 0) writeSelectedModels(selected);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
printRows(filtered, task);
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function loadRows(): Promise<ModelRow[]> {
|
|
114
|
+
const cached = getCachedCatalog();
|
|
115
|
+
if (cached) return rowsFromCatalog(cached.data);
|
|
116
|
+
try {
|
|
117
|
+
const catalog = await fetchCatalog();
|
|
118
|
+
return rowsFromCatalog(catalog.data);
|
|
119
|
+
} catch {
|
|
120
|
+
return rowsFromCostTable();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function printRows(rows: ModelRow[], task?: string): void {
|
|
125
|
+
console.log('');
|
|
126
|
+
console.log(' ' + banner(AGENTGUARD_SPEND_VERSION));
|
|
127
|
+
console.log('');
|
|
128
|
+
if (task && TASK_TEMPLATES[task]) {
|
|
129
|
+
const assignment = suggestedAssignments(task);
|
|
130
|
+
console.log(' ' + cyanBold(`task: ${task}`));
|
|
131
|
+
console.log(` primary: ${assignment.primary?.join(', ') ?? 'n/a'}`);
|
|
132
|
+
console.log(` fallback: ${assignment.fallback?.join(', ') ?? 'n/a'}`);
|
|
133
|
+
console.log('');
|
|
134
|
+
}
|
|
135
|
+
for (const row of rows.slice(0, 80)) {
|
|
136
|
+
console.log(` ${green(row.id.padEnd(38))} ${formatCost(row).padEnd(22)} ${dim(row.capability)}`);
|
|
137
|
+
}
|
|
138
|
+
if (rows.length > 80) console.log(` ${yellow(String(rows.length - 80) + ' more')} refine with agentguard models --search <term>`);
|
|
139
|
+
console.log('');
|
|
140
|
+
console.log(' ' + statusBar());
|
|
141
|
+
console.log('');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function interactivePick(rows: ModelRow[]): Promise<string[]> {
|
|
145
|
+
const selected = new Set<string>();
|
|
146
|
+
let cursor = 0;
|
|
147
|
+
let filtered = rows.slice(0, 80);
|
|
148
|
+
let query = '';
|
|
149
|
+
readline.emitKeypressEvents(process.stdin);
|
|
150
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
151
|
+
|
|
152
|
+
const render = () => {
|
|
153
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
154
|
+
console.log(banner(AGENTGUARD_SPEND_VERSION));
|
|
155
|
+
console.log('');
|
|
156
|
+
console.log(`Search: ${query || dim('press / to filter')} Selected: ${selected.size}`);
|
|
157
|
+
console.log('');
|
|
158
|
+
filtered.slice(0, 18).forEach((row, index) => {
|
|
159
|
+
const marker = selected.has(row.id) ? '[x]' : '[ ]';
|
|
160
|
+
const pointer = index === cursor ? '>' : ' ';
|
|
161
|
+
console.log(`${pointer} ${marker} ${row.id.padEnd(40)} ${formatCost(row).padEnd(20)} ${row.capability}`);
|
|
162
|
+
});
|
|
163
|
+
console.log('');
|
|
164
|
+
console.log(dim('[/] search [space] select [enter] confirm [q] quit'));
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
render();
|
|
168
|
+
return new Promise((resolve) => {
|
|
169
|
+
const onKey = (_chunk: string, key: readline.Key) => {
|
|
170
|
+
if (key.name === 'q' || (key.ctrl && key.name === 'c')) finish([]);
|
|
171
|
+
else if (key.name === 'return') finish([...selected]);
|
|
172
|
+
else if (key.name === 'down') { cursor = Math.min(cursor + 1, Math.max(0, filtered.length - 1)); render(); }
|
|
173
|
+
else if (key.name === 'up') { cursor = Math.max(cursor - 1, 0); render(); }
|
|
174
|
+
else if (key.name === 'space') {
|
|
175
|
+
const row = filtered[cursor];
|
|
176
|
+
if (row) selected.has(row.id) ? selected.delete(row.id) : selected.add(row.id);
|
|
177
|
+
render();
|
|
178
|
+
} else if (key.name === 'backspace') {
|
|
179
|
+
query = query.slice(0, -1);
|
|
180
|
+
filtered = filterRows(rows, { search: query });
|
|
181
|
+
cursor = 0;
|
|
182
|
+
render();
|
|
183
|
+
} else if (key.sequence && key.sequence.length === 1 && key.sequence !== '/') {
|
|
184
|
+
query += key.sequence;
|
|
185
|
+
filtered = filterRows(rows, { search: query });
|
|
186
|
+
cursor = 0;
|
|
187
|
+
render();
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
const finish = (value: string[]) => {
|
|
191
|
+
process.stdin.off('keypress', onKey);
|
|
192
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
193
|
+
process.stdout.write('\n');
|
|
194
|
+
resolve(value);
|
|
195
|
+
};
|
|
196
|
+
process.stdin.on('keypress', onKey);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function writeSelectedModels(models: string[]): void {
|
|
201
|
+
const filePath = path.join(agentguardHome(), 'policy.yaml');
|
|
202
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
203
|
+
let existing = '';
|
|
204
|
+
try { existing = fs.readFileSync(filePath, 'utf8'); } catch { existing = defaultPolicy(); }
|
|
205
|
+
const block = ['allowedModels:', ...models.map((model) => ` - ${model}`)].join('\n') + '\n';
|
|
206
|
+
const next = existing.match(/^allowedModels:/m)
|
|
207
|
+
? existing.replace(/^allowedModels:\n(?: - .*\n)*/m, block)
|
|
208
|
+
: existing.trimEnd() + '\n' + block;
|
|
209
|
+
fs.writeFileSync(filePath, next);
|
|
210
|
+
console.log(`${green('saved')} ${filePath}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function defaultPolicy(): string {
|
|
214
|
+
return 'id: local-policy-v1\nname: Local AgentGuard policy\nversion: 1\neffectiveFrom: "' + new Date().toISOString() + '"\nmode: enforce\nscope:\n tenantId: local\ncaps:\n - amountCents: 100\n window: per_call\n action: allow\n';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function formatCost(row: ModelRow): string {
|
|
218
|
+
if (row.inputCentsPerMtok === null || row.outputCentsPerMtok === null) return '$n/a per 1M';
|
|
219
|
+
return `$${(row.inputCentsPerMtok / 100).toFixed(2)}/$${(row.outputCentsPerMtok / 100).toFixed(2)} per 1M`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function displayCost(row: ModelRow): number {
|
|
223
|
+
return row.inputCentsPerMtok ?? Number.MAX_SAFE_INTEGER;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function capabilityForModel(model: string): string {
|
|
227
|
+
if (model.includes('gpt-5') || model.includes('opus') || model.includes('sonnet')) return 'payment_initiate';
|
|
228
|
+
if (model.includes('mini') || model.includes('haiku') || model.includes('flash')) return 'read_only';
|
|
229
|
+
if (getModelCost(model)) return 'data_write';
|
|
230
|
+
return 'read_only';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function argValue(argv: string[], flag: string): string | undefined {
|
|
234
|
+
const index = argv.indexOf(flag);
|
|
235
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
236
|
+
}
|