Files
rfc-app/backend/app/providers.py
T
Ben Stull 55a8be051a Post-v1: per-RFC credential delegation (funder role) folded into §6.7
Second §19.2 settlement after v1. New §6.7 alongside §6.6: optional
`funder:` frontmatter field names a single gitea_login; a
`funder_consents` app-db row records funder-side opt-in; both halves
required for the binding to activate (two-key rule). Funder universe
replaces — does not augment — the operator universe per-RFC for
attribution-clean resolution. Funder role grants zero §6.1/§6.3
authority. Three revocation paths each restore the operator-credentials
status quo.

§19.2's credential-delegation entry is split: lighter half marked
settled with a pointer to §6.7; operational-realities half (mid-call
failure, rotation, billing, rate-limit attribution) lives on as its
own entry. Test suite is 125/125 green (106 prior + 19 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 06:08:43 -07:00

249 lines
9.8 KiB
Python

"""Multi-provider LLM abstraction — §18 carryover from the prototype.
Each provider speaks a common interface — `send` and `send_streaming` —
so the chat layer in `chat.py` is provider-agnostic. Enabled providers
and their API keys are configured via env per the prototype's
`ENABLED_MODELS` contract; per §16 / §19.2, per-RFC model availability
and credential delegation are deferred until the topic is settled.
"""
from __future__ import annotations
from typing import Iterator
class BaseProvider:
name: str = "base"
display_name: str = "Base"
def send(self, system: str, history: list[dict]) -> str:
raise NotImplementedError
def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]:
raise NotImplementedError
# ---------------------------------------------------------------------------
# Anthropic — Claude
# ---------------------------------------------------------------------------
class AnthropicProvider(BaseProvider):
name = "claude"
def __init__(self, api_key: str, model: str = "claude-sonnet-4-6", display_name: str = "Claude"):
import anthropic
self.client = anthropic.Anthropic(api_key=api_key)
self.model = model
self.display_name = display_name
def send(self, system: str, history: list[dict]) -> str:
response = self.client.messages.create(
model=self.model,
max_tokens=4096,
system=system,
messages=history,
)
return response.content[0].text
def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]:
with self.client.messages.stream(
model=self.model,
max_tokens=4096,
system=system,
messages=history,
) as stream:
for text in stream.text_stream:
yield text
# ---------------------------------------------------------------------------
# Google — Gemini
# ---------------------------------------------------------------------------
class GeminiProvider(BaseProvider):
name = "gemini"
def __init__(self, api_key: str, model: str = "gemini-1.5-pro", display_name: str = "Gemini"):
import google.generativeai as genai
genai.configure(api_key=api_key)
self._genai = genai
self.model_name = model
self.display_name = display_name
def _build_model(self, system: str):
return self._genai.GenerativeModel(model_name=self.model_name, system_instruction=system)
def _convert_history(self, history: list[dict]) -> list[dict]:
return [
{"role": "user" if msg["role"] == "user" else "model", "parts": [msg["content"]]}
for msg in history
]
def send(self, system: str, history: list[dict]) -> str:
model = self._build_model(system)
prior = self._convert_history(history[:-1])
chat = model.start_chat(history=prior)
response = chat.send_message(history[-1]["content"])
return response.text
def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]:
model = self._build_model(system)
prior = self._convert_history(history[:-1])
chat = model.start_chat(history=prior)
response = chat.send_message(history[-1]["content"], stream=True)
for chunk in response:
if chunk.text:
yield chunk.text
# ---------------------------------------------------------------------------
# OpenAI-compatible — OpenAI, Copilot, or any compatible endpoint
# ---------------------------------------------------------------------------
class OpenAIProvider(BaseProvider):
name = "openai"
def __init__(self, api_key: str, model: str = "gpt-4o", base_url: str | None = None, display_name: str = "Copilot"):
from openai import OpenAI
self.client = OpenAI(api_key=api_key, base_url=base_url or "https://api.openai.com/v1")
self.model = model
self.display_name = display_name
def _messages(self, system: str, history: list[dict]) -> list[dict]:
return [{"role": "system", "content": system}] + [
{"role": msg["role"], "content": msg["content"]} for msg in history
]
def send(self, system: str, history: list[dict]) -> str:
response = self.client.chat.completions.create(
model=self.model, max_tokens=4096, messages=self._messages(system, history)
)
return response.choices[0].message.content
def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]:
stream = self.client.chat.completions.create(
model=self.model, max_tokens=4096, messages=self._messages(system, history), stream=True
)
for chunk in stream:
delta = chunk.choices[0].delta.content
if delta:
yield delta
# ---------------------------------------------------------------------------
# Variants and factory — preserved from the prototype to keep the contract.
# ---------------------------------------------------------------------------
_CLAUDE_VARIANTS: dict[str, tuple[str, str]] = {
"claude": ("claude-sonnet-4-6", "Claude"),
"claude-sonnet": ("claude-sonnet-4-6", "Claude Sonnet"),
"claude-opus": ("claude-opus-4-6", "Claude Opus"),
"claude-haiku": ("claude-haiku-4-5-20251001", "Claude Haiku"),
}
_GEMINI_VARIANTS: dict[str, tuple[str, str]] = {
"gemini": ("gemini-1.5-pro", "Gemini"),
"gemini-pro": ("gemini-1.5-pro", "Gemini Pro"),
"gemini-flash": ("gemini-1.5-flash", "Gemini Flash"),
"gemini-2-flash": ("gemini-2.0-flash", "Gemini 2 Flash"),
}
def load_providers(env: dict) -> dict[str, BaseProvider]:
"""Instantiate enabled providers from env — same contract as the prototype."""
enabled = [m.strip() for m in env.get("ENABLED_MODELS", "claude").split(",") if m.strip()]
providers: dict[str, BaseProvider] = {}
anthropic_key = env.get("ANTHROPIC_API_KEY") or ""
google_key = env.get("GOOGLE_API_KEY") or ""
openai_key = env.get("OPENAI_API_KEY") or ""
for key in enabled:
prefix = key.upper().replace("-", "_")
if key in _CLAUDE_VARIANTS and anthropic_key:
default_model, default_name = _CLAUDE_VARIANTS[key]
providers[key] = AnthropicProvider(
api_key=anthropic_key,
model=env.get(f"{prefix}_MODEL", default_model),
display_name=env.get(f"{prefix}_DISPLAY_NAME", default_name),
)
elif key in _GEMINI_VARIANTS and google_key:
default_model, default_name = _GEMINI_VARIANTS[key]
providers[key] = GeminiProvider(
api_key=google_key,
model=env.get(f"{prefix}_MODEL", default_model),
display_name=env.get(f"{prefix}_DISPLAY_NAME", default_name),
)
elif key == "openai" and openai_key:
providers["openai"] = OpenAIProvider(
api_key=openai_key,
model=env.get("OPENAI_MODEL", "gpt-4o"),
base_url=env.get("OPENAI_BASE_URL"),
display_name=env.get("OPENAI_DISPLAY_NAME", "Copilot"),
)
return providers
def load_from_config(config) -> dict[str, BaseProvider]:
"""Convenience adapter so callers can pass our Config dataclass directly."""
env = {
"ENABLED_MODELS": ",".join(config.enabled_models),
"ANTHROPIC_API_KEY": config.anthropic_api_key,
"GOOGLE_API_KEY": config.google_api_key,
"OPENAI_API_KEY": config.openai_api_key,
}
return load_providers(env)
# ---------------------------------------------------------------------------
# §6.7 helpers — per-RFC funder credential plumbing.
#
# A picker key (the operator-enabled identifier like "claude" or
# "gemini-flash") names exactly one provider family (Anthropic, Google,
# OpenAI). The funder registers a key per family; the resolver in
# funder.py picks the right family for a requested picker key, then
# constructs a fresh provider instance keyed on the funder's API key.
# ---------------------------------------------------------------------------
FUNDER_PROVIDER_FAMILIES = ("anthropic", "google", "openai")
def provider_family_for_picker_key(picker_key: str) -> str | None:
"""Map a picker key to its provider-family name per §6.7.
Returns 'anthropic' / 'google' / 'openai' or None for keys outside
the known variant tables.
"""
if picker_key in _CLAUDE_VARIANTS:
return "anthropic"
if picker_key in _GEMINI_VARIANTS:
return "google"
if picker_key == "openai":
return "openai"
return None
def picker_keys_for_family(family: str, enabled_picker_keys: list[str]) -> list[str]:
"""Subset of `enabled_picker_keys` served by `family`. Preserves the
operator's enabled order so the picker reads stably."""
return [k for k in enabled_picker_keys if provider_family_for_picker_key(k) == family]
def construct_for_funder(picker_key: str, api_key: str) -> BaseProvider | None:
"""Instantiate a provider for a funder-supplied API key per §6.7.
Mirrors the variant table `load_providers` uses, without the env
contract. Returns None for picker keys outside the known families —
the funder cannot serve them and the caller treats the (slug,
picker_key) request as unavailable.
"""
if picker_key in _CLAUDE_VARIANTS:
default_model, default_name = _CLAUDE_VARIANTS[picker_key]
return AnthropicProvider(api_key=api_key, model=default_model, display_name=default_name)
if picker_key in _GEMINI_VARIANTS:
default_model, default_name = _GEMINI_VARIANTS[picker_key]
return GeminiProvider(api_key=api_key, model=default_model, display_name=default_name)
if picker_key == "openai":
return OpenAIProvider(api_key=api_key)
return None