@clawdbuilder/jig-bedrock 0.1.1 → 0.2.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/README.md CHANGED
@@ -24,24 +24,33 @@ calling AWS.
24
24
 
25
25
  ## Minimum settings
26
26
 
27
- With Jig, add a `bedrock` section to `jig.json` (or use the environment):
27
+ With Jig, add a `bedrock` section to `jig.json` (or use the environment). Nothing is required: with no settings, the
28
+ provider lists AWS-defined cross-region Claude profiles in `regions`.
28
29
 
29
30
  ```json
30
- { "bedrock": { "team": "my-team" } }
31
+ { "bedrock": { "regions": ["us-east-1"] } }
31
32
  ```
32
33
 
33
34
  A plain Pi install has no `jig.json`: put the same keys in `~/.pi/agent/bedrock.json` (or in a `bedrock` object in that
34
35
  file). With Jig, that file is read first and Jig's `bedrock` section is merged over it. A project's `jig.json` may set the
35
36
  catalog keys, but `ssoLoginCommand`, `usageDir` and `browser` are read only from the user's file or the environment.
36
37
 
37
- `team` is the value of the AWS tag on your inference profiles. The package discovers active profiles with that value.
38
+ Three modes, chosen by the settings:
39
+
40
+ 1. **System-defined (default).** No tag settings: lists AWS-defined cross-region Claude inference profiles with
41
+ `ListInferenceProfiles --type-equals SYSTEM_DEFINED`.
42
+ 2. **Tag filter.** `tagKey` and `tagValue` filter application profiles by any tag. `team` is a shorthand for
43
+ `tagValue` with `tagKey` defaulting to `Team`. `activeTagKey`/`activeTagValue` are optional; when `team` is set and
44
+ `activeTagKey` is not, `Active=true` is still required, so existing settings behave exactly as before.
45
+ 3. **Explicit list.** `profiles` names ARNs or profile ids; each is read with `GetInferenceProfile` and no tag search runs.
38
46
 
39
47
  | Setting | Default | Meaning |
40
48
  | --------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------- |
41
- | `team` | required | The exact AWS tag value on your application inference profiles |
42
- | `tagKey` | `Team` | The AWS tag that names your team on the inference profiles |
43
- | `tagValue` | the value of `team` | The tag value to discover; `team` is kept for compatibility |
44
- | `activeTagKey` / `activeTagValue` | `Active` / `true` | The tag that marks a profile as usable |
49
+ | `team` | none | Shorthand for `tagValue`; existing settings keep the `Active=true` filter |
50
+ | `tagKey` | `Team` when tagged | The tag key for mode 2 |
51
+ | `tagValue` | the value of `team` | The tag value for mode 2 |
52
+ | `activeTagKey` / `activeTagValue` | none | Optional tag that marks a profile usable; `team` alone still defaults to `Active`/`true` |
53
+ | `profiles` | none | Mode 3: explicit inference-profile ARNs or ids |
45
54
  | `profile` | `my-bedrock-profile` | The AWS profile for discovery, requests and sign-in |
46
55
  | `regions` | `["us-east-1"]` | Where to look for profiles |
47
56
  | `usageDir` | `~/.local/share/usage-tool` | Optional directory holding an executable `usage` that prints the usage JSON; omit if you have none |
@@ -62,13 +71,14 @@ when Pi starts an SSO check, or when you run a command.
62
71
 
63
72
  ### Discovery
64
73
 
65
- Discovery runs the AWS CLI with the configured `profile` and `regions`:
74
+ Discovery runs the AWS CLI with the configured `profile` and `regions`. It always starts with
75
+ `aws sts get-caller-identity`. Then:
66
76
 
67
- - `aws sts get-caller-identity` learns the 12-digit account.
68
- - `aws resourcegroupstaggingapi get-resources --resource-type-filters bedrock:application-inference-profile` asks for the
69
- profiles in each region. `--tag-filters` keeps the profile's `Team` tag (or `tagKey`) and its `Active=true` tag (or
70
- `activeTagKey`/`activeTagValue`).
71
- - `aws bedrock get-inference-profile --inference-profile-identifier <arn>` reads each profile's name, status and model.
77
+ - **System:** `aws bedrock list-inference-profiles --type-equals SYSTEM_DEFINED`, paginated per region; only Claude
78
+ profiles are kept, then `GetInferenceProfile` reads each one.
79
+ - **Tag:** `aws resourcegroupstaggingapi get-resources --resource-type-filters
80
+ bedrock:application-inference-profile`, filtered by `tagKey`/`tagValue` and, only when configured, the active tag.
81
+ - **Explicit:** `GetInferenceProfile` for each `profiles` entry; no tag call and no system listing.
72
82
 
73
83
  Only `ACTIVE` profiles are listed. Their foundation model is read from the profile's model ARN; the provider does not call
74
84
  `GetFoundationModel`. A successful catalog is cached for **24 hours** in `~/.cache/jig-bedrock/catalog.json`, with private
@@ -84,8 +94,9 @@ There are two routes to the same Claude models:
84
94
  configured profile.
85
95
  - **Converse.** Pi's Bedrock provider uses the AWS SDK's `ConverseStream` operation at the bedrock-runtime endpoint.
86
96
 
87
- A profile is put on Messages only when `api` is `messages` (the default) and its foundation model is an Anthropic model.
88
- Every other model uses Converse. `api: "converse"` puts every model on Converse. Both routes stream. On Messages the tools array stays
97
+ A profile is put on Messages only when `api` is `messages` (the default), it is an **application** inference profile, and
98
+ its foundation model is an Anthropic model. System-defined profile ids use Converse, because they are not application
99
+ inference-profile ARNs. Every other model uses Converse. `api: "converse"` puts every model on Converse. Both routes stream. On Messages the tools array stays
89
100
  stable, so the prompt cache survives a tool change; on Converse a changed tool list rewrites it. The footer and usage
90
101
  figures are the same.
91
102
 
@@ -93,6 +104,19 @@ A child agent (a Pi session with `PI_SUBAGENT_ID`, or the older `PI_SUBAGENT_CHI
93
104
  usage program and its events, and the sign-in at start. The parent does those; the child still sends requests and reads
94
105
  the parent's cached catalog.
95
106
 
107
+ ## IAM
108
+
109
+ | Mode | Discovery permissions | Invoke |
110
+ | -------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
111
+ | System | `bedrock:ListInferenceProfiles`, `bedrock:GetInferenceProfile` | `bedrock:InvokeModel*` on the profile and on each foundation model it routes to, in every routing region |
112
+ | Tag | `tag:GetResources`, `bedrock:GetInferenceProfile` | same |
113
+ | Explicit | `bedrock:GetInferenceProfile` | same |
114
+
115
+ Add `sts:GetCallerIdentity` for the account check. For an AWS-defined cross-region profile the policy must allow the
116
+ profile **and** the foundation model in every region it routes to; AWS's [inference-profile permissions][ip-iam] say the
117
+ same. Application profiles additionally need the profile's own ARN. [ip-iam]:
118
+ https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-prereq.html
119
+
96
120
  ## Credentials
97
121
 
98
122
  Requests use the AWS SDK's default credential chain, loaded from Pi's install (`pi-host.ts` loads
@@ -154,7 +178,16 @@ mode bits), and the no-browser fallback is the WSL-only `bin/windows-browser` sh
154
178
  | `/bedrock-doctor` | Probe Node, AWS CLI, bash, jq and the optional usage program; it does not call AWS |
155
179
 
156
180
  The footer and `/bedrock-usage` read an executable named `usage` in `usageDir`; it is called with `--daily`,
157
- `--weekly` or `--monthly` and `--output json`, and prints the spend as JSON.
181
+ `--weekly` or `--monthly` and `--output json`, and prints the spend as JSON. It receives `AWS_PROFILE` and
182
+ `BEDROCK_REGIONS` in its environment.
183
+
184
+ If your usage CLI's own command is `usage-cli`, put this two-line wrapper at
185
+ `~/.local/share/usage-tool/usage` and make it executable:
186
+
187
+ ```sh
188
+ #!/bin/sh
189
+ exec usage-cli usage "$@"
190
+ ```
158
191
 
159
192
  ## Tests
160
193
 
@@ -171,3 +204,11 @@ Real-AWS tests are manual and need the user's approval; unit tests use recorded
171
204
  ## Licence
172
205
 
173
206
  MIT.
207
+
208
+ ## Not verified against real AWS
209
+
210
+ - The exact Messages behaviour for a system-defined profile id (this package sends those through Converse).
211
+ - The response shapes of `ListInferenceProfiles` pagination and system-profile model ARNs, which the tests fake from
212
+ AWS documentation.
213
+ - IAM policy evaluation for cross-region profile plus foundation-model permissions in every routing region.
214
+ - Pricing and model metadata for system-defined profiles come from Pi's registry; no live `GetFoundationModel` call is made.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawdbuilder/jig-bedrock",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
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
5
  "type": "module",
6
6
  "license": "MIT",
@@ -3,7 +3,7 @@ import { rmSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { atomicCache } from "./cache.ts";
6
- import { type Catalog, type Profile, readCatalog, validArn } from "./catalog.ts";
6
+ import { type Catalog, filterHash, type Profile, readCatalog, validArn } from "./catalog.ts";
7
7
  import type { ResolvedConfig } from "./config.ts";
8
8
 
9
9
  // biome-ignore lint/suspicious/noExplicitAny: parsed JSON from the AWS CLI or usage program, checked field by field where it is used
@@ -65,6 +65,22 @@ export function discoveryHelp(status: Status): string {
65
65
  return "Check AWS access and the configuration in README.md, then /bedrock-refresh.";
66
66
  }
67
67
  }
68
+ const PROFILE_ERROR =
69
+ /profile scope|model scope|model ID|ambiguous model|models|profile name|profile mismatch|profile tags/;
70
+
71
+ function profileError(error: unknown): boolean {
72
+ return PROFILE_ERROR.test(String(error));
73
+ }
74
+
75
+ function entryMessage(error: unknown): string {
76
+ const text = String(error);
77
+ if (/profile scope/.test(text)) return text.replace(/^Error:\s*/, "");
78
+ if (/model scope/.test(text)) return "model scope";
79
+ if (category(error) === "permission_denied") return "denied";
80
+ if (category(error) === "configuration") return "not found";
81
+ return category(error);
82
+ }
83
+
68
84
  export function createAwsDiscovery(
69
85
  config: ResolvedConfig,
70
86
  runner: Runner = runFile,
@@ -73,7 +89,13 @@ export function createAwsDiscovery(
73
89
  ): CatalogSource {
74
90
  let flight: Promise<Status> | undefined,
75
91
  invalidAccount = false;
76
- const load = () => (invalidAccount ? undefined : readCatalog(file, config, now()));
92
+ const mode =
93
+ config.mode ??
94
+ (config.profiles?.length ? "explicit" : config.team || config.tagValue || config.tagKey ? "tag" : "system");
95
+ const activeKey = config.activeTagKey ?? (config.team !== undefined ? "Active" : undefined);
96
+ const activeValue = config.activeTagValue ?? "true";
97
+ const scope = { ...config, mode, team: config.team };
98
+ const load = () => (invalidAccount ? undefined : readCatalog(file, scope, now()));
77
99
  const perform = async (force = false): Promise<Status> => {
78
100
  let remaining = 8 * 1_048_576,
79
101
  pages = 0,
@@ -92,20 +114,21 @@ export function createAwsDiscovery(
92
114
  return JSON.parse(output);
93
115
  };
94
116
  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
117
  if (!force && !invalidAccount) {
97
- const cached = readCatalog(file, config, now());
118
+ const cached = readCatalog(file, scope, now());
98
119
  if (cached && now() / 1000 - cached.fetched_at < 86_400) return "ok";
99
120
  }
100
121
  const account = (await aws(["sts", "get-caller-identity"])).Account;
101
122
  if (typeof account !== "string" || !/^\d{12}$/.test(account)) throw new Error("identity");
102
- const old = readCatalog(file, config, now());
123
+ const old = readCatalog(file, scope, now());
103
124
  if (old && old.account_id !== account) {
104
125
  invalidAccount = true;
105
126
  rmSync(file, { force: true });
106
127
  } else invalidAccount = false;
107
128
  const profiles: Profile[] = [],
108
- seen = new Set<string>();
129
+ seen = new Set<string>(),
130
+ explicitErrors: { profile: string; message: string }[] = [],
131
+ warnings: string[] = [];
109
132
  const info = (arn: string, region: string) =>
110
133
  aws(["bedrock", "get-inference-profile", "--inference-profile-identifier", arn, "--region", region]);
111
134
  const foundation = async (value: Json, depth = 0): Promise<string> => {
@@ -123,95 +146,184 @@ export function createAwsDiscovery(
123
146
  // biome-ignore lint/suspicious/noControlCharactersInRegex: reject control characters in an AWS model id
124
147
  if (!id || id.length > 256 || /[\x00-\x1f]/.test(id)) throw new Error("model ID");
125
148
  ids.add(id);
126
- } else if (
127
- resource.startsWith("inference-profile/") &&
128
- depth < 2 &&
129
- (!match[3] || match[3] === account)
130
- )
149
+ } else if (resource.startsWith("inference-profile/") && depth < 2) {
150
+ if (match[3] && match[3] !== account && match[3] !== "aws") throw new Error("model scope");
131
151
  ids.add(await foundation(await info(row.modelArn, match[2]), depth + 1));
132
- else throw new Error("model scope");
152
+ } else throw new Error("model scope");
133
153
  }
134
154
  if (ids.size !== 1) throw new Error("ambiguous model");
135
155
  return [...ids][0] as string;
136
156
  };
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
- ![
157
+ const nameOf = (value: Json): string => {
158
+ const name = value.inferenceProfileName;
159
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: reject control characters in an AWS profile name
160
+ if (typeof name !== "string" || !name || name.length > 256 || /[\x00-\x1f\x7f]/.test(name))
161
+ throw new Error("profile name");
162
+ return name;
163
+ };
164
+ const add = async (candidate: string, region: string, kind: "application" | "system") => {
165
+ if (!validArn(candidate, region, account, kind) || seen.has(candidate) || seen.size >= 256)
166
+ throw new Error("profile scope");
167
+ const value = await info(candidate, region);
168
+ if (value.inferenceProfileArn !== undefined && value.inferenceProfileArn !== candidate)
169
+ throw new Error("profile mismatch");
170
+ if (value.status !== "ACTIVE") return;
171
+ seen.add(candidate);
172
+ const foundationModelId = await foundation(value);
173
+ if (kind === "system" && !foundationModelId.startsWith("anthropic.claude")) return;
174
+ profiles.push({
175
+ arn: candidate,
176
+ profile_name: nameOf(value),
177
+ foundation_model_id: foundationModelId,
178
+ region,
179
+ kind,
180
+ });
181
+ };
182
+ if (mode === "explicit") {
183
+ for (const entry of config.profiles ?? []) {
184
+ const match = entry.match(
185
+ /^arn:(aws|aws-cn|aws-us-gov):bedrock:([^:]+):([^:]*):(application-inference-profile|inference-profile)\/(.+)$/,
186
+ );
187
+ const kind = (match?.[4] === "inference-profile" ? "system" : "application") as
188
+ | "application"
189
+ | "system";
190
+ const region = match?.[2] ?? config.regions[0];
191
+ if (!region) throw new Error("profile scope");
192
+ try {
193
+ if (match && !config.regions.includes(region))
194
+ throw new Error(
195
+ `profile scope: ${entry} is in ${region}; add ${region} to bedrock.regions`,
196
+ );
197
+ if (match) await add(entry, region, kind);
198
+ else {
199
+ const found = await info(entry, region);
200
+ const foundArn = found.inferenceProfileArn;
201
+ if (typeof foundArn !== "string") throw new Error("profile ARN");
202
+ await add(foundArn, region, found.type === "SYSTEM_DEFINED" ? "system" : "application");
203
+ }
204
+ } catch (error) {
205
+ if (!profileError(error) && !/AccessDenied/i.test(String(error))) throw error;
206
+ explicitErrors.push({ profile: entry, message: entryMessage(error) });
207
+ }
208
+ }
209
+ } else if (mode === "tag") {
210
+ for (const region of config.regions) {
211
+ let token = "";
212
+ const tokens = new Set<string>();
213
+ for (let page = 0; page < 32; page++) {
214
+ if (++pages > 64) throw new Error("pagination");
215
+ const result = await aws([
216
+ "resourcegroupstaggingapi",
217
+ "get-resources",
218
+ "--region",
219
+ region,
220
+ "--resource-type-filters",
221
+ "bedrock:application-inference-profile",
222
+ "--tag-filters",
223
+ JSON.stringify([
224
+ { Key: config.tagKey ?? "Team", Values: [config.tagValue ?? config.team] },
225
+ ...(activeKey === undefined ? [] : [{ Key: activeKey, Values: [activeValue] }]),
226
+ ]),
227
+ "--no-paginate",
228
+ ...(token ? ["--pagination-token", token] : []),
229
+ ]);
230
+ if (!Array.isArray(result.ResourceTagMappingList) || result.ResourceTagMappingList.length > 256)
231
+ throw new Error("resources");
232
+ for (const row of result.ResourceTagMappingList) {
233
+ const arn = row.ResourceARN;
234
+ if (
235
+ typeof arn !== "string" ||
236
+ !validArn(arn, region, account) ||
237
+ seen.has(arn) ||
238
+ seen.size >= 256
239
+ )
240
+ throw new Error("profile scope");
241
+ const wanted = [
171
242
  [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),
243
+ ...(activeKey === undefined ? [] : [[activeKey, activeValue]]),
244
+ ] as Array<[string, string | undefined]>;
245
+ if (
246
+ !Array.isArray(row.Tags) ||
247
+ !wanted.every(
248
+ ([key, value]) =>
249
+ row.Tags.filter((tag: Json) => tag?.Key === key).length === 1 &&
250
+ row.Tags.some((tag: Json) => tag?.Key === key && tag.Value === value),
251
+ )
177
252
  )
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),
253
+ throw new Error("profile tags");
254
+ await add(arn, region, "application");
255
+ }
256
+ token = result.PaginationToken ?? "";
257
+ if (!token) break;
258
+ if (typeof token !== "string" || token.length > 2048 || tokens.has(token) || page === 31)
259
+ throw new Error("pagination");
260
+ tokens.add(token);
261
+ }
262
+ }
263
+ } else {
264
+ for (const region of config.regions) {
265
+ let token = "";
266
+ const tokens = new Set<string>();
267
+ for (let page = 0; page < 32; page++) {
268
+ if (++pages > 64) throw new Error("pagination");
269
+ const result = await aws([
270
+ "bedrock",
271
+ "list-inference-profiles",
272
+ "--type-equals",
273
+ "SYSTEM_DEFINED",
274
+ "--region",
193
275
  region,
194
- });
276
+ ...(token ? ["--next-token", token] : []),
277
+ ]);
278
+ const rows = result.inferenceProfileSummaries;
279
+ if (!Array.isArray(rows) || rows.length > 256) throw new Error("profiles");
280
+ for (const row of rows) {
281
+ const arn = row?.inferenceProfileArn;
282
+ if (typeof arn !== "string") throw new Error("profile scope");
283
+ if (!/anthropic\.claude/i.test(`${arn} ${row.inferenceProfileName ?? ""}`)) continue;
284
+ try {
285
+ await add(arn, region, "system");
286
+ } catch (error) {
287
+ if (!profileError(error)) throw error;
288
+ explicitErrors.push({ profile: arn, message: entryMessage(error) });
289
+ }
290
+ }
291
+ token = result.nextToken ?? "";
292
+ if (!token) break;
293
+ if (typeof token !== "string" || token.length > 2048 || tokens.has(token) || page === 31)
294
+ throw new Error("pagination");
295
+ tokens.add(token);
195
296
  }
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
297
  }
202
298
  }
203
299
  profiles.sort((a, b) => (a.arn < b.arn ? -1 : a.arn > b.arn ? 1 : 0));
204
- atomicCache(file, {
300
+ const failed =
301
+ (mode === "explicit" || mode === "system") && profiles.length === 0 && explicitErrors.length > 0;
302
+ if (mode === "tag" && profiles.length === 0) {
303
+ const active = activeKey === undefined ? "" : ` and ${activeKey}=${activeValue}`;
304
+ warnings.push(
305
+ `No application inference profiles matched ${config.tagKey ?? "Team"}=${config.tagValue}${active} in ${config.regions.join(", ")}. Check bedrock.tagKey/tagValue.`,
306
+ );
307
+ }
308
+ if (mode === "system" && profiles.length === 0)
309
+ warnings.push(`No AWS-defined Claude inference profiles in ${config.regions.join(", ")}.`);
310
+ if (failed) return "unavailable";
311
+ const catalog: Catalog = {
205
312
  schema: 1,
313
+ mode,
206
314
  team: config.team,
207
315
  aws_profile: config.profile,
208
316
  account_id: account,
209
317
  regions: config.regions,
210
318
  fetched_at: Math.floor(now() / 1000),
319
+ filter_hash: filterHash(scope),
320
+ ...(warnings.length > 0 ? { warnings } : {}),
211
321
  profiles,
212
- });
322
+ ...(explicitErrors.length > 0 ? { errors: explicitErrors } : {}),
323
+ };
324
+ atomicCache(file, catalog);
213
325
  invalidAccount = false;
214
- return "ok";
326
+ return failed ? "unavailable" : "ok";
215
327
  } catch (e) {
216
328
  return category(e);
217
329
  }
package/src/catalog.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readSync } from "node:fs";
2
3
  import { homedir } from "node:os";
3
4
  import { dirname, join, resolve } from "node:path";
@@ -54,15 +55,26 @@ const MESSAGES_COMPAT = {
54
55
  supportsMidConvoSystemMessages: true,
55
56
  supportsMidConvoToolChanges: true,
56
57
  } as const;
57
- export type Profile = { arn: string; profile_name: string; foundation_model_id: string; region: string };
58
+ export type Profile = {
59
+ arn: string;
60
+ profile_name: string;
61
+ foundation_model_id: string;
62
+ region: string;
63
+ kind?: "application" | "system";
64
+ };
58
65
  export type Catalog = {
59
66
  schema: 1;
60
- team: string;
67
+ mode?: "explicit" | "tag" | "system";
68
+ team?: string;
61
69
  aws_profile: string;
62
70
  account_id: string;
63
71
  regions: string[];
64
72
  fetched_at: number;
73
+ filter_hash?: string;
74
+ warnings?: string[];
65
75
  profiles: Profile[];
76
+ /** Explicit-list entries that could not be read; they do not stop the others. */
77
+ errors?: { profile: string; message: string }[];
66
78
  };
67
79
  const slug = (s: string) =>
68
80
  s
@@ -92,27 +104,68 @@ export function boundedRegular(file: string, max: number, privateMode = false):
92
104
  closeSync(fd);
93
105
  }
94
106
  }
95
- export function validArn(arn: string, region: string, account: string): boolean {
107
+ export function validArn(
108
+ arn: string,
109
+ region: string,
110
+ account: string,
111
+ kind: "application" | "system" | "any" = "application",
112
+ ): boolean {
96
113
  const p = arn.split(":");
114
+ const app = (p[5] ?? "").startsWith("application-inference-profile/");
115
+ const system = (p[5] ?? "").startsWith("inference-profile/");
97
116
  return (
98
117
  p.length === 6 &&
99
118
  p[0] === "arn" &&
100
119
  ["aws", "aws-cn", "aws-us-gov"].includes(p[1] ?? "") &&
101
120
  p[2] === "bedrock" &&
102
121
  p[3] === region &&
103
- p[4] === account &&
104
- /^application-inference-profile\/[A-Za-z0-9-]{1,128}$/.test(p[5] ?? "")
122
+ (system ? p[4] === "" || p[4] === "aws" || p[4] === account : p[4] === account) &&
123
+ (kind === "any" ? app || system : kind === "application" ? app : system) &&
124
+ /^(?:application-inference-profile|inference-profile)\/[A-Za-z0-9.-]{1,256}$/.test(p[5] ?? "")
105
125
  );
106
126
  }
127
+ /** The discovery filter, hashed into the catalog so a changed tag or list is never reused. */
128
+ export function filterHash(expected: {
129
+ mode?: "explicit" | "tag" | "system";
130
+ tagKey?: string;
131
+ tagValue?: string;
132
+ activeTagKey?: string;
133
+ activeTagValue?: string;
134
+ profiles?: string[];
135
+ }): string {
136
+ return createHash("sha256")
137
+ .update(
138
+ JSON.stringify([
139
+ expected.mode ?? null,
140
+ expected.tagKey ?? null,
141
+ expected.tagValue ?? null,
142
+ expected.activeTagKey ?? null,
143
+ expected.activeTagValue ?? null,
144
+ expected.profiles ?? null,
145
+ ]),
146
+ )
147
+ .digest("hex");
148
+ }
149
+
107
150
  export function readCatalog(
108
151
  file: string,
109
- expected: { team: string; profile: string; regions: string[] },
152
+ expected: {
153
+ mode?: "explicit" | "tag" | "system";
154
+ team?: string;
155
+ tagKey?: string;
156
+ tagValue?: string;
157
+ activeTagKey?: string;
158
+ activeTagValue?: string;
159
+ profiles?: string[];
160
+ profile: string;
161
+ regions: string[];
162
+ },
110
163
  now = Date.now(),
111
164
  ): Catalog | undefined {
112
165
  expected = { ...expected, regions: [...expected.regions].sort() };
113
166
  const directory = dirname(file);
114
167
  if (
115
- !label(expected.team, 256) ||
168
+ (expected.team !== undefined && !label(expected.team, 256)) ||
116
169
  !label(expected.profile, 128) ||
117
170
  !expected.regions.length ||
118
171
  new Set(expected.regions).size !== expected.regions.length
@@ -127,6 +180,9 @@ export function readCatalog(
127
180
  if (
128
181
  c.schema !== 1 ||
129
182
  c.team !== expected.team ||
183
+ (c.mode === undefined
184
+ ? expected.mode !== "tag" || c.filter_hash !== undefined
185
+ : c.mode !== expected.mode || c.filter_hash !== filterHash(expected)) ||
130
186
  c.aws_profile !== expected.profile ||
131
187
  typeof c.account_id !== "string" ||
132
188
  !/^\d{12}$/.test(c.account_id) ||
@@ -151,7 +207,7 @@ export function readCatalog(
151
207
  !label(p.profile_name, 256) ||
152
208
  !label(p.foundation_model_id, 256) ||
153
209
  typeof p.arn !== "string" ||
154
- !validArn(p.arn, p.region, c.account_id) ||
210
+ !validArn(p.arn, p.region, c.account_id, p.kind ?? "application") ||
155
211
  seen.has(p.arn)
156
212
  )
157
213
  return;
@@ -198,6 +254,42 @@ function thinkingLevelMap(
198
254
  return { thinkingLevelMap: { ...(base ?? {}), off: null } };
199
255
  }
200
256
 
257
+ /** System profiles first, by the geography of their id: the region's prefix, then global, then the rest. */
258
+ function orderedSystem(rows: Profile[], catalog: Catalog | undefined): Profile[] {
259
+ const firstRegion = catalog?.regions?.[0] ?? "us-east-1";
260
+ const first = /^us-gov-/.test(firstRegion)
261
+ ? "us-gov"
262
+ : /^ap-northeast-(?:1|3)$/.test(firstRegion)
263
+ ? "jp"
264
+ : /^ap-southeast-(?:2|4)$/.test(firstRegion)
265
+ ? "au"
266
+ : /^ap-/.test(firstRegion)
267
+ ? "apac"
268
+ : (firstRegion.split("-")[0] ?? "us");
269
+ const geography = (p: Profile) => {
270
+ const id = p.arn.slice(p.arn.lastIndexOf("/") + 1);
271
+ const prefix = id.split(".")[0] ?? "";
272
+ return prefix;
273
+ };
274
+ const order = catalog?.regions ?? [firstRegion];
275
+ const rank = (p: Profile): number => {
276
+ const prefix = geography(p);
277
+ if (p.kind !== "system") return 999;
278
+ if (prefix === first) return 0;
279
+ if (prefix === "global") return 1;
280
+ return 2 + Math.max(0, order.indexOf(p.region));
281
+ };
282
+ return rows
283
+ .filter((p) => p.kind === "system")
284
+ .sort((a, b) => rank(a) - rank(b) || (a.arn < b.arn ? -1 : a.arn > b.arn ? 1 : 0));
285
+ }
286
+
287
+ function orderedApplication(rows: Profile[]): Profile[] {
288
+ const system = rows.filter((p) => p.kind === "system");
289
+ const apps = rows.filter((p) => p.kind !== "system");
290
+ return [...system, ...apps];
291
+ }
292
+
201
293
  export function discoveredProfiles(
202
294
  catalog: Catalog | undefined,
203
295
  usageDir: string,
@@ -228,10 +320,12 @@ export function discoveredProfiles(
228
320
  const key = slug(registryName(r.name));
229
321
  grouped.set(key, [...(grouped.get(key) || []), p]);
230
322
  }
231
- for (const [key, rows] of grouped)
232
- for (const p of rows) {
323
+ for (const [key, rows] of grouped) {
324
+ const system = orderedSystem(rows, catalog);
325
+ const preferred = system[0] ?? (rows.length === 1 ? rows[0] : undefined);
326
+ for (const p of orderedApplication(rows)) {
233
327
  const r = registryEntry(p.foundation_model_id);
234
- const id = rows.length === 1 ? key : `${key}--${p.region}--${p.arn.slice(p.arn.lastIndexOf("/") + 1)}`;
328
+ const id = p === preferred ? key : `${key}--${p.region}--${p.arn.slice(p.arn.lastIndexOf("/") + 1)}`;
235
329
  const pricing = rates.get(key);
236
330
  const fallback =
237
331
  r.cost &&
@@ -250,13 +344,13 @@ export function discoveredProfiles(
250
344
  }
251
345
  const promptCache = promptCacheFor(p.foundation_model_id);
252
346
  // Only Claude profiles go on the Anthropic route; gpt, nova and the rest keep their own route.
253
- const messages = api === "messages" && isAnthropicModel(p.foundation_model_id);
347
+ const messages = api === "messages" && p.kind !== "system" && isAnthropicModel(p.foundation_model_id);
254
348
  available.push({
255
349
  id,
256
350
  arn: p.arn,
257
351
  foundationModelId: p.foundation_model_id,
258
352
  aliases: [],
259
- name: registryName(r.name) + (rows.length === 1 ? "" : ` (${p.region}, ${p.profile_name})`),
353
+ name: registryName(r.name) + (p === preferred ? "" : ` (${p.region}, ${p.profile_name})`),
260
354
  api: messages ? "anthropic-messages" : "bedrock-converse-stream",
261
355
  baseUrl: messages
262
356
  ? `https://bedrock-runtime.${p.region}.amazonaws.com/anthropic`
@@ -280,6 +374,7 @@ export function discoveredProfiles(
280
374
  ...(thinkingLevelMap(r.thinkingLevelMap, p.foundation_model_id) ?? {}),
281
375
  });
282
376
  }
377
+ }
283
378
  return { available, unavailable };
284
379
  }
285
380
  export function updateModels(
package/src/commands.ts CHANGED
@@ -276,7 +276,7 @@ export function registerStatisticsCommands(
276
276
  },
277
277
  });
278
278
  pi.registerCommand("bedrock-profiles", {
279
- description: "Show approved team Bedrock models, regions and pricing sources from the current catalog",
279
+ description: "Show your approved Bedrock models, regions and pricing sources from the current catalog",
280
280
  handler: async (args, ctx) => {
281
281
  if (args.trim()) {
282
282
  ctx.ui.notify("Usage: /bedrock-profiles", "warning");
@@ -284,16 +284,20 @@ export function registerStatisticsCommands(
284
284
  }
285
285
  await showReport(
286
286
  ctx,
287
- "Bedrock · Team profiles",
287
+ "Bedrock · Your profiles",
288
288
  async () => {
289
289
  const catalog = sources.catalog.load(),
290
290
  models = sources.models();
291
291
  return [
292
- `Team: ${clean(config.team)} · AWS profile: ${clean(config.profile)} · regions: ${config.regions.join(", ")}`,
292
+ `${config.mode === "system" ? "Mode: system-defined" : config.mode === "explicit" ? "Mode: explicit list" : `Tag: ${clean(config.tagKey ?? "Team")}=${clean(config.tagValue)}`} · AWS profile: ${clean(config.profile)} · regions: ${config.regions.join(", ")}`,
293
293
  catalog
294
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
295
  : "No approved catalog. Run /aws-login, then /bedrock-refresh.",
296
296
  "Catalog tags filter presentation; AWS IAM authorizes model access.",
297
+ ...(catalog?.warnings ?? []).map((line) => `Note: ${line}`),
298
+ ...(catalog?.errors ?? []).map(
299
+ (error) => `Could not read ${clean(error.profile)}: ${clean(error.message)}.`,
300
+ ),
297
301
  ...models.map(
298
302
  (m) =>
299
303
  `${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)}`,
package/src/config.ts CHANGED
@@ -19,11 +19,15 @@ import type { Check } from "./check.ts";
19
19
  import { isObject, knownKeys } from "./check.ts";
20
20
 
21
21
  export interface ResolvedConfig {
22
- team: string;
22
+ team?: string;
23
23
  tagKey?: string;
24
24
  tagValue?: string;
25
25
  activeTagKey?: string;
26
26
  activeTagValue?: string;
27
+ /** Explicit inference-profile ARNs or ids; when nonempty, no tag or system search runs. */
28
+ profiles?: string[];
29
+ /** Derived: explicit, tag or system. */
30
+ mode?: "explicit" | "tag" | "system";
27
31
  profile: string;
28
32
  regions: string[];
29
33
  usageDir: string;
@@ -52,6 +56,7 @@ export const FIELDS = [
52
56
  "tagValue",
53
57
  "activeTagKey",
54
58
  "activeTagValue",
59
+ "profiles",
55
60
  "profile",
56
61
  "regions",
57
62
  "usageDir",
@@ -63,9 +68,6 @@ export const FIELDS = [
63
68
  ] as const;
64
69
 
65
70
  export const DEFAULTS = {
66
- tagKey: "Team",
67
- activeTagKey: "Active",
68
- activeTagValue: "true",
69
71
  profile: "my-bedrock-profile",
70
72
  regions: ["us-east-1"],
71
73
  usageDir: "~/.local/share/usage-tool",
@@ -86,6 +88,7 @@ export function problemsOf(c: Record<string, unknown>): string[] {
86
88
  tagValue,
87
89
  activeTagKey,
88
90
  activeTagValue,
91
+ profiles,
89
92
  profile,
90
93
  regions,
91
94
  usageDir,
@@ -95,7 +98,10 @@ export function problemsOf(c: Record<string, unknown>): string[] {
95
98
  browser,
96
99
  api,
97
100
  } = c;
98
- if (typeof team !== "string" || team.trim() === "" || team.length > 256 || control.test(team)) {
101
+ if (
102
+ team !== undefined &&
103
+ (typeof team !== "string" || team.trim() === "" || team.length > 256 || control.test(team))
104
+ ) {
99
105
  problems.push("bedrock.team must be your exact AWS tag value (1-256 characters, no control characters)");
100
106
  }
101
107
  for (const [name, value] of [
@@ -109,6 +115,24 @@ export function problemsOf(c: Record<string, unknown>): string[] {
109
115
  (typeof value !== "string" || value.trim() === "" || value.length > 256 || control.test(value))
110
116
  )
111
117
  problems.push(`bedrock.${name} must be a tag name or value of 1-256 characters`);
118
+ if (
119
+ profiles !== undefined &&
120
+ (!Array.isArray(profiles) ||
121
+ profiles.length === 0 ||
122
+ profiles.length > 256 ||
123
+ profiles.some(
124
+ (value) =>
125
+ typeof value !== "string" ||
126
+ value.trim() === "" ||
127
+ value.length > 2048 ||
128
+ value.startsWith("-") ||
129
+ !/^[A-Za-z0-9._:/-]+$/.test(value) ||
130
+ control.test(value),
131
+ ) ||
132
+ new Set(profiles).size !== profiles.length)
133
+ ) {
134
+ problems.push("bedrock.profiles must be a nonempty list of up to 256 unique inference-profile ARNs or ids");
135
+ }
112
136
  if (typeof profile !== "string" || !profilePattern.test(profile)) {
113
137
  problems.push(
114
138
  "bedrock.profile must be an AWS profile name: 1-128 letters, digits, underscores, dots or hyphens, starting with a letter or digit",
@@ -200,15 +224,20 @@ export function loadConfig(
200
224
  if (env.JIG_BEDROCK_REGIONS !== undefined)
201
225
  c.regions = env.JIG_BEDROCK_REGIONS.split(",").map((region) => region.trim());
202
226
  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
- };
227
+ if (problem !== undefined) return { error: `${problem}.` };
228
+ const team = typeof c.team === "string" && c.team !== "" ? c.team : undefined;
229
+ const tagValue = typeof c.tagValue === "string" && c.tagValue !== "" ? c.tagValue : team;
230
+ const explicit = Array.isArray(c.profiles) && c.profiles.length > 0;
231
+ c.team = team;
232
+ c.tagValue = tagValue;
233
+ c.mode = explicit ? "explicit" : tagValue !== undefined || c.tagKey !== undefined ? "tag" : "system";
234
+ // Legacy team settings keep Active=true unless the user sets an active tag key.
235
+ if (c.mode === "tag" && c.activeTagKey === undefined && team !== undefined)
236
+ c.activeTagValue = c.activeTagValue ?? "true";
237
+ if (c.mode !== "tag") {
238
+ delete c.tagKey;
239
+ delete c.activeTagKey;
240
+ delete c.activeTagValue;
210
241
  }
211
- (c.regions as string[]).sort();
212
- c.tagValue = typeof c.tagValue === "string" && c.tagValue !== "" ? c.tagValue : c.team;
213
242
  return { config: c as unknown as ResolvedConfig };
214
243
  }
package/src/provider.ts CHANGED
@@ -1,4 +1,4 @@
1
- // The Bedrock model provider: the team's application inference profiles as Pi models, AWS SSO recovery, a monthly spend figure and
1
+ // The Bedrock model provider: your inference profiles as Pi models, AWS SSO recovery, a monthly spend figure and
2
2
  // the commands around them. Its settings are the `bedrock` section when Jig is present, or the extension's own settings (see config.ts); it also
3
3
  // publishes the optional display events in events.ts.
4
4
  //
@@ -150,10 +150,7 @@ export function createJigBedrock(
150
150
  }
151
151
  if (!config) {
152
152
  const file = path.join(agentFolder(options.env), "bedrock.json");
153
- const where =
154
- options.api === undefined
155
- ? `Add "team" to ${file}, then /reload.`
156
- : `Set it in bedrock in jig.json or in ${file}, then /reload.`;
153
+ const where = `Fix bedrock in jig.json or in ${file}, then /reload.`;
157
154
  pi.on("session_start", (_event, ctx) =>
158
155
  ctx.ui.notify(`Bedrock provider disabled: ${resolved.error} ${where}`, "warning"),
159
156
  );
@@ -258,11 +255,17 @@ export function createJigBedrock(
258
255
  }
259
256
  }
260
257
  const change = updateModels(models, updated);
261
- if (ctx && (change.added || change.removed)) {
262
- ctx.ui.notify(
263
- `Bedrock catalog updated: ${change.added} added, ${change.removed} removed. Choose /model if your selection was removed.`,
264
- "info",
265
- );
258
+ const current = catalog.load();
259
+ const problems = current?.errors ?? [];
260
+ const warnings = current?.warnings ?? [];
261
+ if (ctx && (change.added || change.removed || problems.length > 0 || warnings.length > 0)) {
262
+ const detail = [
263
+ `Bedrock catalog updated: ${change.added} added, ${change.removed} removed.`,
264
+ ...warnings,
265
+ ...problems.map((problem) => `${problem.profile}: ${problem.message}.`),
266
+ "Choose /model if your selection was removed.",
267
+ ].join(" ");
268
+ ctx.ui.notify(detail, problems.length > 0 || warnings.length > 0 ? "warning" : "info");
266
269
  }
267
270
  if (change.changed && native) {
268
271
  await registerTeamBedrock(
@@ -290,7 +293,7 @@ export function createJigBedrock(
290
293
  });
291
294
  }
292
295
  pi.registerCommand("bedrock-refresh", {
293
- description: "Refresh team Bedrock models",
296
+ description: "Refresh your Bedrock models",
294
297
  handler: async (_args, context) => {
295
298
  const status = await refresh();
296
299
  context.ui.notify(
package/src/sso-check.ts CHANGED
@@ -100,12 +100,18 @@ export async function checkSso(
100
100
  ): Promise<SsoState> {
101
101
  // The fast path: a cached token that stays valid needs no network check and no SDK.
102
102
  if (cachedTokenFresh(profile, deps)) return "ok";
103
- const limit = AbortSignal.timeout(deps.timeoutMs);
103
+ // `AbortSignal.timeout` uses an unreferenced timer. With a stalled SDK and nothing else in the event loop, the process
104
+ // would exit before the check could return an error. A referenced timer keeps the check alive for its own timeout.
105
+ const controller = new AbortController();
106
+ const limit = controller.signal;
107
+ const timer = setTimeout(() => controller.abort(), deps.timeoutMs);
104
108
  try {
105
109
  // The SDK's answer is dropped: only whether it resolved matters.
106
110
  await until(deps.credentialProvider()({ profile })(), signal ? AbortSignal.any([signal, limit]) : limit);
107
111
  return "ok";
108
112
  } catch (error) {
109
113
  return classify(error);
114
+ } finally {
115
+ clearTimeout(timer);
110
116
  }
111
117
  }
package/src/statistics.ts CHANGED
@@ -250,24 +250,20 @@ export function createStatistics(
250
250
  previous = load(period);
251
251
  if (!options.force && previous && !previous.stale) return previous;
252
252
  try {
253
- const output = await runner(
254
- usageExecutable(config),
255
- ["usage", `--${period}`, "--output", "json", "--aws-profile", config.profile],
256
- {
257
- timeout: 120_000,
258
- maxBuffer: 2_097_152,
259
- signal: options.signal,
260
- env: {
261
- ...process.env,
262
- BEDROCK_REGIONS: config.regions.join(","),
263
- AWS_PROFILE: config.profile,
264
- AWS_CLI_AUTO_PROMPT: "off",
265
- AWS_PAGER: "",
266
- NO_COLOR: "1",
267
- PAT_SKIP_UPDATE_CHECK: "1",
268
- },
253
+ const output = await runner(usageExecutable(config), [`--${period}`, "--output", "json"], {
254
+ timeout: 120_000,
255
+ maxBuffer: 2_097_152,
256
+ signal: options.signal,
257
+ env: {
258
+ ...process.env,
259
+ BEDROCK_REGIONS: config.regions.join(","),
260
+ AWS_PROFILE: config.profile,
261
+ AWS_CLI_AUTO_PROMPT: "off",
262
+ AWS_PAGER: "",
263
+ NO_COLOR: "1",
264
+ PAT_SKIP_UPDATE_CHECK: "1",
269
265
  },
270
- );
266
+ });
271
267
  if (options.signal?.aborted) throw new UsageError("cancelled");
272
268
  const observedAt = now();
273
269
  // Legacy reports have no explicit range, so a UTC rollover cannot be safely relabelled.
package/src/usage.ts CHANGED
@@ -113,8 +113,9 @@ export const runUsage: UsageRunner = (program, args, options) =>
113
113
  );
114
114
  child.on("close", (code) => finish(code === 0 ? undefined : new UsageError(classify(stderr))));
115
115
  options.signal?.addEventListener("abort", abort, { once: true });
116
+ // A referenced timer keeps a stalled program's timeout alive when nothing else holds the event loop. It is
117
+ // cleared in finish(), so a program that answers does not delay exit.
116
118
  timer = setTimeout(() => finish(new UsageError("timeout")), options.timeout);
117
- timer.unref();
118
119
  if (options.signal?.aborted) abort();
119
120
  });
120
121
  export const usageExecutable = (config: ResolvedConfig): string =>