fb9b4fa422
The §9.1 Slice-2 AI-suggested tag chips, deferred since the propose modal landed, now wired up. As the propose-RFC draft fills in, the backend asks Claude Haiku for tags drawn ONLY from the corpus's existing tag set (the de-facto taxonomy — v1 tags are free-form chip input, there is no curated list), surfaced as clickable suggestion chips. Nothing auto-applies; the user clicks to add. Backend: - tag_suggest.py: universe gather (distinct corpus tags, most-common first), Haiku prompt + tolerant reply parser (drops invented tags, dedupes, clamps confidence), in-process per-user rate limit. - providers.construct_haiku(): dedicated Haiku provider from the operator key, independent of ENABLED_MODELS — tag suggestion always uses the cheap+fast model. No RFC slug at propose time, so the §6.7 funder path does not apply. - POST /api/rfcs/suggest-tags: contributor-gated, rate-limited. Degrades to an empty list (never an error) when no Anthropic key is bound, the corpus has no tags, or the draft is empty. Frontend: - ProposeModal: debounced suggestion fetch (700ms, stale-response guarded), clickable suggestion chips, and the required inline disclosure that the draft text is sent to Anthropic. - api.suggestTags(): forgiving — any non-OK resolves to []. Tests: 11 new (vertical contributor-gating / filtering / no-key / empty-corpus / 429, plus units for gather, parser tolerance, max, short-circuit, provider-failure). Full suite 351 green. New secret on deploy: ANTHROPIC_API_KEY (see CHANGELOG Upgrade steps). Disclosure copy wants a counsel pass before deploy, per #22 discipline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
264 lines
10 KiB
Python
264 lines
10 KiB
Python
"""Multi-provider LLM abstraction — §18 stack.
|
|
|
|
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 via the `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.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_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."""
|
|
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 construct_haiku(api_key: str) -> AnthropicProvider:
|
|
"""A dedicated Claude Haiku provider, independent of the
|
|
`ENABLED_MODELS` chat-picker universe.
|
|
|
|
The §9.1 tag-suggestion surface (roadmap #27) always wants the
|
|
cheap + fast model regardless of which models the operator surfaces
|
|
in the §8.12 picker, so it constructs Haiku directly from the
|
|
operator's Anthropic key rather than going through `load_providers`.
|
|
The model id is sourced from the same `_CLAUDE_VARIANTS` table the
|
|
picker uses, so a model-string bump lands in one place.
|
|
"""
|
|
model, name = _CLAUDE_VARIANTS["claude-haiku"]
|
|
return AnthropicProvider(api_key=api_key, model=model, display_name=name)
|
|
|
|
|
|
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
|