ambitry 0.1.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/LICENSE +202 -0
- package/README.md +163 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +114 -0
- package/dist/db.d.ts +94 -0
- package/dist/db.js +137 -0
- package/dist/demo.d.ts +18 -0
- package/dist/demo.js +113 -0
- package/dist/dryrun.d.ts +30 -0
- package/dist/dryrun.js +64 -0
- package/dist/enforce.d.ts +24 -0
- package/dist/enforce.js +120 -0
- package/dist/parse.d.ts +60 -0
- package/dist/parse.js +197 -0
- package/dist/policy.d.ts +50 -0
- package/dist/policy.js +141 -0
- package/dist/pricing.d.ts +45 -0
- package/dist/pricing.js +70 -0
- package/dist/providers.d.ts +32 -0
- package/dist/providers.js +112 -0
- package/dist/secrets.d.ts +23 -0
- package/dist/secrets.js +129 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +193 -0
- package/dist/viewer.d.ts +13 -0
- package/dist/viewer.js +182 -0
- package/package.json +36 -0
package/dist/parse.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
|
+
/** Best-effort JSON: a malformed body must degrade, never throw. */
|
|
3
|
+
function tryJson(text) {
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(text);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function parseRequest(provider, body) {
|
|
12
|
+
const json = tryJson(body.toString('utf8'));
|
|
13
|
+
if (!json)
|
|
14
|
+
return { model: null, stream: false, toolNames: [] };
|
|
15
|
+
const toolNames = Array.isArray(json.tools)
|
|
16
|
+
? json.tools
|
|
17
|
+
.map((t) => (provider === 'openai' ? t?.function?.name : t?.name))
|
|
18
|
+
.filter((n) => typeof n === 'string')
|
|
19
|
+
: [];
|
|
20
|
+
return {
|
|
21
|
+
model: typeof json.model === 'string' ? json.model : null,
|
|
22
|
+
stream: json.stream === true,
|
|
23
|
+
toolNames,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Accumulates a response — streaming or not — into a ParsedResponse.
|
|
28
|
+
*
|
|
29
|
+
* Two things make this fiddly, and both are silent-corruption bugs rather
|
|
30
|
+
* than crashes, so they are worth naming:
|
|
31
|
+
*
|
|
32
|
+
* 1. TCP chunks do not align with SSE event boundaries. A `data:` line can
|
|
33
|
+
* be split across two `push()` calls, so we hold a line buffer and only
|
|
34
|
+
* process complete lines.
|
|
35
|
+
* 2. Chunks can split a multi-byte UTF-8 character. `Buffer.toString()` per
|
|
36
|
+
* chunk would replace the halves with U+FFFD and corrupt any non-ASCII
|
|
37
|
+
* prompt. StringDecoder holds the partial bytes until the rest arrives.
|
|
38
|
+
*/
|
|
39
|
+
export class ResponseParser {
|
|
40
|
+
decoder = new StringDecoder('utf8');
|
|
41
|
+
lineBuffer = '';
|
|
42
|
+
/** Whole-body text, used only in the non-streaming path. */
|
|
43
|
+
body = '';
|
|
44
|
+
model = null;
|
|
45
|
+
inputTokens = null;
|
|
46
|
+
outputTokens = null;
|
|
47
|
+
/** Streaming tool calls, keyed by content-block / choice index. */
|
|
48
|
+
partial = new Map();
|
|
49
|
+
done = [];
|
|
50
|
+
// Written out longhand rather than as parameter properties: Node's
|
|
51
|
+
// strip-only TypeScript mode rejects those, and running straight from
|
|
52
|
+
// source with no build step is worth more than the shorthand.
|
|
53
|
+
provider;
|
|
54
|
+
streaming;
|
|
55
|
+
constructor(provider, streaming) {
|
|
56
|
+
this.provider = provider;
|
|
57
|
+
this.streaming = streaming;
|
|
58
|
+
}
|
|
59
|
+
push(chunk) {
|
|
60
|
+
const text = this.decoder.write(chunk);
|
|
61
|
+
if (!this.streaming) {
|
|
62
|
+
this.body += text;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
this.lineBuffer += text;
|
|
66
|
+
const lines = this.lineBuffer.split('\n');
|
|
67
|
+
// The trailing element is whatever came after the last newline — it may
|
|
68
|
+
// be an incomplete line, so it stays buffered for the next chunk.
|
|
69
|
+
this.lineBuffer = lines.pop() ?? '';
|
|
70
|
+
for (const line of lines)
|
|
71
|
+
this.handleLine(line);
|
|
72
|
+
}
|
|
73
|
+
handleLine(line) {
|
|
74
|
+
const trimmed = line.trim();
|
|
75
|
+
if (!trimmed.startsWith('data:'))
|
|
76
|
+
return;
|
|
77
|
+
const payload = trimmed.slice(5).trim();
|
|
78
|
+
if (payload === '' || payload === '[DONE]')
|
|
79
|
+
return;
|
|
80
|
+
const event = tryJson(payload);
|
|
81
|
+
if (!event)
|
|
82
|
+
return;
|
|
83
|
+
if (this.provider === 'anthropic')
|
|
84
|
+
this.anthropicEvent(event);
|
|
85
|
+
else
|
|
86
|
+
this.openaiEvent(event);
|
|
87
|
+
}
|
|
88
|
+
anthropicEvent(e) {
|
|
89
|
+
switch (e.type) {
|
|
90
|
+
case 'message_start':
|
|
91
|
+
this.model = e.message?.model ?? this.model;
|
|
92
|
+
this.inputTokens = e.message?.usage?.input_tokens ?? this.inputTokens;
|
|
93
|
+
// A resumed or cached turn can report output tokens here too.
|
|
94
|
+
this.outputTokens = e.message?.usage?.output_tokens ?? this.outputTokens;
|
|
95
|
+
break;
|
|
96
|
+
case 'content_block_start':
|
|
97
|
+
if (e.content_block?.type === 'tool_use') {
|
|
98
|
+
this.partial.set(e.index, { name: e.content_block.name, json: '' });
|
|
99
|
+
}
|
|
100
|
+
break;
|
|
101
|
+
case 'content_block_delta':
|
|
102
|
+
if (e.delta?.type === 'input_json_delta') {
|
|
103
|
+
const slot = this.partial.get(e.index);
|
|
104
|
+
if (slot)
|
|
105
|
+
slot.json += e.delta.partial_json ?? '';
|
|
106
|
+
}
|
|
107
|
+
break;
|
|
108
|
+
case 'content_block_stop':
|
|
109
|
+
this.flush(e.index);
|
|
110
|
+
break;
|
|
111
|
+
case 'message_delta':
|
|
112
|
+
// Cumulative for the message, so last write wins rather than summing.
|
|
113
|
+
if (typeof e.usage?.output_tokens === 'number')
|
|
114
|
+
this.outputTokens = e.usage.output_tokens;
|
|
115
|
+
if (typeof e.usage?.input_tokens === 'number')
|
|
116
|
+
this.inputTokens = e.usage.input_tokens;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
openaiEvent(e) {
|
|
121
|
+
this.model = e.model ?? this.model;
|
|
122
|
+
if (e.usage) {
|
|
123
|
+
this.inputTokens = e.usage.prompt_tokens ?? this.inputTokens;
|
|
124
|
+
this.outputTokens = e.usage.completion_tokens ?? this.outputTokens;
|
|
125
|
+
}
|
|
126
|
+
for (const call of e.choices?.[0]?.delta?.tool_calls ?? []) {
|
|
127
|
+
const index = call.index ?? 0;
|
|
128
|
+
const slot = this.partial.get(index) ?? { name: '', json: '' };
|
|
129
|
+
// The name arrives once, on the first fragment; arguments stream after.
|
|
130
|
+
if (call.function?.name)
|
|
131
|
+
slot.name = call.function.name;
|
|
132
|
+
slot.json += call.function?.arguments ?? '';
|
|
133
|
+
this.partial.set(index, slot);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
flush(index) {
|
|
137
|
+
const slot = this.partial.get(index);
|
|
138
|
+
if (!slot)
|
|
139
|
+
return;
|
|
140
|
+
this.partial.delete(index);
|
|
141
|
+
this.done.push({
|
|
142
|
+
name: slot.name,
|
|
143
|
+
// Empty is a legitimate no-argument call; anything unparseable is kept
|
|
144
|
+
// verbatim so the audit log shows what the model actually emitted.
|
|
145
|
+
input: slot.json === '' ? {} : (tryJson(slot.json) ?? slot.json),
|
|
146
|
+
index,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
finish() {
|
|
150
|
+
this.body += this.decoder.end();
|
|
151
|
+
if (this.streaming) {
|
|
152
|
+
if (this.lineBuffer)
|
|
153
|
+
this.handleLine(this.lineBuffer);
|
|
154
|
+
// OpenAI has no per-call terminator, so anything still open at the end
|
|
155
|
+
// of the stream is complete by definition.
|
|
156
|
+
for (const index of [...this.partial.keys()])
|
|
157
|
+
this.flush(index);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
this.parseWholeBody();
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
model: this.model,
|
|
164
|
+
inputTokens: this.inputTokens,
|
|
165
|
+
outputTokens: this.outputTokens,
|
|
166
|
+
toolCalls: this.done,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
parseWholeBody() {
|
|
170
|
+
const json = tryJson(this.body);
|
|
171
|
+
if (!json)
|
|
172
|
+
return;
|
|
173
|
+
this.model = json.model ?? null;
|
|
174
|
+
if (this.provider === 'anthropic') {
|
|
175
|
+
this.inputTokens = json.usage?.input_tokens ?? null;
|
|
176
|
+
this.outputTokens = json.usage?.output_tokens ?? null;
|
|
177
|
+
const content = json.content ?? [];
|
|
178
|
+
for (let i = 0; i < content.length; i++) {
|
|
179
|
+
if (content[i]?.type === 'tool_use') {
|
|
180
|
+
this.done.push({ name: content[i].name, input: content[i].input ?? {}, index: i });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.inputTokens = json.usage?.prompt_tokens ?? null;
|
|
186
|
+
this.outputTokens = json.usage?.completion_tokens ?? null;
|
|
187
|
+
const calls = json.choices?.[0]?.message?.tool_calls ?? [];
|
|
188
|
+
for (let i = 0; i < calls.length; i++) {
|
|
189
|
+
const args = calls[i].function?.arguments;
|
|
190
|
+
this.done.push({
|
|
191
|
+
name: calls[i].function?.name ?? '',
|
|
192
|
+
input: typeof args === 'string' ? (args === '' ? {} : (tryJson(args) ?? args)) : (args ?? {}),
|
|
193
|
+
index: i,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Store } from './db.ts';
|
|
2
|
+
export interface SpendLimits {
|
|
3
|
+
perHourUsd?: number;
|
|
4
|
+
perDayUsd?: number;
|
|
5
|
+
}
|
|
6
|
+
export interface Policy {
|
|
7
|
+
/** Applied when no allow/deny pattern matches. */
|
|
8
|
+
default: 'allow' | 'deny';
|
|
9
|
+
tools: {
|
|
10
|
+
allow: string[];
|
|
11
|
+
deny: string[];
|
|
12
|
+
};
|
|
13
|
+
spend: SpendLimits;
|
|
14
|
+
}
|
|
15
|
+
export declare const DEFAULT_POLICY: Policy;
|
|
16
|
+
export interface Decision {
|
|
17
|
+
allowed: boolean;
|
|
18
|
+
/** Human-readable cause, shown in the trace and returned to the agent. */
|
|
19
|
+
reason: string | null;
|
|
20
|
+
}
|
|
21
|
+
export declare class Controls {
|
|
22
|
+
private policy;
|
|
23
|
+
private killed;
|
|
24
|
+
private killReason;
|
|
25
|
+
constructor(policy?: Policy);
|
|
26
|
+
/** True when any rule could reject a tool call. Drives stream buffering. */
|
|
27
|
+
get enforcing(): boolean;
|
|
28
|
+
get limits(): SpendLimits;
|
|
29
|
+
get isKilled(): boolean;
|
|
30
|
+
kill(reason: string): void;
|
|
31
|
+
revive(): void;
|
|
32
|
+
/** Checked before a request is forwarded, so a kill stops spend immediately. */
|
|
33
|
+
checkRequest(store: Store): Decision;
|
|
34
|
+
/**
|
|
35
|
+
* Decide a single tool call.
|
|
36
|
+
*
|
|
37
|
+
* Deny wins over allow. If a name matches both lists the safe reading is
|
|
38
|
+
* that someone intended to carve an exception out of a broad allow, and
|
|
39
|
+
* the cost of getting that backwards is an action that should not have run.
|
|
40
|
+
*/
|
|
41
|
+
checkTool(name: string): Decision;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Read a policy file, filling in defaults for anything absent.
|
|
45
|
+
*
|
|
46
|
+
* An unreadable or malformed policy throws rather than falling back to the
|
|
47
|
+
* permissive default: silently ignoring a typo'd security config is how you
|
|
48
|
+
* end up believing you have controls that were never loaded.
|
|
49
|
+
*/
|
|
50
|
+
export declare function loadPolicy(path: string): Policy;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
export const DEFAULT_POLICY = {
|
|
3
|
+
// Allow-by-default so `npx ambitry` in front of a running agent changes
|
|
4
|
+
// nothing on the first run. A tool that breaks your agent the moment you
|
|
5
|
+
// install it never gets a second run — deny-by-default is what the README
|
|
6
|
+
// recommends once you know which tools you actually use.
|
|
7
|
+
default: 'allow',
|
|
8
|
+
tools: { allow: [], deny: [] },
|
|
9
|
+
spend: {},
|
|
10
|
+
};
|
|
11
|
+
const ALLOWED = { allowed: true, reason: null };
|
|
12
|
+
/**
|
|
13
|
+
* Glob match supporting `*` only.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately not full regex: policy files are security configuration, and
|
|
16
|
+
* a rule that is easy to misread is a rule that gets written wrong. `*`
|
|
17
|
+
* covers `delete_*` and `*_admin`, which is the shape real rules take.
|
|
18
|
+
*/
|
|
19
|
+
function matches(pattern, name) {
|
|
20
|
+
if (pattern === '*')
|
|
21
|
+
return true;
|
|
22
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
23
|
+
return new RegExp(`^${escaped}$`).test(name);
|
|
24
|
+
}
|
|
25
|
+
function matchesAny(patterns, name) {
|
|
26
|
+
return patterns.find((p) => matches(p, name)) ?? null;
|
|
27
|
+
}
|
|
28
|
+
export class Controls {
|
|
29
|
+
policy;
|
|
30
|
+
killed = false;
|
|
31
|
+
killReason = null;
|
|
32
|
+
constructor(policy = DEFAULT_POLICY) {
|
|
33
|
+
this.policy = policy;
|
|
34
|
+
}
|
|
35
|
+
/** True when any rule could reject a tool call. Drives stream buffering. */
|
|
36
|
+
get enforcing() {
|
|
37
|
+
return this.policy.default === 'deny' || this.policy.tools.deny.length > 0;
|
|
38
|
+
}
|
|
39
|
+
get limits() {
|
|
40
|
+
return this.policy.spend;
|
|
41
|
+
}
|
|
42
|
+
get isKilled() {
|
|
43
|
+
return this.killed;
|
|
44
|
+
}
|
|
45
|
+
kill(reason) {
|
|
46
|
+
this.killed = true;
|
|
47
|
+
this.killReason = reason;
|
|
48
|
+
}
|
|
49
|
+
revive() {
|
|
50
|
+
this.killed = false;
|
|
51
|
+
this.killReason = null;
|
|
52
|
+
}
|
|
53
|
+
/** Checked before a request is forwarded, so a kill stops spend immediately. */
|
|
54
|
+
checkRequest(store) {
|
|
55
|
+
if (this.killed) {
|
|
56
|
+
return { allowed: false, reason: this.killReason ?? 'Stopped by kill switch' };
|
|
57
|
+
}
|
|
58
|
+
const { perHourUsd, perDayUsd } = this.policy.spend;
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
if (perHourUsd !== undefined) {
|
|
61
|
+
const spent = store.spendSince(now - 3_600_000);
|
|
62
|
+
if (spent >= perHourUsd) {
|
|
63
|
+
return { allowed: false, reason: `Hourly spend cap reached: $${spent.toFixed(2)} of $${perHourUsd.toFixed(2)}` };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (perDayUsd !== undefined) {
|
|
67
|
+
const spent = store.spendSince(now - 86_400_000);
|
|
68
|
+
if (spent >= perDayUsd) {
|
|
69
|
+
return { allowed: false, reason: `Daily spend cap reached: $${spent.toFixed(2)} of $${perDayUsd.toFixed(2)}` };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return ALLOWED;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Decide a single tool call.
|
|
76
|
+
*
|
|
77
|
+
* Deny wins over allow. If a name matches both lists the safe reading is
|
|
78
|
+
* that someone intended to carve an exception out of a broad allow, and
|
|
79
|
+
* the cost of getting that backwards is an action that should not have run.
|
|
80
|
+
*/
|
|
81
|
+
checkTool(name) {
|
|
82
|
+
const denied = matchesAny(this.policy.tools.deny, name);
|
|
83
|
+
if (denied)
|
|
84
|
+
return { allowed: false, reason: `Blocked by deny rule "${denied}"` };
|
|
85
|
+
const allowed = matchesAny(this.policy.tools.allow, name);
|
|
86
|
+
if (allowed)
|
|
87
|
+
return ALLOWED;
|
|
88
|
+
if (this.policy.default === 'deny') {
|
|
89
|
+
return { allowed: false, reason: 'Blocked by deny-by-default policy' };
|
|
90
|
+
}
|
|
91
|
+
return ALLOWED;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Read a policy file, filling in defaults for anything absent.
|
|
96
|
+
*
|
|
97
|
+
* An unreadable or malformed policy throws rather than falling back to the
|
|
98
|
+
* permissive default: silently ignoring a typo'd security config is how you
|
|
99
|
+
* end up believing you have controls that were never loaded.
|
|
100
|
+
*/
|
|
101
|
+
export function loadPolicy(path) {
|
|
102
|
+
if (!existsSync(path))
|
|
103
|
+
return DEFAULT_POLICY;
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
throw new Error(`${path} is not valid JSON: ${err.message}`);
|
|
110
|
+
}
|
|
111
|
+
if (raw.default !== undefined && raw.default !== 'allow' && raw.default !== 'deny') {
|
|
112
|
+
throw new Error(`${path}: "default" must be "allow" or "deny", got ${JSON.stringify(raw.default)}`);
|
|
113
|
+
}
|
|
114
|
+
const list = (value, field) => {
|
|
115
|
+
if (value === undefined)
|
|
116
|
+
return [];
|
|
117
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
118
|
+
throw new Error(`${path}: "${field}" must be an array of strings`);
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
};
|
|
122
|
+
const positive = (value, field) => {
|
|
123
|
+
if (value === undefined)
|
|
124
|
+
return undefined;
|
|
125
|
+
if (typeof value !== 'number' || !(value > 0)) {
|
|
126
|
+
throw new Error(`${path}: "${field}" must be a positive number`);
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
default: raw.default ?? DEFAULT_POLICY.default,
|
|
132
|
+
tools: {
|
|
133
|
+
allow: list(raw.tools?.allow, 'tools.allow'),
|
|
134
|
+
deny: list(raw.tools?.deny, 'tools.deny'),
|
|
135
|
+
},
|
|
136
|
+
spend: {
|
|
137
|
+
perHourUsd: positive(raw.spend?.perHourUsd, 'spend.perHourUsd'),
|
|
138
|
+
perDayUsd: positive(raw.spend?.perDayUsd, 'spend.perDayUsd'),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-million-token rates, USD.
|
|
3
|
+
*
|
|
4
|
+
* Anthropic rates are first-party API list prices (verified 2026-08-01).
|
|
5
|
+
* OpenAI rates are NOT bundled: we will not ship guessed numbers for a tool
|
|
6
|
+
* whose entire value proposition is telling you what your agents actually
|
|
7
|
+
* cost. Supply them via `pricing.json` (see `loadPricingOverrides`), and
|
|
8
|
+
* unpriced models simply report `cost_usd: null` rather than a wrong number.
|
|
9
|
+
*/
|
|
10
|
+
export interface Rate {
|
|
11
|
+
/** USD per 1M input tokens. */
|
|
12
|
+
input: number;
|
|
13
|
+
/** USD per 1M output tokens. */
|
|
14
|
+
output: number;
|
|
15
|
+
/**
|
|
16
|
+
* Optional promotional rate that supersedes the above until `until`
|
|
17
|
+
* (exclusive, ISO date). Anthropic runs these; getting it wrong means
|
|
18
|
+
* silently misreporting spend on either side of the cutover.
|
|
19
|
+
*/
|
|
20
|
+
promo?: {
|
|
21
|
+
input: number;
|
|
22
|
+
output: number;
|
|
23
|
+
until: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export declare function loadPricingOverrides(table: Record<string, Rate>): void;
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a model id to a rate.
|
|
29
|
+
*
|
|
30
|
+
* Providers prefix and suffix model ids freely — Bedrock prepends
|
|
31
|
+
* `anthropic.`, and callers pin dated snapshots like
|
|
32
|
+
* `claude-haiku-4-5-20251001`. Match on the longest key that the id
|
|
33
|
+
* contains so both forms resolve without a separate alias table.
|
|
34
|
+
*/
|
|
35
|
+
export declare function rateFor(model: string | null): Rate | null;
|
|
36
|
+
/**
|
|
37
|
+
* Cost in USD for one request, or null when we have no rate for the model.
|
|
38
|
+
*
|
|
39
|
+
* `at` is the request timestamp, which decides whether a promotional rate
|
|
40
|
+
* still applies — backfilling old traces must price them as they were billed,
|
|
41
|
+
* not as they would be billed today.
|
|
42
|
+
*/
|
|
43
|
+
export declare function costUsd(model: string | null, inputTokens: number | null, outputTokens: number | null, at?: number): number | null;
|
|
44
|
+
/** Model ids we can price, for the `ambitry models` CLI listing. */
|
|
45
|
+
export declare function pricedModels(): string[];
|
package/dist/pricing.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-million-token rates, USD.
|
|
3
|
+
*
|
|
4
|
+
* Anthropic rates are first-party API list prices (verified 2026-08-01).
|
|
5
|
+
* OpenAI rates are NOT bundled: we will not ship guessed numbers for a tool
|
|
6
|
+
* whose entire value proposition is telling you what your agents actually
|
|
7
|
+
* cost. Supply them via `pricing.json` (see `loadPricingOverrides`), and
|
|
8
|
+
* unpriced models simply report `cost_usd: null` rather than a wrong number.
|
|
9
|
+
*/
|
|
10
|
+
const ANTHROPIC = {
|
|
11
|
+
'claude-fable-5': { input: 10, output: 50 },
|
|
12
|
+
'claude-mythos-5': { input: 10, output: 50 },
|
|
13
|
+
'claude-opus-5': { input: 5, output: 25 },
|
|
14
|
+
'claude-opus-4-8': { input: 5, output: 25 },
|
|
15
|
+
'claude-opus-4-7': { input: 5, output: 25 },
|
|
16
|
+
'claude-opus-4-6': { input: 5, output: 25 },
|
|
17
|
+
'claude-sonnet-5': {
|
|
18
|
+
input: 3,
|
|
19
|
+
output: 15,
|
|
20
|
+
promo: { input: 2, output: 10, until: '2026-09-01' },
|
|
21
|
+
},
|
|
22
|
+
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
23
|
+
'claude-haiku-4-5': { input: 1, output: 5 },
|
|
24
|
+
};
|
|
25
|
+
/** User-supplied rates, merged over the built-ins. */
|
|
26
|
+
let overrides = {};
|
|
27
|
+
export function loadPricingOverrides(table) {
|
|
28
|
+
overrides = table;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve a model id to a rate.
|
|
32
|
+
*
|
|
33
|
+
* Providers prefix and suffix model ids freely — Bedrock prepends
|
|
34
|
+
* `anthropic.`, and callers pin dated snapshots like
|
|
35
|
+
* `claude-haiku-4-5-20251001`. Match on the longest key that the id
|
|
36
|
+
* contains so both forms resolve without a separate alias table.
|
|
37
|
+
*/
|
|
38
|
+
export function rateFor(model) {
|
|
39
|
+
if (!model)
|
|
40
|
+
return null;
|
|
41
|
+
const table = { ...ANTHROPIC, ...overrides };
|
|
42
|
+
if (table[model])
|
|
43
|
+
return table[model];
|
|
44
|
+
let best = null;
|
|
45
|
+
for (const key of Object.keys(table)) {
|
|
46
|
+
if (model.includes(key) && (best === null || key.length > best.length))
|
|
47
|
+
best = key;
|
|
48
|
+
}
|
|
49
|
+
return best ? table[best] : null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Cost in USD for one request, or null when we have no rate for the model.
|
|
53
|
+
*
|
|
54
|
+
* `at` is the request timestamp, which decides whether a promotional rate
|
|
55
|
+
* still applies — backfilling old traces must price them as they were billed,
|
|
56
|
+
* not as they would be billed today.
|
|
57
|
+
*/
|
|
58
|
+
export function costUsd(model, inputTokens, outputTokens, at = Date.now()) {
|
|
59
|
+
const rate = rateFor(model);
|
|
60
|
+
if (!rate)
|
|
61
|
+
return null;
|
|
62
|
+
const active = rate.promo && at < Date.parse(rate.promo.until)
|
|
63
|
+
? { input: rate.promo.input, output: rate.promo.output }
|
|
64
|
+
: { input: rate.input, output: rate.output };
|
|
65
|
+
return ((inputTokens ?? 0) * active.input + (outputTokens ?? 0) * active.output) / 1_000_000;
|
|
66
|
+
}
|
|
67
|
+
/** Model ids we can price, for the `ambitry models` CLI listing. */
|
|
68
|
+
export function pricedModels() {
|
|
69
|
+
return Object.keys({ ...ANTHROPIC, ...overrides }).sort();
|
|
70
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface Provider {
|
|
2
|
+
id: string;
|
|
3
|
+
base: string;
|
|
4
|
+
/** Path the SDK appends after the base URL the developer configures. */
|
|
5
|
+
example: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Upstream bases, overridable per provider via `AMBITRY_<ID>_BASE`.
|
|
9
|
+
*
|
|
10
|
+
* Read at call time rather than module load so tests can point a provider at
|
|
11
|
+
* a local stub. Also covers Azure-hosted OpenAI, gateways, and local models
|
|
12
|
+
* serving a compatible API.
|
|
13
|
+
*/
|
|
14
|
+
export declare const PROVIDERS: Record<string, Provider>;
|
|
15
|
+
export interface Route {
|
|
16
|
+
provider: Provider;
|
|
17
|
+
/** Absolute upstream URL, with the /<provider> prefix stripped. */
|
|
18
|
+
upstream: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Map an inbound request path to an upstream.
|
|
22
|
+
*
|
|
23
|
+
* `/anthropic/v1/messages` -> `https://api.anthropic.com/v1/messages`
|
|
24
|
+
*
|
|
25
|
+
* Routing by path prefix rather than by inspecting the body keeps this
|
|
26
|
+
* decision cheap and total: we never have to buffer a request to know where
|
|
27
|
+
* it goes, which matters because request bodies can be megabytes.
|
|
28
|
+
*/
|
|
29
|
+
export declare function routeFor(rawUrl: string): Route | null;
|
|
30
|
+
/** Strip a header set down to what is safe to persist. */
|
|
31
|
+
export declare function redactHeaders(headers: Record<string, string | string[] | undefined>): Record<string, string>;
|
|
32
|
+
export declare function forwardHeaders(headers: Record<string, string | string[] | undefined>): Headers;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upstream bases, overridable per provider via `AMBITRY_<ID>_BASE`.
|
|
3
|
+
*
|
|
4
|
+
* Read at call time rather than module load so tests can point a provider at
|
|
5
|
+
* a local stub. Also covers Azure-hosted OpenAI, gateways, and local models
|
|
6
|
+
* serving a compatible API.
|
|
7
|
+
*/
|
|
8
|
+
export const PROVIDERS = {
|
|
9
|
+
anthropic: {
|
|
10
|
+
id: 'anthropic',
|
|
11
|
+
base: 'https://api.anthropic.com',
|
|
12
|
+
example: 'base_url="http://localhost:8787/anthropic"',
|
|
13
|
+
},
|
|
14
|
+
openai: {
|
|
15
|
+
id: 'openai',
|
|
16
|
+
base: 'https://api.openai.com',
|
|
17
|
+
example: 'base_url="http://localhost:8787/openai/v1"',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
function baseFor(provider) {
|
|
21
|
+
return process.env[`AMBITRY_${provider.id.toUpperCase()}_BASE`] ?? provider.base;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Map an inbound request path to an upstream.
|
|
25
|
+
*
|
|
26
|
+
* `/anthropic/v1/messages` -> `https://api.anthropic.com/v1/messages`
|
|
27
|
+
*
|
|
28
|
+
* Routing by path prefix rather than by inspecting the body keeps this
|
|
29
|
+
* decision cheap and total: we never have to buffer a request to know where
|
|
30
|
+
* it goes, which matters because request bodies can be megabytes.
|
|
31
|
+
*/
|
|
32
|
+
export function routeFor(rawUrl) {
|
|
33
|
+
const [pathname, query] = rawUrl.split('?');
|
|
34
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
35
|
+
if (segments.length === 0)
|
|
36
|
+
return null;
|
|
37
|
+
const provider = PROVIDERS[segments[0]];
|
|
38
|
+
if (!provider)
|
|
39
|
+
return null;
|
|
40
|
+
const rest = segments.slice(1).join('/');
|
|
41
|
+
return {
|
|
42
|
+
provider,
|
|
43
|
+
upstream: `${baseFor(provider)}/${rest}${query ? `?${query}` : ''}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Headers preserved in the trace store.
|
|
48
|
+
*
|
|
49
|
+
* This is an allowlist, not a blocklist, and deliberately so: a blocklist
|
|
50
|
+
* silently leaks the first credential header a provider invents that we
|
|
51
|
+
* did not think to name. Anything not listed here never reaches disk, so
|
|
52
|
+
* the failure mode of an unknown header is a missing debug field rather
|
|
53
|
+
* than a leaked API key.
|
|
54
|
+
*/
|
|
55
|
+
const LOGGABLE_HEADERS = new Set([
|
|
56
|
+
'content-type',
|
|
57
|
+
'accept',
|
|
58
|
+
'user-agent',
|
|
59
|
+
'anthropic-version',
|
|
60
|
+
'anthropic-beta',
|
|
61
|
+
'openai-organization',
|
|
62
|
+
'openai-beta',
|
|
63
|
+
'x-stainless-lang',
|
|
64
|
+
'x-stainless-package-version',
|
|
65
|
+
'x-stainless-runtime',
|
|
66
|
+
'x-stainless-runtime-version',
|
|
67
|
+
]);
|
|
68
|
+
/** Strip a header set down to what is safe to persist. */
|
|
69
|
+
export function redactHeaders(headers) {
|
|
70
|
+
const safe = {};
|
|
71
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
72
|
+
if (value === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
const key = name.toLowerCase();
|
|
75
|
+
if (!LOGGABLE_HEADERS.has(key))
|
|
76
|
+
continue;
|
|
77
|
+
safe[key] = Array.isArray(value) ? value.join(', ') : value;
|
|
78
|
+
}
|
|
79
|
+
return safe;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Headers forwarded upstream.
|
|
83
|
+
*
|
|
84
|
+
* Credentials pass through untouched — the proxy is transparent to auth and
|
|
85
|
+
* never mints, stores, or substitutes keys. `host` is dropped so undici sets
|
|
86
|
+
* it from the upstream URL, and hop-by-hop headers are dropped per RFC 9110;
|
|
87
|
+
* forwarding `connection` or `transfer-encoding` to a different connection
|
|
88
|
+
* corrupts the response framing.
|
|
89
|
+
*/
|
|
90
|
+
const HOP_BY_HOP = new Set([
|
|
91
|
+
'host',
|
|
92
|
+
'connection',
|
|
93
|
+
'keep-alive',
|
|
94
|
+
'proxy-authenticate',
|
|
95
|
+
'proxy-authorization',
|
|
96
|
+
'te',
|
|
97
|
+
'trailer',
|
|
98
|
+
'transfer-encoding',
|
|
99
|
+
'upgrade',
|
|
100
|
+
'content-length',
|
|
101
|
+
]);
|
|
102
|
+
export function forwardHeaders(headers) {
|
|
103
|
+
const out = new Headers();
|
|
104
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
105
|
+
if (value === undefined)
|
|
106
|
+
continue;
|
|
107
|
+
if (HOP_BY_HOP.has(name.toLowerCase()))
|
|
108
|
+
continue;
|
|
109
|
+
out.set(name, Array.isArray(value) ? value.join(', ') : value);
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detecting credentials and personal data in outgoing prompts.
|
|
3
|
+
*
|
|
4
|
+
* Everything here runs locally and only ever reports *that* something was
|
|
5
|
+
* found, plus a masked preview. The secret itself is never written to the
|
|
6
|
+
* trace store — a tool that logs the credentials it catches has moved the
|
|
7
|
+
* leak rather than closed it.
|
|
8
|
+
*
|
|
9
|
+
* The design bias throughout is against false positives. A detector that
|
|
10
|
+
* fires on ordinary text gets muted within a day, and a muted detector is
|
|
11
|
+
* worth less than none at all because it also buys false confidence.
|
|
12
|
+
*/
|
|
13
|
+
export type Severity = 'critical' | 'high' | 'medium';
|
|
14
|
+
export interface Finding {
|
|
15
|
+
kind: string;
|
|
16
|
+
severity: Severity;
|
|
17
|
+
/** First and last few characters only; the middle is never retained. */
|
|
18
|
+
preview: string;
|
|
19
|
+
count: number;
|
|
20
|
+
}
|
|
21
|
+
export declare function scan(text: string): Finding[];
|
|
22
|
+
/** Walk a parsed request body and scan every string it contains. */
|
|
23
|
+
export declare function scanRequest(body: unknown): Finding[];
|