Files
openworker/coworker/providers/registry.py
T
Rohit C Prasad 8cb9524f1f 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.
2026-07-25 16:15:18 -07:00

609 lines
22 KiB
Python

"""Model-provider registry — descriptors + a factory, mirroring the connector
(`connectors/descriptors.py`) and web-search (`web/providers.py`) patterns.
A `ProviderDescriptor` declares a provider's UI config `fields` (rendered dynamically by the
GUI, same `to_dict()` shape connectors use) and a `build(profile, secrets)` factory that returns
a `ProviderClient`. The `ProviderRouter` selects a descriptor by the `provider:` prefix of a
model string and builds (and caches) its client from the matching SecretStore profile.
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`), `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
import os
from dataclasses import dataclass, field
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
DEFAULT_OLLAMA_URL = "http://localhost:11434"
@dataclass(frozen=True)
class ProviderField:
"""One config input for a provider, rendered by the GUI (mirrors connectors' `Field`)."""
key: str
label: str
secret: bool = False
required: bool = True
help: str = ""
placeholder: str = ""
# Pre-filled (still editable) form value — e.g. an OpenAI-compatible vendor's official
# endpoint, so the user only has to paste a key. Distinct from `placeholder` (grey hint).
default: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"key": self.key,
"label": self.label,
"secret": self.secret,
"required": self.required,
"help": self.help,
"placeholder": self.placeholder,
"default": self.default,
}
@dataclass(frozen=True)
class ProviderDescriptor:
"""A model provider: its UI fields + a factory that builds its `ProviderClient`."""
name: str
title: str
needs_key: bool
fields: list[ProviderField]
build: Callable[[dict[str, Any], Any], ProviderClient] = field(repr=False)
recommended_model: Optional[str] = (
None # pre-filled in the UI; auto-added on configure
)
env_key: Optional[str] = (
None # env var that can supply the API key (e.g. ANTHROPIC_API_KEY)
)
# One-line note under the provider title (e.g. "Connects through X's OpenAI-compatible API").
blurb: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"name": self.name,
"title": self.title,
"needs_key": self.needs_key,
"fields": [f.to_dict() for f in self.fields],
"recommended_model": self.recommended_model,
"blurb": self.blurb,
}
def _normalize_ollama_url(url: Optional[str]) -> str:
"""Accept `http://host:11434` or `.../v1` and return an OpenAI-compatible base URL.
Ollama serves its OpenAI-compatible API under `/v1`; the native API lives at the root, so we
always target `<root>/v1`.
"""
base = (url or DEFAULT_OLLAMA_URL).strip().rstrip("/")
if not base:
base = DEFAULT_OLLAMA_URL
if not base.endswith("/v1"):
base = base + "/v1"
return base
def _build_openai(profile: dict[str, Any], secrets: Any) -> ProviderClient:
# Key resolution stays in OpenAIProvider/resolve_api_key (explicit → env → SecretStore),
# so we just hand it the SecretStore. An optional custom endpoint (Azure OpenAI /openai/v1,
# OpenRouter, vLLM, …) comes from the stored profile.
base_url = ((profile or {}).get("base_url") or "").strip() or None
return OpenAIProvider(secrets=secrets, base_url=base_url)
def _build_anthropic(profile: dict[str, Any], secrets: Any) -> ProviderClient:
# Key resolution stays in AnthropicProvider/resolve_api_key (explicit → env → SecretStore),
# deferred to first call so the provider can be built before a key exists.
# thinking_budget: hidden profile override — absent/invalid → the default (ON),
# explicit 0 → off (see DEFAULT_THINKING_BUDGET).
from .anthropic_provider import DEFAULT_THINKING_BUDGET
api_key = ((profile or {}).get("api_key") or "").strip() or None
try:
thinking_budget = int(str((profile or {}).get("thinking_budget") or "").strip())
except ValueError:
thinking_budget = DEFAULT_THINKING_BUDGET
return AnthropicProvider(
api_key=api_key, secrets=secrets, thinking_budget=thinking_budget
)
def _build_gemini(profile: dict[str, Any], secrets: Any) -> ProviderClient:
# Same deferred-key contract as anthropic (GeminiProvider/resolve_api_key).
api_key = ((profile or {}).get("api_key") or "").strip() or None
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).
base_url = _normalize_ollama_url((profile or {}).get("base_url"))
return OpenAIProvider(api_key="ollama", base_url=base_url)
def _openai_compat(vendor: str, default_base_url: str, env_key: Optional[str] = None):
"""Builder factory for vendors reached through their OpenAI-compatible API (Z AI, DeepSeek,
Kimi, MiniMax, Qwen, xAI, Mistral). The key is resolved from the vendor's OWN profile (or its
env var) — deliberately NOT from the OpenAI env/SecretStore fallback, so a configured OpenAI
key is never silently sent to a different vendor's endpoint. Missing key ⇒ fail fast with a
vendor-named error (these are only built on demand, when one of their models is selected).
"""
def build(profile: dict[str, Any], secrets: Any) -> ProviderClient:
base_url = ((profile or {}).get("base_url") or "").strip() or default_base_url
api_key = ((profile or {}).get("api_key") or "").strip() or (
os.environ.get(env_key, "").strip() if env_key else ""
)
if not api_key:
raise RuntimeError(
f"No {vendor} API key configured — add it in Settings ▸ Models."
)
return OpenAIProvider(api_key=api_key, base_url=base_url)
return build
def _compat(
name: str,
title: str,
*,
base_url: str,
recommended_model: str,
env_key: str,
endpoint_help: str = "",
) -> ProviderDescriptor:
"""Descriptor for an OpenAI-compatible vendor: key + a prefilled, editable endpoint."""
vendor = title.split(" (")[0]
return ProviderDescriptor(
name=name,
title=title,
needs_key=True,
fields=[
ProviderField(
"api_key",
f"{vendor} API key",
secret=True,
),
ProviderField(
"base_url",
"Endpoint",
required=False,
default=base_url,
placeholder=base_url,
help=endpoint_help
or f"Prefilled with {vendor}'s official endpoint; edit only for a regional or proxy variant.",
),
],
build=_openai_compat(vendor, base_url, env_key),
recommended_model=recommended_model,
env_key=env_key,
blurb=f"Uses {vendor}'s OpenAI-compatible API — the endpoint is prefilled, just add your key.",
)
DESCRIPTORS: list[ProviderDescriptor] = [
ProviderDescriptor(
name="openai",
title="OpenAI",
needs_key=True,
fields=[
ProviderField(
"api_key",
"OpenAI API key",
secret=True,
placeholder="sk-…",
),
ProviderField(
"base_url",
"Custom endpoint (optional)",
secret=False,
required=False,
placeholder="https://…/openai/v1",
help="For Azure OpenAI, vLLM, or any OpenAI-compliant server. Leave blank for api.openai.com.",
),
],
build=_build_openai,
recommended_model="gpt-5.6-sol",
env_key="OPENAI_API_KEY",
),
ProviderDescriptor(
name="anthropic",
title="Claude (Anthropic)",
needs_key=True,
fields=[
ProviderField(
"api_key",
"Anthropic API key",
secret=True,
placeholder="sk-ant-…",
),
# No thinking_budget field (owner call 2026-07-23): extended thinking is
# on by default; the profile key stays a hidden override (0 = off).
],
build=_build_anthropic,
recommended_model="claude-fable-5",
env_key="ANTHROPIC_API_KEY",
),
ProviderDescriptor(
name="gemini",
title="Gemini (Google)",
needs_key=True,
fields=[
ProviderField(
"api_key",
"Gemini API key",
secret=True,
placeholder="AIza…",
),
],
build=_build_gemini,
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`).
_compat(
"zai",
"Z AI (GLM)",
base_url="https://api.z.ai/api/paas/v4",
recommended_model="glm-5.2",
env_key="ZAI_API_KEY",
endpoint_help="Prefilled with Z AI's international endpoint. China mainland: https://open.bigmodel.cn/api/paas/v4",
),
_compat(
"deepseek",
"DeepSeek",
base_url="https://api.deepseek.com",
recommended_model="deepseek-v4-flash",
env_key="DEEPSEEK_API_KEY",
),
_compat(
"kimi",
"Kimi (Moonshot AI)",
base_url="https://api.moonshot.ai/v1",
recommended_model="kimi-k2.6",
env_key="MOONSHOT_API_KEY",
endpoint_help="Prefilled with Moonshot's international endpoint. China mainland: https://api.moonshot.cn/v1",
),
_compat(
"minimax",
"MiniMax",
base_url="https://api.minimax.io/v1",
recommended_model="MiniMax-M2.5",
env_key="MINIMAX_API_KEY",
),
_compat(
"qwen",
"Qwen (Alibaba)",
base_url="https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
recommended_model="qwen3-max",
env_key="DASHSCOPE_API_KEY",
endpoint_help="Prefilled with Alibaba Model Studio's international endpoint. China (Beijing): https://dashscope.aliyuncs.com/compatible-mode/v1",
),
_compat(
"xai",
"xAI (Grok)",
base_url="https://api.x.ai/v1",
recommended_model="grok-4.3",
env_key="XAI_API_KEY",
),
_compat(
"mistral",
"Mistral",
base_url="https://api.mistral.ai/v1",
recommended_model="mistral-large-latest",
env_key="MISTRAL_API_KEY",
),
_compat(
"meta",
"Meta (Muse Spark)",
base_url="https://api.meta.ai/v1",
recommended_model="muse-spark-1.1",
env_key="META_API_KEY",
endpoint_help="Prefilled with the Meta Model API endpoint (public preview, US-only as of 2026-07).",
),
# Resellers: many labs' models behind one key, using THEIR model namespaces (the curated
# ids + display labels live in providers/matrix.py). TODO: add Groq here (+ its matrix
# rows) once the current provider surface is tested — deliberately deferred to bound
# how much needs verifying at once (owner call, 2026-07-04).
_compat(
"together",
"Together AI",
base_url="https://api.together.xyz/v1",
recommended_model="zai-org/GLM-5.2",
env_key="TOGETHER_API_KEY",
),
_compat(
"fireworks",
"Fireworks AI",
base_url="https://api.fireworks.ai/inference/v1",
recommended_model="accounts/fireworks/models/glm-5p2",
env_key="FIREWORKS_API_KEY",
),
_compat(
"openrouter",
"OpenRouter",
base_url="https://openrouter.ai/api/v1",
recommended_model="z-ai/glm-5.2",
env_key="OPENROUTER_API_KEY",
),
ProviderDescriptor(
name="ollama",
title="Ollama (local models)",
needs_key=False,
fields=[
ProviderField(
"base_url",
"Ollama server URL",
secret=False,
required=False,
placeholder=DEFAULT_OLLAMA_URL,
help="Where `ollama serve` is listening. The OpenAI-compatible /v1 path is added automatically.",
),
],
build=_build_ollama,
# Reliable native tool-calling + strong coding quality (verified). Pull with
# `ollama pull qwen3-coder:30b`.
recommended_model="qwen3-coder:30b",
),
]
_BY_NAME = {d.name: d for d in DESCRIPTORS}
def provider_descriptors() -> list[ProviderDescriptor]:
return list(DESCRIPTORS)
def provider_names() -> list[str]:
return [d.name for d in DESCRIPTORS]
def get_descriptor(name: str) -> Optional[ProviderDescriptor]:
return _BY_NAME.get(name)
def build_provider_client(
name: str, profile: dict[str, Any], secrets: Any
) -> ProviderClient:
"""Build a `ProviderClient` for `name` from its stored profile. Unknown → OpenAI default."""
descriptor = _BY_NAME.get(name) or _BY_NAME["openai"]
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.
"""
key = (api_key or "").strip()
if not key:
return None
if key.startswith("sk-ant-"):
return "anthropic"
if key.startswith("sk-or-"):
return "openrouter"
if key.startswith("AIza"):
return "gemini"
if key.startswith(("sk-", "sk_")):
return "openai"
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?}. 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(
"https://api.anthropic.com/v1/models",
headers={"x-api-key": key, "anthropic-version": "2023-06-01"},
timeout=timeout,
)
elif name == "gemini":
resp = httpx.get(
"https://generativelanguage.googleapis.com/v1beta/models",
params={"key": key},
timeout=timeout,
)
elif name == "ollama":
base = _normalize_ollama_url(base_url)
resp = httpx.get(base.rstrip("/") + "/models", timeout=timeout)
else: # openai + any OpenAI-compatible endpoint (Azure, OpenRouter, vendors, vLLM…)
default_base = next(
(f.default for f in d.fields if f.key == "base_url" and f.default), ""
)
base = (
(base_url or "").strip().rstrip("/")
or default_base.rstrip("/")
or "https://api.openai.com/v1"
)
resp = httpx.get(
base + "/models",
headers={"Authorization": f"Bearer {key}"},
timeout=timeout,
)
except Exception as exc: # DNS/connection/timeout — never let it bubble to a 500
return {
"ok": False,
"error": f"Couldn't reach {d.title} ({exc.__class__.__name__}).",
}
if resp.status_code < 300:
return {"ok": True}
if resp.status_code in (401, 403):
if name == "ollama":
return {"ok": False, "error": "Server rejected the request."}
return {"ok": False, "error": "Invalid API key."}
if resp.status_code == 404 and name == "ollama":
return {
"ok": False,
"error": "Reached the server, but no OpenAI-compatible /v1 API there.",
}
return {"ok": False, "error": f"{d.title} returned HTTP {resp.status_code}."}