mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-12 07:10:09 +00:00
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:
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user