@clawdbuilder/jig-bedrock 0.0.0-stage → 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 +9 -0
- package/README.md +124 -2
- package/bin/windows-browser +4 -0
- package/index.ts +3 -0
- package/package.json +51 -5
- package/src/aws-discovery.ts +226 -0
- package/src/bedrock-fetch.ts +77 -0
- package/src/budget.ts +129 -0
- package/src/cache.ts +22 -0
- package/src/catalog.ts +514 -0
- package/src/check.ts +14 -0
- package/src/child.ts +7 -0
- package/src/commands.ts +344 -0
- package/src/config.ts +214 -0
- package/src/doctor.ts +172 -0
- package/src/events.ts +46 -0
- package/src/identity.ts +3 -0
- package/src/index.ts +33 -0
- package/src/jig/client.ts +63 -0
- package/src/jig/types.ts +12 -0
- package/src/login.ts +176 -0
- package/src/messages.ts +45 -0
- package/src/pi-host.ts +54 -0
- package/src/prompt-cache.ts +50 -0
- package/src/provider.ts +342 -0
- package/src/sigv4.ts +92 -0
- package/src/sso-check.ts +111 -0
- package/src/sso.ts +339 -0
- package/src/start-sign-in.ts +53 -0
- package/src/statistics.ts +315 -0
- package/src/switch-cost.ts +91 -0
- package/src/usage.ts +138 -0
- package/src/wsl.ts +107 -0
package/src/commands.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { CatalogSource } from "./aws-discovery.ts";
|
|
3
|
+
import type { BudgetSource } from "./budget.ts";
|
|
4
|
+
import type { TeamBedrockModel } from "./catalog.ts";
|
|
5
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
6
|
+
import { checkLocalDependencies, dependencyLines } from "./doctor.ts";
|
|
7
|
+
import { cacheWriteRate } from "./prompt-cache.ts";
|
|
8
|
+
import {
|
|
9
|
+
periodLabels,
|
|
10
|
+
pricedSpend,
|
|
11
|
+
type StatisticsSource,
|
|
12
|
+
type UsagePeriod,
|
|
13
|
+
type UsageReport,
|
|
14
|
+
type UsageSnapshot,
|
|
15
|
+
} from "./statistics.ts";
|
|
16
|
+
import { UsageError } from "./usage.ts";
|
|
17
|
+
|
|
18
|
+
// biome-ignore lint/suspicious/noExplicitAny: Pi's session records, checked field by field where they are used
|
|
19
|
+
type Json = any;
|
|
20
|
+
const clean = (value: unknown, max = 256) =>
|
|
21
|
+
String(value ?? "")
|
|
22
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strip terminal and bidi controls from a line
|
|
23
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, " ")
|
|
24
|
+
.slice(0, max);
|
|
25
|
+
const count = (value: number) => value.toLocaleString("en-US");
|
|
26
|
+
const money = (value: number | null) => (value === null ? "unknown (unpriced usage)" : `$${value.toFixed(4)}`);
|
|
27
|
+
/** usage program leaves the total null when any row is unpriced; the priced rows are still real spend, so show them as a lower bound. */
|
|
28
|
+
const totalCost = (report: UsageReport) => {
|
|
29
|
+
const { spentUsd } = pricedSpend(report);
|
|
30
|
+
return report.totals.costUsd === null && spentUsd > 0
|
|
31
|
+
? `${money(spentUsd)} + unpriced usage`
|
|
32
|
+
: money(report.totals.costUsd);
|
|
33
|
+
};
|
|
34
|
+
/** The 5-minute cache write rate and, for models with a 1-hour tier (PI_CACHE_RETENTION=long), Pi's 1-hour rate: twice the input rate. */
|
|
35
|
+
function writeRates(m: TeamBedrockModel): string {
|
|
36
|
+
const rate = (value: number) => String(Number(value.toFixed(4)));
|
|
37
|
+
if (m.promptCache?.long === undefined)
|
|
38
|
+
return `write $${rate(m.cost.cacheWrite)}${m.promptCache ? " (5 min only)" : ""}`;
|
|
39
|
+
return `write $${rate(m.cost.cacheWrite)} (5 min) / $${rate(cacheWriteRate(m.cost, 0, "long"))} (1 h)`;
|
|
40
|
+
}
|
|
41
|
+
export function usageLines(snapshot: UsageSnapshot): string[] {
|
|
42
|
+
const { report, observedAt } = snapshot,
|
|
43
|
+
t = report.totals;
|
|
44
|
+
return [
|
|
45
|
+
`usage program user estimate · ${periodLabels[report.period]} (UTC) · ${snapshot.stale ? "STALE" : "fresh"}`,
|
|
46
|
+
`User: ${clean(report.user)} · observed ${new Date(observedAt).toISOString()} · contract ${report.contract}`,
|
|
47
|
+
"Scope: this AWS user's inference profiles; excludes other users and is not a team total.",
|
|
48
|
+
`Requests ${count(t.requests)} · fresh input ${count(t.inputFresh)} · output ${count(t.output)}`,
|
|
49
|
+
`Cache read ${count(t.cacheRead)} · cache write ${count(t.cacheWrite)} · total input ${count(t.input)}`,
|
|
50
|
+
`Estimated cost: ${totalCost(report)}. AWS metrics may arrive late.`,
|
|
51
|
+
...(snapshot.refreshFailed ? ["Last refresh failed; showing the last valid report."] : []),
|
|
52
|
+
"Models / inference profiles:",
|
|
53
|
+
...report.profiles.flatMap((p) => [
|
|
54
|
+
`${clean(p.model)} · ${clean(p.name)}`,
|
|
55
|
+
` ${count(p.requests)} requests · ${money(p.costUsd)}`,
|
|
56
|
+
` Fresh input ${count(p.inputFresh)} · output ${count(p.output)} · cache read ${count(p.cacheRead)} / write ${count(p.cacheWrite)}`,
|
|
57
|
+
]),
|
|
58
|
+
...(!report.profiles.length ? ["No usage reported for this period."] : []),
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
function sessionLines(ctx: ExtensionCommandContext): string[] {
|
|
62
|
+
let requests = 0,
|
|
63
|
+
input = 0,
|
|
64
|
+
output = 0,
|
|
65
|
+
cacheRead = 0,
|
|
66
|
+
cacheWrite = 0,
|
|
67
|
+
costUsd = 0,
|
|
68
|
+
unknown = 0;
|
|
69
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
70
|
+
const message = entry.type === "message" ? (entry as { message?: Json }).message : undefined;
|
|
71
|
+
if (message?.role !== "assistant" || !["jig-bedrock", "amazon-bedrock"].includes(message.provider)) continue;
|
|
72
|
+
const usage = message.usage;
|
|
73
|
+
if (!usage) continue;
|
|
74
|
+
requests++;
|
|
75
|
+
for (const [key, add] of [
|
|
76
|
+
[
|
|
77
|
+
"input",
|
|
78
|
+
(n: number) => {
|
|
79
|
+
input += n;
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
[
|
|
83
|
+
"output",
|
|
84
|
+
(n: number) => {
|
|
85
|
+
output += n;
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
"cacheRead",
|
|
90
|
+
(n: number) => {
|
|
91
|
+
cacheRead += n;
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
"cacheWrite",
|
|
96
|
+
(n: number) => {
|
|
97
|
+
cacheWrite += n;
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
] as const) {
|
|
101
|
+
const value = usage[key];
|
|
102
|
+
if (typeof value === "number" && Number.isFinite(value) && value >= 0) add(value);
|
|
103
|
+
}
|
|
104
|
+
if (typeof usage.cost?.total === "number" && Number.isFinite(usage.cost.total) && usage.cost.total >= 0)
|
|
105
|
+
costUsd += usage.cost.total;
|
|
106
|
+
else unknown++;
|
|
107
|
+
}
|
|
108
|
+
return [
|
|
109
|
+
"Current Pi branch · Bedrock only (local session records)",
|
|
110
|
+
`${count(requests)} responses · input ${count(input)} · output ${count(output)} · cache read ${count(cacheRead)} / write ${count(cacheWrite)}`,
|
|
111
|
+
`Pi estimated cost: $${costUsd.toFixed(4)}${unknown ? ` + ${unknown} unpriced responses` : ""}. This is separate from usage program's user estimate.`,
|
|
112
|
+
];
|
|
113
|
+
}
|
|
114
|
+
/** A local dialog owns the request and cancels it when dismissed; nothing enters model context. */
|
|
115
|
+
async function showReport(
|
|
116
|
+
ctx: ExtensionCommandContext,
|
|
117
|
+
title: string,
|
|
118
|
+
load: (signal: AbortSignal) => Promise<string[]>,
|
|
119
|
+
active: Set<AbortController>,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
active.add(controller);
|
|
123
|
+
const abort = () => controller.abort();
|
|
124
|
+
ctx.signal?.addEventListener("abort", abort, { once: true });
|
|
125
|
+
const rows = async () => {
|
|
126
|
+
try {
|
|
127
|
+
return await load(controller.signal);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return [error instanceof UsageError ? error.message : "Report unavailable. Run /bedrock-doctor and retry."];
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
if (ctx.signal?.aborted) controller.abort();
|
|
134
|
+
if (ctx.mode !== "tui" || !ctx.ui.custom) {
|
|
135
|
+
if (!controller.signal.aborted) {
|
|
136
|
+
const body = await rows();
|
|
137
|
+
if (!controller.signal.aborted) ctx.ui.notify(body.map((row) => clean(row, 1024)).join("\n"), "info");
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
142
|
+
let body = ["Loading…"],
|
|
143
|
+
offset = 0,
|
|
144
|
+
closed = false;
|
|
145
|
+
const close = () => {
|
|
146
|
+
if (closed) return;
|
|
147
|
+
closed = true;
|
|
148
|
+
controller.abort();
|
|
149
|
+
done();
|
|
150
|
+
};
|
|
151
|
+
controller.signal.addEventListener("abort", close, { once: true });
|
|
152
|
+
const component = {
|
|
153
|
+
invalidate() {},
|
|
154
|
+
dispose() {
|
|
155
|
+
closed = true;
|
|
156
|
+
controller.abort();
|
|
157
|
+
controller.signal.removeEventListener("abort", close);
|
|
158
|
+
},
|
|
159
|
+
handleInput(data: string) {
|
|
160
|
+
if (data === "q" || data === "\x1b") {
|
|
161
|
+
close();
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (data === "\x1b[B" || data === "j") offset++;
|
|
165
|
+
if (data === "\x1b[A" || data === "k") offset = Math.max(0, offset - 1);
|
|
166
|
+
tui.requestRender();
|
|
167
|
+
},
|
|
168
|
+
render(width: number): string[] {
|
|
169
|
+
const height = Math.max(4, Math.min(24, tui.terminal.rows - 4)),
|
|
170
|
+
slots = height - 2,
|
|
171
|
+
columns = Math.max(1, width);
|
|
172
|
+
const wrapped = body.flatMap((row) => {
|
|
173
|
+
const chars = Array.from(clean(row, 1024)),
|
|
174
|
+
rows: string[] = [];
|
|
175
|
+
do {
|
|
176
|
+
rows.push(chars.splice(0, columns).join(""));
|
|
177
|
+
} while (chars.length);
|
|
178
|
+
return rows;
|
|
179
|
+
});
|
|
180
|
+
offset = Math.min(offset, Math.max(0, wrapped.length - slots));
|
|
181
|
+
const clip = (row: string) => Array.from(clean(row)).slice(0, Math.max(0, width)).join("");
|
|
182
|
+
return [
|
|
183
|
+
theme.fg("accent", clip(title)),
|
|
184
|
+
...wrapped.slice(offset, offset + slots),
|
|
185
|
+
theme.fg("muted", clip("↑/↓ or j/k scroll · q/Esc close")),
|
|
186
|
+
];
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
if (controller.signal.aborted) {
|
|
190
|
+
queueMicrotask(close);
|
|
191
|
+
return component;
|
|
192
|
+
}
|
|
193
|
+
void rows().then((value) => {
|
|
194
|
+
if (!closed) {
|
|
195
|
+
body = value;
|
|
196
|
+
tui.requestRender();
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
return component;
|
|
200
|
+
});
|
|
201
|
+
} finally {
|
|
202
|
+
controller.abort();
|
|
203
|
+
active.delete(controller);
|
|
204
|
+
ctx.signal?.removeEventListener("abort", abort);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
export function registerStatisticsCommands(
|
|
208
|
+
pi: ExtensionAPI,
|
|
209
|
+
sources: {
|
|
210
|
+
config: ResolvedConfig;
|
|
211
|
+
statistics: StatisticsSource;
|
|
212
|
+
catalog: CatalogSource;
|
|
213
|
+
models(): TeamBedrockModel[];
|
|
214
|
+
budget: BudgetSource;
|
|
215
|
+
publish(): void;
|
|
216
|
+
},
|
|
217
|
+
): void {
|
|
218
|
+
const { config, statistics } = sources,
|
|
219
|
+
active = new Set<AbortController>();
|
|
220
|
+
const usage = async (period: UsagePeriod, signal: AbortSignal) => {
|
|
221
|
+
try {
|
|
222
|
+
const snapshot = await statistics.refresh(period, { force: true, signal });
|
|
223
|
+
if (period === "monthly") {
|
|
224
|
+
await sources.budget.refresh({ force: true, signal });
|
|
225
|
+
sources.publish();
|
|
226
|
+
}
|
|
227
|
+
return usageLines(snapshot);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (signal.aborted) throw new UsageError("cancelled");
|
|
230
|
+
const old = statistics.load(period);
|
|
231
|
+
if (!old) throw error;
|
|
232
|
+
return [
|
|
233
|
+
error instanceof UsageError ? error.message : "Refresh failed.",
|
|
234
|
+
...usageLines({ ...old, stale: true, refreshFailed: true }),
|
|
235
|
+
];
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
pi.registerCommand("bedrock-usage", {
|
|
239
|
+
description: "usage program user usage: daily | weekly | monthly (default), with tokens and estimated cost",
|
|
240
|
+
handler: async (args, ctx) => {
|
|
241
|
+
const period = args.trim() || "monthly";
|
|
242
|
+
if (!["daily", "weekly", "monthly"].includes(period)) {
|
|
243
|
+
ctx.ui.notify("Usage: /bedrock-usage [daily|weekly|monthly]", "warning");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
await showReport(
|
|
247
|
+
ctx,
|
|
248
|
+
"Bedrock · usage program usage",
|
|
249
|
+
(signal) => usage(period as UsagePeriod, signal),
|
|
250
|
+
active,
|
|
251
|
+
);
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
pi.registerCommand("bedrock-stats", {
|
|
255
|
+
description: "Compare local Pi Bedrock session usage with usage program's monthly user estimate",
|
|
256
|
+
handler: async (args, ctx) => {
|
|
257
|
+
if (args.trim()) {
|
|
258
|
+
ctx.ui.notify("Usage: /bedrock-stats", "warning");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
await showReport(
|
|
262
|
+
ctx,
|
|
263
|
+
"Bedrock · Statistics",
|
|
264
|
+
async (signal) => [
|
|
265
|
+
...sessionLines(ctx),
|
|
266
|
+
"",
|
|
267
|
+
...(await usage("monthly", signal)),
|
|
268
|
+
...(config.monthlyBudgetUsd === undefined
|
|
269
|
+
? []
|
|
270
|
+
: [
|
|
271
|
+
`Personal monthly display budget: $${config.monthlyBudgetUsd}. This is not an AWS quota or enforced spend limit.`,
|
|
272
|
+
]),
|
|
273
|
+
],
|
|
274
|
+
active,
|
|
275
|
+
);
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
pi.registerCommand("bedrock-profiles", {
|
|
279
|
+
description: "Show approved team Bedrock models, regions and pricing sources from the current catalog",
|
|
280
|
+
handler: async (args, ctx) => {
|
|
281
|
+
if (args.trim()) {
|
|
282
|
+
ctx.ui.notify("Usage: /bedrock-profiles", "warning");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
await showReport(
|
|
286
|
+
ctx,
|
|
287
|
+
"Bedrock · Team profiles",
|
|
288
|
+
async () => {
|
|
289
|
+
const catalog = sources.catalog.load(),
|
|
290
|
+
models = sources.models();
|
|
291
|
+
return [
|
|
292
|
+
`Team: ${clean(config.team)} · AWS profile: ${clean(config.profile)} · regions: ${config.regions.join(", ")}`,
|
|
293
|
+
catalog
|
|
294
|
+
? `Catalog observed ${new Date(catalog.fetched_at * 1000).toISOString()}${Date.now() / 1000 - catalog.fetched_at > 86_400 ? " (stale; /bedrock-refresh)" : ""}. ${catalog.profiles.length} active profiles / ${models.length} usable models.`
|
|
295
|
+
: "No approved catalog. Run /aws-login, then /bedrock-refresh.",
|
|
296
|
+
"Catalog tags filter presentation; AWS IAM authorizes model access.",
|
|
297
|
+
...models.map(
|
|
298
|
+
(m) =>
|
|
299
|
+
`${clean(m.name)} · ${m.arn.split(":")[3]} · ${m.rateSource} / million tokens: input $${m.cost.input}, output $${m.cost.output}, cache read $${m.cost.cacheRead}, ${writeRates(m)}`,
|
|
300
|
+
),
|
|
301
|
+
];
|
|
302
|
+
},
|
|
303
|
+
active,
|
|
304
|
+
);
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
pi.registerCommand("bedrock-doctor", {
|
|
308
|
+
description:
|
|
309
|
+
"Check local dependency versions, optional usage program setup and catalog health without AWS calls or reading credentials",
|
|
310
|
+
handler: async (args, ctx) => {
|
|
311
|
+
if (args.trim()) {
|
|
312
|
+
ctx.ui.notify("Usage: /bedrock-doctor", "warning");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
await showReport(
|
|
316
|
+
ctx,
|
|
317
|
+
"Bedrock · Local health",
|
|
318
|
+
async (signal) => {
|
|
319
|
+
const h = await checkLocalDependencies(config, { signal }),
|
|
320
|
+
cached = statistics.load("monthly");
|
|
321
|
+
return [
|
|
322
|
+
"Local checks only; no AWS calls, credential reads, login or model invocation.",
|
|
323
|
+
...dependencyLines(h),
|
|
324
|
+
`Team/profile configuration: loaded · configured regions: ${config.regions.length} · approved catalog: ${sources.catalog.load() ? "loaded" : "unavailable"}`,
|
|
325
|
+
cached
|
|
326
|
+
? `usage program report contract: ${cached.report.contract} · ${cached.stale ? "stale" : "fresh"} · ${cached.refreshFailed ? "last refresh failed" : "last refresh succeeded"}`
|
|
327
|
+
: "usage program report: unavailable; /bedrock-usage tests the read-only statistics path.",
|
|
328
|
+
...(!h.usage || h.bash.status !== "ready" || h.jq.status !== "ready"
|
|
329
|
+
? [
|
|
330
|
+
"Install usage program's prerequisites and set usageDir to the directory containing its executable usage file. Shell aliases and RC functions are not used.",
|
|
331
|
+
]
|
|
332
|
+
: []),
|
|
333
|
+
"usage program is optional for model use. /aws-login handles sign-in; /bedrock-refresh refreshes the catalog.",
|
|
334
|
+
];
|
|
335
|
+
},
|
|
336
|
+
active,
|
|
337
|
+
);
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
pi.on("session_shutdown", () => {
|
|
341
|
+
for (const controller of active) controller.abort();
|
|
342
|
+
active.clear();
|
|
343
|
+
});
|
|
344
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// The Bedrock provider's settings: the `bedrock` section of jig.json, over the package defaults, over which environment
|
|
2
|
+
// variables win. Diagnostics name fields and never echo configuration values or parser output.
|
|
3
|
+
//
|
|
4
|
+
// team your exact AWS "Team" tag value: the inference profiles that carry it (and Active=true) are the models
|
|
5
|
+
// profile the AWS profile to sign in with (default my-bedrock-profile)
|
|
6
|
+
// regions where to look for profiles (default us-east-1)
|
|
7
|
+
// usageDir a folder holding an executable named usage that prints the usage JSON (optional; default ~/.local/share/usage-tool)
|
|
8
|
+
// monthlyBudgetUsd a figure to show in the footer next to the month's spend; not an AWS limit (optional)
|
|
9
|
+
// ssoLoginCommand the argv of the login, with {profile} for the profile (default aws sso login --profile {profile})
|
|
10
|
+
// signInAtStart sign in when Pi starts and the SSO session has expired, instead of at the first failed request (default true)
|
|
11
|
+
// browser the program that opens the sign-in address, as BROWSER for the login (optional; under WSL the Windows browser)
|
|
12
|
+
// api the Bedrock route: messages, the Anthropic Messages route (default), or converse, the older route
|
|
13
|
+
//
|
|
14
|
+
// Environment variables override the settings: JIG_BEDROCK_TEAM, JIG_BEDROCK_PROFILE, JIG_BEDROCK_REGIONS (comma
|
|
15
|
+
// separated) and JIG_BEDROCK_USAGE_DIR.
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import type { Check } from "./check.ts";
|
|
19
|
+
import { isObject, knownKeys } from "./check.ts";
|
|
20
|
+
|
|
21
|
+
export interface ResolvedConfig {
|
|
22
|
+
team: string;
|
|
23
|
+
tagKey?: string;
|
|
24
|
+
tagValue?: string;
|
|
25
|
+
activeTagKey?: string;
|
|
26
|
+
activeTagValue?: string;
|
|
27
|
+
profile: string;
|
|
28
|
+
regions: string[];
|
|
29
|
+
usageDir: string;
|
|
30
|
+
monthlyBudgetUsd?: number;
|
|
31
|
+
ssoLoginCommand: string[];
|
|
32
|
+
signInAtStart: boolean;
|
|
33
|
+
browser?: string;
|
|
34
|
+
/** Set by loadConfig; a hand-written fixture may leave it out, and the provider then uses messages. */
|
|
35
|
+
api?: BedrockApi;
|
|
36
|
+
agentDir?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The two ways to reach the same models: the Anthropic Messages route, or the older Converse route. */
|
|
40
|
+
export type BedrockApi = "messages" | "converse";
|
|
41
|
+
export type ConfigResult = { config: ResolvedConfig; error?: never } | { config?: never; error: string };
|
|
42
|
+
|
|
43
|
+
export const profilePattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
44
|
+
const regionPattern = /^[a-z]{2}(?:-[a-z]+)+-\d+$/;
|
|
45
|
+
/** A full path, or a bare program name: never a path relative to the folder Pi happens to be in, and no arguments. */
|
|
46
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: a browser path may not contain control characters
|
|
47
|
+
const browserPattern = /^(?:\/[^\s\x00-\x1f\x7f]{1,4095}|[A-Za-z0-9][A-Za-z0-9._+-]{0,127})$/;
|
|
48
|
+
/** Every setting of the `bedrock` section. A key that is not one of these is an error. */
|
|
49
|
+
export const FIELDS = [
|
|
50
|
+
"team",
|
|
51
|
+
"tagKey",
|
|
52
|
+
"tagValue",
|
|
53
|
+
"activeTagKey",
|
|
54
|
+
"activeTagValue",
|
|
55
|
+
"profile",
|
|
56
|
+
"regions",
|
|
57
|
+
"usageDir",
|
|
58
|
+
"monthlyBudgetUsd",
|
|
59
|
+
"ssoLoginCommand",
|
|
60
|
+
"signInAtStart",
|
|
61
|
+
"browser",
|
|
62
|
+
"api",
|
|
63
|
+
] as const;
|
|
64
|
+
|
|
65
|
+
export const DEFAULTS = {
|
|
66
|
+
tagKey: "Team",
|
|
67
|
+
activeTagKey: "Active",
|
|
68
|
+
activeTagValue: "true",
|
|
69
|
+
profile: "my-bedrock-profile",
|
|
70
|
+
regions: ["us-east-1"],
|
|
71
|
+
usageDir: "~/.local/share/usage-tool",
|
|
72
|
+
ssoLoginCommand: ["aws", "sso", "login", "--profile", "{profile}"],
|
|
73
|
+
signInAtStart: true,
|
|
74
|
+
api: "messages" as BedrockApi,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: config values reject control characters
|
|
78
|
+
const control = /[\x00-\x1f\x7f]/;
|
|
79
|
+
|
|
80
|
+
/** The problems with a configuration, one message each, each naming its field. Empty when it is fine. */
|
|
81
|
+
export function problemsOf(c: Record<string, unknown>): string[] {
|
|
82
|
+
const problems: string[] = [];
|
|
83
|
+
const {
|
|
84
|
+
team,
|
|
85
|
+
tagKey,
|
|
86
|
+
tagValue,
|
|
87
|
+
activeTagKey,
|
|
88
|
+
activeTagValue,
|
|
89
|
+
profile,
|
|
90
|
+
regions,
|
|
91
|
+
usageDir,
|
|
92
|
+
monthlyBudgetUsd,
|
|
93
|
+
ssoLoginCommand,
|
|
94
|
+
signInAtStart,
|
|
95
|
+
browser,
|
|
96
|
+
api,
|
|
97
|
+
} = c;
|
|
98
|
+
if (typeof team !== "string" || team.trim() === "" || team.length > 256 || control.test(team)) {
|
|
99
|
+
problems.push("bedrock.team must be your exact AWS tag value (1-256 characters, no control characters)");
|
|
100
|
+
}
|
|
101
|
+
for (const [name, value] of [
|
|
102
|
+
["tagKey", tagKey],
|
|
103
|
+
["tagValue", tagValue],
|
|
104
|
+
["activeTagKey", activeTagKey],
|
|
105
|
+
["activeTagValue", activeTagValue],
|
|
106
|
+
] as const)
|
|
107
|
+
if (
|
|
108
|
+
value !== undefined &&
|
|
109
|
+
(typeof value !== "string" || value.trim() === "" || value.length > 256 || control.test(value))
|
|
110
|
+
)
|
|
111
|
+
problems.push(`bedrock.${name} must be a tag name or value of 1-256 characters`);
|
|
112
|
+
if (typeof profile !== "string" || !profilePattern.test(profile)) {
|
|
113
|
+
problems.push(
|
|
114
|
+
"bedrock.profile must be an AWS profile name: 1-128 letters, digits, underscores, dots or hyphens, starting with a letter or digit",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (
|
|
118
|
+
!Array.isArray(regions) ||
|
|
119
|
+
regions.length === 0 ||
|
|
120
|
+
regions.length > 16 ||
|
|
121
|
+
regions.some((region) => typeof region !== "string" || !regionPattern.test(region)) ||
|
|
122
|
+
new Set(regions).size !== regions.length
|
|
123
|
+
) {
|
|
124
|
+
problems.push("bedrock.regions must contain 1-16 unique AWS region names");
|
|
125
|
+
}
|
|
126
|
+
if (typeof usageDir !== "string" || usageDir.trim() === "" || control.test(usageDir)) {
|
|
127
|
+
problems.push("bedrock.usageDir must be a nonempty local path");
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
monthlyBudgetUsd !== undefined &&
|
|
131
|
+
(typeof monthlyBudgetUsd !== "number" || !Number.isFinite(monthlyBudgetUsd) || monthlyBudgetUsd <= 0)
|
|
132
|
+
) {
|
|
133
|
+
problems.push("bedrock.monthlyBudgetUsd must be a positive finite number, or omitted");
|
|
134
|
+
}
|
|
135
|
+
if (
|
|
136
|
+
!Array.isArray(ssoLoginCommand) ||
|
|
137
|
+
ssoLoginCommand.length === 0 ||
|
|
138
|
+
ssoLoginCommand.length > 64 ||
|
|
139
|
+
ssoLoginCommand.some(
|
|
140
|
+
(part) =>
|
|
141
|
+
typeof part !== "string" ||
|
|
142
|
+
part === "" ||
|
|
143
|
+
part.length > 4096 ||
|
|
144
|
+
control.test(part) ||
|
|
145
|
+
/[{}]/.test(part.replaceAll("{profile}", "")),
|
|
146
|
+
) ||
|
|
147
|
+
String(ssoLoginCommand[0]).includes("{profile}")
|
|
148
|
+
) {
|
|
149
|
+
problems.push("bedrock.ssoLoginCommand must be an argv array; only {profile} is supported in arguments");
|
|
150
|
+
}
|
|
151
|
+
if (typeof signInAtStart !== "boolean") problems.push("bedrock.signInAtStart must be true or false");
|
|
152
|
+
if (browser !== undefined && (typeof browser !== "string" || !browserPattern.test(browser))) {
|
|
153
|
+
problems.push(
|
|
154
|
+
"bedrock.browser must be the full path of a program that opens an address, or the name of one on the PATH (no spaces or arguments)",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (api !== undefined && api !== "messages" && api !== "converse") {
|
|
158
|
+
problems.push('bedrock.api must be "messages" (the Anthropic Messages route) or "converse" (the older route)');
|
|
159
|
+
}
|
|
160
|
+
return problems;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The check the `bedrock` section goes through when jig-core reads jig.json. The package defaults fill what is left out. */
|
|
164
|
+
export const checkBedrock: Check = (raw, errors) => {
|
|
165
|
+
if (!isObject(raw)) {
|
|
166
|
+
errors.push("bedrock must be an object");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
knownKeys(raw, FIELDS, "bedrock", errors);
|
|
170
|
+
errors.push(...problemsOf({ ...DEFAULTS, ...raw }));
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** The same check as jig-core's `registerSection` wants it: the messages, empty when the section is fine. */
|
|
174
|
+
export function validateBedrock(raw: unknown): string[] {
|
|
175
|
+
const errors: string[] = [];
|
|
176
|
+
checkBedrock(raw, errors);
|
|
177
|
+
return errors;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The settings as the provider uses them: the package defaults, then `raw` (the `bedrock` section, or undefined when
|
|
182
|
+
* there is none), then the environment variables.
|
|
183
|
+
*/
|
|
184
|
+
export function loadConfig(
|
|
185
|
+
options: { raw: Record<string, unknown> | undefined; env?: NodeJS.ProcessEnv; agentDir?: string } = {
|
|
186
|
+
raw: undefined,
|
|
187
|
+
},
|
|
188
|
+
): ConfigResult {
|
|
189
|
+
const env = options.env ?? process.env;
|
|
190
|
+
const agentDir = options.agentDir ?? (env.PI_CODING_AGENT_DIR || join(homedir(), ".pi/agent"));
|
|
191
|
+
const unknown = Object.keys(options.raw ?? {}).filter((key) => !(FIELDS as readonly string[]).includes(key));
|
|
192
|
+
if (unknown.length > 0)
|
|
193
|
+
return { error: "Unknown field in bedrock in jig.json; see the configuration table in README.md." };
|
|
194
|
+
const c: Record<string, unknown> = { ...DEFAULTS, ...options.raw, agentDir };
|
|
195
|
+
if (env.JIG_BEDROCK_TEAM !== undefined) c.team = env.JIG_BEDROCK_TEAM;
|
|
196
|
+
if (env.JIG_BEDROCK_TAG_KEY !== undefined) c.tagKey = env.JIG_BEDROCK_TAG_KEY;
|
|
197
|
+
if (env.JIG_BEDROCK_TAG_VALUE !== undefined) c.tagValue = env.JIG_BEDROCK_TAG_VALUE;
|
|
198
|
+
if (env.JIG_BEDROCK_PROFILE !== undefined) c.profile = env.JIG_BEDROCK_PROFILE;
|
|
199
|
+
if (env.JIG_BEDROCK_USAGE_DIR !== undefined) c.usageDir = env.JIG_BEDROCK_USAGE_DIR;
|
|
200
|
+
if (env.JIG_BEDROCK_REGIONS !== undefined)
|
|
201
|
+
c.regions = env.JIG_BEDROCK_REGIONS.split(",").map((region) => region.trim());
|
|
202
|
+
const [problem] = problemsOf(c);
|
|
203
|
+
if (problem !== undefined) {
|
|
204
|
+
return {
|
|
205
|
+
error:
|
|
206
|
+
c.team === undefined
|
|
207
|
+
? "Set bedrock.team in the bedrock settings to your exact AWS tag value."
|
|
208
|
+
: `${problem}.`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
(c.regions as string[]).sort();
|
|
212
|
+
c.tagValue = typeof c.tagValue === "string" && c.tagValue !== "" ? c.tagValue : c.team;
|
|
213
|
+
return { config: c as unknown as ResolvedConfig };
|
|
214
|
+
}
|