Add AWS Bedrock provider with per-family dispatch

claude/ ids use Anthropic's native Bedrock client; everything else goes via Converse.
Credentials: explicit keys, named profile (incl. SSO), or the ambient AWS chain.
This commit is contained in:
Rohit C Prasad
2026-07-25 16:15:18 -07:00
parent ee495b9006
commit 8cb9524f1f
8 changed files with 1164 additions and 24 deletions
+4
View File
@@ -1,4 +1,5 @@
from .anthropic_provider import AnthropicProvider from .anthropic_provider import AnthropicProvider
from .bedrock_provider import BedrockProvider
from .base import ( from .base import (
AssistantTurn, AssistantTurn,
ModelCapabilities, ModelCapabilities,
@@ -13,6 +14,7 @@ from .registry import (
ProviderDescriptor, ProviderDescriptor,
ProviderField, ProviderField,
build_provider_client, build_provider_client,
descriptor_configured,
detect_provider, detect_provider,
get_descriptor, get_descriptor,
provider_descriptors, provider_descriptors,
@@ -28,6 +30,7 @@ __all__ = [
"StreamChunk", "StreamChunk",
"ToolCall", "ToolCall",
"AnthropicProvider", "AnthropicProvider",
"BedrockProvider",
"GeminiProvider", "GeminiProvider",
"OpenAIProvider", "OpenAIProvider",
"resolve_api_key", "resolve_api_key",
@@ -39,6 +42,7 @@ __all__ = [
"provider_names", "provider_names",
"get_descriptor", "get_descriptor",
"build_provider_client", "build_provider_client",
"descriptor_configured",
"detect_provider", "detect_provider",
"verify_provider_key", "verify_provider_key",
] ]
+523
View File
@@ -0,0 +1,523 @@
"""AWS Bedrock provider — one entry in Settings, two wire paths by model family.
Routed ids look like `bedrock:<family>/<bedrock model id>`; the router strips `bedrock:`
and this provider splits the family segment:
- `claude/…` → the native `AnthropicProvider` over the SDK's `AnthropicBedrock` client,
so Claude-on-Bedrock gets everything direct Anthropic gets (thinking, refusal handling).
- `other/…` → the Converse API (`bedrock-runtime.converse/converse_stream`), Bedrock's
unified wire format across Llama, Nova, Mistral, Cohere, DeepSeek, …
An id with no family segment falls back to Converse as-is — Converse serves every Bedrock
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.
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.
"""
from __future__ import annotations
import base64
import json
import re
from typing import Any, Optional
from .anthropic_provider import AnthropicProvider
from .base import (
AssistantTurn,
ModelCapabilities,
ProviderClient,
StreamChunk,
ToolCall,
)
from .capabilities import capabilities_for
# Converse has no required max token param but per-model defaults vary wildly (Meta's is
# 512 — an agent turn gets truncated mid-tool-call); 4096 fits every family's ceiling.
DEFAULT_MAX_TOKENS = 4096
# Converse stopReason → the engine's OpenAI-shaped finish_reason vocabulary.
_STOP_REASON_MAP = {
"end_turn": "stop",
"tool_use": "tool_calls",
"max_tokens": "length",
"stop_sequence": "stop",
"guardrail_intervened": "stop",
"content_filtered": "stop",
}
_DATA_URL_RE = re.compile(
r"^data:image/([a-z0-9.+-]+);base64,(.+)$", re.IGNORECASE | re.DOTALL
)
_PDF_DATA_URL_RE = re.compile(
r"^data:application/pdf;base64,(.+)$", re.IGNORECASE | re.DOTALL
)
# Bedrock document names: alphanumeric, whitespace, hyphens, parens, brackets only.
_DOC_NAME_RE = re.compile(r"[^A-Za-z0-9\s\-\(\)\[\]]+")
def _session_kwargs(
profile_name: Optional[str],
access_key_id: Optional[str],
secret_access_key: Optional[str],
session_token: Optional[str],
) -> dict[str, Any]:
"""boto3.Session kwargs for the explicit → profile → ambient resolution order."""
if access_key_id and secret_access_key:
kwargs: dict[str, Any] = {
"aws_access_key_id": access_key_id,
"aws_secret_access_key": secret_access_key,
}
if session_token:
kwargs["aws_session_token"] = session_token
return kwargs
if profile_name:
return {"profile_name": profile_name}
return {}
def _parse_args(raw: Any) -> dict[str, Any]:
if isinstance(raw, dict):
return raw
if not raw:
return {}
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {"_raw": raw}
except (TypeError, json.JSONDecodeError):
return {"_raw": raw}
def _user_blocks(content: Any) -> list[dict[str, Any]]:
"""User content (str or OpenAI parts list) → Converse content blocks (bytes, not URLs)."""
if isinstance(content, str):
return [{"text": content}] if content else []
blocks: list[dict[str, Any]] = []
for part in content or []:
kind = part.get("type") if isinstance(part, dict) else None
if kind == "text":
if part.get("text"):
blocks.append({"text": part["text"]})
elif kind == "image_url":
url = (part.get("image_url") or {}).get("url") or ""
match = _DATA_URL_RE.match(url)
if match:
fmt = match.group(1).lower()
blocks.append(
{
"image": {
"format": "jpeg" if fmt == "jpg" else fmt,
"source": {"bytes": base64.b64decode(match.group(2))},
}
}
)
else: # Converse takes bytes only — no URL sources.
blocks.append({"text": "[unsupported image attachment]"})
elif kind == "file":
file = part.get("file") or {}
match = _PDF_DATA_URL_RE.match(file.get("file_data") or "")
if match:
name = _DOC_NAME_RE.sub("-", str(file.get("filename") or "document"))
blocks.append(
{
"document": {
"format": "pdf",
"name": name or "document",
"source": {"bytes": base64.b64decode(match.group(1))},
}
}
)
else:
blocks.append({"text": "[unsupported file attachment]"})
return blocks
def convert_messages(
messages: list[dict[str, Any]],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
"""OpenAI-shaped history → (Converse `system`, Converse `messages`).
Same shape discipline as the Anthropic converter (it's the same API family): leading
system messages become the top-level param, `role:"tool"` results become toolResult
blocks inside a user message, and consecutive same-role messages fold together so all
of a turn's parallel tool results land in the single next user message.
"""
system_parts: list[str] = []
index = 0
while index < len(messages) and messages[index].get("role") == "system":
content = messages[index].get("content")
if isinstance(content, str) and content:
system_parts.append(content)
index += 1
converted: list[dict[str, Any]] = []
for message in messages[index:]:
role = message.get("role")
if role == "system":
text = message.get("content") or ""
if text:
converted.append(
{"role": "user", "content": [{"text": f"<system>\n{text}\n</system>"}]}
)
elif role == "user":
blocks = _user_blocks(message.get("content"))
if blocks:
converted.append({"role": "user", "content": blocks})
elif role == "assistant":
blocks = []
text = message.get("content")
if isinstance(text, str) and text:
blocks.append({"text": text})
for call in message.get("tool_calls") or []:
function = call.get("function") or {}
blocks.append(
{
"toolUse": {
"toolUseId": call.get("id") or "",
"name": function.get("name") or "",
"input": _parse_args(function.get("arguments")),
}
}
)
if blocks:
converted.append({"role": "assistant", "content": blocks})
elif role == "tool":
converted.append(
{
"role": "user",
"content": [
{
"toolResult": {
"toolUseId": message.get("tool_call_id") or "",
"content": [
{"text": str(message.get("content") or "")}
],
}
}
],
}
)
folded: list[dict[str, Any]] = []
for message in converted:
if folded and folded[-1]["role"] == message["role"]:
folded[-1]["content"].extend(message["content"])
else:
folded.append(message)
if not folded:
raise ValueError("no convertible messages for the Bedrock Converse API")
if folded[0]["role"] != "user":
folded.insert(0, {"role": "user", "content": [{"text": "(continued)"}]})
system = [{"text": "\n\n".join(system_parts)}] if system_parts else []
return system, folded
def convert_tools(tools: Optional[list[dict[str, Any]]]) -> Optional[dict[str, Any]]:
"""OpenAI function schemas → Converse `toolConfig` (None when there are no tools —
Converse rejects an empty tool list)."""
specs = []
for tool in tools or []:
function = tool.get("function") or {}
parameters = function.get("parameters")
if not isinstance(parameters, dict) or not parameters.get("type"):
parameters = {"type": "object", "properties": {}}
spec: dict[str, Any] = {
"name": function.get("name") or "",
"inputSchema": {"json": parameters},
}
if function.get("description"):
spec["description"] = function["description"]
specs.append({"toolSpec": spec})
return {"tools": specs} if specs else None
def _inference_config(settings: dict[str, Any]) -> dict[str, Any]:
"""Whitelisted engine settings → Converse `inferenceConfig` (camelCase)."""
config: dict[str, Any] = {
"maxTokens": int(settings.get("max_tokens") or DEFAULT_MAX_TOKENS)
}
if settings.get("temperature") is not None:
config["temperature"] = settings["temperature"]
if settings.get("top_p") is not None:
config["topP"] = settings["top_p"]
stop = settings.get("stop_sequences") or settings.get("stop")
if stop:
config["stopSequences"] = [stop] if isinstance(stop, str) else list(stop)
return config
class _BedrockConverseClient(ProviderClient):
"""The `other/` family: any Bedrock model over the unified Converse API."""
def __init__(
self,
client: Any = None,
*,
region: Optional[str] = None,
profile_name: Optional[str] = None,
access_key_id: Optional[str] = None,
secret_access_key: Optional[str] = None,
session_token: Optional[str] = None,
):
self._client = client # tests inject a dict-returning fake
self._region = region
self._session_kwargs = _session_kwargs(
profile_name, access_key_id, secret_access_key, session_token
)
def _ensure_client(self) -> Any:
if self._client is None:
try:
import boto3
except ImportError as exc:
raise RuntimeError(
"AWS Bedrock support needs the boto3 package — "
"install with `pip install 'openworker[bedrock]'`."
) from exc
session = boto3.session.Session(**self._session_kwargs)
self._client = session.client("bedrock-runtime", region_name=self._region)
return self._client
def _request_kwargs(
self,
*,
model: str,
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]],
settings: dict[str, Any],
) -> dict[str, Any]:
system, converted = convert_messages(messages)
kwargs: dict[str, Any] = {
"modelId": model,
"messages": converted,
"inferenceConfig": _inference_config(settings),
}
if system:
kwargs["system"] = system
tool_config = convert_tools(tools)
if tool_config:
kwargs["toolConfig"] = tool_config
return kwargs
@staticmethod
def _call(client: Any, method: str, kwargs: dict[str, Any]) -> Any:
try:
return getattr(client, method)(**kwargs)
except Exception as exc:
# boto3's "Unable to locate credentials" is famously cryptic — name the fix.
if exc.__class__.__name__ == "NoCredentialsError":
raise RuntimeError(
"No AWS credentials found — add keys or a profile in Settings ▸ "
"Models, or configure the AWS CLI (`aws configure` / `aws sso login`)."
) from exc
raise
def complete(
self,
*,
model: str,
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
**settings: Any,
) -> AssistantTurn:
kwargs = self._request_kwargs(
model=model, messages=messages, tools=tools, settings=settings
)
response = self._call(self._ensure_client(), "converse", kwargs)
text_parts: list[str] = []
reasoning_parts: list[str] = []
tool_calls: list[ToolCall] = []
content = ((response.get("output") or {}).get("message") or {}).get(
"content"
) or []
for block in content:
if "text" in block:
text_parts.append(block["text"] or "")
elif "toolUse" in block:
tool = block["toolUse"]
tool_calls.append(
ToolCall(
id=tool.get("toolUseId") or "",
name=tool.get("name") or "",
arguments=_parse_args(tool.get("input")),
)
)
elif "reasoningContent" in block:
text = (block["reasoningContent"].get("reasoningText") or {}).get(
"text"
) or ""
if text:
reasoning_parts.append(text)
stop_reason = response.get("stopReason")
return AssistantTurn(
text="".join(text_parts) or None,
tool_calls=tool_calls,
finish_reason=_STOP_REASON_MAP.get(stop_reason, stop_reason),
raw=response,
reasoning="".join(reasoning_parts) or None,
)
def stream(
self,
*,
model: str,
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
**settings: Any,
):
kwargs = self._request_kwargs(
model=model, messages=messages, tools=tools, settings=settings
)
response = self._call(self._ensure_client(), "converse_stream", kwargs)
text_parts: list[str] = []
reasoning_parts: list[str] = []
tool_accum: dict[int, dict[str, str]] = {}
stop_reason = None
for event in response.get("stream") or []:
if "contentBlockStart" in event:
start = (event["contentBlockStart"].get("start") or {}).get("toolUse")
if start:
tool_accum[event["contentBlockStart"].get("contentBlockIndex", 0)] = {
"id": start.get("toolUseId") or "",
"name": start.get("name") or "",
"json": "",
}
elif "contentBlockDelta" in event:
block = event["contentBlockDelta"]
delta = block.get("delta") or {}
if delta.get("text"):
text_parts.append(delta["text"])
yield StreamChunk(text_delta=delta["text"])
elif "toolUse" in delta:
acc = tool_accum.get(block.get("contentBlockIndex", 0))
if acc is not None:
acc["json"] += delta["toolUse"].get("input") or ""
elif "reasoningContent" in delta:
thought = delta["reasoningContent"].get("text") or ""
if thought:
reasoning_parts.append(thought)
yield StreamChunk(reasoning_delta=thought)
elif "messageStop" in event:
stop_reason = event["messageStop"].get("stopReason") or stop_reason
tool_calls = [
ToolCall(
id=tool_accum[i]["id"],
name=tool_accum[i]["name"],
arguments=_parse_args(tool_accum[i]["json"]),
)
for i in sorted(tool_accum)
]
yield StreamChunk(
turn=AssistantTurn(
text="".join(text_parts) or None,
tool_calls=tool_calls,
finish_reason=_STOP_REASON_MAP.get(stop_reason, stop_reason),
reasoning="".join(reasoning_parts) or None,
)
)
def capabilities(self, model: str) -> ModelCapabilities:
return capabilities_for(f"bedrock:other/{model}")
class BedrockProvider(ProviderClient):
"""Family dispatcher: splits `<family>/<model id>` and delegates to the sub-client."""
def __init__(
self,
*,
region: Optional[str] = None,
profile_name: Optional[str] = None,
access_key_id: Optional[str] = None,
secret_access_key: Optional[str] = None,
session_token: Optional[str] = None,
claude_client: Optional[ProviderClient] = None,
converse_client: Optional[ProviderClient] = None,
):
self._region = region
self._profile_name = profile_name
self._access_key_id = access_key_id
self._secret_access_key = secret_access_key
self._session_token = session_token
# Test seams: pre-built sub-providers skip the SDK construction below.
self._clients: dict[str, ProviderClient] = {}
if claude_client is not None:
self._clients["claude"] = claude_client
if converse_client is not None:
self._clients["other"] = converse_client
@staticmethod
def _split(model: str) -> tuple[str, str]:
"""`claude/<id>` → the native path; anything else (including a raw Bedrock id with
no family segment) → Converse, which serves every Bedrock model."""
if "/" in model:
family, rest = model.split("/", 1)
if family in ("claude", "other"):
return family, rest
return "other", model
def _family_client(self, family: str) -> ProviderClient:
client = self._clients.get(family)
if client is None:
if family == "claude":
from anthropic import AnthropicBedrock
client = AnthropicProvider(
client=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,
)
)
else:
client = _BedrockConverseClient(
region=self._region,
profile_name=self._profile_name,
access_key_id=self._access_key_id,
secret_access_key=self._secret_access_key,
session_token=self._session_token,
)
self._clients[family] = client
return client
def complete(
self,
*,
model: str,
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
**settings: Any,
) -> AssistantTurn:
family, rest = self._split(model)
return self._family_client(family).complete(
model=rest, messages=messages, tools=tools, **settings
)
def stream(
self,
*,
model: str,
messages: list[dict[str, Any]],
tools: Optional[list[dict[str, Any]]] = None,
**settings: Any,
):
family, rest = self._split(model)
return self._family_client(family).stream(
model=rest, messages=messages, tools=tools, **settings
)
def capabilities(self, model: str) -> ModelCapabilities:
qualified = model if model.startswith("bedrock:") else f"bedrock:{model}"
return capabilities_for(qualified)
+13
View File
@@ -29,6 +29,19 @@ def capabilities_for(model: str) -> ModelCapabilities:
tools=True, vision=False, parallel_tool_calls=False, streaming=True tools=True, vision=False, parallel_tool_calls=False, streaming=True
) )
# Cloud-account providers (custom-added ids; curated ones answered from the matrix).
# The family segment decides: Claude keeps its native capabilities; everything else
# stays conservative until probed (Converse tool calling works across families, but
# parallel calls and vision vary per model).
if provider == "bedrock":
if name.startswith("claude/"):
return ModelCapabilities(
tools=True, vision=True, pdf=True, parallel_tool_calls=True, streaming=True
)
return ModelCapabilities(
tools=True, vision=False, parallel_tool_calls=False, streaming=True
)
# Claude / Gemini (both native): tools + vision + parallel tool calls + streaming. The # Claude / Gemini (both native): tools + vision + parallel tool calls + streaming. The
# engine executes parallel calls sequentially and each converter folds the results into # engine executes parallel calls sequentially and each converter folds the results into
# the single next user message — exactly what both APIs require. # the single next user message — exactly what both APIs require.
+17
View File
@@ -120,6 +120,23 @@ MATRIX: dict[str, ModelEntry] = {
"openrouter:meta-llama/llama-4-maverick": ModelEntry( "openrouter:meta-llama/llama-4-maverick": ModelEntry(
"Llama 4 Maverick · via OpenRouter" "Llama 4 Maverick · via OpenRouter"
), ),
# -- cloud accounts (models running in the user's own AWS/GCP) ----------------
# Bedrock ids carry a family segment (claude/ → native Anthropic path, other/ →
# Converse) plus AWS's own `-v<n>:<m>` version suffix. Some regions require the
# `us.`/`eu.` cross-region inference-profile prefix — custom add-model accepts those.
"bedrock:claude/anthropic.claude-sonnet-4-6-v1:0": ModelEntry(
"Claude Sonnet 4.6 · AWS Bedrock", _AGENTIC_VISION
),
"bedrock:claude/anthropic.claude-haiku-4-5-v1:0": ModelEntry(
"Claude Haiku 4.5 · AWS Bedrock", _AGENTIC_VISION
),
"bedrock:other/amazon.nova-2-pro-v1:0": ModelEntry("Nova 2 Pro · AWS Bedrock"),
"bedrock:other/meta.llama4-maverick-17b-instruct-v1:0": ModelEntry(
"Llama 4 Maverick · AWS Bedrock"
),
"bedrock:other/mistral.mistral-large-3-v1:0": ModelEntry(
"Mistral Large 3 · AWS Bedrock"
),
} }
+144 -3
View File
@@ -8,8 +8,9 @@ model string and builds (and caches) its client from the matching SecretStore pr
Today: `openai` (the default, with an optional custom endpoint that covers Azure OpenAI's Today: `openai` (the default, with an optional custom endpoint that covers Azure OpenAI's
`/openai/v1` and any OpenAI-compliant gateway), `anthropic` (native Messages API via `/openai/v1` and any OpenAI-compliant gateway), `anthropic` (native Messages API via
`AnthropicProvider`), `gemini` (native Google GenAI API via `GeminiProvider`), and `ollama` `AnthropicProvider`), `gemini` (native Google GenAI API via `GeminiProvider`), `bedrock`
(local, OpenAI-compatible `/v1`). Bedrock/Vertex auth for Claude is future work. (models in the user's own AWS account — Claude natively, everything else via Converse),
and `ollama` (local, OpenAI-compatible `/v1`).
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,6 +21,7 @@ from typing import Any, Callable, Optional
from .anthropic_provider import AnthropicProvider from .anthropic_provider import AnthropicProvider
from .base import ProviderClient from .base import ProviderClient
from .bedrock_provider import BedrockProvider
from .gemini_provider import GeminiProvider from .gemini_provider import GeminiProvider
from .openai_provider import OpenAIProvider from .openai_provider import OpenAIProvider
@@ -126,6 +128,23 @@ def _build_gemini(profile: dict[str, Any], secrets: Any) -> ProviderClient:
return GeminiProvider(api_key=api_key, secrets=secrets) return GeminiProvider(api_key=api_key, secrets=secrets)
def _build_bedrock(profile: dict[str, Any], secrets: Any) -> ProviderClient:
# Credentials resolve inside boto3/AnthropicBedrock at call time: explicit keys →
# named profile → ambient chain (env / ~/.aws default / instance role).
p = profile or {}
def get(key: str) -> Optional[str]:
return (p.get(key) or "").strip() or None
return BedrockProvider(
region=get("region"),
profile_name=get("aws_profile"),
access_key_id=get("aws_access_key_id"),
secret_access_key=get("aws_secret_access_key"),
session_token=get("aws_session_token"),
)
def _build_ollama(profile: dict[str, Any], secrets: Any) -> ProviderClient: def _build_ollama(profile: dict[str, Any], secrets: Any) -> ProviderClient:
# Ollama's OpenAI-compatible endpoint ignores the key but the SDK requires a non-empty # Ollama's OpenAI-compatible endpoint ignores the key but the SDK requires a non-empty
# string, so we pass a placeholder. `base_url` comes from the stored profile (or the default). # string, so we pass a placeholder. `base_url` comes from the stored profile (or the default).
@@ -252,6 +271,54 @@ DESCRIPTORS: list[ProviderDescriptor] = [
recommended_model="gemini-3.6-flash", recommended_model="gemini-3.6-flash",
env_key="GEMINI_API_KEY", env_key="GEMINI_API_KEY",
), ),
ProviderDescriptor(
name="bedrock",
title="AWS Bedrock",
needs_key=True,
fields=[
ProviderField(
"region",
"AWS region",
secret=False,
placeholder="us-east-1",
help="The region your Bedrock model access is enabled in.",
),
ProviderField(
"aws_profile",
"AWS profile (optional)",
secret=False,
required=False,
placeholder="default",
help="A named profile from ~/.aws — works with `aws configure` and "
"`aws sso login` (IAM Identity Center). Leave blank to use explicit "
"keys below, or the default credential chain.",
),
ProviderField(
"aws_access_key_id",
"Access key ID (optional)",
secret=False,
required=False,
placeholder="AKIA…",
),
ProviderField(
"aws_secret_access_key",
"Secret access key (optional)",
secret=True,
required=False,
),
ProviderField(
"aws_session_token",
"Session token (optional)",
secret=True,
required=False,
help="Only for temporary credentials (STS).",
),
],
build=_build_bedrock,
recommended_model="claude/anthropic.claude-sonnet-4-6-v1:0",
blurb="Runs models inside your own AWS account. Claude uses Anthropic's native "
"Bedrock path; every other model goes through the Converse API.",
),
# OpenAI-compatible vendors, listed as first-class providers so users don't need to know the # OpenAI-compatible vendors, listed as first-class providers so users don't need to know the
# "point the OpenAI slot at a different endpoint" trick (owner call, 2026-07-04). Each keeps # "point the OpenAI slot at a different endpoint" trick (owner call, 2026-07-04). Each keeps
# its own key profile; the endpoint is prefilled and editable (regional variants in `help`). # its own key profile; the endpoint is prefilled and editable (regional variants in `help`).
@@ -384,6 +451,21 @@ def build_provider_client(
return descriptor.build(profile or {}, secrets) return descriptor.build(profile or {}, secrets)
def descriptor_configured(d: ProviderDescriptor, profile: dict[str, Any]) -> bool:
"""Whether a provider is usable with the given stored profile. Single-key providers:
a stored or env key. Multi-field cloud providers (no `api_key` field, e.g. Bedrock):
every required field present — their actual credentials may be ambient (~/.aws, ADC).
"""
if not d.needs_key:
return True # keyless (Ollama) — usable out of the box
profile = profile or {}
if any(f.key == "api_key" for f in d.fields):
return bool(profile.get("api_key")) or bool(
d.env_key and os.environ.get(d.env_key)
)
return all(profile.get(f.key) for f in d.fields if f.required)
def detect_provider(api_key: str) -> Optional[str]: def detect_provider(api_key: str) -> Optional[str]:
"""Best-effort provider guess from an API key's shape, for the onboarding auto-detect. """Best-effort provider guess from an API key's shape, for the onboarding auto-detect.
Returns a known provider name or None. Mirrors the GUI's client-side detection so both agree. Returns a known provider name or None. Mirrors the GUI's client-side detection so both agree.
@@ -402,21 +484,80 @@ def detect_provider(api_key: str) -> Optional[str]:
return None return None
def _verify_bedrock(fields: dict[str, Any], timeout: float) -> dict[str, Any]:
"""One cheap read-only Bedrock call (list models) with the same explicit → profile →
ambient credential resolution the provider itself uses."""
from .bedrock_provider import _session_kwargs
def get(key: str) -> Optional[str]:
return (fields.get(key) or "").strip() or None
try:
import boto3
from botocore.config import Config
except ImportError:
return {
"ok": False,
"error": "boto3 is not installed — `pip install 'openworker[bedrock]'`.",
}
try:
session = boto3.session.Session(
**_session_kwargs(
get("aws_profile"),
get("aws_access_key_id"),
get("aws_secret_access_key"),
get("aws_session_token"),
)
)
client = session.client(
"bedrock",
region_name=get("region"),
config=Config(connect_timeout=timeout, read_timeout=timeout),
)
client.list_foundation_models()
except Exception as exc:
kind = exc.__class__.__name__
if kind == "NoCredentialsError":
return {
"ok": False,
"error": "No AWS credentials found — enter keys or a profile, or run "
"`aws configure` / `aws sso login` first.",
}
if kind == "ProfileNotFound":
return {"ok": False, "error": f"{exc}"}
if kind == "ClientError":
code = (getattr(exc, "response", {}) or {}).get("Error", {}).get("Code", "")
if code in ("UnrecognizedClientException", "InvalidSignatureException"):
return {"ok": False, "error": "AWS rejected the credentials."}
if code in ("AccessDeniedException", "AccessDenied"):
return {
"ok": False,
"error": "Credentials work but lack Bedrock access (bedrock:ListFoundationModels).",
}
return {"ok": False, "error": f"AWS Bedrock returned {code or kind}."}
return {"ok": False, "error": f"Couldn't reach AWS Bedrock ({kind})."}
return {"ok": True}
def verify_provider_key( def verify_provider_key(
name: str, name: str,
*, *,
api_key: Optional[str] = None, api_key: Optional[str] = None,
base_url: Optional[str] = None, base_url: Optional[str] = None,
fields: Optional[dict[str, Any]] = None,
timeout: float = 10.0, timeout: float = 10.0,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Validate a provider's credentials with one cheap, read-only call (list models) — the same """Validate a provider's credentials with one cheap, read-only call (list models) — the same
pattern connectors use to validate tokens. Transient: callers pass the key directly so a user pattern connectors use to validate tokens. Transient: callers pass the key directly so a user
can Test before saving. Never raises; returns {ok, error?}. can Test before saving. Never raises; returns {ok, error?}. Multi-field cloud providers
(Bedrock) take their whole form via `fields`; everyone else uses api_key/base_url.
""" """
import httpx import httpx
d = _BY_NAME.get(name) or _BY_NAME["openai"] d = _BY_NAME.get(name) or _BY_NAME["openai"]
key = (api_key or "").strip() key = (api_key or "").strip()
if name == "bedrock":
return _verify_bedrock(fields or {}, timeout)
try: try:
if name == "anthropic": if name == "anthropic":
resp = httpx.get( resp = httpx.get(
+24 -20
View File
@@ -75,6 +75,7 @@ from ..agents import list_agents as _list_agents
from ..providers import ( from ..providers import (
ProviderClient, ProviderClient,
ProviderRouter, ProviderRouter,
descriptor_configured,
get_descriptor, get_descriptor,
provider_descriptors, provider_descriptors,
verify_provider_key, verify_provider_key,
@@ -1391,17 +1392,10 @@ class SessionManager:
"""Descriptor + per-provider status for the Settings UI. Never returns secret values; """Descriptor + per-provider status for the Settings UI. Never returns secret values;
non-secret field values (e.g. the Ollama base URL) ARE returned so the form can prefill. non-secret field values (e.g. the Ollama base URL) ARE returned so the form can prefill.
""" """
import os
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for d in provider_descriptors(): for d in provider_descriptors():
profile = self.secrets.get(f"provider:{d.name}") or {} profile = self.secrets.get(f"provider:{d.name}") or {}
if d.needs_key: configured = descriptor_configured(d, profile)
configured = bool(profile.get("api_key")) or bool(
d.env_key and os.environ.get(d.env_key)
)
else:
configured = True # keyless (Ollama) — usable out of the box
values = { values = {
f.key: profile.get(f.key) f.key: profile.get(f.key)
for f in d.fields for f in d.fields
@@ -1566,8 +1560,8 @@ class SessionManager:
self, name: str, fields: Optional[dict[str, Any]] self, name: str, fields: Optional[dict[str, Any]]
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Test a provider's credentials with a live read-only call, WITHOUT persisting them, so """Test a provider's credentials with a live read-only call, WITHOUT persisting them, so
onboarding can offer a "Test" button. Falls back to the stored/env key when the form left onboarding can offer a "Test" button. Falls back to stored/env values when the form left
the key blank (e.g. testing an already-configured provider).""" a field blank (e.g. testing an already-configured provider)."""
import os import os
d = get_descriptor(name) d = get_descriptor(name)
@@ -1575,13 +1569,28 @@ class SessionManager:
return {"ok": False, "error": f"unknown provider: {name}"} return {"ok": False, "error": f"unknown provider: {name}"}
fields = fields or {} fields = fields or {}
profile = self.secrets.get(f"provider:{name}") or {} profile = self.secrets.get(f"provider:{name}") or {}
api_key = (fields.get("api_key") or profile.get("api_key") or "").strip() merged = {}
for f in d.fields:
val = fields.get(f.key) or profile.get(f.key) or ""
if isinstance(val, str):
val = val.strip()
if val:
merged[f.key] = val
api_key = merged.get("api_key", "")
if not api_key and d.env_key: if not api_key and d.env_key:
api_key = os.environ.get(d.env_key, "").strip() api_key = os.environ.get(d.env_key, "").strip()
base_url = (fields.get("base_url") or profile.get("base_url") or "").strip() has_key_field = any(f.key == "api_key" for f in d.fields)
if d.needs_key and not api_key: if d.needs_key and has_key_field and not api_key:
return {"ok": False, "error": "Enter an API key to test."} return {"ok": False, "error": "Enter an API key to test."}
return verify_provider_key(name, api_key=api_key, base_url=base_url) if d.needs_key and not has_key_field:
# Multi-field cloud providers (Bedrock): required fields must be present;
# actual credentials may be ambient (~/.aws, env) and are checked by the call.
missing = [f.label for f in d.fields if f.required and not merged.get(f.key)]
if missing:
return {"ok": False, "error": "missing: " + ", ".join(missing)}
return verify_provider_key(
name, api_key=api_key, base_url=merged.get("base_url", ""), fields=merged
)
def _model_provider(self, model: str) -> str: def _model_provider(self, model: str) -> str:
"""The provider a model string routes to (known `prefix:` or the OpenAI default).""" """The provider a model string routes to (known `prefix:` or the OpenAI default)."""
@@ -1595,12 +1604,7 @@ class SessionManager:
d = get_descriptor(name) d = get_descriptor(name)
if d is None: if d is None:
return False return False
if not d.needs_key: return descriptor_configured(d, self.secrets.get(f"provider:{name}") or {})
return True # keyless (Ollama)
profile = self.secrets.get(f"provider:{name}") or {}
return bool(profile.get("api_key")) or bool(
d.env_key and os.environ.get(d.env_key)
)
# -- settings / prefs (model API key, default model, onboarding) ------------- # -- settings / prefs (model API key, default model, onboarding) -------------
def _prefs_path(self) -> Path: def _prefs_path(self) -> Path:
+438
View File
@@ -0,0 +1,438 @@
"""AWS Bedrock provider — family dispatch, Converse mapping (plain dicts), registry glue."""
from __future__ import annotations
from typing import Any, Optional
import pytest
from coworker.providers import capabilities_for
from coworker.providers.base import AssistantTurn, ProviderClient, StreamChunk
from coworker.providers.bedrock_provider import (
BedrockProvider,
_BedrockConverseClient,
_session_kwargs,
convert_messages,
convert_tools,
)
# -- converters -------------------------------------------------------------------
def test_convert_messages_system_and_folding():
system, messages = convert_messages(
[
{"role": "system", "content": "be terse"},
{"role": "user", "content": "hi"},
{
"role": "assistant",
"content": "checking",
"tool_calls": [
{
"id": "t1",
"type": "function",
"function": {"name": "ls", "arguments": '{"path": "."}'},
},
{
"id": "t2",
"type": "function",
"function": {"name": "pwd", "arguments": "{}"},
},
],
},
{"role": "tool", "tool_call_id": "t1", "content": "a.txt"},
{"role": "tool", "tool_call_id": "t2", "content": "/repo"},
]
)
assert system == [{"text": "be terse"}]
assert [m["role"] for m in messages] == ["user", "assistant", "user"]
assistant = messages[1]["content"]
assert assistant[0] == {"text": "checking"}
assert assistant[1]["toolUse"] == {
"toolUseId": "t1",
"name": "ls",
"input": {"path": "."},
}
# Both parallel results folded into the single next user message.
results = messages[2]["content"]
assert [r["toolResult"]["toolUseId"] for r in results] == ["t1", "t2"]
assert results[0]["toolResult"]["content"] == [{"text": "a.txt"}]
def test_convert_messages_inserts_leading_user():
_, messages = convert_messages([{"role": "assistant", "content": "hello"}])
assert messages[0] == {"role": "user", "content": [{"text": "(continued)"}]}
def test_convert_tools_shape_and_empty():
config = convert_tools(
[
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
},
},
},
{"type": "function", "function": {"name": "noargs"}},
]
)
spec = config["tools"][0]["toolSpec"]
assert spec["name"] == "read_file"
assert spec["description"] == "Read a file"
assert spec["inputSchema"]["json"]["properties"]["path"] == {"type": "string"}
# Typeless parameters become an empty object schema (Converse requires one).
empty = config["tools"][1]["toolSpec"]["inputSchema"]["json"]
assert empty == {"type": "object", "properties": {}}
assert convert_tools(None) is None
assert convert_tools([]) is None
# -- Converse client (dict-returning fakes, like boto3) ----------------------------
class _FakeConverse:
def __init__(self, response: Optional[dict] = None, stream: Optional[list] = None):
self.response = response
self.stream_events = stream or []
self.calls: list[tuple[str, dict]] = []
def converse(self, **kwargs):
self.calls.append(("converse", kwargs))
return self.response
def converse_stream(self, **kwargs):
self.calls.append(("converse_stream", kwargs))
return {"stream": iter(self.stream_events)}
def test_converse_complete_text():
fake = _FakeConverse(
response={
"output": {"message": {"content": [{"text": "hello there"}]}},
"stopReason": "end_turn",
}
)
client = _BedrockConverseClient(client=fake)
turn = client.complete(
model="meta.llama4-maverick-17b-instruct-v1:0",
messages=[
{"role": "system", "content": "sys"},
{"role": "user", "content": "hi"},
],
temperature=0.5,
frequency_penalty=0.2, # not a Converse knob — must be dropped
)
assert turn.text == "hello there"
assert turn.finish_reason == "stop"
method, kwargs = fake.calls[0]
assert method == "converse"
assert kwargs["modelId"] == "meta.llama4-maverick-17b-instruct-v1:0"
assert kwargs["system"] == [{"text": "sys"}]
assert kwargs["inferenceConfig"]["temperature"] == 0.5
assert kwargs["inferenceConfig"]["maxTokens"] > 0 # default applied
assert "frequency_penalty" not in kwargs["inferenceConfig"]
assert "toolConfig" not in kwargs
def test_converse_complete_tool_use_and_reasoning():
fake = _FakeConverse(
response={
"output": {
"message": {
"content": [
{"reasoningContent": {"reasoningText": {"text": "hmm"}}},
{"text": "let me check"},
{
"toolUse": {
"toolUseId": "call-1",
"name": "ls",
"input": {"path": "."},
}
},
]
}
},
"stopReason": "tool_use",
}
)
client = _BedrockConverseClient(client=fake)
turn = client.complete(
model="amazon.nova-2-pro-v1:0",
messages=[{"role": "user", "content": "list files"}],
tools=[{"type": "function", "function": {"name": "ls", "parameters": {}}}],
)
assert turn.finish_reason == "tool_calls"
assert turn.reasoning == "hmm"
assert turn.text == "let me check"
(call,) = turn.tool_calls
assert (call.id, call.name, call.arguments) == ("call-1", "ls", {"path": "."})
_, kwargs = fake.calls[0]
assert kwargs["toolConfig"]["tools"][0]["toolSpec"]["name"] == "ls"
def test_converse_stream_accumulates_text_and_tool():
fake = _FakeConverse(
stream=[
{"messageStart": {"role": "assistant"}},
{"contentBlockDelta": {"delta": {"text": "hel"}, "contentBlockIndex": 0}},
{"contentBlockDelta": {"delta": {"text": "lo"}, "contentBlockIndex": 0}},
{
"contentBlockStart": {
"start": {"toolUse": {"toolUseId": "c1", "name": "ls"}},
"contentBlockIndex": 1,
}
},
{
"contentBlockDelta": {
"delta": {"toolUse": {"input": '{"path"'}},
"contentBlockIndex": 1,
}
},
{
"contentBlockDelta": {
"delta": {"toolUse": {"input": ': "."}'}},
"contentBlockIndex": 1,
}
},
{"contentBlockStop": {"contentBlockIndex": 1}},
{"messageStop": {"stopReason": "tool_use"}},
]
)
client = _BedrockConverseClient(client=fake)
chunks = list(
client.stream(
model="mistral.mistral-large-3-v1:0",
messages=[{"role": "user", "content": "go"}],
)
)
assert [c.text_delta for c in chunks if c.text_delta] == ["hel", "lo"]
final = chunks[-1].turn
assert final.text == "hello"
assert final.finish_reason == "tool_calls"
(call,) = final.tool_calls
assert (call.id, call.name, call.arguments) == ("c1", "ls", {"path": "."})
def test_no_credentials_error_becomes_friendly():
from botocore.exceptions import NoCredentialsError
class _Raises:
def converse(self, **kwargs):
raise NoCredentialsError()
client = _BedrockConverseClient(client=_Raises())
with pytest.raises(RuntimeError, match="Settings"):
client.complete(model="m", messages=[{"role": "user", "content": "x"}])
# -- credential resolution ----------------------------------------------------------
def test_session_kwargs_resolution_order():
explicit = _session_kwargs("work", "AKIA1", "secret", "token")
assert explicit == {
"aws_access_key_id": "AKIA1",
"aws_secret_access_key": "secret",
"aws_session_token": "token",
}
assert _session_kwargs("work", None, None, None) == {"profile_name": "work"}
assert _session_kwargs(None, None, None, None) == {} # ambient chain
# -- family dispatch ----------------------------------------------------------------
class _Recorder(ProviderClient):
def __init__(self):
self.seen: list[str] = []
def complete(self, *, model, messages, tools=None, **settings):
self.seen.append(model)
return AssistantTurn(text="ok")
def stream(self, *, model, messages, tools=None, **settings):
self.seen.append(model)
yield StreamChunk(turn=AssistantTurn(text="ok"))
def capabilities(self, model):
return capabilities_for(model)
def test_family_dispatch():
claude, converse = _Recorder(), _Recorder()
p = BedrockProvider(
region="us-east-1", claude_client=claude, converse_client=converse
)
p.complete(
model="claude/anthropic.claude-sonnet-4-6-v1:0",
messages=[{"role": "user", "content": "x"}],
)
p.complete(
model="other/amazon.nova-2-pro-v1:0",
messages=[{"role": "user", "content": "x"}],
)
# A raw Bedrock id with no family segment still works — Converse serves everything.
p.complete(
model="meta.llama4-maverick-17b-instruct-v1:0",
messages=[{"role": "user", "content": "x"}],
)
assert claude.seen == ["anthropic.claude-sonnet-4-6-v1:0"]
assert converse.seen == [
"amazon.nova-2-pro-v1:0",
"meta.llama4-maverick-17b-instruct-v1:0",
]
def test_claude_family_builds_native_anthropic_over_bedrock():
from anthropic import AnthropicBedrock
from coworker.providers import AnthropicProvider
p = BedrockProvider(region="us-east-1", profile_name="work")
sub = p._family_client("claude")
assert isinstance(sub, AnthropicProvider)
assert isinstance(sub._client, AnthropicBedrock)
# -- capabilities / matrix ------------------------------------------------------------
def test_bedrock_capabilities_from_matrix_and_fallback():
curated = capabilities_for("bedrock:claude/anthropic.claude-sonnet-4-6-v1:0")
assert curated.vision and curated.pdf and curated.parallel_tool_calls
assert capabilities_for("bedrock:other/amazon.nova-2-pro-v1:0").tools
# Custom ids fall back on the family segment: Claude keeps native caps,
# unknown Converse models stay conservative.
custom_claude = capabilities_for("bedrock:claude/us.anthropic.claude-opus-4-8-v1:0")
assert custom_claude.vision and custom_claude.parallel_tool_calls
custom_other = capabilities_for("bedrock:other/cohere.command-b-v1:0")
assert custom_other.tools and not custom_other.parallel_tool_calls
def test_router_prefix_survives_bedrock_version_colons():
from coworker.providers.router import ProviderRouter
router = ProviderRouter.__new__(ProviderRouter)
model = "bedrock:claude/anthropic.claude-sonnet-4-6-v1:0"
assert router._provider_name(model) == "bedrock"
assert ProviderRouter._bare(model) == "claude/anthropic.claude-sonnet-4-6-v1:0"
# -- registry / manager glue -----------------------------------------------------------
def test_bedrock_descriptor_and_builder():
from coworker.providers.registry import build_provider_client, get_descriptor
d = get_descriptor("bedrock")
assert d is not None and d.needs_key
keys = [f.key for f in d.fields]
assert keys == [
"region",
"aws_profile",
"aws_access_key_id",
"aws_secret_access_key",
"aws_session_token",
]
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"}
# Recommended model is curated in the matrix (set_provider's auto-add depends on it).
from coworker.providers.matrix import models_for_provider
assert d.recommended_model in models_for_provider("bedrock")
p = build_provider_client(
"bedrock", {"region": "eu-west-1", "aws_profile": "work"}, None
)
assert isinstance(p, BedrockProvider)
assert p._region == "eu-west-1" and p._profile_name == "work"
def test_bedrock_configured_needs_region_only():
from coworker.providers.registry import descriptor_configured, get_descriptor
d = get_descriptor("bedrock")
assert not descriptor_configured(d, {})
assert not descriptor_configured(d, {"aws_profile": "work"})
assert descriptor_configured(d, {"region": "us-east-1"})
def test_single_key_providers_keep_api_key_configured_semantics(monkeypatch):
from coworker.providers.registry import descriptor_configured, get_descriptor
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
d = get_descriptor("anthropic")
assert not descriptor_configured(d, {})
assert descriptor_configured(d, {"api_key": "sk-ant-x"})
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-env")
assert descriptor_configured(d, {})
# -- verify ---------------------------------------------------------------------------
class _FakeBedrockControl:
def __init__(self, exc: Optional[Exception] = None):
self.exc = exc
def list_foundation_models(self):
if self.exc:
raise self.exc
return {"modelSummaries": []}
def _patch_session(monkeypatch, control: Any, captured: dict):
import boto3
class _FakeSession:
def __init__(self, **kwargs):
captured["session"] = kwargs
def client(self, service, **kwargs):
captured["service"] = service
captured["client"] = kwargs
return control
monkeypatch.setattr(boto3.session, "Session", _FakeSession)
def test_verify_bedrock_ok(monkeypatch):
from coworker.providers.registry import verify_provider_key
captured: dict = {}
_patch_session(monkeypatch, _FakeBedrockControl(), captured)
out = verify_provider_key(
"bedrock", fields={"region": "us-east-1", "aws_profile": "work"}
)
assert out == {"ok": True}
assert captured["service"] == "bedrock"
assert captured["session"] == {"profile_name": "work"}
assert captured["client"]["region_name"] == "us-east-1"
def test_verify_bedrock_maps_client_errors(monkeypatch):
from botocore.exceptions import ClientError
from coworker.providers.registry import verify_provider_key
denied = ClientError(
{"Error": {"Code": "AccessDeniedException", "Message": "no"}},
"ListFoundationModels",
)
_patch_session(monkeypatch, _FakeBedrockControl(exc=denied), {})
out = verify_provider_key("bedrock", fields={"region": "us-east-1"})
assert not out["ok"] and "Bedrock access" in out["error"]
bad_key = ClientError(
{"Error": {"Code": "UnrecognizedClientException", "Message": "no"}},
"ListFoundationModels",
)
_patch_session(monkeypatch, _FakeBedrockControl(exc=bad_key), {})
out = verify_provider_key("bedrock", fields={"region": "us-east-1"})
assert not out["ok"] and "rejected" in out["error"]
+1 -1
View File
@@ -395,7 +395,7 @@ def test_matrix_labels_and_custom_model_fallback():
assert labels["together:zai-org/GLM-5.2"] == "GLM-5.2 · via Together" assert labels["together:zai-org/GLM-5.2"] == "GLM-5.2 · via Together"
assert labels["zai:glm-5.2"] == "GLM-5.2 · Z AI" assert labels["zai:glm-5.2"] == "GLM-5.2 · Z AI"
# Deliberately small: agent-capable current models only (owner call, 2026-07-04). # Deliberately small: agent-capable current models only (owner call, 2026-07-04).
assert len(MATRIX) < 40 assert len(MATRIX) < 60
assert all(e.caps.tools for e in MATRIX.values()) assert all(e.caps.tools for e in MATRIX.values())
# A custom (unlisted) reseller model falls back to the conservative default — usable, # A custom (unlisted) reseller model falls back to the conservative default — usable,
# but at the user's own risk (no parallel tool calls assumed). # but at the user's own risk (no parallel tool calls assumed).