Răsfoiți Sursa

fix(core): resolve Bedrock credentials from the AWS default chain

Aiden Cline 1 săptămână în urmă
părinte
comite
d6bb74cb8e

+ 51 - 1
packages/core/src/model-resolver.ts

@@ -209,9 +209,12 @@ export const fromCatalogModel = (
       body: Provider.mergeOverlay(mapping?.body, resolved.body),
       limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
     }
+    const finalized = specifier.startsWith("@opencode-ai/ai/providers/amazon-bedrock")
+      ? yield* withBedrockCredentials(settings, configured)
+      : settings
     return yield* Effect.try({
       try: () => {
-        const runtime = module.model(resolved.modelID ?? resolved.id, settings)
+        const runtime = module.model(resolved.modelID ?? resolved.id, finalized)
         return LanguageModel.update(runtime, {
           provider: resolved.providerID,
           compatibility: resolved.compatibility
@@ -245,6 +248,53 @@ const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
   return rest
 }
 
+// Chain providers cache and refresh resolved credentials internally, so reuse one per
+// AWS profile instead of re-walking SSO, shared config, and instance metadata each turn.
+const bedrockChains = new Map<
+  string,
+  () => Promise<{ accessKeyId: string; secretAccessKey: string; sessionToken?: string }>
+>()
+
+// Bedrock signs requests with static credentials captured at model build time; the route
+// never refreshes them. Model resolution runs before every provider turn, so falling back
+// to the AWS default chain (env, shared config, SSO, process, instance roles) here keeps
+// credentials fresh without persisting them.
+const withBedrockCredentials = Effect.fnUntraced(function* (
+  settings: Readonly<Record<string, unknown>>,
+  configured: Readonly<Record<string, unknown>>,
+) {
+  const base =
+    typeof settings.region !== "string" && process.env.AWS_REGION
+      ? { ...settings, region: process.env.AWS_REGION }
+      : settings
+  if (typeof base.apiKey === "string" || base.credentials !== undefined) return base
+  if (process.env.AWS_BEARER_TOKEN_BEDROCK) return { ...base, apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK }
+  const profile = typeof configured.profile === "string" ? configured.profile : process.env.AWS_PROFILE
+  const identity = yield* Effect.tryPromise(() => {
+    const chain = bedrockChains.get(profile ?? "")
+    if (chain) return chain()
+    return import("@aws-sdk/credential-providers").then((mod) => {
+      const created = mod.fromNodeProviderChain(profile === undefined ? {} : { profile })
+      bedrockChains.set(profile ?? "", created)
+      return created()
+    })
+  }).pipe(
+    Effect.catch((cause) =>
+      Effect.logWarning("AWS credential chain resolution failed for Bedrock", { cause }).pipe(Effect.as(undefined)),
+    ),
+  )
+  if (!identity) return base
+  return {
+    ...base,
+    credentials: {
+      region: typeof base.region === "string" ? base.region : "us-east-1",
+      accessKeyId: identity.accessKeyId,
+      secretAccessKey: identity.secretAccessKey,
+      ...(identity.sessionToken === undefined ? {} : { sessionToken: identity.sessionToken }),
+    },
+  }
+})
+
 const unsupported = (model: Info) =>
   new UnsupportedPackageError({
     providerID: model.providerID,

+ 109 - 0
packages/core/test/model-resolver.test.ts

@@ -21,6 +21,22 @@ interface ModelOptions {
   readonly limit?: Info["limit"]
 }
 
+const withEnv =
+  (env: Record<string, string | undefined>) =>
+  <A, E, R>(effect: Effect.Effect<A, E, R>) => {
+    const saved = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]))
+    const apply = (values: Record<string, string | undefined>) => {
+      for (const [key, value] of Object.entries(values)) {
+        if (value === undefined) delete process.env[key]
+        else process.env[key] = value
+      }
+    }
+    return Effect.sync(() => apply(env)).pipe(
+      Effect.andThen(effect),
+      Effect.ensuring(Effect.sync(() => apply(saved))),
+    )
+  }
+
 const model = (packageName: string | undefined, options: ModelOptions = {}) =>
   Info.make({
     id: ID.make("test-model"),
@@ -479,6 +495,97 @@ describe("ModelResolver", () => {
     }),
   )
 
+  it.effect("resolves Bedrock credentials from the AWS default chain when none are configured", () =>
+    Effect.gen(function* () {
+      const native = yield* ModelResolver.fromCatalogModel(
+        model(Provider.aisdk("@ai-sdk/openai"), {
+          settings: { baseURL: "https://openai.example/v1" },
+        }),
+      )
+      const env = {
+        AWS_ACCESS_KEY_ID: "chain-access",
+        AWS_SECRET_ACCESS_KEY: "chain-secret",
+        AWS_SESSION_TOKEN: "chain-session",
+        AWS_REGION: "eu-west-1",
+        AWS_PROFILE: undefined,
+        AWS_BEARER_TOKEN_BEDROCK: undefined,
+      }
+      yield* Effect.gen(function* () {
+        const resolved = yield* ModelResolver.fromCatalogModel(
+          model("@opencode-ai/ai/providers/amazon-bedrock", { settings: {} }),
+          undefined,
+          {
+            loadPackage: () =>
+              Effect.succeed({
+                model: (modelID, settings) => {
+                  expect(settings.credentials).toEqual({
+                    region: "eu-west-1",
+                    accessKeyId: "chain-access",
+                    secretAccessKey: "chain-secret",
+                    sessionToken: "chain-session",
+                  })
+                  expect(settings).not.toHaveProperty("apiKey")
+                  return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
+                },
+              }),
+          },
+        )
+        expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
+      }).pipe(withEnv(env))
+    }),
+  )
+
+  it.effect("uses the Bedrock bearer token env before the AWS default chain", () =>
+    Effect.gen(function* () {
+      const native = yield* ModelResolver.fromCatalogModel(
+        model(Provider.aisdk("@ai-sdk/openai"), {
+          settings: { baseURL: "https://openai.example/v1" },
+        }),
+      )
+      yield* Effect.gen(function* () {
+        yield* ModelResolver.fromCatalogModel(
+          model("@opencode-ai/ai/providers/amazon-bedrock", { settings: { region: "us-west-2" } }),
+          undefined,
+          {
+            loadPackage: () =>
+              Effect.succeed({
+                model: (modelID, settings) => {
+                  expect(settings.apiKey).toBe("bearer-token")
+                  expect(settings).not.toHaveProperty("credentials")
+                  return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
+                },
+              }),
+          },
+        )
+      }).pipe(withEnv({ AWS_BEARER_TOKEN_BEDROCK: "bearer-token" }))
+    }),
+  )
+
+  it.effect("keeps explicitly configured Bedrock credentials untouched", () =>
+    Effect.gen(function* () {
+      const native = yield* ModelResolver.fromCatalogModel(
+        model(Provider.aisdk("@ai-sdk/openai"), {
+          settings: { baseURL: "https://openai.example/v1" },
+        }),
+      )
+      const credentials = { region: "us-east-1", accessKeyId: "configured", secretAccessKey: "configured-secret" }
+      yield* ModelResolver.fromCatalogModel(
+        model("@opencode-ai/ai/providers/amazon-bedrock", { settings: { credentials } }),
+        undefined,
+        {
+          loadPackage: () =>
+            Effect.succeed({
+              model: (modelID, settings) => {
+                expect(settings.credentials).toEqual(credentials)
+                expect(settings).not.toHaveProperty("apiKey")
+                return LanguageModel.make({ id: modelID, provider: "package-provider", route: native.route })
+              },
+            }),
+        },
+      )
+    }),
+  )
+
   it.effect("maps OAuth credentials to native provider auth settings", () =>
     Effect.gen(function* () {
       const native = yield* ModelResolver.fromCatalogModel(
@@ -625,12 +732,14 @@ describe("ModelResolver", () => {
           settings: { region: "us-east-1", topP: 0.8, serviceTier: "priority" },
           body: {},
         }),
+        Credential.Key.make({ type: "key", key: "secret" }),
       )
       const mantle = yield* ModelResolver.fromCatalogModel(
         model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
           modelID: "openai.gpt-oss-120b",
           settings: { region: "us-east-1" },
         }),
+        Credential.Key.make({ type: "key", key: "secret" }),
       )
 
       expect(google.route.id).toBe("gemini")