@lobu/gateway 3.0.7 → 3.0.9

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.
Files changed (90) hide show
  1. package/dist/api/platform.d.ts.map +1 -1
  2. package/dist/api/platform.js +1 -0
  3. package/dist/api/platform.js.map +1 -1
  4. package/dist/auth/bedrock/models.d.ts +13 -0
  5. package/dist/auth/bedrock/models.d.ts.map +1 -0
  6. package/dist/auth/bedrock/models.js +75 -0
  7. package/dist/auth/bedrock/models.js.map +1 -0
  8. package/dist/auth/bedrock/provider-module.d.ts +17 -0
  9. package/dist/auth/bedrock/provider-module.d.ts.map +1 -0
  10. package/dist/auth/bedrock/provider-module.js +80 -0
  11. package/dist/auth/bedrock/provider-module.js.map +1 -0
  12. package/dist/auth/external/device-code-client.d.ts +2 -0
  13. package/dist/auth/external/device-code-client.d.ts.map +1 -1
  14. package/dist/auth/external/device-code-client.js +12 -4
  15. package/dist/auth/external/device-code-client.js.map +1 -1
  16. package/dist/auth/mcp/config-service.d.ts +3 -4
  17. package/dist/auth/mcp/config-service.d.ts.map +1 -1
  18. package/dist/auth/mcp/config-service.js +40 -12
  19. package/dist/auth/mcp/config-service.js.map +1 -1
  20. package/dist/auth/mcp/proxy.d.ts +1 -3
  21. package/dist/auth/mcp/proxy.d.ts.map +1 -1
  22. package/dist/auth/mcp/proxy.js.map +1 -1
  23. package/dist/cli/gateway.d.ts.map +1 -1
  24. package/dist/cli/gateway.js +12 -5
  25. package/dist/cli/gateway.js.map +1 -1
  26. package/dist/cli/index.js +2 -2
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/connections/interaction-bridge.d.ts.map +1 -1
  29. package/dist/connections/interaction-bridge.js +57 -15
  30. package/dist/connections/interaction-bridge.js.map +1 -1
  31. package/dist/connections/message-handler-bridge.d.ts.map +1 -1
  32. package/dist/connections/message-handler-bridge.js +44 -26
  33. package/dist/connections/message-handler-bridge.js.map +1 -1
  34. package/dist/gateway/index.d.ts.map +1 -1
  35. package/dist/gateway/index.js +1 -3
  36. package/dist/gateway/index.js.map +1 -1
  37. package/dist/orchestration/base-deployment-manager.js +7 -7
  38. package/dist/orchestration/base-deployment-manager.js.map +1 -1
  39. package/dist/platform/unified-thread-consumer.d.ts.map +1 -1
  40. package/dist/platform/unified-thread-consumer.js +38 -34
  41. package/dist/platform/unified-thread-consumer.js.map +1 -1
  42. package/dist/routes/internal/device-auth.d.ts +7 -0
  43. package/dist/routes/internal/device-auth.d.ts.map +1 -1
  44. package/dist/routes/internal/device-auth.js +101 -48
  45. package/dist/routes/internal/device-auth.js.map +1 -1
  46. package/dist/routes/public/cli-auth.d.ts.map +1 -1
  47. package/dist/routes/public/cli-auth.js +10 -0
  48. package/dist/routes/public/cli-auth.js.map +1 -1
  49. package/dist/services/bedrock-anthropic-service.d.ts +87 -0
  50. package/dist/services/bedrock-anthropic-service.d.ts.map +1 -0
  51. package/dist/services/bedrock-anthropic-service.js +453 -0
  52. package/dist/services/bedrock-anthropic-service.js.map +1 -0
  53. package/dist/services/bedrock-model-catalog.d.ts +28 -0
  54. package/dist/services/bedrock-model-catalog.d.ts.map +1 -0
  55. package/dist/services/bedrock-model-catalog.js +160 -0
  56. package/dist/services/bedrock-model-catalog.js.map +1 -0
  57. package/dist/services/bedrock-openai-service.d.ts +119 -0
  58. package/dist/services/bedrock-openai-service.d.ts.map +1 -0
  59. package/dist/services/bedrock-openai-service.js +412 -0
  60. package/dist/services/bedrock-openai-service.js.map +1 -0
  61. package/dist/services/core-services.d.ts +3 -0
  62. package/dist/services/core-services.d.ts.map +1 -1
  63. package/dist/services/core-services.js +13 -0
  64. package/dist/services/core-services.js.map +1 -1
  65. package/dist/services/system-config-resolver.d.ts.map +1 -1
  66. package/dist/services/system-config-resolver.js +0 -2
  67. package/dist/services/system-config-resolver.js.map +1 -1
  68. package/package.json +12 -10
  69. package/src/__tests__/bedrock-model-catalog.test.ts +40 -0
  70. package/src/__tests__/bedrock-openai-service.test.ts +157 -0
  71. package/src/__tests__/bedrock-provider-module.test.ts +56 -0
  72. package/src/__tests__/mcp-config-service.test.ts +1 -1
  73. package/src/__tests__/mcp-proxy.test.ts +1 -3
  74. package/src/auth/bedrock/provider-module.ts +110 -0
  75. package/src/auth/external/device-code-client.ts +14 -4
  76. package/src/auth/mcp/config-service.ts +49 -21
  77. package/src/auth/mcp/proxy.ts +1 -3
  78. package/src/cli/gateway.ts +8 -0
  79. package/src/cli/index.ts +2 -2
  80. package/src/connections/message-handler-bridge.ts +76 -51
  81. package/src/gateway/index.ts +1 -3
  82. package/src/orchestration/base-deployment-manager.ts +7 -7
  83. package/src/platform/unified-thread-consumer.ts +49 -42
  84. package/src/routes/internal/device-auth.ts +137 -51
  85. package/src/routes/public/cli-auth.ts +13 -0
  86. package/src/services/bedrock-model-catalog.ts +217 -0
  87. package/src/services/bedrock-openai-service.ts +658 -0
  88. package/src/services/core-services.ts +19 -0
  89. package/src/services/system-config-resolver.ts +0 -1
  90. package/tsconfig.tsbuildinfo +1 -0
@@ -1,4 +1,9 @@
1
- import { createLogger, decrypt, encrypt } from "@lobu/core";
1
+ import {
2
+ type McpOAuthConfig,
3
+ createLogger,
4
+ decrypt,
5
+ encrypt,
6
+ } from "@lobu/core";
2
7
  import { Hono } from "hono";
3
8
  import type Redis from "ioredis";
4
9
  import { GenericDeviceCodeClient } from "../../auth/external/device-code-client";
@@ -18,6 +23,8 @@ interface StoredCredential {
18
23
  clientId: string;
19
24
  clientSecret?: string;
20
25
  tokenUrl: string;
26
+ /** RFC 8707 resource indicator, included in refresh requests. */
27
+ resource?: string;
21
28
  }
22
29
 
23
30
  interface StoredDeviceAuth {
@@ -29,6 +36,12 @@ interface StoredDeviceAuth {
29
36
  expiresAt: number;
30
37
  tokenUrl: string;
31
38
  issuer: string;
39
+ /** Stored so poll/complete can reconstruct the client without re-deriving. */
40
+ deviceAuthorizationUrl?: string;
41
+ /** RFC 8707 resource indicator. */
42
+ resource?: string;
43
+ /** Custom scopes from oauth config. */
44
+ scope?: string;
32
45
  }
33
46
 
34
47
  interface StoredClient {
@@ -41,6 +54,17 @@ export interface DeviceAuthConfig {
41
54
  mcpConfigService: McpConfigService;
42
55
  }
43
56
 
57
+ interface ResolvedOAuthEndpoints {
58
+ registrationUrl: string;
59
+ deviceAuthorizationUrl: string;
60
+ tokenUrl: string;
61
+ verificationUri: string;
62
+ scope: string;
63
+ clientId?: string;
64
+ clientSecret?: string;
65
+ resource?: string;
66
+ }
67
+
44
68
  function credentialKey(agentId: string, userId: string, mcpId: string): string {
45
69
  return `auth:credential:${agentId}:${userId}:${mcpId}`;
46
70
  }
@@ -61,6 +85,36 @@ function refreshLockKey(
61
85
  return `auth:refresh-lock:${agentId}:${userId}:${mcpId}`;
62
86
  }
63
87
 
88
+ function deriveOAuthBaseUrl(upstreamUrl: string): string {
89
+ const url = new URL(upstreamUrl);
90
+ url.pathname = "/";
91
+ url.search = "";
92
+ url.hash = "";
93
+ return url.toString().replace(/\/$/, "");
94
+ }
95
+
96
+ /**
97
+ * Resolve OAuth endpoints from explicit config, falling back to
98
+ * auto-derived endpoints from the MCP server's URL origin.
99
+ */
100
+ function resolveOAuthEndpoints(
101
+ upstreamUrl: string,
102
+ oauth?: McpOAuthConfig
103
+ ): ResolvedOAuthEndpoints {
104
+ const issuer = deriveOAuthBaseUrl(upstreamUrl);
105
+ return {
106
+ registrationUrl: oauth?.registrationUrl ?? `${issuer}/oauth/register`,
107
+ deviceAuthorizationUrl:
108
+ oauth?.deviceAuthorizationUrl ?? `${issuer}/oauth/device_authorization`,
109
+ tokenUrl: oauth?.tokenUrl ?? `${issuer}/oauth/token`,
110
+ verificationUri: oauth?.authUrl ?? `${issuer}/oauth/device`,
111
+ scope: oauth?.scopes?.join(" ") || DEFAULT_MCP_SCOPE,
112
+ clientId: oauth?.clientId,
113
+ clientSecret: oauth?.clientSecret,
114
+ resource: oauth?.resource,
115
+ };
116
+ }
117
+
64
118
  export async function getStoredCredential(
65
119
  redis: Redis,
66
120
  agentId: string,
@@ -120,6 +174,9 @@ export async function refreshCredential(
120
174
  if (credential.clientSecret) {
121
175
  body.client_secret = credential.clientSecret;
122
176
  }
177
+ if (credential.resource) {
178
+ body.resource = credential.resource;
179
+ }
123
180
 
124
181
  const response = await fetch(credential.tokenUrl, {
125
182
  method: "POST",
@@ -156,6 +213,7 @@ export async function refreshCredential(
156
213
  clientId: credential.clientId,
157
214
  clientSecret: credential.clientSecret,
158
215
  tokenUrl: credential.tokenUrl,
216
+ resource: credential.resource,
159
217
  };
160
218
 
161
219
  await storeCredential(redis, agentId, userId, mcpId, refreshed);
@@ -201,8 +259,11 @@ export async function tryCompletePendingDeviceAuth(
201
259
  clientId: deviceState.clientId,
202
260
  clientSecret: deviceState.clientSecret,
203
261
  tokenUrl: deviceState.tokenUrl,
204
- deviceAuthorizationUrl: `${deviceState.issuer}/oauth/device_authorization`,
205
- scope: DEFAULT_MCP_SCOPE,
262
+ deviceAuthorizationUrl:
263
+ deviceState.deviceAuthorizationUrl ??
264
+ `${deviceState.issuer}/oauth/device_authorization`,
265
+ scope: deviceState.scope ?? DEFAULT_MCP_SCOPE,
266
+ resource: deviceState.resource,
206
267
  tokenEndpointAuthMethod: deviceState.clientSecret
207
268
  ? "client_secret_post"
208
269
  : "none",
@@ -231,6 +292,7 @@ export async function tryCompletePendingDeviceAuth(
231
292
  clientId: deviceState.clientId,
232
293
  clientSecret: deviceState.clientSecret,
233
294
  tokenUrl: deviceState.tokenUrl,
295
+ resource: deviceState.resource,
234
296
  };
235
297
 
236
298
  await storeCredential(redis, agentId, userId, mcpId, storedCred);
@@ -251,17 +313,12 @@ export async function tryCompletePendingDeviceAuth(
251
313
  }
252
314
  }
253
315
 
254
- function deriveOAuthBaseUrl(upstreamUrl: string): string {
255
- const url = new URL(upstreamUrl);
256
- url.pathname = "/";
257
- url.search = "";
258
- url.hash = "";
259
- return url.toString().replace(/\/$/, "");
260
- }
261
-
262
316
  /**
263
317
  * Start device-code auth flow for a given MCP server.
264
318
  * Reusable by the MCP proxy to auto-initiate auth on "unauthorized" errors.
319
+ *
320
+ * When the MCP server's oauth config provides a clientId, dynamic client
321
+ * registration is skipped entirely.
265
322
  */
266
323
  export async function startDeviceAuth(
267
324
  redis: Redis,
@@ -269,7 +326,7 @@ export async function startDeviceAuth(
269
326
  getHttpServer: (
270
327
  id: string,
271
328
  agentId?: string
272
- ) => Promise<{ upstreamUrl: string } | undefined>;
329
+ ) => Promise<{ upstreamUrl: string; oauth?: McpOAuthConfig } | undefined>;
273
330
  },
274
331
  mcpId: string,
275
332
  agentId: string,
@@ -290,7 +347,11 @@ export async function startDeviceAuth(
290
347
  const issuer = httpServer
291
348
  ? deriveOAuthBaseUrl(httpServer.upstreamUrl)
292
349
  : existing.issuer;
293
- const verificationUri = `${issuer}/oauth/device`;
350
+ const endpoints = httpServer
351
+ ? resolveOAuthEndpoints(httpServer.upstreamUrl, httpServer.oauth)
352
+ : null;
353
+ const verificationUri =
354
+ endpoints?.verificationUri ?? `${issuer}/oauth/device`;
294
355
  logger.info("Reusing existing pending device auth", {
295
356
  mcpId,
296
357
  agentId,
@@ -316,53 +377,71 @@ export async function startDeviceAuth(
316
377
  return null;
317
378
  }
318
379
 
319
- const issuer = deriveOAuthBaseUrl(httpServer.upstreamUrl);
380
+ const endpoints = resolveOAuthEndpoints(
381
+ httpServer.upstreamUrl,
382
+ httpServer.oauth
383
+ );
320
384
 
321
- // Check cached client registration
385
+ // Resolve client: use explicit config clientId, or cached registration, or register new
322
386
  let client: StoredClient | null = null;
323
- const cachedClient = await redis.get(clientCacheKey(mcpId));
324
- if (cachedClient) {
325
- try {
326
- client = JSON.parse(cachedClient) as StoredClient;
327
- } catch {
328
- client = null;
329
- }
330
- }
331
387
 
332
- // Register a new client if needed
333
- if (!client) {
334
- const regResponse = await fetch(`${issuer}/oauth/register`, {
335
- method: "POST",
336
- headers: { "Content-Type": "application/json" },
337
- body: JSON.stringify({
338
- grant_types: [DEVICE_CODE_GRANT_TYPE, "refresh_token"],
339
- token_endpoint_auth_method: "none",
340
- client_name: "Lobu Gateway Device Auth",
341
- scope: DEFAULT_MCP_SCOPE,
342
- }),
388
+ if (endpoints.clientId) {
389
+ // Config provides a pre-registered client — skip dynamic registration
390
+ client = {
391
+ clientId: endpoints.clientId,
392
+ clientSecret: endpoints.clientSecret,
393
+ };
394
+ logger.info("Using pre-registered OAuth client from config", {
395
+ mcpId,
396
+ clientId: endpoints.clientId,
343
397
  });
398
+ } else {
399
+ // Check cached client registration
400
+ const cachedClient = await redis.get(clientCacheKey(mcpId));
401
+ if (cachedClient) {
402
+ try {
403
+ client = JSON.parse(cachedClient) as StoredClient;
404
+ } catch {
405
+ client = null;
406
+ }
407
+ }
344
408
 
345
- if (!regResponse.ok) return null;
409
+ // Register a new client if needed
410
+ if (!client) {
411
+ const regResponse = await fetch(endpoints.registrationUrl, {
412
+ method: "POST",
413
+ headers: { "Content-Type": "application/json" },
414
+ body: JSON.stringify({
415
+ grant_types: [DEVICE_CODE_GRANT_TYPE, "refresh_token"],
416
+ token_endpoint_auth_method: "none",
417
+ client_name: "Lobu Gateway Device Auth",
418
+ scope: endpoints.scope,
419
+ }),
420
+ });
346
421
 
347
- const registration = (await regResponse.json()) as {
348
- client_id: string;
349
- client_secret?: string;
350
- };
422
+ if (!regResponse.ok) return null;
351
423
 
352
- client = {
353
- clientId: registration.client_id,
354
- clientSecret: registration.client_secret,
355
- };
424
+ const registration = (await regResponse.json()) as {
425
+ client_id: string;
426
+ client_secret?: string;
427
+ };
356
428
 
357
- await redis.set(clientCacheKey(mcpId), JSON.stringify(client));
429
+ client = {
430
+ clientId: registration.client_id,
431
+ clientSecret: registration.client_secret,
432
+ };
433
+
434
+ await redis.set(clientCacheKey(mcpId), JSON.stringify(client));
435
+ }
358
436
  }
359
437
 
360
438
  const deviceCodeClient = new GenericDeviceCodeClient({
361
439
  clientId: client.clientId,
362
440
  clientSecret: client.clientSecret,
363
- tokenUrl: `${issuer}/oauth/token`,
364
- deviceAuthorizationUrl: `${issuer}/oauth/device_authorization`,
365
- scope: DEFAULT_MCP_SCOPE,
441
+ tokenUrl: endpoints.tokenUrl,
442
+ deviceAuthorizationUrl: endpoints.deviceAuthorizationUrl,
443
+ scope: endpoints.scope,
444
+ resource: endpoints.resource,
366
445
  tokenEndpointAuthMethod: client.clientSecret
367
446
  ? "client_secret_post"
368
447
  : "none",
@@ -377,8 +456,11 @@ export async function startDeviceAuth(
377
456
  clientSecret: client.clientSecret,
378
457
  interval: started.interval,
379
458
  expiresAt: Date.now() + started.expiresIn * 1000,
380
- tokenUrl: `${issuer}/oauth/token`,
381
- issuer,
459
+ tokenUrl: endpoints.tokenUrl,
460
+ issuer: deriveOAuthBaseUrl(httpServer.upstreamUrl),
461
+ deviceAuthorizationUrl: endpoints.deviceAuthorizationUrl,
462
+ resource: endpoints.resource,
463
+ scope: endpoints.scope,
382
464
  };
383
465
 
384
466
  await redis.set(
@@ -483,8 +565,11 @@ export function createDeviceAuthRoutes(
483
565
  clientId: deviceState.clientId,
484
566
  clientSecret: deviceState.clientSecret,
485
567
  tokenUrl: deviceState.tokenUrl,
486
- deviceAuthorizationUrl: `${deviceState.issuer}/oauth/device_authorization`,
487
- scope: DEFAULT_MCP_SCOPE,
568
+ deviceAuthorizationUrl:
569
+ deviceState.deviceAuthorizationUrl ??
570
+ `${deviceState.issuer}/oauth/device_authorization`,
571
+ scope: deviceState.scope ?? DEFAULT_MCP_SCOPE,
572
+ resource: deviceState.resource,
488
573
  tokenEndpointAuthMethod: deviceState.clientSecret
489
574
  ? "client_secret_post"
490
575
  : "none",
@@ -530,6 +615,7 @@ export function createDeviceAuthRoutes(
530
615
  clientId: deviceState.clientId,
531
616
  clientSecret: deviceState.clientSecret,
532
617
  tokenUrl: deviceState.tokenUrl,
618
+ resource: deviceState.resource,
533
619
  };
534
620
 
535
621
  await storeCredential(redis, agentId, userId, mcpId, storedCred);
@@ -651,6 +651,19 @@ export function createCliAuthRoutes(config: CliAuthRoutesConfig): Hono {
651
651
  return c.json(refreshed);
652
652
  });
653
653
 
654
+ router.post("/logout", async (c) => {
655
+ const rawBody = (await c.req.json().catch(() => ({}))) as {
656
+ refreshToken?: string;
657
+ };
658
+ const refreshToken = rawBody.refreshToken?.trim();
659
+ if (!refreshToken) {
660
+ return c.json({ error: "Missing refreshToken" }, 400);
661
+ }
662
+
663
+ await tokenService.revokeSessionByRefreshToken(refreshToken);
664
+ return c.json({ ok: true });
665
+ });
666
+
654
667
  router.get("/whoami", async (c) => {
655
668
  const authHeader = c.req.header("authorization");
656
669
  if (!authHeader?.startsWith("Bearer ")) {
@@ -0,0 +1,217 @@
1
+ import {
2
+ BedrockClient,
3
+ InferenceType,
4
+ ListFoundationModelsCommand,
5
+ ModelModality,
6
+ type FoundationModelSummary,
7
+ } from "@aws-sdk/client-bedrock";
8
+ import { getModels, type Model } from "@mariozechner/pi-ai";
9
+ import { createLogger } from "@lobu/core";
10
+
11
+ const logger = createLogger("bedrock-model-catalog");
12
+
13
+ const CACHE_TTL_MS = 5 * 60 * 1000;
14
+
15
+ export interface BedrockCatalogModel {
16
+ id: string;
17
+ label: string;
18
+ providerName?: string;
19
+ modelName?: string;
20
+ inputModalities?: string[];
21
+ outputModalities?: string[];
22
+ }
23
+
24
+ export interface BedrockModelCatalogOptions {
25
+ cacheTtlMs?: number;
26
+ loadModels?: () => Promise<BedrockCatalogModel[]>;
27
+ }
28
+
29
+ export function resolveAwsRegion(): string | undefined {
30
+ return process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || undefined;
31
+ }
32
+
33
+ function hasTextOutput(summary: FoundationModelSummary): boolean {
34
+ return (summary.outputModalities || []).includes(ModelModality.TEXT);
35
+ }
36
+
37
+ function hasTextInput(summary: FoundationModelSummary): boolean {
38
+ return (summary.inputModalities || []).includes(ModelModality.TEXT);
39
+ }
40
+
41
+ function supportsStreaming(summary: FoundationModelSummary): boolean {
42
+ return summary.responseStreamingSupported === true;
43
+ }
44
+
45
+ function supportsOnDemand(summary: FoundationModelSummary): boolean {
46
+ const inferenceTypes = summary.inferenceTypesSupported || [];
47
+ return (
48
+ inferenceTypes.length === 0 ||
49
+ inferenceTypes.includes(InferenceType.ON_DEMAND)
50
+ );
51
+ }
52
+
53
+ function buildModelLabel(model: {
54
+ providerName?: string;
55
+ modelName?: string;
56
+ id: string;
57
+ }): string {
58
+ const provider = model.providerName?.trim();
59
+ const name = model.modelName?.trim();
60
+ if (provider && name) return `${provider} / ${name}`;
61
+ return name || model.id;
62
+ }
63
+
64
+ function normalizeFoundationModel(
65
+ summary: FoundationModelSummary
66
+ ): BedrockCatalogModel | null {
67
+ const id = summary.modelId?.trim();
68
+ if (!id) return null;
69
+ if (!hasTextOutput(summary) || !hasTextInput(summary)) return null;
70
+ if (!supportsStreaming(summary) || !supportsOnDemand(summary)) return null;
71
+
72
+ return {
73
+ id,
74
+ label: buildModelLabel({
75
+ providerName: summary.providerName,
76
+ modelName: summary.modelName,
77
+ id,
78
+ }),
79
+ providerName: summary.providerName,
80
+ modelName: summary.modelName,
81
+ inputModalities: summary.inputModalities,
82
+ outputModalities: summary.outputModalities,
83
+ };
84
+ }
85
+
86
+ function normalizeRegistryModel(model: Model<any>): BedrockCatalogModel {
87
+ return {
88
+ id: model.id,
89
+ label: model.name || model.id,
90
+ inputModalities: model.input.map((input) => input.toUpperCase()),
91
+ outputModalities: ["TEXT"],
92
+ };
93
+ }
94
+
95
+ function sortModels(a: BedrockCatalogModel, b: BedrockCatalogModel): number {
96
+ return a.label.localeCompare(b.label) || a.id.localeCompare(b.id);
97
+ }
98
+
99
+ export function buildDynamicBedrockModel(
100
+ modelId: string,
101
+ discovered?: Pick<
102
+ BedrockCatalogModel,
103
+ "inputModalities" | "modelName" | "providerName"
104
+ > | null
105
+ ): Model<"bedrock-converse-stream"> {
106
+ const staticModel = getModels("amazon-bedrock").find((m) => m.id === modelId);
107
+ if (staticModel) {
108
+ return staticModel as Model<"bedrock-converse-stream">;
109
+ }
110
+
111
+ const input = (discovered?.inputModalities || [])
112
+ .map((value) => value.toLowerCase())
113
+ .filter(
114
+ (value): value is "text" | "image" =>
115
+ value === "text" || value === "image"
116
+ );
117
+
118
+ return {
119
+ id: modelId,
120
+ name:
121
+ buildModelLabel({
122
+ providerName: discovered?.providerName,
123
+ modelName: discovered?.modelName,
124
+ id: modelId,
125
+ }) || modelId,
126
+ api: "bedrock-converse-stream",
127
+ provider: "amazon-bedrock",
128
+ baseUrl: "",
129
+ reasoning: false,
130
+ input: input.length > 0 ? input : ["text"],
131
+ contextWindow: 128000,
132
+ maxTokens: 8192,
133
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
134
+ };
135
+ }
136
+
137
+ async function loadBedrockModelsFromAws(): Promise<BedrockCatalogModel[]> {
138
+ const region = resolveAwsRegion();
139
+ if (!region) {
140
+ throw new Error("AWS region is not configured");
141
+ }
142
+
143
+ const client = new BedrockClient({ region });
144
+ const response = await client.send(
145
+ new ListFoundationModelsCommand({
146
+ byOutputModality: ModelModality.TEXT,
147
+ })
148
+ );
149
+
150
+ return (response.modelSummaries || [])
151
+ .map(normalizeFoundationModel)
152
+ .filter((model): model is BedrockCatalogModel => Boolean(model))
153
+ .sort(sortModels);
154
+ }
155
+
156
+ function loadFallbackRegistryModels(): BedrockCatalogModel[] {
157
+ return getModels("amazon-bedrock")
158
+ .map(normalizeRegistryModel)
159
+ .sort(sortModels);
160
+ }
161
+
162
+ export class BedrockModelCatalog {
163
+ private readonly cacheTtlMs: number;
164
+ private readonly loadModelsImpl: () => Promise<BedrockCatalogModel[]>;
165
+ private cachedModels:
166
+ | { expiresAt: number; models: BedrockCatalogModel[] }
167
+ | undefined;
168
+
169
+ constructor(options: BedrockModelCatalogOptions = {}) {
170
+ this.cacheTtlMs = options.cacheTtlMs ?? CACHE_TTL_MS;
171
+ this.loadModelsImpl = options.loadModels || loadBedrockModelsFromAws;
172
+ }
173
+
174
+ async listModels(): Promise<BedrockCatalogModel[]> {
175
+ const now = Date.now();
176
+ if (this.cachedModels && this.cachedModels.expiresAt > now) {
177
+ return this.cachedModels.models;
178
+ }
179
+
180
+ try {
181
+ const models = await this.loadModelsImpl();
182
+ this.cachedModels = {
183
+ expiresAt: now + this.cacheTtlMs,
184
+ models,
185
+ };
186
+ return models;
187
+ } catch (error) {
188
+ logger.warn(
189
+ {
190
+ error: error instanceof Error ? error.message : String(error),
191
+ },
192
+ "Falling back to static Bedrock model registry"
193
+ );
194
+ const fallback = loadFallbackRegistryModels();
195
+ this.cachedModels = {
196
+ expiresAt: now + this.cacheTtlMs,
197
+ models: fallback,
198
+ };
199
+ return fallback;
200
+ }
201
+ }
202
+
203
+ async listModelOptions(): Promise<Array<{ id: string; label: string }>> {
204
+ const models = await this.listModels();
205
+ return models.map((model) => ({
206
+ id: model.id,
207
+ label: model.label,
208
+ }));
209
+ }
210
+
211
+ async getModel(modelId: string): Promise<BedrockCatalogModel | null> {
212
+ const normalized = modelId.trim();
213
+ if (!normalized) return null;
214
+ const models = await this.listModels();
215
+ return models.find((model) => model.id === normalized) || null;
216
+ }
217
+ }