@boardwalk-labs/engine 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 +69 -0
- package/bin/boardwalk-server.js +16 -0
- package/dist/agent/conversation.d.ts +42 -0
- package/dist/agent/conversation.js +4 -0
- package/dist/agent/leaf.d.ts +81 -0
- package/dist/agent/leaf.js +190 -0
- package/dist/agent/providers.d.ts +23 -0
- package/dist/agent/providers.js +347 -0
- package/dist/agent/rates.d.ts +13 -0
- package/dist/agent/rates.js +35 -0
- package/dist/agent/redact.d.ts +9 -0
- package/dist/agent/redact.js +27 -0
- package/dist/agent/resolve.d.ts +58 -0
- package/dist/agent/resolve.js +153 -0
- package/dist/agent/sse.d.ts +2 -0
- package/dist/agent/sse.js +30 -0
- package/dist/agent/tools.d.ts +57 -0
- package/dist/agent/tools.js +324 -0
- package/dist/clock.d.ts +8 -0
- package/dist/clock.js +32 -0
- package/dist/cron/cron.d.ts +34 -0
- package/dist/cron/cron.js +331 -0
- package/dist/engine.d.ts +106 -0
- package/dist/engine.js +183 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +40 -0
- package/dist/ids.d.ts +7 -0
- package/dist/ids.js +42 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/json_value.d.ts +7 -0
- package/dist/json_value.js +29 -0
- package/dist/mcp/client.d.ts +39 -0
- package/dist/mcp/client.js +112 -0
- package/dist/mcp/jsonrpc.d.ts +57 -0
- package/dist/mcp/jsonrpc.js +117 -0
- package/dist/mcp/oauth.d.ts +72 -0
- package/dist/mcp/oauth.js +337 -0
- package/dist/mcp/token_store.d.ts +30 -0
- package/dist/mcp/token_store.js +101 -0
- package/dist/mcp/transport_http.d.ts +38 -0
- package/dist/mcp/transport_http.js +143 -0
- package/dist/mcp/transport_stdio.d.ts +27 -0
- package/dist/mcp/transport_stdio.js +94 -0
- package/dist/run/child.d.ts +1 -0
- package/dist/run/child.js +139 -0
- package/dist/run/child_host.d.ts +26 -0
- package/dist/run/child_host.js +124 -0
- package/dist/run/idempotency.d.ts +5 -0
- package/dist/run/idempotency.js +31 -0
- package/dist/run/ipc.d.ts +159 -0
- package/dist/run/ipc.js +150 -0
- package/dist/run/run_dir.d.ts +31 -0
- package/dist/run/run_dir.js +106 -0
- package/dist/run/supervisor.d.ts +107 -0
- package/dist/run/supervisor.js +676 -0
- package/dist/scheduler/scheduler.d.ts +54 -0
- package/dist/scheduler/scheduler.js +215 -0
- package/dist/server/http.d.ts +42 -0
- package/dist/server/http.js +183 -0
- package/dist/server/routes/api.d.ts +17 -0
- package/dist/server/routes/api.js +107 -0
- package/dist/server/routes/hooks.d.ts +2 -0
- package/dist/server/routes/hooks.js +88 -0
- package/dist/server/routes/router.d.ts +15 -0
- package/dist/server/routes/router.js +75 -0
- package/dist/server/routes/stream.d.ts +2 -0
- package/dist/server/routes/stream.js +79 -0
- package/dist/server/routes/ui.d.ts +2 -0
- package/dist/server/routes/ui.js +120 -0
- package/dist/server/server.d.ts +25 -0
- package/dist/server/server.js +67 -0
- package/dist/server_main.d.ts +46 -0
- package/dist/server_main.js +203 -0
- package/dist/store/migrations.d.ts +21 -0
- package/dist/store/migrations.js +159 -0
- package/dist/store/store.d.ts +194 -0
- package/dist/store/store.js +567 -0
- package/package.json +57 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Provider adapters for the agent() leaf — two wire protocols cover everything (SPEC §2.3):
|
|
2
|
+
// Anthropic's Messages API (streamed) and OpenAI-style chat completions (the lingua franca of
|
|
3
|
+
// OpenAI, Google's compat surface, vLLM, Ollama, Together, Fireworks, Groq…). Each adapter
|
|
4
|
+
// maps the loop's neutral conversation (conversation.ts) to its wire format and executes ONE
|
|
5
|
+
// model turn; the tool loop itself lives in leaf.ts.
|
|
6
|
+
//
|
|
7
|
+
// Zero SDK dependencies: plain fetch + Zod-validated responses (a provider's response is a
|
|
8
|
+
// trust boundary like any other). Retry policy per CODE_QUALITY §4.3: exponential backoff with
|
|
9
|
+
// jitter on 429/5xx/network errors; a non-rate-limit 4xx never retries.
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { EngineError } from "../errors.js";
|
|
12
|
+
import { isJsonValue, isPlainObject } from "../json_value.js";
|
|
13
|
+
import { sseDataLines } from "./sse.js";
|
|
14
|
+
const DEFAULT_MAX_TOKENS = 8192;
|
|
15
|
+
const RETRY_ATTEMPTS = 5;
|
|
16
|
+
const RETRY_BASE_MS = 500;
|
|
17
|
+
/** An HTTP failure that carries its status so the retry policy can classify it. */
|
|
18
|
+
class ProviderHttpError extends Error {
|
|
19
|
+
status;
|
|
20
|
+
constructor(status, body) {
|
|
21
|
+
super(`provider returned ${String(status)}: ${body.slice(0, 300)}`);
|
|
22
|
+
this.status = status;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// ----------------------------------------------------------------------------
|
|
26
|
+
// Anthropic Messages API (streaming)
|
|
27
|
+
// ----------------------------------------------------------------------------
|
|
28
|
+
function anthropicMessages(messages) {
|
|
29
|
+
return messages.map((message) => {
|
|
30
|
+
switch (message.role) {
|
|
31
|
+
case "user":
|
|
32
|
+
return { role: "user", content: [{ type: "text", text: message.text }] };
|
|
33
|
+
case "assistant": {
|
|
34
|
+
const content = [];
|
|
35
|
+
if (message.text.length > 0)
|
|
36
|
+
content.push({ type: "text", text: message.text });
|
|
37
|
+
for (const call of message.toolCalls) {
|
|
38
|
+
content.push({ type: "tool_use", id: call.id, name: call.name, input: call.input });
|
|
39
|
+
}
|
|
40
|
+
return { role: "assistant", content };
|
|
41
|
+
}
|
|
42
|
+
case "tool_results":
|
|
43
|
+
return {
|
|
44
|
+
role: "user",
|
|
45
|
+
content: message.results.map((result) => ({
|
|
46
|
+
type: "tool_result",
|
|
47
|
+
tool_use_id: result.id,
|
|
48
|
+
content: result.content,
|
|
49
|
+
is_error: result.isError,
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const frameHeadSchema = z.looseObject({ type: z.string() });
|
|
56
|
+
const messageStartSchema = z.looseObject({
|
|
57
|
+
message: z.looseObject({
|
|
58
|
+
usage: z.looseObject({ input_tokens: z.number().int().nonnegative().optional() }),
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
const contentBlockStartSchema = z.looseObject({
|
|
62
|
+
content_block: z.looseObject({
|
|
63
|
+
type: z.string(),
|
|
64
|
+
id: z.string().optional(),
|
|
65
|
+
name: z.string().optional(),
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
const contentBlockDeltaSchema = z.looseObject({
|
|
69
|
+
delta: z.looseObject({
|
|
70
|
+
type: z.string(),
|
|
71
|
+
text: z.string().optional(),
|
|
72
|
+
partial_json: z.string().optional(),
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
const messageDeltaSchema = z.looseObject({
|
|
76
|
+
delta: z.looseObject({ stop_reason: z.string().nullable().optional() }).optional(),
|
|
77
|
+
usage: z.looseObject({ output_tokens: z.number().int().nonnegative().optional() }).optional(),
|
|
78
|
+
});
|
|
79
|
+
export async function chatAnthropic(args, io = {}) {
|
|
80
|
+
const doFetch = io.fetchImpl ?? fetch;
|
|
81
|
+
const response = await withRetry(io, async () => {
|
|
82
|
+
const res = await doFetch(`${args.baseUrl}/v1/messages`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: {
|
|
85
|
+
"anthropic-version": "2023-06-01",
|
|
86
|
+
...(args.apiKey !== null ? { "x-api-key": args.apiKey } : {}),
|
|
87
|
+
// Custom headers win over computed auth (the point: non-standard auth schemes);
|
|
88
|
+
// content-type comes last — the engine owns the body format.
|
|
89
|
+
...args.headers,
|
|
90
|
+
"content-type": "application/json",
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
model: args.model,
|
|
94
|
+
max_tokens: args.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
95
|
+
stream: true,
|
|
96
|
+
messages: anthropicMessages(args.messages),
|
|
97
|
+
...(args.tools.length > 0
|
|
98
|
+
? {
|
|
99
|
+
tools: args.tools.map((tool) => ({
|
|
100
|
+
name: tool.name,
|
|
101
|
+
description: tool.description,
|
|
102
|
+
input_schema: tool.inputSchema,
|
|
103
|
+
})),
|
|
104
|
+
}
|
|
105
|
+
: {}),
|
|
106
|
+
}),
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok)
|
|
109
|
+
throw new ProviderHttpError(res.status, await res.text());
|
|
110
|
+
return res;
|
|
111
|
+
});
|
|
112
|
+
let text = "";
|
|
113
|
+
let inputTokens;
|
|
114
|
+
let outputTokens;
|
|
115
|
+
let stopReason = null;
|
|
116
|
+
const toolCalls = [];
|
|
117
|
+
// The currently-streaming tool_use block: input arrives as partial JSON deltas.
|
|
118
|
+
let openToolCall = null;
|
|
119
|
+
for await (const data of sseDataLines(response)) {
|
|
120
|
+
const json = safeJson(data);
|
|
121
|
+
const head = frameHeadSchema.safeParse(json);
|
|
122
|
+
if (!head.success)
|
|
123
|
+
continue; // unknown frame kinds are forward compatibility, not errors
|
|
124
|
+
switch (head.data.type) {
|
|
125
|
+
case "message_start": {
|
|
126
|
+
const frame = messageStartSchema.safeParse(json);
|
|
127
|
+
if (frame.success)
|
|
128
|
+
inputTokens = frame.data.message.usage.input_tokens ?? inputTokens;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case "content_block_start": {
|
|
132
|
+
const frame = contentBlockStartSchema.safeParse(json);
|
|
133
|
+
if (frame.success && frame.data.content_block.type === "tool_use") {
|
|
134
|
+
openToolCall = {
|
|
135
|
+
id: frame.data.content_block.id ?? `call-${String(toolCalls.length + 1)}`,
|
|
136
|
+
name: frame.data.content_block.name ?? "",
|
|
137
|
+
partialJson: "",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
case "content_block_delta": {
|
|
143
|
+
const frame = contentBlockDeltaSchema.safeParse(json);
|
|
144
|
+
if (!frame.success)
|
|
145
|
+
break;
|
|
146
|
+
const chunk = frame.data.delta.text;
|
|
147
|
+
if (chunk !== undefined && chunk.length > 0) {
|
|
148
|
+
text += chunk;
|
|
149
|
+
io.onDelta?.(chunk);
|
|
150
|
+
}
|
|
151
|
+
if (frame.data.delta.partial_json !== undefined && openToolCall !== null) {
|
|
152
|
+
openToolCall.partialJson += frame.data.delta.partial_json;
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "content_block_stop": {
|
|
157
|
+
if (openToolCall !== null) {
|
|
158
|
+
toolCalls.push({
|
|
159
|
+
id: openToolCall.id,
|
|
160
|
+
name: openToolCall.name,
|
|
161
|
+
input: parseToolInput(openToolCall.partialJson, openToolCall.name),
|
|
162
|
+
});
|
|
163
|
+
openToolCall = null;
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
case "message_delta": {
|
|
168
|
+
const frame = messageDeltaSchema.safeParse(json);
|
|
169
|
+
if (frame.success) {
|
|
170
|
+
outputTokens = frame.data.usage?.output_tokens ?? outputTokens;
|
|
171
|
+
stopReason = frame.data.delta?.stop_reason ?? stopReason;
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return {
|
|
178
|
+
text,
|
|
179
|
+
toolCalls,
|
|
180
|
+
usage: {
|
|
181
|
+
...(inputTokens !== undefined ? { inputTokens } : {}),
|
|
182
|
+
...(outputTokens !== undefined ? { outputTokens } : {}),
|
|
183
|
+
},
|
|
184
|
+
wantsTools: stopReason === "tool_use" || toolCalls.length > 0,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// ----------------------------------------------------------------------------
|
|
188
|
+
// OpenAI-compatible chat completions (non-streaming in v0 — one final delta)
|
|
189
|
+
// ----------------------------------------------------------------------------
|
|
190
|
+
function openAiMessages(messages) {
|
|
191
|
+
const out = [];
|
|
192
|
+
for (const message of messages) {
|
|
193
|
+
switch (message.role) {
|
|
194
|
+
case "user":
|
|
195
|
+
out.push({ role: "user", content: message.text });
|
|
196
|
+
break;
|
|
197
|
+
case "assistant":
|
|
198
|
+
out.push({
|
|
199
|
+
role: "assistant",
|
|
200
|
+
content: message.text.length > 0 ? message.text : null,
|
|
201
|
+
...(message.toolCalls.length > 0
|
|
202
|
+
? {
|
|
203
|
+
tool_calls: message.toolCalls.map((call) => ({
|
|
204
|
+
id: call.id,
|
|
205
|
+
type: "function",
|
|
206
|
+
function: { name: call.name, arguments: JSON.stringify(call.input) },
|
|
207
|
+
})),
|
|
208
|
+
}
|
|
209
|
+
: {}),
|
|
210
|
+
});
|
|
211
|
+
break;
|
|
212
|
+
case "tool_results":
|
|
213
|
+
for (const result of message.results) {
|
|
214
|
+
out.push({ role: "tool", tool_call_id: result.id, content: result.content });
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
const openAiResponseSchema = z.looseObject({
|
|
222
|
+
choices: z
|
|
223
|
+
.array(z.looseObject({
|
|
224
|
+
finish_reason: z.string().nullable().optional(),
|
|
225
|
+
message: z.looseObject({
|
|
226
|
+
content: z.string().nullable().optional(),
|
|
227
|
+
tool_calls: z
|
|
228
|
+
.array(z.looseObject({
|
|
229
|
+
id: z.string(),
|
|
230
|
+
function: z.looseObject({ name: z.string(), arguments: z.string() }),
|
|
231
|
+
}))
|
|
232
|
+
.optional(),
|
|
233
|
+
}),
|
|
234
|
+
}))
|
|
235
|
+
.min(1),
|
|
236
|
+
usage: z
|
|
237
|
+
.looseObject({
|
|
238
|
+
prompt_tokens: z.number().int().nonnegative().optional(),
|
|
239
|
+
completion_tokens: z.number().int().nonnegative().optional(),
|
|
240
|
+
})
|
|
241
|
+
.optional(),
|
|
242
|
+
});
|
|
243
|
+
export async function chatOpenAi(args, io = {}) {
|
|
244
|
+
const doFetch = io.fetchImpl ?? fetch;
|
|
245
|
+
const response = await withRetry(io, async () => {
|
|
246
|
+
const res = await doFetch(`${args.baseUrl}/chat/completions`, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers: {
|
|
249
|
+
...(args.apiKey !== null ? { authorization: `Bearer ${args.apiKey}` } : {}),
|
|
250
|
+
// Custom headers win over computed auth (the point: non-standard auth schemes);
|
|
251
|
+
// content-type comes last — the engine owns the body format.
|
|
252
|
+
...args.headers,
|
|
253
|
+
"content-type": "application/json",
|
|
254
|
+
},
|
|
255
|
+
body: JSON.stringify({
|
|
256
|
+
model: args.model,
|
|
257
|
+
messages: openAiMessages(args.messages),
|
|
258
|
+
...(args.tools.length > 0
|
|
259
|
+
? {
|
|
260
|
+
tools: args.tools.map((tool) => ({
|
|
261
|
+
type: "function",
|
|
262
|
+
function: {
|
|
263
|
+
name: tool.name,
|
|
264
|
+
description: tool.description,
|
|
265
|
+
parameters: tool.inputSchema,
|
|
266
|
+
},
|
|
267
|
+
})),
|
|
268
|
+
}
|
|
269
|
+
: {}),
|
|
270
|
+
}),
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok)
|
|
273
|
+
throw new ProviderHttpError(res.status, await res.text());
|
|
274
|
+
return res;
|
|
275
|
+
});
|
|
276
|
+
const parsed = openAiResponseSchema.safeParse(await response.json());
|
|
277
|
+
if (!parsed.success) {
|
|
278
|
+
throw new EngineError("PROVIDER_ERROR", "Provider returned a malformed chat completion.");
|
|
279
|
+
}
|
|
280
|
+
const choice = parsed.data.choices[0];
|
|
281
|
+
const text = choice?.message.content ?? "";
|
|
282
|
+
if (text.length > 0)
|
|
283
|
+
io.onDelta?.(text);
|
|
284
|
+
const toolCalls = (choice?.message.tool_calls ?? []).map((call) => ({
|
|
285
|
+
id: call.id,
|
|
286
|
+
name: call.function.name,
|
|
287
|
+
input: parseToolInput(call.function.arguments, call.function.name),
|
|
288
|
+
}));
|
|
289
|
+
const usage = parsed.data.usage;
|
|
290
|
+
return {
|
|
291
|
+
text,
|
|
292
|
+
toolCalls,
|
|
293
|
+
usage: {
|
|
294
|
+
...(usage?.prompt_tokens !== undefined ? { inputTokens: usage.prompt_tokens } : {}),
|
|
295
|
+
...(usage?.completion_tokens !== undefined ? { outputTokens: usage.completion_tokens } : {}),
|
|
296
|
+
},
|
|
297
|
+
wantsTools: choice?.finish_reason === "tool_calls" || toolCalls.length > 0,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
// ----------------------------------------------------------------------------
|
|
301
|
+
// Shared plumbing
|
|
302
|
+
// ----------------------------------------------------------------------------
|
|
303
|
+
/** Model-produced tool input is untrusted (CODE_QUALITY §2.1): parse, demand a JSON object. */
|
|
304
|
+
function parseToolInput(raw, toolName) {
|
|
305
|
+
const value = raw.trim().length === 0 ? {} : safeJson(raw);
|
|
306
|
+
if (isPlainObject(value) && isJsonValue(value)) {
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
throw new EngineError("PROVIDER_ERROR", `The model produced malformed input for tool "${toolName}" (not a JSON object).`);
|
|
310
|
+
}
|
|
311
|
+
/** Retry transient failures (429/5xx/network) with exponential backoff + jitter. */
|
|
312
|
+
async function withRetry(io, fn) {
|
|
313
|
+
const sleep = io.sleepImpl ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
314
|
+
let lastError;
|
|
315
|
+
for (let attempt = 0; attempt < RETRY_ATTEMPTS; attempt++) {
|
|
316
|
+
if (attempt > 0) {
|
|
317
|
+
await sleep(RETRY_BASE_MS * 2 ** (attempt - 1) + Math.random() * 250);
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
return await fn();
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
lastError = err;
|
|
324
|
+
if (err instanceof ProviderHttpError) {
|
|
325
|
+
const retryable = err.status === 429 || err.status >= 500;
|
|
326
|
+
if (!retryable) {
|
|
327
|
+
throw new EngineError("PROVIDER_ERROR", err.message);
|
|
328
|
+
}
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
// fetch network failures surface as TypeError — retryable; anything else is a bug.
|
|
332
|
+
if (err instanceof TypeError)
|
|
333
|
+
continue;
|
|
334
|
+
throw err;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const detail = lastError instanceof Error ? lastError.message : String(lastError);
|
|
338
|
+
throw new EngineError("PROVIDER_ERROR", `Provider still failing after ${String(RETRY_ATTEMPTS)} attempts: ${detail}`);
|
|
339
|
+
}
|
|
340
|
+
function safeJson(text) {
|
|
341
|
+
try {
|
|
342
|
+
return JSON.parse(text);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface TokenRate {
|
|
2
|
+
/** USD per million input tokens. */
|
|
3
|
+
inUsdPerMtok: number;
|
|
4
|
+
/** USD per million output tokens. */
|
|
5
|
+
outUsdPerMtok: number;
|
|
6
|
+
}
|
|
7
|
+
/** The approximate rate for a model ref. Never returns zero — budgets must always accrue. */
|
|
8
|
+
export declare function rateFor(modelRef: string): TokenRate;
|
|
9
|
+
/** Approximate cost of a usage report in micro-USD (integers end-to-end; SQLite-friendly). */
|
|
10
|
+
export declare function usageUsdMicros(modelRef: string, usage: {
|
|
11
|
+
inputTokens?: number | undefined;
|
|
12
|
+
outputTokens?: number | undefined;
|
|
13
|
+
}): number;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The approximate token-price table behind budget.max_usd (SPEC §2.2: "USD via a bundled
|
|
2
|
+
// approximate rate table, documented as approximate").
|
|
3
|
+
//
|
|
4
|
+
// This is a GUARDRAIL, not a bill: the engine never charges anyone — it terminates a run that
|
|
5
|
+
// crosses the author's declared ceiling. Prices drift; entries here are deliberately coarse
|
|
6
|
+
// pattern matches with a conservative default, so an unknown model still consumes budget
|
|
7
|
+
// rather than running unmetered.
|
|
8
|
+
// Order matters: first match wins, most-specific first.
|
|
9
|
+
const RULES = [
|
|
10
|
+
{ pattern: /claude-opus/i, rate: { inUsdPerMtok: 15, outUsdPerMtok: 75 } },
|
|
11
|
+
{ pattern: /claude-sonnet/i, rate: { inUsdPerMtok: 3, outUsdPerMtok: 15 } },
|
|
12
|
+
{ pattern: /claude-haiku/i, rate: { inUsdPerMtok: 1, outUsdPerMtok: 5 } },
|
|
13
|
+
{ pattern: /\bo[13](-|$)/i, rate: { inUsdPerMtok: 15, outUsdPerMtok: 60 } },
|
|
14
|
+
{ pattern: /gpt-4o-mini/i, rate: { inUsdPerMtok: 0.15, outUsdPerMtok: 0.6 } },
|
|
15
|
+
{ pattern: /gpt-4o|gpt-4\.1/i, rate: { inUsdPerMtok: 2.5, outUsdPerMtok: 10 } },
|
|
16
|
+
{ pattern: /gemini-.*-pro/i, rate: { inUsdPerMtok: 1.25, outUsdPerMtok: 10 } },
|
|
17
|
+
{ pattern: /gemini-.*-flash/i, rate: { inUsdPerMtok: 0.15, outUsdPerMtok: 0.6 } },
|
|
18
|
+
];
|
|
19
|
+
/** Why mid-tier: an unknown model priced at zero would make max_usd unenforceable. */
|
|
20
|
+
const DEFAULT_RATE = { inUsdPerMtok: 3, outUsdPerMtok: 15 };
|
|
21
|
+
/** The approximate rate for a model ref. Never returns zero — budgets must always accrue. */
|
|
22
|
+
export function rateFor(modelRef) {
|
|
23
|
+
for (const rule of RULES) {
|
|
24
|
+
if (rule.pattern.test(modelRef))
|
|
25
|
+
return rule.rate;
|
|
26
|
+
}
|
|
27
|
+
return DEFAULT_RATE;
|
|
28
|
+
}
|
|
29
|
+
/** Approximate cost of a usage report in micro-USD (integers end-to-end; SQLite-friendly). */
|
|
30
|
+
export function usageUsdMicros(modelRef, usage) {
|
|
31
|
+
const rate = rateFor(modelRef);
|
|
32
|
+
const inUsd = ((usage.inputTokens ?? 0) / 1_000_000) * rate.inUsdPerMtok;
|
|
33
|
+
const outUsd = ((usage.outputTokens ?? 0) / 1_000_000) * rate.outUsdPerMtok;
|
|
34
|
+
return Math.round((inUsd + outUsd) * 1_000_000);
|
|
35
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare class Redactor {
|
|
2
|
+
private readonly values;
|
|
3
|
+
/** Register a secret value under a label (its declared name). Short values are ignored —
|
|
4
|
+
* redacting 1–3 chars would shred ordinary text while protecting nothing. */
|
|
5
|
+
add(label: string, value: string): void;
|
|
6
|
+
/** Scrub every registered value out of `text`, longest value first so substrings of a longer
|
|
7
|
+
* secret can't leave recoverable fragments behind. */
|
|
8
|
+
redact(text: string): string;
|
|
9
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Secret redaction for the agent() leaf (MASTER_SPEC §6.2, CODE_QUALITY §7.4).
|
|
2
|
+
//
|
|
3
|
+
// The invariant: secret VALUES live only in deterministic program code; everything bound for a
|
|
4
|
+
// model — prompts now, tool args/results/MCP traffic/skills/memory later — is scrubbed of every
|
|
5
|
+
// known secret value first, so prompt injection has nothing to exfiltrate. "Known" means every
|
|
6
|
+
// value the run has actually been handed: secrets.get results and provider API keys.
|
|
7
|
+
export class Redactor {
|
|
8
|
+
values = new Map(); // value → label
|
|
9
|
+
/** Register a secret value under a label (its declared name). Short values are ignored —
|
|
10
|
+
* redacting 1–3 chars would shred ordinary text while protecting nothing. */
|
|
11
|
+
add(label, value) {
|
|
12
|
+
if (value.length >= 4)
|
|
13
|
+
this.values.set(value, label);
|
|
14
|
+
}
|
|
15
|
+
/** Scrub every registered value out of `text`, longest value first so substrings of a longer
|
|
16
|
+
* secret can't leave recoverable fragments behind. */
|
|
17
|
+
redact(text) {
|
|
18
|
+
if (this.values.size === 0)
|
|
19
|
+
return text;
|
|
20
|
+
let out = text;
|
|
21
|
+
const byLength = [...this.values.entries()].sort((a, b) => b[0].length - a[0].length);
|
|
22
|
+
for (const [value, label] of byLength) {
|
|
23
|
+
out = out.split(value).join(`[redacted:${label}]`);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Wire protocol an endpoint speaks. Anthropic has its own; everything else is OpenAI-shaped. */
|
|
2
|
+
export type ProviderProtocol = "anthropic" | "openai";
|
|
3
|
+
/** The default provider when an agent() call names none. */
|
|
4
|
+
export declare const BOARDWALK_PROVIDER = "boardwalk";
|
|
5
|
+
/** A custom header value: a static string, or `{ from_env }` to read it from the engine's
|
|
6
|
+
* environment at call time (for secret-bearing headers — values never sit in config). */
|
|
7
|
+
export type HeaderValue = string | {
|
|
8
|
+
from_env: string;
|
|
9
|
+
};
|
|
10
|
+
/** One entry in the engine config's provider table. */
|
|
11
|
+
export interface ProviderConfig {
|
|
12
|
+
/** OpenAI-compatible endpoint base URL (e.g. http://localhost:11434/v1 for a local Ollama). */
|
|
13
|
+
base_url: string;
|
|
14
|
+
/** Env var holding the API key. Omit for endpoints that need none (local servers). */
|
|
15
|
+
api_key_env?: string;
|
|
16
|
+
/** Defaults to "openai" — the lingua franca of self-hosted/compatible endpoints. */
|
|
17
|
+
protocol?: ProviderProtocol;
|
|
18
|
+
/**
|
|
19
|
+
* Extra request headers, for endpoints whose auth isn't bearer/x-api-key shaped (e.g. Azure
|
|
20
|
+
* OpenAI's `api-key`). Custom headers WIN over the computed auth header on collision;
|
|
21
|
+
* `content-type` is engine-owned and cannot be overridden. `{ from_env }` values are
|
|
22
|
+
* redacted from all model-bound context, like the API key.
|
|
23
|
+
*/
|
|
24
|
+
headers?: Record<string, HeaderValue>;
|
|
25
|
+
}
|
|
26
|
+
export interface InferenceConfig {
|
|
27
|
+
/** Used when an agent() call omits `model` — still passed VERBATIM to the chosen provider. */
|
|
28
|
+
default_model?: string;
|
|
29
|
+
/** Named providers, selected per call via `opts.provider`. */
|
|
30
|
+
providers?: Record<string, ProviderConfig>;
|
|
31
|
+
/** Override the Boardwalk managed-inference gateway URL (else BOARDWALK_INFERENCE_URL, else the default). */
|
|
32
|
+
boardwalk_base_url?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ResolvedModel {
|
|
35
|
+
/** Who fulfills the call. */
|
|
36
|
+
provider: string;
|
|
37
|
+
/** The model string, exactly as the program supplied it (or the configured/auto default). */
|
|
38
|
+
model: string;
|
|
39
|
+
protocol: ProviderProtocol;
|
|
40
|
+
baseUrl: string;
|
|
41
|
+
/** Plaintext key, resolved from the environment. Null when the provider needs none. */
|
|
42
|
+
apiKey: string | null;
|
|
43
|
+
/** Extra request headers, already resolved (custom auth schemes, org headers, …). */
|
|
44
|
+
headers: Record<string, string>;
|
|
45
|
+
/** Names of `headers` whose values came from the environment — redacted like the API key. */
|
|
46
|
+
secretHeaderNames: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
export interface ResolveArgs {
|
|
49
|
+
/** The agent() call's `model`, if given. Opaque — never parsed. */
|
|
50
|
+
model?: string | undefined;
|
|
51
|
+
/** The agent() call's `provider`, if given. Default: the Boardwalk managed lane. */
|
|
52
|
+
provider?: string | undefined;
|
|
53
|
+
config: InferenceConfig;
|
|
54
|
+
/** Secret/env lookup (the engine's env map layered over process.env). */
|
|
55
|
+
getEnv: (name: string) => string | undefined;
|
|
56
|
+
}
|
|
57
|
+
/** Resolve an agent() call to a concrete endpoint + key, or throw with a pointer at the fix. */
|
|
58
|
+
export declare function resolveModel(args: ResolveArgs): ResolvedModel;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Model + provider resolution for the agent() leaf (SPEC §2.3 "model resolution").
|
|
2
|
+
//
|
|
3
|
+
// `provider` and `model` are ORTHOGONAL (decided 2026-06-12):
|
|
4
|
+
// - `provider` picks who FULFILLS the call. Default: `boardwalk` (managed inference).
|
|
5
|
+
// - `model` is an OPAQUE string passed VERBATIM to that provider. The engine never parses,
|
|
6
|
+
// prefixes, or rewrites it — if a local server hosts "anthropic/sonnet-4.5", that exact
|
|
7
|
+
// string is what the server receives.
|
|
8
|
+
//
|
|
9
|
+
// Inference is EXPLICIT: the engine never silently reaches for a user's own provider key.
|
|
10
|
+
// Using your own key (a built-in vendor) or any configured endpoint — including a LOCAL
|
|
11
|
+
// OpenAI-compatible server — requires naming the provider. With no provider named, the call
|
|
12
|
+
// goes to the Boardwalk managed lane, which is "set up" iff BOARDWALK_API_KEY is present;
|
|
13
|
+
// otherwise it errors with every way to fix it.
|
|
14
|
+
//
|
|
15
|
+
// Pure function: the supervisor calls it server-side so engine config and key material stay
|
|
16
|
+
// out of reach of anything but the run that asked.
|
|
17
|
+
import { EngineError } from "../errors.js";
|
|
18
|
+
/** The default provider when an agent() call names none. */
|
|
19
|
+
export const BOARDWALK_PROVIDER = "boardwalk";
|
|
20
|
+
// What the managed lane is asked for when no model is named — the gateway routes ("Auto").
|
|
21
|
+
// NOTE (pending the gateway contract): confirm the route-for-me signal against the real
|
|
22
|
+
// Boardwalk inference gateway API; this engine sends model: "auto".
|
|
23
|
+
const AUTO_MODEL = "auto";
|
|
24
|
+
// The Boardwalk managed-inference gateway (OpenAI-compatible). `boardwalk.sh` is the placeholder
|
|
25
|
+
// domain (MASTER_SPEC stack notes); override with BOARDWALK_INFERENCE_URL or config. The Auto
|
|
26
|
+
// ROUTER itself lives in hosted Boardwalk — this engine only forwards to the gateway.
|
|
27
|
+
const DEFAULT_BOARDWALK_INFERENCE_URL = "https://api.boardwalk.sh/v1";
|
|
28
|
+
// Built-in direct-call providers — used only when NAMED explicitly; key from the conventional
|
|
29
|
+
// env var. The engine never auto-selects one (that would be spending your key without you
|
|
30
|
+
// asking); you opt in with `provider: "anthropic"` etc.
|
|
31
|
+
const BUILTINS = {
|
|
32
|
+
anthropic: {
|
|
33
|
+
protocol: "anthropic",
|
|
34
|
+
baseUrl: "https://api.anthropic.com",
|
|
35
|
+
keyEnvs: ["ANTHROPIC_API_KEY"],
|
|
36
|
+
},
|
|
37
|
+
openai: {
|
|
38
|
+
protocol: "openai",
|
|
39
|
+
baseUrl: "https://api.openai.com/v1",
|
|
40
|
+
keyEnvs: ["OPENAI_API_KEY"],
|
|
41
|
+
},
|
|
42
|
+
google: {
|
|
43
|
+
// Why the /openai path: Google publishes an OpenAI-compatible surface for Gemini; using it
|
|
44
|
+
// keeps this engine at two wire protocols instead of three.
|
|
45
|
+
protocol: "openai",
|
|
46
|
+
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
|
47
|
+
keyEnvs: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
/** Resolve an agent() call to a concrete endpoint + key, or throw with a pointer at the fix. */
|
|
51
|
+
export function resolveModel(args) {
|
|
52
|
+
const provider = args.provider ?? BOARDWALK_PROVIDER;
|
|
53
|
+
const model = args.model ?? args.config.default_model;
|
|
54
|
+
if (provider === BOARDWALK_PROVIDER) {
|
|
55
|
+
return resolveBoardwalk(model, args);
|
|
56
|
+
}
|
|
57
|
+
// Only the managed lane routes for you — an explicit provider needs a model.
|
|
58
|
+
if (model === undefined || model.length === 0) {
|
|
59
|
+
throw new EngineError("MODEL_UNRESOLVED", `Provider "${provider}" needs a model.`, `Pass { model: "..." } with whatever model id "${provider}" expects, or set ` +
|
|
60
|
+
"inference.default_model in the engine config.");
|
|
61
|
+
}
|
|
62
|
+
const configured = args.config.providers?.[provider];
|
|
63
|
+
if (configured !== undefined) {
|
|
64
|
+
return resolveConfigured(provider, model, configured, args.getEnv);
|
|
65
|
+
}
|
|
66
|
+
const builtin = BUILTINS[provider];
|
|
67
|
+
if (builtin !== undefined) {
|
|
68
|
+
return resolveBuiltin(provider, model, builtin, args.getEnv);
|
|
69
|
+
}
|
|
70
|
+
throw new EngineError("MODEL_UNRESOLVED", `Unknown inference provider "${provider}".`, `Built-ins: ${Object.keys(BUILTINS).join(", ")}, plus "${BOARDWALK_PROVIDER}" (managed, the ` +
|
|
71
|
+
`default). Any OpenAI-compatible endpoint — including a local server — works via ` +
|
|
72
|
+
`inference.providers.${provider} = { base_url, api_key_env } in the engine config.`);
|
|
73
|
+
}
|
|
74
|
+
/** The managed lane: forward to the Boardwalk gateway. "Set up" iff BOARDWALK_API_KEY is present. */
|
|
75
|
+
function resolveBoardwalk(model, args) {
|
|
76
|
+
const apiKey = args.getEnv("BOARDWALK_API_KEY");
|
|
77
|
+
if (apiKey === undefined || apiKey.length === 0) {
|
|
78
|
+
throw new EngineError("MODEL_UNRESOLVED", "No inference is set up for this run. agent() defaults to Boardwalk managed inference, " +
|
|
79
|
+
"but BOARDWALK_API_KEY is not set.", "Set BOARDWALK_API_KEY to use Boardwalk managed inference, or name a provider explicitly: " +
|
|
80
|
+
'{ provider: "anthropic" } (or openai/google) with that provider\'s API key set, or ' +
|
|
81
|
+
"point inference.providers at any OpenAI-compatible server — including a local one " +
|
|
82
|
+
"like Ollama.");
|
|
83
|
+
}
|
|
84
|
+
const baseUrl = args.config.boardwalk_base_url ??
|
|
85
|
+
args.getEnv("BOARDWALK_INFERENCE_URL") ??
|
|
86
|
+
DEFAULT_BOARDWALK_INFERENCE_URL;
|
|
87
|
+
return {
|
|
88
|
+
provider: BOARDWALK_PROVIDER,
|
|
89
|
+
model: model !== undefined && model.length > 0 ? model : AUTO_MODEL,
|
|
90
|
+
protocol: "openai",
|
|
91
|
+
baseUrl,
|
|
92
|
+
apiKey,
|
|
93
|
+
headers: {},
|
|
94
|
+
secretHeaderNames: [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function resolveConfigured(provider, model, configured, getEnv) {
|
|
98
|
+
let apiKey = null;
|
|
99
|
+
if (configured.api_key_env !== undefined) {
|
|
100
|
+
const value = getEnv(configured.api_key_env);
|
|
101
|
+
if (value === undefined || value.length === 0) {
|
|
102
|
+
throw new EngineError("PROVIDER_ERROR", `Provider "${provider}" needs an API key but ${configured.api_key_env} is not set.`, `Set ${configured.api_key_env} in the engine's environment.`);
|
|
103
|
+
}
|
|
104
|
+
apiKey = value;
|
|
105
|
+
}
|
|
106
|
+
const { headers, secretHeaderNames } = resolveHeaders(provider, configured.headers, getEnv);
|
|
107
|
+
return {
|
|
108
|
+
provider,
|
|
109
|
+
model,
|
|
110
|
+
protocol: configured.protocol ?? "openai",
|
|
111
|
+
baseUrl: configured.base_url,
|
|
112
|
+
apiKey,
|
|
113
|
+
headers,
|
|
114
|
+
secretHeaderNames,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Resolve a provider's custom header map; `{ from_env }` values are looked up fail-closed. */
|
|
118
|
+
function resolveHeaders(provider, configured, getEnv) {
|
|
119
|
+
const headers = {};
|
|
120
|
+
const secretHeaderNames = [];
|
|
121
|
+
for (const [name, value] of Object.entries(configured ?? {})) {
|
|
122
|
+
if (name.toLowerCase() === "content-type") {
|
|
123
|
+
// The engine owns the body format; a configured content-type would silently break it.
|
|
124
|
+
throw new EngineError("VALIDATION", `Provider "${provider}" configures a content-type header — the engine owns that header.`);
|
|
125
|
+
}
|
|
126
|
+
if (typeof value === "string") {
|
|
127
|
+
headers[name] = value;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const resolved = getEnv(value.from_env);
|
|
131
|
+
if (resolved === undefined || resolved.length === 0) {
|
|
132
|
+
throw new EngineError("PROVIDER_ERROR", `Provider "${provider}" header "${name}" needs ${value.from_env}, which is not set.`, `Set ${value.from_env} in the engine's environment.`);
|
|
133
|
+
}
|
|
134
|
+
headers[name] = resolved;
|
|
135
|
+
secretHeaderNames.push(name);
|
|
136
|
+
}
|
|
137
|
+
return { headers, secretHeaderNames };
|
|
138
|
+
}
|
|
139
|
+
function resolveBuiltin(provider, model, builtin, getEnv) {
|
|
140
|
+
const apiKey = builtin.keyEnvs.map(getEnv).find((v) => v !== undefined && v.length > 0);
|
|
141
|
+
if (apiKey === undefined) {
|
|
142
|
+
throw new EngineError("PROVIDER_ERROR", `Provider "${provider}" needs an API key but ${builtin.keyEnvs.join(" / ")} is not set.`, `Set ${builtin.keyEnvs[0] ?? "the provider API key"} in the engine's environment.`);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
provider,
|
|
146
|
+
model,
|
|
147
|
+
protocol: builtin.protocol,
|
|
148
|
+
baseUrl: builtin.baseUrl,
|
|
149
|
+
apiKey,
|
|
150
|
+
headers: {},
|
|
151
|
+
secretHeaderNames: [],
|
|
152
|
+
};
|
|
153
|
+
}
|