Jelajahi Sumber

feat(bedrock): config options and authentication precedence (#6377)

Grégoire Morpain 1 bulan lalu
induk
melakukan
e3b4d4ad49

+ 5 - 1
packages/opencode/src/cli/cmd/auth.ts

@@ -335,7 +335,11 @@ export const AuthLoginCommand = cmd({
 
         if (provider === "amazon-bedrock") {
           prompts.log.info(
-            "Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
+            "Amazon Bedrock authentication priority:\n" +
+              "  1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
+              "  2. AWS credential chain (profile, access keys, IAM roles)\n\n" +
+              "Configure via opencode.json options (profile, region, endpoint) or\n" +
+              "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
           )
           prompts.outro("Done")
           return

+ 32 - 10
packages/opencode/src/provider/provider.ts

@@ -15,7 +15,7 @@ import { Flag } from "../flag/flag"
 import { iife } from "@/util/iife"
 
 // Direct imports for bundled providers
-import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
+import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
 import { createAnthropic } from "@ai-sdk/anthropic"
 import { createAzure } from "@ai-sdk/azure"
 import { createGoogleGenerativeAI } from "@ai-sdk/google"
@@ -168,10 +168,22 @@ export namespace Provider {
       }
     },
     "amazon-bedrock": async () => {
+      const config = await Config.get()
+      const providerConfig = config.provider?.["amazon-bedrock"]
+
       const auth = await Auth.get("amazon-bedrock")
-      const awsProfile = Env.get("AWS_PROFILE")
+
+      // Region precedence: 1) config file, 2) env var, 3) default
+      const configRegion = providerConfig?.options?.region
+      const envRegion = Env.get("AWS_REGION")
+      const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
+
+      // Profile: config file takes precedence over env var
+      const configProfile = providerConfig?.options?.profile
+      const envProfile = Env.get("AWS_PROFILE")
+      const profile = configProfile ?? envProfile
+
       const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
-      const awsRegion = Env.get("AWS_REGION")
 
       const awsBearerToken = iife(() => {
         const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
@@ -183,17 +195,27 @@ export namespace Provider {
         return undefined
       })
 
-      if (!awsProfile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
-
-      const defaultRegion = awsRegion ?? "us-east-1"
+      if (!profile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
 
       const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
+
+      // Build credential provider options (only pass profile if specified)
+      const credentialProviderOptions = profile ? { profile } : {}
+
+      const providerOptions: AmazonBedrockProviderSettings = {
+        region: defaultRegion,
+        credentialProvider: fromNodeProviderChain(credentialProviderOptions),
+      }
+
+      // Add custom endpoint if specified (endpoint takes precedence over baseURL)
+      const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL
+      if (endpoint) {
+        providerOptions.baseURL = endpoint
+      }
+
       return {
         autoload: true,
-        options: {
-          region: defaultRegion,
-          credentialProvider: fromNodeProviderChain(),
-        },
+        options: providerOptions,
         async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
           // Skip region prefixing if model already has global prefix
           if (modelID.startsWith("global.")) {

+ 2 - 0
packages/opencode/test/preload.ts

@@ -42,6 +42,8 @@ delete process.env["GOOGLE_GENERATIVE_AI_API_KEY"]
 delete process.env["AZURE_OPENAI_API_KEY"]
 delete process.env["AWS_ACCESS_KEY_ID"]
 delete process.env["AWS_PROFILE"]
+delete process.env["AWS_REGION"]
+delete process.env["AWS_BEARER_TOKEN_BEDROCK"]
 delete process.env["OPENROUTER_API_KEY"]
 delete process.env["GROQ_API_KEY"]
 delete process.env["MISTRAL_API_KEY"]

+ 71 - 102
packages/opencode/test/provider/amazon-bedrock.test.ts

@@ -1,11 +1,40 @@
-import { test, expect } from "bun:test"
+import { test, expect, mock } from "bun:test"
 import path from "path"
-import { tmpdir } from "../fixture/fixture"
-import { Instance } from "../../src/project/instance"
-import { Provider } from "../../src/provider/provider"
-import { Env } from "../../src/env"
-import { Auth } from "../../src/auth"
-import { Global } from "../../src/global"
+
+// === Mocks ===
+// These mocks are required because Provider.list() triggers:
+// 1. BunProc.install("@aws-sdk/credential-providers") - in bedrock custom loader
+// 2. Plugin.list() which calls BunProc.install() for default plugins
+// Without mocks, these would attempt real package installations that timeout in tests.
+
+mock.module("../../src/bun/index", () => ({
+  BunProc: {
+    install: async (pkg: string) => pkg,
+    run: async () => {
+      throw new Error("BunProc.run should not be called in tests")
+    },
+    which: () => process.execPath,
+    InstallFailedError: class extends Error {},
+  },
+}))
+
+mock.module("@aws-sdk/credential-providers", () => ({
+  fromNodeProviderChain: () => async () => ({
+    accessKeyId: "mock-access-key-id",
+    secretAccessKey: "mock-secret-access-key",
+  }),
+}))
+
+const mockPlugin = () => ({})
+mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
+mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
+
+// Import after mocks are set up
+const { tmpdir } = await import("../fixture/fixture")
+const { Instance } = await import("../../src/project/instance")
+const { Provider } = await import("../../src/provider/provider")
+const { Env } = await import("../../src/env")
+const { Global } = await import("../../src/global")
 
 test("Bedrock: config region takes precedence over AWS_REGION env var", async () => {
   await using tmp = await tmpdir({
@@ -34,13 +63,12 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
     fn: async () => {
       const providers = await Provider.list()
       expect(providers["amazon-bedrock"]).toBeDefined()
-      // Region from config should be used (not env var)
       expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
     },
   })
 })
 
-test("Bedrock: falls back to AWS_REGION env var when no config", async () => {
+test("Bedrock: falls back to AWS_REGION env var when no config region", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
       await Bun.write(
@@ -65,34 +93,7 @@ test("Bedrock: falls back to AWS_REGION env var when no config", async () => {
   })
 })
 
-test("Bedrock: without explicit region config, uses AWS_REGION env or defaults", async () => {
-  await using tmp = await tmpdir({
-    init: async (dir) => {
-      await Bun.write(
-        path.join(dir, "opencode.json"),
-        JSON.stringify({
-          $schema: "https://opencode.ai/config.json",
-        }),
-      )
-    },
-  })
-  await Instance.provide({
-    directory: tmp.path,
-    init: async () => {
-      Env.set("AWS_PROFILE", "default")
-      // AWS_REGION might be set in the environment, use that or default
-    },
-    fn: async () => {
-      const providers = await Provider.list()
-      expect(providers["amazon-bedrock"]).toBeDefined()
-      // Should have some region set (either from env or default)
-      expect(providers["amazon-bedrock"].options?.region).toBeDefined()
-      expect(typeof providers["amazon-bedrock"].options?.region).toBe("string")
-    },
-  })
-})
-
-test("Bedrock: uses config region in provider options", async () => {
+test("Bedrock: loads when bearer token from auth.json is present", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
       await Bun.write(
@@ -102,7 +103,7 @@ test("Bedrock: uses config region in provider options", async () => {
           provider: {
             "amazon-bedrock": {
               options: {
-                region: "eu-north-1",
+                region: "eu-west-1",
               },
             },
           },
@@ -110,54 +111,35 @@ test("Bedrock: uses config region in provider options", async () => {
       )
     },
   })
+
+  const authPath = path.join(Global.Path.data, "auth.json")
+  await Bun.write(
+    authPath,
+    JSON.stringify({
+      "amazon-bedrock": {
+        type: "api",
+        key: "test-bearer-token",
+      },
+    }),
+  )
+
   await Instance.provide({
     directory: tmp.path,
     init: async () => {
-      Env.set("AWS_PROFILE", "default")
+      Env.set("AWS_PROFILE", "")
+      Env.set("AWS_ACCESS_KEY_ID", "")
+      Env.set("AWS_BEARER_TOKEN_BEDROCK", "")
     },
     fn: async () => {
       const providers = await Provider.list()
-      const bedrockProvider = providers["amazon-bedrock"]
-      expect(bedrockProvider).toBeDefined()
-      expect(bedrockProvider.options?.region).toBe("eu-north-1")
+      expect(providers["amazon-bedrock"]).toBeDefined()
+      expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
     },
   })
 })
 
-test("Bedrock: respects config region for different instances", async () => {
-  // First instance with EU config
-  await using tmp1 = await tmpdir({
-    init: async (dir) => {
-      await Bun.write(
-        path.join(dir, "opencode.json"),
-        JSON.stringify({
-          $schema: "https://opencode.ai/config.json",
-          provider: {
-            "amazon-bedrock": {
-              options: {
-                region: "eu-west-1",
-              },
-            },
-          },
-        }),
-      )
-    },
-  })
-
-  await Instance.provide({
-    directory: tmp1.path,
-    init: async () => {
-      Env.set("AWS_PROFILE", "default")
-      Env.set("AWS_REGION", "us-east-1")
-    },
-    fn: async () => {
-      const providers1 = await Provider.list()
-      expect(providers1["amazon-bedrock"].options?.region).toBe("eu-west-1")
-    },
-  })
-
-  // Second instance with US config
-  await using tmp2 = await tmpdir({
+test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async () => {
+  await using tmp = await tmpdir({
     init: async (dir) => {
       await Bun.write(
         path.join(dir, "opencode.json"),
@@ -166,7 +148,8 @@ test("Bedrock: respects config region for different instances", async () => {
           provider: {
             "amazon-bedrock": {
               options: {
-                region: "us-west-2",
+                profile: "my-custom-profile",
+                region: "us-east-1",
               },
             },
           },
@@ -174,21 +157,21 @@ test("Bedrock: respects config region for different instances", async () => {
       )
     },
   })
-
   await Instance.provide({
-    directory: tmp2.path,
+    directory: tmp.path,
     init: async () => {
       Env.set("AWS_PROFILE", "default")
-      Env.set("AWS_REGION", "eu-west-1")
+      Env.set("AWS_ACCESS_KEY_ID", "test-key-id")
     },
     fn: async () => {
-      const providers2 = await Provider.list()
-      expect(providers2["amazon-bedrock"].options?.region).toBe("us-west-2")
+      const providers = await Provider.list()
+      expect(providers["amazon-bedrock"]).toBeDefined()
+      expect(providers["amazon-bedrock"].options?.region).toBe("us-east-1")
     },
   })
 })
 
-test("Bedrock: loads when bearer token from auth.json is present", async () => {
+test("Bedrock: includes custom endpoint in options when specified", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
       await Bun.write(
@@ -198,7 +181,7 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
           provider: {
             "amazon-bedrock": {
               options: {
-                region: "eu-west-1",
+                endpoint: "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
               },
             },
           },
@@ -206,31 +189,17 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
       )
     },
   })
-
-  // Setup auth.json with bearer token for amazon-bedrock
-  const authPath = path.join(Global.Path.data, "auth.json")
-  await Bun.write(
-    authPath,
-    JSON.stringify({
-      "amazon-bedrock": {
-        type: "api",
-        key: "test-bearer-token",
-      },
-    }),
-  )
-
   await Instance.provide({
     directory: tmp.path,
     init: async () => {
-      // Clear env vars so only auth.json should trigger autoload
-      Env.set("AWS_PROFILE", "")
-      Env.set("AWS_ACCESS_KEY_ID", "")
-      Env.set("AWS_BEARER_TOKEN_BEDROCK", "")
+      Env.set("AWS_PROFILE", "default")
     },
     fn: async () => {
       const providers = await Provider.list()
       expect(providers["amazon-bedrock"]).toBeDefined()
-      expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
+      expect(providers["amazon-bedrock"].options?.endpoint).toBe(
+        "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
+      )
     },
   })
 })

+ 35 - 0
packages/web/src/content/docs/config.mdx

@@ -205,6 +205,41 @@ You can also configure [local models](/docs/models#local). [Learn more](/docs/mo
 
 ---
 
+#### Provider-Specific Options
+
+Some providers support additional configuration options beyond the generic `timeout` and `apiKey` settings.
+
+##### Amazon Bedrock
+
+Amazon Bedrock supports AWS-specific configuration:
+
+```json title="opencode.json"
+{
+  "$schema": "https://opencode.ai/config.json",
+  "provider": {
+    "amazon-bedrock": {
+      "options": {
+        "region": "us-east-1",
+        "profile": "my-aws-profile",
+        "endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com"
+      }
+    }
+  }
+}
+```
+
+- `region` - AWS region for Bedrock (defaults to `AWS_REGION` env var or `us-east-1`)
+- `profile` - AWS named profile from `~/.aws/credentials` (defaults to `AWS_PROFILE` env var)
+- `endpoint` - Custom endpoint URL for VPC endpoints. This is an alias for the generic `baseURL` option using AWS-specific terminology. If both are specified, `endpoint` takes precedence.
+
+:::note
+Bearer tokens (`AWS_BEARER_TOKEN_BEDROCK` or `/connect`) take precedence over profile-based authentication. See [authentication precedence](/docs/providers#authentication-precedence) for details.
+:::
+
+[Learn more about Amazon Bedrock configuration](/docs/providers#amazon-bedrock).
+
+---
+
 ### Themes
 
 You can configure the theme you want to use in your OpenCode config through the `theme` option.

+ 81 - 12
packages/web/src/content/docs/providers.mdx

@@ -107,27 +107,96 @@ To use Amazon Bedrock with OpenCode:
    You need to have access to the model you want in Amazon Bedrock.
    :::
 
-1. You'll need either to set one of the following environment variables:
-   - `AWS_ACCESS_KEY_ID`: You can get this by creating an IAM user and generating
-     an access key for it.
-   - `AWS_PROFILE`: First login through AWS IAM Identity Center (or AWS SSO) using
-     `aws sso login`. Then get the name of the profile you want to use.
-   - `AWS_BEARER_TOKEN_BEDROCK`: You can generate a long-term API key from the
-     Amazon Bedrock console.
+2. **Configure authentication** using one of the following methods:
 
-   Once you have one of the above, set it while running opencode.
+   #### Environment Variables (Quick Start)
+
+   Set one of these environment variables while running opencode:
 
    ```bash
-   AWS_ACCESS_KEY_ID=XXX opencode
+   # Option 1: Using AWS access keys
+   AWS_ACCESS_KEY_ID=XXX AWS_SECRET_ACCESS_KEY=YYY opencode
+
+   # Option 2: Using named AWS profile
+   AWS_PROFILE=my-profile opencode
+
+   # Option 3: Using Bedrock bearer token
+   AWS_BEARER_TOKEN_BEDROCK=XXX opencode
    ```
 
-   Or add it to your bash profile.
+   Or add them to your bash profile:
 
    ```bash title="~/.bash_profile"
-   export AWS_ACCESS_KEY_ID=XXX
+   export AWS_PROFILE=my-dev-profile
+   export AWS_REGION=us-east-1
+   ```
+
+   #### Configuration File (Recommended)
+
+   For project-specific or persistent configuration, use `opencode.json`:
+
+   ```json title="opencode.json"
+   {
+     "$schema": "https://opencode.ai/config.json",
+     "provider": {
+       "amazon-bedrock": {
+         "options": {
+           "region": "us-east-1",
+           "profile": "my-aws-profile"
+         }
+       }
+     }
+   }
    ```
 
-1. Run the `/models` command to select the model you want.
+   **Available options:**
+   - `region` - AWS region (e.g., `us-east-1`, `eu-west-1`)
+   - `profile` - AWS named profile from `~/.aws/credentials`
+   - `endpoint` - Custom endpoint URL for VPC endpoints (alias for generic `baseURL` option)
+
+   :::tip
+   Configuration file options take precedence over environment variables.
+   :::
+
+   #### Advanced: VPC Endpoints
+
+   If you're using VPC endpoints for Bedrock:
+
+   ```json title="opencode.json"
+   {
+     "$schema": "https://opencode.ai/config.json",
+     "provider": {
+       "amazon-bedrock": {
+         "options": {
+           "region": "us-east-1",
+           "profile": "production",
+           "endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com"
+         }
+       }
+     }
+   }
+   ```
+
+   :::note
+   The `endpoint` option is an alias for the generic `baseURL` option, using AWS-specific terminology. If both `endpoint` and `baseURL` are specified, `endpoint` takes precedence.
+   :::
+
+   #### Authentication Methods
+   - **`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`**: Create an IAM user and generate access keys in the AWS Console
+   - **`AWS_PROFILE`**: Use named profiles from `~/.aws/credentials`. First configure with `aws configure --profile my-profile` or `aws sso login`
+   - **`AWS_BEARER_TOKEN_BEDROCK`**: Generate long-term API keys from the Amazon Bedrock console
+
+   #### Authentication Precedence
+
+   Amazon Bedrock uses the following authentication priority:
+   1. **Bearer Token** - `AWS_BEARER_TOKEN_BEDROCK` environment variable or token from `/connect` command
+   2. **AWS Credential Chain** - Profile, access keys, shared credentials, IAM roles, instance metadata
+
+   :::note
+   When a bearer token is set (via `/connect` or `AWS_BEARER_TOKEN_BEDROCK`), it takes precedence over all AWS credential methods including configured profiles.
+   :::
+
+3. Run the `/models` command to select the model you want.
 
    ```txt
    /models