@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/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jig contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,125 @@
|
|
|
1
|
-
#
|
|
1
|
+
# jig-bedrock: Claude on AWS Bedrock for Pi
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Pi provider for Claude on AWS Bedrock. It finds the application inference profiles your account can use, keeps the
|
|
4
|
+
Anthropic Messages route (where the prompt cache survives tool changes), and falls back to Converse per model.
|
|
5
|
+
|
|
6
|
+
## The gap it fills
|
|
7
|
+
|
|
8
|
+
Pi's built-in Bedrock provider uses Converse only, has a fixed model list and no profile discovery, SSO repair or budget
|
|
9
|
+
footer. This package adds the Messages route with SigV4, discovery by an AWS tag, repair through `aws sso login`, and an
|
|
10
|
+
optional spend line. It works in plain Pi; when [Jig](https://github.com/ClawdBuilder/jig) is installed it also uses Jig's
|
|
11
|
+
records and footer, but Jig is not required.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pi install npm:@clawdbuilder/jig-bedrock
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requirements: Node 22.19 or later, Pi 1.5 or later, AWS CLI v2, and an AWS profile whose SSO session the CLI can refresh.
|
|
20
|
+
Linux, WSL2 and macOS are supported; Windows is not.
|
|
21
|
+
|
|
22
|
+
## Minimum settings
|
|
23
|
+
|
|
24
|
+
Add a `bedrock` section to `jig.json` (or the same keys through the environment):
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{ "bedrock": { "team": "my-team" } }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`team` is the value of the AWS tag on your inference profiles. The package discovers active profiles with that value.
|
|
31
|
+
|
|
32
|
+
| Setting | Default | Meaning |
|
|
33
|
+
| --------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
34
|
+
| `team` | required | The exact AWS tag value on your application inference profiles |
|
|
35
|
+
| `tagKey` | `Team` | The AWS tag that names your team on the inference profiles |
|
|
36
|
+
| `tagValue` | the value of `team` | The tag value to discover; `team` is kept for compatibility |
|
|
37
|
+
| `activeTagKey` / `activeTagValue` | `Active` / `true` | The tag that marks a profile as usable |
|
|
38
|
+
| `profile` | `my-bedrock-profile` | The AWS profile for discovery, requests and sign-in |
|
|
39
|
+
| `regions` | `["us-east-1"]` | Where to look for profiles |
|
|
40
|
+
| `usageDir` | `~/.local/share/usage-tool` | Optional directory holding an executable `usage` that prints the usage JSON; omit if you have none |
|
|
41
|
+
| `monthlyBudgetUsd` | none | A figure for the footer, not an AWS limit |
|
|
42
|
+
| `ssoLoginCommand` | `["aws","sso","login","--profile","{profile}"]` | The owning tool starts the sign-in; this package never handles a password or token |
|
|
43
|
+
| `signInAtStart` | `true` | Refresh an expired SSO session when Pi starts |
|
|
44
|
+
| `browser` | WSL's Windows browser | Program that opens the sign-in address |
|
|
45
|
+
| `api` | `messages` | `messages` (cache-friendly) or `converse` |
|
|
46
|
+
|
|
47
|
+
Plain Pi reads the same keys from `~/.pi/agent/bedrock.json` (or a `bedrock` object in it). With Jig, that file is read
|
|
48
|
+
first and Jig's `bedrock` section is merged on top; Jig must load before the provider, as in Jig's normal order. A project's `jig.json` may set
|
|
49
|
+
the catalog keys, but `ssoLoginCommand`, `usageDir` and `browser` are ignored there: programs run only from the user's file or the
|
|
50
|
+
environment.
|
|
51
|
+
|
|
52
|
+
Environment: `JIG_BEDROCK_TEAM`, `JIG_BEDROCK_PROFILE`, `JIG_BEDROCK_REGIONS`, `JIG_BEDROCK_USAGE_DIR`,
|
|
53
|
+
`JIG_BEDROCK_TAG_KEY`, `JIG_BEDROCK_TAG_VALUE`. `PI_HOST_DIR` points at the Pi install when it is not beside `node`.
|
|
54
|
+
`PI_CACHE_RETENTION` sets Pi's own cache retention; the provider does not change it.
|
|
55
|
+
|
|
56
|
+
## Commands
|
|
57
|
+
|
|
58
|
+
| Command | What it does |
|
|
59
|
+
| ------------------------- | ---------------------------------------------------------------------------------- |
|
|
60
|
+
| `/bedrock-refresh` | Refresh the discovered profiles |
|
|
61
|
+
| `/bedrock-usage [period]` | Show the usage program's daily, weekly or monthly figures |
|
|
62
|
+
| `/bedrock-stats` | Show the same cached figures as a short line |
|
|
63
|
+
| `/bedrock-profiles` | List the discovered profiles and why any are unavailable |
|
|
64
|
+
| `/aws-login` | Start the owning AWS CLI's `sso login` for the configured profile |
|
|
65
|
+
| `/bedrock-doctor` | Probe Node, AWS CLI, bash, jq and the optional usage program; it does not call AWS |
|
|
66
|
+
|
|
67
|
+
The footer shows the month's spend when a usage program and budget are configured. That program is an executable named
|
|
68
|
+
`usage` in `usageDir`; it is called as `usage --daily|--weekly|--monthly --output json` with `AWS_PROFILE` set, and it must
|
|
69
|
+
print one JSON object:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"schema_version": 1,
|
|
74
|
+
"command": "usage",
|
|
75
|
+
"user": "me",
|
|
76
|
+
"period": "Current Month",
|
|
77
|
+
"summary": {
|
|
78
|
+
"total_requests": 2,
|
|
79
|
+
"total_input_tokens_fresh": 100,
|
|
80
|
+
"total_cache_read_tokens": 20,
|
|
81
|
+
"total_cache_write_tokens": 10,
|
|
82
|
+
"total_input_tokens": 130,
|
|
83
|
+
"total_output_tokens": 40,
|
|
84
|
+
"total_estimated_cost_usd": 0.42
|
|
85
|
+
},
|
|
86
|
+
"profiles": [
|
|
87
|
+
{
|
|
88
|
+
"model_slug": "claude-sonnet-5-5",
|
|
89
|
+
"profile_name": "my-profile",
|
|
90
|
+
"requests": 2,
|
|
91
|
+
"input_tokens_fresh": 100,
|
|
92
|
+
"cache_read_tokens": 20,
|
|
93
|
+
"cache_write_tokens": 10,
|
|
94
|
+
"total_input_tokens": 130,
|
|
95
|
+
"output_tokens": 40,
|
|
96
|
+
"estimated_cost_usd": 0.42
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The 2.x shape may omit `schema_version`/`command` and each row's `user`; extra keys are ignored. A missing or unpriced row
|
|
103
|
+
leaves the spend unknown, not zero.
|
|
104
|
+
|
|
105
|
+
## Cache
|
|
106
|
+
|
|
107
|
+
On the Messages route, the tools array stays stable and the prompt cache survives a tool change. On Converse, a changed
|
|
108
|
+
tool list rewrites the cache; choose `api: "converse"` only for a model that needs it.
|
|
109
|
+
|
|
110
|
+
## Tests
|
|
111
|
+
|
|
112
|
+
```sh
|
|
113
|
+
npm test
|
|
114
|
+
npm run check
|
|
115
|
+
npm run scan
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
WSL uses `bin/windows-browser` when `browser` is unset, so the sign-in opens on Windows.
|
|
119
|
+
|
|
120
|
+
The real-AWS tests are manual: they cost money and need the user's approval. Unit tests use recorded AWS shapes and a fake
|
|
121
|
+
CLI.
|
|
122
|
+
|
|
123
|
+
## Licence
|
|
124
|
+
|
|
125
|
+
MIT.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Opens the address in $1 in the Windows default browser, from WSL. jig-bedrock gives this to `aws sso login` as its BROWSER when
|
|
3
|
+
# Pi runs under WSL and nobody set one (src/wsl.ts). rundll32 passes the whole address on, `&` included; cmd.exe would split it there.
|
|
4
|
+
exec /mnt/c/Windows/System32/rundll32.exe url.dll,FileProtocolHandler "$1"
|
package/index.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// The provider's extension entry, the second of the two in package.json's `pi.extensions`. It sits at bedrock/ so Pi loads the
|
|
2
|
+
// folder as its own extension, and the rest of the provider stays under bedrock/ with it. The code is in src/.
|
|
3
|
+
export { default } from "./src/index.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@clawdbuilder/jig-bedrock",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude on AWS Bedrock for Pi: inference-profile discovery, the Messages route with cache-friendly tools, Converse fallback, SSO repair and a budget footer.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi",
|
|
10
|
+
"bedrock",
|
|
11
|
+
"aws",
|
|
12
|
+
"claude",
|
|
13
|
+
"anthropic"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
"index.ts",
|
|
17
|
+
"src/**",
|
|
18
|
+
"bin/**",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": [
|
|
24
|
+
"index.ts"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"check": "tsc -p tsconfig.json && biome ci .",
|
|
29
|
+
"test": "node --import ./test/support/host.mjs --test \"test/*.test.ts\"",
|
|
30
|
+
"scan": "node scripts/scan-company-strings.mjs"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@biomejs/biome": "2.6.4",
|
|
34
|
+
"@types/node": "26.6.6",
|
|
35
|
+
"typescript": "7.0.2",
|
|
36
|
+
"@earendil-works/pi-ai": "1.5.0",
|
|
37
|
+
"@earendil-works/pi-coding-agent": "1.5.0"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"provenance": true,
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"repository": "git+https://github.com/ClawdBuilder/jig-bedrock.git",
|
|
44
|
+
"homepage": "https://github.com/ClawdBuilder/jig-bedrock#readme",
|
|
45
|
+
"engines": {
|
|
46
|
+
"node": ">=22.19.0"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@earendil-works/pi-ai": "1.5.0",
|
|
50
|
+
"@earendil-works/pi-coding-agent": "1.5.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { atomicCache } from "./cache.ts";
|
|
6
|
+
import { type Catalog, type Profile, readCatalog, validArn } from "./catalog.ts";
|
|
7
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
8
|
+
|
|
9
|
+
// biome-ignore lint/suspicious/noExplicitAny: parsed JSON from the AWS CLI or usage program, checked field by field where it is used
|
|
10
|
+
type Json = any;
|
|
11
|
+
export type Status =
|
|
12
|
+
| "ok"
|
|
13
|
+
| "sso_expired"
|
|
14
|
+
| "permission_denied"
|
|
15
|
+
| "configuration"
|
|
16
|
+
| "network"
|
|
17
|
+
| "timeout"
|
|
18
|
+
| "unavailable";
|
|
19
|
+
export interface CatalogSource {
|
|
20
|
+
load(): Catalog | undefined;
|
|
21
|
+
refresh(force?: boolean): Promise<Status>;
|
|
22
|
+
}
|
|
23
|
+
export type Runner = (
|
|
24
|
+
program: string,
|
|
25
|
+
args: string[],
|
|
26
|
+
options: { timeout: number; maxBuffer: number },
|
|
27
|
+
) => Promise<string>;
|
|
28
|
+
export const runFile: Runner = (program, args, options) =>
|
|
29
|
+
new Promise((resolve, reject) => {
|
|
30
|
+
execFile(
|
|
31
|
+
program,
|
|
32
|
+
args,
|
|
33
|
+
{ ...options, encoding: "utf8", env: { ...process.env, AWS_CLI_AUTO_PROMPT: "off", AWS_PAGER: "" } },
|
|
34
|
+
(error, stdout, stderr) =>
|
|
35
|
+
error ? reject(new Error(error.killed ? "timeout" : stderr || error.message)) : resolve(stdout),
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
export function category(error: unknown): Status {
|
|
39
|
+
const m = String(error).toLowerCase();
|
|
40
|
+
return /access.?denied|not authorized to perform|unauthorizedoperation/.test(m)
|
|
41
|
+
? "permission_denied"
|
|
42
|
+
: /could not be found|config profile|profile.*not found|missing.*(?:sso|region)|enoent/.test(m)
|
|
43
|
+
? "configuration"
|
|
44
|
+
: /sso|token/.test(m) && /expired|invalid|login|unauthorized|does not exist/.test(m)
|
|
45
|
+
? "sso_expired"
|
|
46
|
+
: /timeout|timed out/.test(m)
|
|
47
|
+
? "timeout"
|
|
48
|
+
: /network|connect|dns|resolve|unreachable|proxy/.test(m)
|
|
49
|
+
? "network"
|
|
50
|
+
: "unavailable";
|
|
51
|
+
}
|
|
52
|
+
export function discoveryHelp(status: Status): string {
|
|
53
|
+
switch (status) {
|
|
54
|
+
case "sso_expired":
|
|
55
|
+
return "Run /aws-login, finish browser sign-in, then /bedrock-refresh.";
|
|
56
|
+
case "permission_denied":
|
|
57
|
+
return "Ask your AWS administrator to check catalog read permissions for the configured profile; then /bedrock-refresh.";
|
|
58
|
+
case "configuration":
|
|
59
|
+
return "Check AWS CLI v2 on PATH and the configured AWS profile and regions; then /bedrock-refresh.";
|
|
60
|
+
case "network":
|
|
61
|
+
return "Check network access to AWS, then /bedrock-refresh.";
|
|
62
|
+
case "timeout":
|
|
63
|
+
return "AWS discovery timed out; check network access, then /bedrock-refresh.";
|
|
64
|
+
default:
|
|
65
|
+
return "Check AWS access and the configuration in README.md, then /bedrock-refresh.";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function createAwsDiscovery(
|
|
69
|
+
config: ResolvedConfig,
|
|
70
|
+
runner: Runner = runFile,
|
|
71
|
+
file = join(homedir(), ".cache/jig-bedrock/catalog.json"),
|
|
72
|
+
now = Date.now,
|
|
73
|
+
): CatalogSource {
|
|
74
|
+
let flight: Promise<Status> | undefined,
|
|
75
|
+
invalidAccount = false;
|
|
76
|
+
const load = () => (invalidAccount ? undefined : readCatalog(file, config, now()));
|
|
77
|
+
const perform = async (force = false): Promise<Status> => {
|
|
78
|
+
let remaining = 8 * 1_048_576,
|
|
79
|
+
pages = 0,
|
|
80
|
+
deadline = now() + 90_000;
|
|
81
|
+
const aws = async (args: string[]) => {
|
|
82
|
+
const timeout = Math.min(20_000, deadline - now());
|
|
83
|
+
if (timeout <= 0) throw new Error("timeout");
|
|
84
|
+
const output = await runner(
|
|
85
|
+
"aws",
|
|
86
|
+
[...args, "--profile", config.profile, "--output", "json", "--no-cli-pager"],
|
|
87
|
+
{ timeout, maxBuffer: Math.min(remaining, 1_048_576) },
|
|
88
|
+
);
|
|
89
|
+
remaining -= Buffer.byteLength(output);
|
|
90
|
+
if (remaining < 0 || Buffer.byteLength(output) > 1_048_576) throw new Error("byte limit");
|
|
91
|
+
if (now() > deadline) throw new Error("timeout");
|
|
92
|
+
return JSON.parse(output);
|
|
93
|
+
};
|
|
94
|
+
try {
|
|
95
|
+
// A catalog younger than the TTL needs no AWS call at all, not even to learn who the caller is; /bedrock-refresh forces one.
|
|
96
|
+
if (!force && !invalidAccount) {
|
|
97
|
+
const cached = readCatalog(file, config, now());
|
|
98
|
+
if (cached && now() / 1000 - cached.fetched_at < 86_400) return "ok";
|
|
99
|
+
}
|
|
100
|
+
const account = (await aws(["sts", "get-caller-identity"])).Account;
|
|
101
|
+
if (typeof account !== "string" || !/^\d{12}$/.test(account)) throw new Error("identity");
|
|
102
|
+
const old = readCatalog(file, config, now());
|
|
103
|
+
if (old && old.account_id !== account) {
|
|
104
|
+
invalidAccount = true;
|
|
105
|
+
rmSync(file, { force: true });
|
|
106
|
+
} else invalidAccount = false;
|
|
107
|
+
const profiles: Profile[] = [],
|
|
108
|
+
seen = new Set<string>();
|
|
109
|
+
const info = (arn: string, region: string) =>
|
|
110
|
+
aws(["bedrock", "get-inference-profile", "--inference-profile-identifier", arn, "--region", region]);
|
|
111
|
+
const foundation = async (value: Json, depth = 0): Promise<string> => {
|
|
112
|
+
if (!Array.isArray(value.models) || !value.models.length || value.models.length > 32)
|
|
113
|
+
throw new Error("models");
|
|
114
|
+
const ids = new Set<string>();
|
|
115
|
+
for (const row of value.models) {
|
|
116
|
+
const match =
|
|
117
|
+
typeof row.modelArn === "string" &&
|
|
118
|
+
row.modelArn.match(/^arn:(aws|aws-cn|aws-us-gov):bedrock:([^:]+):([^:]*):(.+)$/);
|
|
119
|
+
if (!match) throw new Error("model ARN");
|
|
120
|
+
const resource = match[4];
|
|
121
|
+
if (resource.startsWith("foundation-model/")) {
|
|
122
|
+
const id = resource.slice(17);
|
|
123
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: reject control characters in an AWS model id
|
|
124
|
+
if (!id || id.length > 256 || /[\x00-\x1f]/.test(id)) throw new Error("model ID");
|
|
125
|
+
ids.add(id);
|
|
126
|
+
} else if (
|
|
127
|
+
resource.startsWith("inference-profile/") &&
|
|
128
|
+
depth < 2 &&
|
|
129
|
+
(!match[3] || match[3] === account)
|
|
130
|
+
)
|
|
131
|
+
ids.add(await foundation(await info(row.modelArn, match[2]), depth + 1));
|
|
132
|
+
else throw new Error("model scope");
|
|
133
|
+
}
|
|
134
|
+
if (ids.size !== 1) throw new Error("ambiguous model");
|
|
135
|
+
return [...ids][0] as string;
|
|
136
|
+
};
|
|
137
|
+
for (const region of config.regions) {
|
|
138
|
+
let token = "";
|
|
139
|
+
const tokens = new Set<string>();
|
|
140
|
+
for (let page = 0; page < 32; page++) {
|
|
141
|
+
if (++pages > 64) throw new Error("pagination");
|
|
142
|
+
const result = await aws([
|
|
143
|
+
"resourcegroupstaggingapi",
|
|
144
|
+
"get-resources",
|
|
145
|
+
"--region",
|
|
146
|
+
region,
|
|
147
|
+
"--resource-type-filters",
|
|
148
|
+
"bedrock:application-inference-profile",
|
|
149
|
+
"--tag-filters",
|
|
150
|
+
JSON.stringify([
|
|
151
|
+
{ Key: config.tagKey ?? "Team", Values: [config.tagValue ?? config.team] },
|
|
152
|
+
{ Key: config.activeTagKey ?? "Active", Values: [config.activeTagValue ?? "true"] },
|
|
153
|
+
]),
|
|
154
|
+
"--no-paginate",
|
|
155
|
+
...(token ? ["--pagination-token", token] : []),
|
|
156
|
+
]);
|
|
157
|
+
if (!Array.isArray(result.ResourceTagMappingList) || result.ResourceTagMappingList.length > 256)
|
|
158
|
+
throw new Error("resources");
|
|
159
|
+
for (const row of result.ResourceTagMappingList) {
|
|
160
|
+
const arn = row.ResourceARN;
|
|
161
|
+
if (
|
|
162
|
+
typeof arn !== "string" ||
|
|
163
|
+
!validArn(arn, region, account) ||
|
|
164
|
+
seen.has(arn) ||
|
|
165
|
+
seen.size >= 256
|
|
166
|
+
)
|
|
167
|
+
throw new Error("profile scope");
|
|
168
|
+
if (
|
|
169
|
+
!Array.isArray(row.Tags) ||
|
|
170
|
+
![
|
|
171
|
+
[config.tagKey ?? "Team", config.tagValue ?? config.team],
|
|
172
|
+
[config.activeTagKey ?? "Active", config.activeTagValue ?? "true"],
|
|
173
|
+
].every(
|
|
174
|
+
([key, value]) =>
|
|
175
|
+
row.Tags.filter((tag: Json) => tag?.Key === key).length === 1 &&
|
|
176
|
+
row.Tags.some((tag: Json) => tag?.Key === key && tag.Value === value),
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
throw new Error("profile tags");
|
|
180
|
+
seen.add(arn);
|
|
181
|
+
const value = await info(arn, region);
|
|
182
|
+
if (value.inferenceProfileArn !== undefined && value.inferenceProfileArn !== arn)
|
|
183
|
+
throw new Error("profile mismatch");
|
|
184
|
+
if (value.status !== "ACTIVE") continue;
|
|
185
|
+
const name = value.inferenceProfileName;
|
|
186
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: reject control characters in an AWS profile name
|
|
187
|
+
if (typeof name !== "string" || !name || name.length > 256 || /[\x00-\x1f\x7f]/.test(name))
|
|
188
|
+
throw new Error("profile name");
|
|
189
|
+
profiles.push({
|
|
190
|
+
arn,
|
|
191
|
+
profile_name: name,
|
|
192
|
+
foundation_model_id: await foundation(value),
|
|
193
|
+
region,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
token = result.PaginationToken ?? "";
|
|
197
|
+
if (!token) break;
|
|
198
|
+
if (typeof token !== "string" || token.length > 2048 || tokens.has(token) || page === 31)
|
|
199
|
+
throw new Error("pagination");
|
|
200
|
+
tokens.add(token);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
profiles.sort((a, b) => (a.arn < b.arn ? -1 : a.arn > b.arn ? 1 : 0));
|
|
204
|
+
atomicCache(file, {
|
|
205
|
+
schema: 1,
|
|
206
|
+
team: config.team,
|
|
207
|
+
aws_profile: config.profile,
|
|
208
|
+
account_id: account,
|
|
209
|
+
regions: config.regions,
|
|
210
|
+
fetched_at: Math.floor(now() / 1000),
|
|
211
|
+
profiles,
|
|
212
|
+
});
|
|
213
|
+
invalidAccount = false;
|
|
214
|
+
return "ok";
|
|
215
|
+
} catch (e) {
|
|
216
|
+
return category(e);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
return {
|
|
220
|
+
load,
|
|
221
|
+
refresh: (force = false) =>
|
|
222
|
+
(flight ??= perform(force).finally(() => {
|
|
223
|
+
flight = undefined;
|
|
224
|
+
})),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A fetch for Pi's Anthropic Messages client that signs every request with the configured AWS profile's SSO credentials. The
|
|
2
|
+
// provider's route is Bedrock's Anthropic endpoint, which authenticates with SigV4 and not with an Anthropic key, so the client's
|
|
3
|
+
// own auth headers are removed before signing. Credentials stay in the credential chain's memory and are never written by this
|
|
4
|
+
// module.
|
|
5
|
+
import type { AwsCredentials } from "./sigv4.ts";
|
|
6
|
+
import { signRequest } from "./sigv4.ts";
|
|
7
|
+
|
|
8
|
+
/** The region in a Bedrock runtime host, or undefined when the URL is not one. */
|
|
9
|
+
export function regionOf(url: string): string | undefined {
|
|
10
|
+
return /^bedrock-runtime(?:-fips)?\.([a-z0-9-]+)\.amazonaws\.com(?:\.cn)?$/.exec(new URL(url).host)?.[1];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A body this signer can hash as one buffer: what the Anthropic SDK sends for a JSON request. */
|
|
14
|
+
function bodyOf(body: RequestInit["body"]): string | Uint8Array {
|
|
15
|
+
if (body === undefined || body === null) return "";
|
|
16
|
+
if (typeof body === "string") return body;
|
|
17
|
+
if (body instanceof Uint8Array) return body;
|
|
18
|
+
if (body instanceof ArrayBuffer) return new Uint8Array(body);
|
|
19
|
+
throw new Error("Bedrock signing needs the whole request body, not a stream");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SignedFetchOptions {
|
|
23
|
+
/** Resolves the profile's credentials; the AWS SDK's default provider in production. */
|
|
24
|
+
credentials: () => Promise<AwsCredentials>;
|
|
25
|
+
/** The fetch requests go through, so a test can watch them. */
|
|
26
|
+
fetch?: typeof globalThis.fetch;
|
|
27
|
+
now?: () => Date;
|
|
28
|
+
/** A credential failure that means the SSO session must be restored: the message has to reach Pi in one piece. */
|
|
29
|
+
signInHint?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SSO_FAILURE = /aws sso login|sso session|token is expired|expired or is otherwise invalid/i;
|
|
33
|
+
|
|
34
|
+
/** `fetch` with the SigV4 headers of the profile's credentials, and without the client's own auth headers. */
|
|
35
|
+
export function createSignedFetch(options: SignedFetchOptions): typeof globalThis.fetch {
|
|
36
|
+
const inner = options.fetch ?? globalThis.fetch;
|
|
37
|
+
const now = options.now ?? (() => new Date());
|
|
38
|
+
return async (input, init) => {
|
|
39
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
40
|
+
const method = init?.method ?? (typeof input === "string" || input instanceof URL ? "GET" : input.method);
|
|
41
|
+
const region = regionOf(url);
|
|
42
|
+
if (region === undefined) throw new Error(`Not a Bedrock runtime URL: ${new URL(url).host}`);
|
|
43
|
+
|
|
44
|
+
const headers = new Headers(init?.headers);
|
|
45
|
+
// The Anthropic SDK always sends one of these because it is constructed with a key; neither may reach Bedrock.
|
|
46
|
+
headers.delete("x-api-key");
|
|
47
|
+
headers.delete("authorization");
|
|
48
|
+
const given: Record<string, string> = {};
|
|
49
|
+
headers.forEach((value, name) => {
|
|
50
|
+
given[name] = value;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
let credentials: AwsCredentials;
|
|
54
|
+
try {
|
|
55
|
+
credentials = await options.credentials();
|
|
56
|
+
} catch (error) {
|
|
57
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
58
|
+
if (SSO_FAILURE.test(message)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`SSO session for the configured AWS profile is expired or invalid; run aws sso login with the configured profile. ${message}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
const signed = signRequest({
|
|
66
|
+
method,
|
|
67
|
+
url,
|
|
68
|
+
headers: given,
|
|
69
|
+
body: bodyOf(init?.body),
|
|
70
|
+
region,
|
|
71
|
+
credentials,
|
|
72
|
+
now: now(),
|
|
73
|
+
});
|
|
74
|
+
for (const [name, value] of Object.entries(signed)) headers.set(name, value);
|
|
75
|
+
return inner(url, { ...init, headers });
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/budget.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { atomicCache } from "./cache.ts";
|
|
4
|
+
import { boundedRegular } from "./catalog.ts";
|
|
5
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
6
|
+
import { createStatistics, parseUsage, pricedSpend, type StatisticsSource } from "./statistics.ts";
|
|
7
|
+
import { runUsage, type UsageRunner } from "./usage.ts";
|
|
8
|
+
/** spentUsd sums the priced rows; unpriced says some usage had no price, so the real spend is higher. */
|
|
9
|
+
export interface Budget {
|
|
10
|
+
monthUtc: string;
|
|
11
|
+
spentUsd: number;
|
|
12
|
+
unpriced: boolean;
|
|
13
|
+
limitUsd?: number;
|
|
14
|
+
remainingUsd?: number;
|
|
15
|
+
observedAt: number;
|
|
16
|
+
refreshFailed: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface BudgetSource {
|
|
19
|
+
load(): Budget | undefined;
|
|
20
|
+
refresh(options?: { force?: boolean; signal?: AbortSignal }): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
export function parseBudget(output: string, monthUtc: string, observedAt: number, limitUsd?: number): Budget {
|
|
23
|
+
if (new Date(observedAt).toISOString().slice(0, 7) !== monthUtc)
|
|
24
|
+
throw new Error("usage program monthly result unavailable");
|
|
25
|
+
const { spentUsd, unpriced } = pricedSpend(parseUsage(output, "monthly"));
|
|
26
|
+
return {
|
|
27
|
+
monthUtc,
|
|
28
|
+
spentUsd,
|
|
29
|
+
unpriced,
|
|
30
|
+
observedAt,
|
|
31
|
+
refreshFailed: false,
|
|
32
|
+
...(limitUsd === undefined ? {} : { limitUsd, remainingUsd: limitUsd - spentUsd }),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function createBudget(
|
|
36
|
+
config: ResolvedConfig,
|
|
37
|
+
runner: UsageRunner = runUsage,
|
|
38
|
+
file = join(homedir(), ".cache/jig-bedrock/budget.json"),
|
|
39
|
+
now = Date.now,
|
|
40
|
+
statistics: StatisticsSource = createStatistics(config, runner, dirname(file), now),
|
|
41
|
+
): BudgetSource {
|
|
42
|
+
const scope = JSON.stringify({
|
|
43
|
+
profile: config.profile,
|
|
44
|
+
team: config.team,
|
|
45
|
+
regions: [...config.regions].sort(),
|
|
46
|
+
usageDir: config.usageDir,
|
|
47
|
+
});
|
|
48
|
+
let flight: Promise<void> | undefined,
|
|
49
|
+
lastAttempt = -Infinity;
|
|
50
|
+
const raw = () => {
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(boundedRegular(file, 32768, true) || "{}");
|
|
53
|
+
} catch {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const load = (): Budget | undefined => {
|
|
58
|
+
const c = raw();
|
|
59
|
+
if (
|
|
60
|
+
c.schema !== 1 ||
|
|
61
|
+
c.profile !== config.profile ||
|
|
62
|
+
c.scope !== scope ||
|
|
63
|
+
c.monthUtc !== new Date(now()).toISOString().slice(0, 7) ||
|
|
64
|
+
!Number.isFinite(c.spentUsd) ||
|
|
65
|
+
c.spentUsd < 0 ||
|
|
66
|
+
!Number.isSafeInteger(c.observedAt) ||
|
|
67
|
+
c.observedAt < 0 ||
|
|
68
|
+
c.observedAt > now()
|
|
69
|
+
)
|
|
70
|
+
return;
|
|
71
|
+
return {
|
|
72
|
+
monthUtc: c.monthUtc,
|
|
73
|
+
spentUsd: c.spentUsd,
|
|
74
|
+
unpriced: c.unpriced === true,
|
|
75
|
+
observedAt: c.observedAt,
|
|
76
|
+
refreshFailed: c.refreshFailed === true,
|
|
77
|
+
...(config.monthlyBudgetUsd === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { limitUsd: config.monthlyBudgetUsd, remainingUsd: config.monthlyBudgetUsd - c.spentUsd }),
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
const fromSnapshot = (snapshot: ReturnType<StatisticsSource["load"]>) =>
|
|
83
|
+
snapshot && { ...pricedSpend(snapshot.report), observedAt: snapshot.observedAt };
|
|
84
|
+
const perform = async (options: { force?: boolean; signal?: AbortSignal } = {}) => {
|
|
85
|
+
const old = raw(),
|
|
86
|
+
started = now(),
|
|
87
|
+
monthUtc = new Date(started).toISOString().slice(0, 7);
|
|
88
|
+
if (
|
|
89
|
+
!options.force &&
|
|
90
|
+
started - Math.max(lastAttempt, old.scope === scope ? old.lastAttemptAt || 0 : 0) < 300_000
|
|
91
|
+
)
|
|
92
|
+
return;
|
|
93
|
+
lastAttempt = started;
|
|
94
|
+
try {
|
|
95
|
+
const snapshot = await statistics.refresh("monthly", { signal: options.signal });
|
|
96
|
+
atomicCache(file, {
|
|
97
|
+
schema: 1,
|
|
98
|
+
scope,
|
|
99
|
+
profile: config.profile,
|
|
100
|
+
monthUtc,
|
|
101
|
+
lastAttemptAt: started,
|
|
102
|
+
observedAt: snapshot.observedAt,
|
|
103
|
+
refreshFailed: false,
|
|
104
|
+
...pricedSpend(snapshot.report),
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
// Keep the last known figure: this file's own, or else the last valid usage program report the statistics cache still holds.
|
|
108
|
+
const known = load() ?? fromSnapshot(statistics.load("monthly"));
|
|
109
|
+
try {
|
|
110
|
+
atomicCache(file, {
|
|
111
|
+
schema: 1,
|
|
112
|
+
scope,
|
|
113
|
+
profile: config.profile,
|
|
114
|
+
monthUtc,
|
|
115
|
+
lastAttemptAt: started,
|
|
116
|
+
refreshFailed: true,
|
|
117
|
+
...(known && { spentUsd: known.spentUsd, unpriced: known.unpriced, observedAt: known.observedAt }),
|
|
118
|
+
});
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
load,
|
|
124
|
+
refresh: (options) =>
|
|
125
|
+
(flight ??= perform(options).finally(() => {
|
|
126
|
+
flight = undefined;
|
|
127
|
+
})),
|
|
128
|
+
};
|
|
129
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstatSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
export function atomicCache(file: string, value: unknown): void {
|
|
5
|
+
const data = JSON.stringify(value);
|
|
6
|
+
if (Buffer.byteLength(data) > 1_048_576) throw new Error("Cache too large");
|
|
7
|
+
for (let p = dirname(file); dirname(p) !== p; p = dirname(p)) {
|
|
8
|
+
try {
|
|
9
|
+
if (lstatSync(p).isSymbolicLink()) throw new Error("Unsafe cache directory");
|
|
10
|
+
} catch (e) {
|
|
11
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
15
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
16
|
+
try {
|
|
17
|
+
writeFileSync(temp, data, { flag: "wx", mode: 0o600 });
|
|
18
|
+
renameSync(temp, file);
|
|
19
|
+
} finally {
|
|
20
|
+
rmSync(temp, { force: true });
|
|
21
|
+
}
|
|
22
|
+
}
|