From 333f589c803b787ad2579d9b4730250f6c40b276 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sat, 25 Jul 2026 21:58:24 -0700 Subject: [PATCH] Support Bedrock API keys (bearer auth) New optional field: paste the console-generated key, no CLI/IAM setup needed. Takes precedence over SigV4 credentials, matching boto3; live-tested on Converse. --- coworker/providers/bedrock_provider.py | 32 +++++++++-- coworker/providers/registry.py | 14 +++++ surfaces/gui/src/providers/ProviderSetup.tsx | 1 + tests/test_bedrock_provider.py | 56 +++++++++++++++++++- 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/coworker/providers/bedrock_provider.py b/coworker/providers/bedrock_provider.py index 82a31e9f..50d6b5fc 100644 --- a/coworker/providers/bedrock_provider.py +++ b/coworker/providers/bedrock_provider.py @@ -12,8 +12,12 @@ An id with no family segment falls back to Converse as-is — Converse serves ev model (including Claude, minus the native extras), so a raw model id pasted without the add-model dropdown still works. -Credentials resolve explicit → named profile → ambient (env / `~/.aws` default / role), -matching what AWS CLI users expect; `aws sso login` sessions arrive via the named profile. +Credentials, in order: +1. A **Bedrock API key** (bearer token from the Bedrock console — the no-CLI path). When + present (field or `AWS_BEARER_TOKEN_BEDROCK` env) it WINS over SigV4 credentials, the + same precedence boto3 applies; mixing both makes `AnthropicBedrock` raise outright. +2. Explicit IAM keys → named profile (covers `aws sso login`) → ambient chain. + boto3 is a lazy import (packaged via the `bedrock` extra) and returns PLAIN DICTS — every response/stream mapping here is dict-shaped, unlike the attribute objects other SDKs return. """ @@ -22,6 +26,7 @@ from __future__ import annotations import base64 import json +import os import re from typing import Any, Optional @@ -260,6 +265,7 @@ class _BedrockConverseClient(ProviderClient): client: Any = None, *, region: Optional[str] = None, + bedrock_api_key: Optional[str] = None, profile_name: Optional[str] = None, access_key_id: Optional[str] = None, secret_access_key: Optional[str] = None, @@ -267,6 +273,7 @@ class _BedrockConverseClient(ProviderClient): ): self._client = client # tests inject a dict-returning fake self._region = region + self._bedrock_api_key = bedrock_api_key self._session_kwargs = _session_kwargs( profile_name, access_key_id, secret_access_key, session_token ) @@ -280,6 +287,11 @@ class _BedrockConverseClient(ProviderClient): "AWS Bedrock support needs the boto3 package — " "install with `pip install 'openworker[bedrock]'`." ) from exc + # boto3 has no per-client bearer parameter — it only reads the env var, and + # prefers bearer auth for Bedrock whenever it's set. The sidecar process is + # ours, so publishing the configured key there is the supported path. + if self._bedrock_api_key: + os.environ["AWS_BEARER_TOKEN_BEDROCK"] = self._bedrock_api_key session = boto3.session.Session(**self._session_kwargs) self._client = session.client("bedrock-runtime", region_name=self._region) return self._client @@ -437,6 +449,7 @@ class BedrockProvider(ProviderClient): self, *, region: Optional[str] = None, + bedrock_api_key: Optional[str] = None, profile_name: Optional[str] = None, access_key_id: Optional[str] = None, secret_access_key: Optional[str] = None, @@ -445,6 +458,7 @@ class BedrockProvider(ProviderClient): converse_client: Optional[ProviderClient] = None, ): self._region = region + self._bedrock_api_key = bedrock_api_key self._profile_name = profile_name self._access_key_id = access_key_id self._secret_access_key = secret_access_key @@ -472,18 +486,26 @@ class BedrockProvider(ProviderClient): if family == "claude": from anthropic import AnthropicBedrock - client = AnthropicProvider( - client=AnthropicBedrock( + # A Bedrock API key (field or ambient env) takes the bearer path and + # EXCLUDES the SigV4 params — AnthropicBedrock raises on a mix. + bearer = self._bedrock_api_key or os.environ.get( + "AWS_BEARER_TOKEN_BEDROCK" + ) + if bearer: + sdk = AnthropicBedrock(api_key=bearer, aws_region=self._region) + else: + sdk = AnthropicBedrock( aws_region=self._region, aws_profile=self._profile_name, aws_access_key=self._access_key_id, aws_secret_key=self._secret_access_key, aws_session_token=self._session_token, ) - ) + client = AnthropicProvider(client=sdk) else: client = _BedrockConverseClient( region=self._region, + bedrock_api_key=self._bedrock_api_key, profile_name=self._profile_name, access_key_id=self._access_key_id, secret_access_key=self._secret_access_key, diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index 1318b86e..50f305e3 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -140,6 +140,7 @@ def _build_bedrock(profile: dict[str, Any], secrets: Any) -> ProviderClient: return BedrockProvider( region=get("region"), + bedrock_api_key=get("bedrock_api_key"), profile_name=get("aws_profile"), access_key_id=get("aws_access_key_id"), secret_access_key=get("aws_secret_access_key"), @@ -299,6 +300,15 @@ DESCRIPTORS: list[ProviderDescriptor] = [ placeholder="us-east-1", help="The region your Bedrock model access is enabled in.", ), + ProviderField( + "bedrock_api_key", + "Bedrock API key (optional)", + secret=True, + required=False, + placeholder="ABSK…", + help="The easiest way in: generate one on the Bedrock console — no AWS " + "CLI or IAM setup needed. Takes precedence over the fields below.", + ), ProviderField( "aws_profile", "AWS profile (optional)", @@ -551,6 +561,10 @@ def _verify_bedrock(fields: dict[str, Any], timeout: float) -> dict[str, Any]: "error": "boto3 is not installed — `pip install 'openworker[bedrock]'`.", } try: + # A Bedrock API key rides the env var (boto3's only bearer channel) and then + # wins over any SigV4 credentials, matching the provider's own precedence. + if get("bedrock_api_key"): + os.environ["AWS_BEARER_TOKEN_BEDROCK"] = get("bedrock_api_key") session = boto3.session.Session( **_session_kwargs( get("aws_profile"), diff --git a/surfaces/gui/src/providers/ProviderSetup.tsx b/surfaces/gui/src/providers/ProviderSetup.tsx index 3bdc5fe2..051a1804 100644 --- a/surfaces/gui/src/providers/ProviderSetup.tsx +++ b/surfaces/gui/src/providers/ProviderSetup.tsx @@ -21,6 +21,7 @@ export const KEY_HELP: Record = { openai: { url: "https://platform.openai.com/api-keys", label: "platform.openai.com" }, gemini: { url: "https://aistudio.google.com/apikey", label: "aistudio.google.com" }, openrouter: { url: "https://openrouter.ai/keys", label: "openrouter.ai" }, + bedrock: { url: "https://console.aws.amazon.com/bedrock/home#/api-keys", label: "the AWS Bedrock console" }, fireworks: { url: "https://fireworks.ai/account/api-keys", label: "fireworks.ai" }, together: { url: "https://api.together.xyz/settings/api-keys", label: "together.xyz" }, zai: { url: "https://z.ai/manage-apikey/apikey-list", label: "z.ai" }, diff --git a/tests/test_bedrock_provider.py b/tests/test_bedrock_provider.py index 89028680..a4689c9a 100644 --- a/tests/test_bedrock_provider.py +++ b/tests/test_bedrock_provider.py @@ -288,17 +288,52 @@ def test_family_dispatch(): ] -def test_claude_family_builds_native_anthropic_over_bedrock(): +def test_claude_family_builds_native_anthropic_over_bedrock(monkeypatch): from anthropic import AnthropicBedrock from coworker.providers import AnthropicProvider + monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False) p = BedrockProvider(region="us-east-1", profile_name="work") sub = p._family_client("claude") assert isinstance(sub, AnthropicProvider) assert isinstance(sub._client, AnthropicBedrock) +def test_claude_family_prefers_bedrock_api_key_over_sigv4(monkeypatch): + """A Bedrock API key must take the bearer path WITHOUT the SigV4 params — + AnthropicBedrock raises outright when both are passed.""" + monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False) + p = BedrockProvider( + region="us-east-1", bedrock_api_key="ABSKtest", profile_name="work" + ) + sub = p._family_client("claude") + assert sub._client.api_key == "ABSKtest" + assert sub._client.aws_profile is None + + +def test_converse_client_publishes_api_key_as_bearer_env(monkeypatch): + import os + + import boto3 + + from coworker.providers.bedrock_provider import _BedrockConverseClient + + monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False) + + class _FakeSession: + def __init__(self, **kwargs): + pass + + def client(self, service, **kwargs): + return object() + + monkeypatch.setattr(boto3.session, "Session", _FakeSession) + client = _BedrockConverseClient(region="us-east-1", bedrock_api_key="ABSKtest") + client._ensure_client() + assert os.environ["AWS_BEARER_TOKEN_BEDROCK"] == "ABSKtest" + + # -- capabilities / matrix ------------------------------------------------------------ @@ -334,6 +369,7 @@ def test_bedrock_descriptor_and_builder(): keys = [f.key for f in d.fields] assert keys == [ "region", + "bedrock_api_key", "aws_profile", "aws_access_key_id", "aws_secret_access_key", @@ -341,7 +377,7 @@ def test_bedrock_descriptor_and_builder(): ] assert [f.key for f in d.fields if f.required] == ["region"] secret = {f.key for f in d.fields if f.secret} - assert secret == {"aws_secret_access_key", "aws_session_token"} + assert secret == {"bedrock_api_key", "aws_secret_access_key", "aws_session_token"} # Recommended model is curated in the matrix (set_provider's auto-add depends on it). from coworker.providers.matrix import models_for_provider @@ -416,6 +452,22 @@ def test_verify_bedrock_ok(monkeypatch): assert captured["client"]["region_name"] == "us-east-1" +def test_verify_bedrock_api_key_rides_the_bearer_env(monkeypatch): + import os + + from coworker.providers.registry import verify_provider_key + + monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False) + captured: dict = {} + _patch_session(monkeypatch, _FakeBedrockControl(), captured) + out = verify_provider_key( + "bedrock", fields={"region": "us-east-1", "bedrock_api_key": "ABSKtest"} + ) + assert out == {"ok": True} + assert captured["session"] == {} # bearer only — no SigV4 session kwargs + assert os.environ["AWS_BEARER_TOKEN_BEDROCK"] == "ABSKtest" + + def test_verify_bedrock_maps_client_errors(monkeypatch): from botocore.exceptions import ClientError