@actagent/amazon-bedrock-mantle-provider 2026.6.2
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 +11 -0
- package/actagent.plugin.json +36 -0
- package/api.ts +16 -0
- package/discovery.test.ts +683 -0
- package/discovery.ts +436 -0
- package/index.test.ts +88 -0
- package/index.ts +15 -0
- package/mantle-anthropic.runtime.test.ts +123 -0
- package/mantle-anthropic.runtime.ts +134 -0
- package/npm-shrinkwrap.json +805 -0
- package/package.json +38 -0
- package/register.sync.runtime.ts +82 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
// Amazon Bedrock Mantle tests cover discovery plugin behavior.
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
discoverMantleModels,
|
|
6
|
+
generateBearerTokenFromIam,
|
|
7
|
+
getCachedIamToken,
|
|
8
|
+
MANTLE_IAM_TOKEN_MARKER,
|
|
9
|
+
mergeImplicitMantleProvider,
|
|
10
|
+
resetIamTokenCacheForTest,
|
|
11
|
+
resetMantleDiscoveryCacheForTest,
|
|
12
|
+
resolveImplicitMantleProvider,
|
|
13
|
+
resolveMantleBearerToken,
|
|
14
|
+
resolveMantleRuntimeBearerToken,
|
|
15
|
+
} = await import("./api.js");
|
|
16
|
+
|
|
17
|
+
function createTokenProviderFactory(tokenProvider: () => Promise<string>) {
|
|
18
|
+
return vi.fn(() => tokenProvider);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type MockWithCalls = {
|
|
22
|
+
mock: { calls: unknown[][] };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function argAt(mock: MockWithCalls, callIndex: number, argIndex: number): unknown {
|
|
26
|
+
const call = mock.mock.calls[callIndex];
|
|
27
|
+
if (!call) {
|
|
28
|
+
throw new Error(`expected call ${callIndex}`);
|
|
29
|
+
}
|
|
30
|
+
if (!(argIndex in call)) {
|
|
31
|
+
throw new Error(`expected call ${callIndex} argument ${argIndex}`);
|
|
32
|
+
}
|
|
33
|
+
return call[argIndex];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function objectArgAt(
|
|
37
|
+
mock: MockWithCalls,
|
|
38
|
+
callIndex: number,
|
|
39
|
+
argIndex: number,
|
|
40
|
+
): Record<string, unknown> {
|
|
41
|
+
const value = argAt(mock, callIndex, argIndex);
|
|
42
|
+
if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
43
|
+
throw new Error(`expected call ${callIndex} argument ${argIndex} to be an object`);
|
|
44
|
+
}
|
|
45
|
+
return value as Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stringArgAt(mock: MockWithCalls, callIndex: number, argIndex: number): string {
|
|
49
|
+
const value = argAt(mock, callIndex, argIndex);
|
|
50
|
+
if (typeof value !== "string") {
|
|
51
|
+
throw new Error(`expected call ${callIndex} argument ${argIndex} to be a string`);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function recordField(value: unknown, field: string): Record<string, unknown> {
|
|
57
|
+
if (value === undefined || value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
58
|
+
throw new Error(`expected ${field} to be an object`);
|
|
59
|
+
}
|
|
60
|
+
return value as Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe("bedrock mantle discovery", () => {
|
|
64
|
+
const originalEnv = process.env;
|
|
65
|
+
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
process.env = { ...originalEnv };
|
|
68
|
+
vi.restoreAllMocks();
|
|
69
|
+
resetMantleDiscoveryCacheForTest();
|
|
70
|
+
resetIamTokenCacheForTest();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
vi.restoreAllMocks();
|
|
75
|
+
resetMantleDiscoveryCacheForTest();
|
|
76
|
+
resetIamTokenCacheForTest();
|
|
77
|
+
process.env = originalEnv;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Bearer token resolution
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
it("resolves bearer token from AWS_BEARER_TOKEN_BEDROCK", () => {
|
|
85
|
+
expect(
|
|
86
|
+
resolveMantleBearerToken({
|
|
87
|
+
AWS_BEARER_TOKEN_BEDROCK: "bedrock-api-key-abc123", // pragma: allowlist secret
|
|
88
|
+
} as NodeJS.ProcessEnv),
|
|
89
|
+
).toBe("bedrock-api-key-abc123");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns undefined when no bearer token env var is set", () => {
|
|
93
|
+
expect(resolveMantleBearerToken({} as NodeJS.ProcessEnv)).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("trims whitespace from bearer token", () => {
|
|
97
|
+
expect(
|
|
98
|
+
resolveMantleBearerToken({
|
|
99
|
+
AWS_BEARER_TOKEN_BEDROCK: " my-token ", // pragma: allowlist secret
|
|
100
|
+
} as NodeJS.ProcessEnv),
|
|
101
|
+
).toBe("my-token");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// IAM token generation
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
it("generates token from IAM credentials when token generation succeeds", async () => {
|
|
109
|
+
const tokenProvider = vi.fn(async () => "bedrock-api-key-generated"); // pragma: allowlist secret
|
|
110
|
+
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
|
111
|
+
|
|
112
|
+
const token = await generateBearerTokenFromIam({
|
|
113
|
+
region: "us-east-1",
|
|
114
|
+
tokenProviderFactory,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(token).toBe("bedrock-api-key-generated");
|
|
118
|
+
expect(tokenProviderFactory).toHaveBeenCalledWith({
|
|
119
|
+
region: "us-east-1",
|
|
120
|
+
expiresInSeconds: 7200,
|
|
121
|
+
});
|
|
122
|
+
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("caches generated IAM tokens within TTL", async () => {
|
|
126
|
+
const tokenProvider = vi.fn(async () => "bedrock-api-key-cached"); // pragma: allowlist secret
|
|
127
|
+
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
|
128
|
+
let now = 1000;
|
|
129
|
+
|
|
130
|
+
const t1 = await generateBearerTokenFromIam({
|
|
131
|
+
region: "us-east-1",
|
|
132
|
+
now: () => now,
|
|
133
|
+
tokenProviderFactory,
|
|
134
|
+
});
|
|
135
|
+
now += 1800_000; // 30 min — within 2hr cache TTL
|
|
136
|
+
const t2 = await generateBearerTokenFromIam({
|
|
137
|
+
region: "us-east-1",
|
|
138
|
+
now: () => now,
|
|
139
|
+
tokenProviderFactory,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(t1).toEqual(t2);
|
|
143
|
+
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("does not reuse an IAM token across regions", async () => {
|
|
147
|
+
const tokenProvider = vi
|
|
148
|
+
.fn<() => Promise<string>>()
|
|
149
|
+
.mockResolvedValueOnce("bedrock-api-key-east") // pragma: allowlist secret
|
|
150
|
+
.mockResolvedValueOnce("bedrock-api-key-west"); // pragma: allowlist secret
|
|
151
|
+
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
|
152
|
+
|
|
153
|
+
const east = await generateBearerTokenFromIam({
|
|
154
|
+
region: "us-east-1",
|
|
155
|
+
now: () => 1000,
|
|
156
|
+
tokenProviderFactory,
|
|
157
|
+
});
|
|
158
|
+
const west = await generateBearerTokenFromIam({
|
|
159
|
+
region: "us-west-2",
|
|
160
|
+
now: () => 2000,
|
|
161
|
+
tokenProviderFactory,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(east).toBe("bedrock-api-key-east");
|
|
165
|
+
expect(west).toBe("bedrock-api-key-west");
|
|
166
|
+
expect(tokenProviderFactory).toHaveBeenNthCalledWith(1, {
|
|
167
|
+
region: "us-east-1",
|
|
168
|
+
expiresInSeconds: 7200,
|
|
169
|
+
});
|
|
170
|
+
expect(tokenProviderFactory).toHaveBeenNthCalledWith(2, {
|
|
171
|
+
region: "us-west-2",
|
|
172
|
+
expiresInSeconds: 7200,
|
|
173
|
+
});
|
|
174
|
+
expect(tokenProvider).toHaveBeenCalledTimes(2);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("returns undefined when IAM token generation fails", async () => {
|
|
178
|
+
const tokenProviderFactory = vi.fn(() => {
|
|
179
|
+
throw new Error("no credentials");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await expect(
|
|
183
|
+
generateBearerTokenFromIam({ region: "us-east-1", tokenProviderFactory }),
|
|
184
|
+
).resolves.toBeUndefined();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("skips IAM token generation when plugin discovery is disabled", async () => {
|
|
188
|
+
const tokenProviderFactory = vi.fn(() => {
|
|
189
|
+
throw new Error("disabled discovery should not generate a token");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await expect(
|
|
193
|
+
resolveImplicitMantleProvider({
|
|
194
|
+
env: { AWS_REGION: "us-east-1" } as NodeJS.ProcessEnv,
|
|
195
|
+
pluginConfig: { discovery: { enabled: false } },
|
|
196
|
+
tokenProviderFactory,
|
|
197
|
+
}),
|
|
198
|
+
).resolves.toBeNull();
|
|
199
|
+
|
|
200
|
+
expect(tokenProviderFactory).not.toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("getCachedIamToken returns cached token when valid", async () => {
|
|
204
|
+
const tokenProvider = vi.fn(async () => "bedrock-cached-token"); // pragma: allowlist secret
|
|
205
|
+
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
|
206
|
+
|
|
207
|
+
// Generate a token to populate the cache
|
|
208
|
+
await generateBearerTokenFromIam({ region: "us-east-1", tokenProviderFactory });
|
|
209
|
+
|
|
210
|
+
// Sync read should return the cached token
|
|
211
|
+
expect(getCachedIamToken("us-east-1")).toBe("bedrock-cached-token");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("getCachedIamToken returns undefined when cache is empty", () => {
|
|
215
|
+
expect(getCachedIamToken("us-east-1")).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("getCachedIamToken returns undefined when cache is expired", async () => {
|
|
219
|
+
const tokenProvider = vi.fn(async () => "bedrock-expired-token"); // pragma: allowlist secret
|
|
220
|
+
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
|
221
|
+
|
|
222
|
+
// Generate with a time far in the past so it's already expired
|
|
223
|
+
await generateBearerTokenFromIam({
|
|
224
|
+
region: "us-east-1",
|
|
225
|
+
now: () => 1000,
|
|
226
|
+
tokenProviderFactory,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// The cache entry exists but expiresAt is 1000 + 3600000 = 3601000
|
|
230
|
+
// Current Date.now() is way past that, so it should be expired
|
|
231
|
+
expect(getCachedIamToken("us-east-1")).toBeUndefined();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("does not cache generated IAM tokens when ttl expiry overflows", async () => {
|
|
235
|
+
const tokenProvider = vi
|
|
236
|
+
.fn<() => Promise<string>>()
|
|
237
|
+
.mockResolvedValueOnce("bedrock-overflow-token-1") // pragma: allowlist secret
|
|
238
|
+
.mockResolvedValueOnce("bedrock-overflow-token-2"); // pragma: allowlist secret
|
|
239
|
+
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
|
240
|
+
|
|
241
|
+
await expect(
|
|
242
|
+
generateBearerTokenFromIam({
|
|
243
|
+
region: "us-east-1",
|
|
244
|
+
now: () => 8_640_000_000_000_000,
|
|
245
|
+
tokenProviderFactory,
|
|
246
|
+
}),
|
|
247
|
+
).resolves.toBe("bedrock-overflow-token-1");
|
|
248
|
+
expect(getCachedIamToken("us-east-1")).toBeUndefined();
|
|
249
|
+
|
|
250
|
+
await expect(
|
|
251
|
+
generateBearerTokenFromIam({
|
|
252
|
+
region: "us-east-1",
|
|
253
|
+
now: () => 8_640_000_000_000_000,
|
|
254
|
+
tokenProviderFactory,
|
|
255
|
+
}),
|
|
256
|
+
).resolves.toBe("bedrock-overflow-token-2");
|
|
257
|
+
expect(tokenProvider).toHaveBeenCalledTimes(2);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Model discovery
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
it("discovers models from Mantle /v1/models endpoint sorted by id", async () => {
|
|
265
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
266
|
+
ok: true,
|
|
267
|
+
json: async () => ({
|
|
268
|
+
data: [
|
|
269
|
+
{ id: "openai.gpt-oss-120b", object: "model", owned_by: "openai" },
|
|
270
|
+
{ id: "anthropic.claude-sonnet-4-6", object: "model", owned_by: "anthropic" },
|
|
271
|
+
{ id: "mistral.devstral-2-123b", object: "model", owned_by: "mistral" },
|
|
272
|
+
],
|
|
273
|
+
}),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const models = await discoverMantleModels({
|
|
277
|
+
region: "us-east-1",
|
|
278
|
+
bearerToken: "test-token",
|
|
279
|
+
fetchFn: mockFetch as unknown as typeof fetch,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(models).toHaveLength(3);
|
|
283
|
+
// Models should be sorted alphabetically by id
|
|
284
|
+
expect(models[0]?.id).toBe("anthropic.claude-sonnet-4-6");
|
|
285
|
+
expect(models[0]?.name).toBe("anthropic.claude-sonnet-4-6");
|
|
286
|
+
expect(models[0]?.reasoning).toBe(false);
|
|
287
|
+
expect(models[0]?.input).toEqual(["text"]);
|
|
288
|
+
expect(models[1]?.id).toBe("mistral.devstral-2-123b");
|
|
289
|
+
expect(models[1]?.reasoning).toBe(false);
|
|
290
|
+
expect(models[2]?.id).toBe("openai.gpt-oss-120b");
|
|
291
|
+
expect(models[2]?.reasoning).toBe(true); // GPT-OSS 120B supports reasoning
|
|
292
|
+
|
|
293
|
+
// Verify correct endpoint and auth header
|
|
294
|
+
expect(stringArgAt(mockFetch, 0, 0)).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/models");
|
|
295
|
+
expect(recordField(objectArgAt(mockFetch, 0, 1).headers, "headers").Authorization).toBe(
|
|
296
|
+
"Bearer test-token",
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("infers reasoning support from model IDs", async () => {
|
|
301
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
302
|
+
ok: true,
|
|
303
|
+
json: async () => ({
|
|
304
|
+
data: [
|
|
305
|
+
{ id: "moonshotai.kimi-k2-thinking", object: "model" },
|
|
306
|
+
{ id: "openai.gpt-oss-120b", object: "model" },
|
|
307
|
+
{ id: "openai.gpt-oss-safeguard-120b", object: "model" },
|
|
308
|
+
{ id: "deepseek.v3.2", object: "model" },
|
|
309
|
+
{ id: "mistral.mistral-large-3-675b-instruct", object: "model" },
|
|
310
|
+
],
|
|
311
|
+
}),
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const models = await discoverMantleModels({
|
|
315
|
+
region: "us-east-1",
|
|
316
|
+
bearerToken: "test-token",
|
|
317
|
+
fetchFn: mockFetch as unknown as typeof fetch,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const byId = Object.fromEntries(models.map((m) => [m.id, m]));
|
|
321
|
+
expect(byId["moonshotai.kimi-k2-thinking"]?.reasoning).toBe(true);
|
|
322
|
+
expect(byId["openai.gpt-oss-120b"]?.reasoning).toBe(true);
|
|
323
|
+
expect(byId["openai.gpt-oss-safeguard-120b"]?.reasoning).toBe(true);
|
|
324
|
+
expect(byId["deepseek.v3.2"]?.reasoning).toBe(false);
|
|
325
|
+
expect(byId["mistral.mistral-large-3-675b-instruct"]?.reasoning).toBe(false);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("returns empty array on permission error", async () => {
|
|
329
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
330
|
+
ok: false,
|
|
331
|
+
status: 403,
|
|
332
|
+
statusText: "Forbidden",
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const models = await discoverMantleModels({
|
|
336
|
+
region: "us-east-1",
|
|
337
|
+
bearerToken: "test-token",
|
|
338
|
+
fetchFn: mockFetch as unknown as typeof fetch,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
expect(models).toStrictEqual([]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("returns empty array on network error", async () => {
|
|
345
|
+
const mockFetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
|
|
346
|
+
|
|
347
|
+
const models = await discoverMantleModels({
|
|
348
|
+
region: "us-east-1",
|
|
349
|
+
bearerToken: "test-token",
|
|
350
|
+
fetchFn: mockFetch as unknown as typeof fetch,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
expect(models).toStrictEqual([]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("filters out models with empty IDs", async () => {
|
|
357
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
358
|
+
ok: true,
|
|
359
|
+
json: async () => ({
|
|
360
|
+
data: [
|
|
361
|
+
{ id: "anthropic.claude-sonnet-4-6", object: "model" },
|
|
362
|
+
{ id: "", object: "model" },
|
|
363
|
+
{ id: " ", object: "model" },
|
|
364
|
+
],
|
|
365
|
+
}),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const models = await discoverMantleModels({
|
|
369
|
+
region: "us-east-1",
|
|
370
|
+
bearerToken: "test-token",
|
|
371
|
+
fetchFn: mockFetch as unknown as typeof fetch,
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
expect(models).toHaveLength(1);
|
|
375
|
+
expect(models[0]?.id).toBe("anthropic.claude-sonnet-4-6");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Discovery caching
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
it("returns cached models on subsequent calls within refresh interval", async () => {
|
|
383
|
+
let now = 1000000;
|
|
384
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
385
|
+
ok: true,
|
|
386
|
+
json: async () => ({
|
|
387
|
+
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
|
|
388
|
+
}),
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// First call — hits the network
|
|
392
|
+
const first = await discoverMantleModels({
|
|
393
|
+
region: "us-east-1",
|
|
394
|
+
bearerToken: "test-token",
|
|
395
|
+
fetchFn: mockFetch as unknown as typeof fetch,
|
|
396
|
+
now: () => now,
|
|
397
|
+
});
|
|
398
|
+
expect(first).toHaveLength(1);
|
|
399
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
400
|
+
|
|
401
|
+
// Second call within refresh interval — uses cache
|
|
402
|
+
now += 60_000; // 1 minute later
|
|
403
|
+
const second = await discoverMantleModels({
|
|
404
|
+
region: "us-east-1",
|
|
405
|
+
bearerToken: "test-token",
|
|
406
|
+
fetchFn: mockFetch as unknown as typeof fetch,
|
|
407
|
+
now: () => now,
|
|
408
|
+
});
|
|
409
|
+
expect(second).toHaveLength(1);
|
|
410
|
+
expect(mockFetch).toHaveBeenCalledTimes(1); // No additional fetch
|
|
411
|
+
|
|
412
|
+
// Third call after refresh interval — re-fetches
|
|
413
|
+
now += 3600_000; // 1 hour later
|
|
414
|
+
const third = await discoverMantleModels({
|
|
415
|
+
region: "us-east-1",
|
|
416
|
+
bearerToken: "test-token",
|
|
417
|
+
fetchFn: mockFetch as unknown as typeof fetch,
|
|
418
|
+
now: () => now,
|
|
419
|
+
});
|
|
420
|
+
expect(third).toHaveLength(1);
|
|
421
|
+
expect(mockFetch).toHaveBeenCalledTimes(2); // Re-fetched
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("returns stale cache on fetch failure", async () => {
|
|
425
|
+
let now = 1000000;
|
|
426
|
+
const mockFetch = vi
|
|
427
|
+
.fn()
|
|
428
|
+
.mockResolvedValueOnce({
|
|
429
|
+
ok: true,
|
|
430
|
+
json: async () => ({
|
|
431
|
+
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
|
|
432
|
+
}),
|
|
433
|
+
})
|
|
434
|
+
.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
|
435
|
+
|
|
436
|
+
// First call — succeeds
|
|
437
|
+
await discoverMantleModels({
|
|
438
|
+
region: "us-east-1",
|
|
439
|
+
bearerToken: "test-token",
|
|
440
|
+
fetchFn: mockFetch as unknown as typeof fetch,
|
|
441
|
+
now: () => now,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// Second call after expiry — fails but returns stale cache
|
|
445
|
+
now += 7200_000;
|
|
446
|
+
const stale = await discoverMantleModels({
|
|
447
|
+
region: "us-east-1",
|
|
448
|
+
bearerToken: "test-token",
|
|
449
|
+
fetchFn: mockFetch as unknown as typeof fetch,
|
|
450
|
+
now: () => now,
|
|
451
|
+
});
|
|
452
|
+
expect(stale).toHaveLength(1);
|
|
453
|
+
expect(stale[0]?.id).toBe("anthropic.claude-sonnet-4-6");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// Implicit provider resolution
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
it("resolves implicit provider when bearer token is set", async () => {
|
|
461
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
462
|
+
ok: true,
|
|
463
|
+
json: async () => ({
|
|
464
|
+
data: [{ id: "anthropic.claude-sonnet-4-6", object: "model" }],
|
|
465
|
+
}),
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const provider = await resolveImplicitMantleProvider({
|
|
469
|
+
env: {
|
|
470
|
+
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
|
|
471
|
+
AWS_REGION: "us-east-1",
|
|
472
|
+
} as NodeJS.ProcessEnv,
|
|
473
|
+
fetchFn: mockFetch as unknown as typeof fetch,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
expect(provider?.baseUrl).toBe("https://bedrock-mantle.us-east-1.api.aws/v1");
|
|
477
|
+
expect(provider?.api).toBe("openai-completions");
|
|
478
|
+
expect(provider?.auth).toBe("api-key");
|
|
479
|
+
expect(provider?.apiKey).toBe("env:AWS_BEARER_TOKEN_BEDROCK");
|
|
480
|
+
expect(provider?.models).toHaveLength(2);
|
|
481
|
+
const opus = provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7");
|
|
482
|
+
expect(opus?.api).toBe("anthropic-messages");
|
|
483
|
+
expect(opus?.reasoning).toBe(false);
|
|
484
|
+
expect(opus).not.toHaveProperty("baseUrl");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("returns null when no auth is available", async () => {
|
|
488
|
+
const tokenProviderFactory = vi.fn(() => {
|
|
489
|
+
throw new Error("no credentials");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
const provider = await resolveImplicitMantleProvider({
|
|
493
|
+
env: {} as NodeJS.ProcessEnv,
|
|
494
|
+
tokenProviderFactory,
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
expect(provider).toBeNull();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("uses a generated IAM token when no explicit token is set", async () => {
|
|
501
|
+
const tokenProvider = vi.fn(async () => "bedrock-api-key-iam"); // pragma: allowlist secret
|
|
502
|
+
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
|
503
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
504
|
+
ok: true,
|
|
505
|
+
json: async () => ({
|
|
506
|
+
data: [{ id: "openai.gpt-oss-120b", object: "model" }],
|
|
507
|
+
}),
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const provider = await resolveImplicitMantleProvider({
|
|
511
|
+
env: {
|
|
512
|
+
AWS_PROFILE: "default",
|
|
513
|
+
AWS_REGION: "us-east-1",
|
|
514
|
+
} as NodeJS.ProcessEnv,
|
|
515
|
+
fetchFn: mockFetch as unknown as typeof fetch,
|
|
516
|
+
tokenProviderFactory,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
expect(provider?.apiKey).toBe(MANTLE_IAM_TOKEN_MARKER);
|
|
520
|
+
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
|
521
|
+
expect(stringArgAt(mockFetch, 0, 0)).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/models");
|
|
522
|
+
expect(recordField(objectArgAt(mockFetch, 0, 1).headers, "headers").Authorization).toBe(
|
|
523
|
+
"Bearer bedrock-api-key-iam",
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it("resolves Mantle runtime auth from the cached IAM token marker", async () => {
|
|
528
|
+
const tokenProvider = vi.fn(async () => "bedrock-api-key-runtime"); // pragma: allowlist secret
|
|
529
|
+
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
|
530
|
+
|
|
531
|
+
await generateBearerTokenFromIam({
|
|
532
|
+
region: "us-east-1",
|
|
533
|
+
now: () => 1000,
|
|
534
|
+
tokenProviderFactory,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const resolved = await resolveMantleRuntimeBearerToken({
|
|
538
|
+
apiKey: MANTLE_IAM_TOKEN_MARKER,
|
|
539
|
+
env: {
|
|
540
|
+
AWS_REGION: "us-east-1",
|
|
541
|
+
} as NodeJS.ProcessEnv,
|
|
542
|
+
now: () => 2000,
|
|
543
|
+
tokenProviderFactory,
|
|
544
|
+
});
|
|
545
|
+
expect(resolved?.apiKey).toBe("bedrock-api-key-runtime");
|
|
546
|
+
expect(resolved?.expiresAt).toBe(1000 + 7200_000);
|
|
547
|
+
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it("generates a fresh Mantle runtime IAM token when the cache is cold", async () => {
|
|
551
|
+
const tokenProvider = vi.fn(async () => "bedrock-api-key-fresh"); // pragma: allowlist secret
|
|
552
|
+
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
|
553
|
+
|
|
554
|
+
const resolved = await resolveMantleRuntimeBearerToken({
|
|
555
|
+
apiKey: MANTLE_IAM_TOKEN_MARKER,
|
|
556
|
+
env: {
|
|
557
|
+
AWS_REGION: "us-east-1",
|
|
558
|
+
} as NodeJS.ProcessEnv,
|
|
559
|
+
now: () => 5000,
|
|
560
|
+
tokenProviderFactory,
|
|
561
|
+
});
|
|
562
|
+
expect(resolved?.apiKey).toBe("bedrock-api-key-fresh");
|
|
563
|
+
expect(resolved?.expiresAt).toBe(5000 + 7200_000);
|
|
564
|
+
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("omits Mantle runtime IAM token expiry when the process clock is invalid", async () => {
|
|
568
|
+
const tokenProvider = vi.fn(async () => "bedrock-api-key-invalid-clock"); // pragma: allowlist secret
|
|
569
|
+
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
|
570
|
+
|
|
571
|
+
const resolved = await resolveMantleRuntimeBearerToken({
|
|
572
|
+
apiKey: MANTLE_IAM_TOKEN_MARKER,
|
|
573
|
+
env: {
|
|
574
|
+
AWS_REGION: "us-east-1",
|
|
575
|
+
} as NodeJS.ProcessEnv,
|
|
576
|
+
now: () => Number.NaN,
|
|
577
|
+
tokenProviderFactory,
|
|
578
|
+
});
|
|
579
|
+
expect(resolved).toEqual({
|
|
580
|
+
apiKey: "bedrock-api-key-invalid-clock",
|
|
581
|
+
});
|
|
582
|
+
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("returns null for unsupported regions", async () => {
|
|
586
|
+
const provider = await resolveImplicitMantleProvider({
|
|
587
|
+
env: {
|
|
588
|
+
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
|
|
589
|
+
AWS_REGION: "af-south-1",
|
|
590
|
+
} as NodeJS.ProcessEnv,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
expect(provider).toBeNull();
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
it("defaults to us-east-1 when no region is set", async () => {
|
|
597
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
598
|
+
ok: true,
|
|
599
|
+
json: async () => ({ data: [{ id: "openai.gpt-oss-120b", object: "model" }] }),
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
const provider = await resolveImplicitMantleProvider({
|
|
603
|
+
env: {
|
|
604
|
+
AWS_BEARER_TOKEN_BEDROCK: "my-token", // pragma: allowlist secret
|
|
605
|
+
} as NodeJS.ProcessEnv,
|
|
606
|
+
fetchFn: mockFetch as unknown as typeof fetch,
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
expect(provider?.baseUrl).toBe("https://bedrock-mantle.us-east-1.api.aws/v1");
|
|
610
|
+
expect(stringArgAt(mockFetch, 0, 0)).toBe("https://bedrock-mantle.us-east-1.api.aws/v1/models");
|
|
611
|
+
objectArgAt(mockFetch, 0, 1);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// ---------------------------------------------------------------------------
|
|
615
|
+
// Provider merging
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
it("merges implicit models when existing provider has empty models", () => {
|
|
619
|
+
const result = mergeImplicitMantleProvider({
|
|
620
|
+
existing: {
|
|
621
|
+
baseUrl: "https://custom.example.com/v1",
|
|
622
|
+
models: [],
|
|
623
|
+
},
|
|
624
|
+
implicit: {
|
|
625
|
+
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
|
|
626
|
+
api: "openai-completions",
|
|
627
|
+
auth: "api-key",
|
|
628
|
+
apiKey: "env:AWS_BEARER_TOKEN_BEDROCK",
|
|
629
|
+
models: [
|
|
630
|
+
{
|
|
631
|
+
id: "openai.gpt-oss-120b",
|
|
632
|
+
name: "GPT-OSS 120B",
|
|
633
|
+
reasoning: true,
|
|
634
|
+
input: ["text"],
|
|
635
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
636
|
+
contextWindow: 32000,
|
|
637
|
+
maxTokens: 4096,
|
|
638
|
+
},
|
|
639
|
+
],
|
|
640
|
+
},
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
expect(result.baseUrl).toBe("https://custom.example.com/v1");
|
|
644
|
+
expect(result.models?.map((m) => m.id)).toEqual(["openai.gpt-oss-120b"]);
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("preserves existing models over implicit ones", () => {
|
|
648
|
+
const result = mergeImplicitMantleProvider({
|
|
649
|
+
existing: {
|
|
650
|
+
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
|
|
651
|
+
models: [
|
|
652
|
+
{
|
|
653
|
+
id: "custom-model",
|
|
654
|
+
name: "My Custom Model",
|
|
655
|
+
reasoning: false,
|
|
656
|
+
input: ["text"],
|
|
657
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
658
|
+
contextWindow: 64000,
|
|
659
|
+
maxTokens: 8192,
|
|
660
|
+
},
|
|
661
|
+
],
|
|
662
|
+
},
|
|
663
|
+
implicit: {
|
|
664
|
+
baseUrl: "https://bedrock-mantle.us-east-1.api.aws/v1",
|
|
665
|
+
api: "openai-completions",
|
|
666
|
+
auth: "api-key",
|
|
667
|
+
models: [
|
|
668
|
+
{
|
|
669
|
+
id: "openai.gpt-oss-120b",
|
|
670
|
+
name: "GPT-OSS 120B",
|
|
671
|
+
reasoning: true,
|
|
672
|
+
input: ["text"],
|
|
673
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
674
|
+
contextWindow: 32000,
|
|
675
|
+
maxTokens: 4096,
|
|
676
|
+
},
|
|
677
|
+
],
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
expect(result.models?.map((m) => m.id)).toEqual(["custom-model"]);
|
|
682
|
+
});
|
|
683
|
+
});
|