diff --git a/coworker/providers/__init__.py b/coworker/providers/__init__.py index 6b34c141..0c526ab1 100644 --- a/coworker/providers/__init__.py +++ b/coworker/providers/__init__.py @@ -1,4 +1,5 @@ from .anthropic_provider import AnthropicProvider +from .bedrock_provider import BedrockProvider from .base import ( AssistantTurn, ModelCapabilities, @@ -13,6 +14,7 @@ from .registry import ( ProviderDescriptor, ProviderField, build_provider_client, + descriptor_configured, detect_provider, get_descriptor, provider_descriptors, @@ -28,6 +30,7 @@ __all__ = [ "StreamChunk", "ToolCall", "AnthropicProvider", + "BedrockProvider", "GeminiProvider", "OpenAIProvider", "resolve_api_key", @@ -39,6 +42,7 @@ __all__ = [ "provider_names", "get_descriptor", "build_provider_client", + "descriptor_configured", "detect_provider", "verify_provider_key", ] diff --git a/coworker/providers/bedrock_provider.py b/coworker/providers/bedrock_provider.py new file mode 100644 index 00000000..82a31e9f --- /dev/null +++ b/coworker/providers/bedrock_provider.py @@ -0,0 +1,523 @@ +"""AWS Bedrock provider — one entry in Settings, two wire paths by model family. + +Routed ids look like `bedrock:/`; 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"\n{text}\n"}]} + ) + 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 `/` 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/` → 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) diff --git a/coworker/providers/capabilities.py b/coworker/providers/capabilities.py index 94755c53..aea3921f 100644 --- a/coworker/providers/capabilities.py +++ b/coworker/providers/capabilities.py @@ -29,6 +29,19 @@ def capabilities_for(model: str) -> ModelCapabilities: 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 # engine executes parallel calls sequentially and each converter folds the results into # the single next user message — exactly what both APIs require. diff --git a/coworker/providers/matrix.py b/coworker/providers/matrix.py index 81457fff..60cff5c7 100644 --- a/coworker/providers/matrix.py +++ b/coworker/providers/matrix.py @@ -120,6 +120,23 @@ MATRIX: dict[str, ModelEntry] = { "openrouter:meta-llama/llama-4-maverick": ModelEntry( "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:` 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" + ), } diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index a8111285..165c027a 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -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 `/openai/v1` and any OpenAI-compliant gateway), `anthropic` (native Messages API via -`AnthropicProvider`), `gemini` (native Google GenAI API via `GeminiProvider`), and `ollama` -(local, OpenAI-compatible `/v1`). Bedrock/Vertex auth for Claude is future work. +`AnthropicProvider`), `gemini` (native Google GenAI API via `GeminiProvider`), `bedrock` +(models in the user's own AWS account — Claude natively, everything else via Converse), +and `ollama` (local, OpenAI-compatible `/v1`). """ from __future__ import annotations @@ -20,6 +21,7 @@ from typing import Any, Callable, Optional from .anthropic_provider import AnthropicProvider from .base import ProviderClient +from .bedrock_provider import BedrockProvider from .gemini_provider import GeminiProvider 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) +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: # 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). @@ -252,6 +271,54 @@ DESCRIPTORS: list[ProviderDescriptor] = [ recommended_model="gemini-3.6-flash", 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 # "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`). @@ -384,6 +451,21 @@ def build_provider_client( 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]: """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. @@ -402,21 +484,80 @@ def detect_provider(api_key: str) -> Optional[str]: 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( name: str, *, api_key: Optional[str] = None, base_url: Optional[str] = None, + fields: Optional[dict[str, Any]] = None, timeout: float = 10.0, ) -> dict[str, Any]: """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 - 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 d = _BY_NAME.get(name) or _BY_NAME["openai"] key = (api_key or "").strip() + if name == "bedrock": + return _verify_bedrock(fields or {}, timeout) try: if name == "anthropic": resp = httpx.get( diff --git a/coworker/server/manager.py b/coworker/server/manager.py index b2d9be0c..9d10716d 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -75,6 +75,7 @@ from ..agents import list_agents as _list_agents from ..providers import ( ProviderClient, ProviderRouter, + descriptor_configured, get_descriptor, provider_descriptors, verify_provider_key, @@ -1391,17 +1392,10 @@ class SessionManager: """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. """ - import os - out: list[dict[str, Any]] = [] for d in provider_descriptors(): profile = self.secrets.get(f"provider:{d.name}") or {} - if d.needs_key: - 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 + configured = descriptor_configured(d, profile) values = { f.key: profile.get(f.key) for f in d.fields @@ -1566,8 +1560,8 @@ class SessionManager: self, name: str, fields: Optional[dict[str, Any]] ) -> dict[str, Any]: """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 - the key blank (e.g. testing an already-configured provider).""" + onboarding can offer a "Test" button. Falls back to stored/env values when the form left + a field blank (e.g. testing an already-configured provider).""" import os d = get_descriptor(name) @@ -1575,13 +1569,28 @@ class SessionManager: return {"ok": False, "error": f"unknown provider: {name}"} fields = fields 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: api_key = os.environ.get(d.env_key, "").strip() - base_url = (fields.get("base_url") or profile.get("base_url") or "").strip() - if d.needs_key and not api_key: + has_key_field = any(f.key == "api_key" for f in d.fields) + if d.needs_key and has_key_field and not api_key: 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: """The provider a model string routes to (known `prefix:` or the OpenAI default).""" @@ -1595,12 +1604,7 @@ class SessionManager: d = get_descriptor(name) if d is None: return False - if not d.needs_key: - 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) - ) + return descriptor_configured(d, self.secrets.get(f"provider:{name}") or {}) # -- settings / prefs (model API key, default model, onboarding) ------------- def _prefs_path(self) -> Path: diff --git a/tests/test_bedrock_provider.py b/tests/test_bedrock_provider.py new file mode 100644 index 00000000..89028680 --- /dev/null +++ b/tests/test_bedrock_provider.py @@ -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"] diff --git a/tests/test_providers.py b/tests/test_providers.py index e34d1e99..943c6e4c 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -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["zai:glm-5.2"] == "GLM-5.2 · Z AI" # 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()) # A custom (unlisted) reseller model falls back to the conservative default — usable, # but at the user's own risk (no parallel tool calls assumed).