Initial commit

This commit is contained in:
2026-04-08 12:43:24 +02:00
commit be674c2f93
148 changed files with 25007 additions and 0 deletions

View File

@@ -0,0 +1 @@
# aide providers package

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,181 @@
"""
providers/anthropic_provider.py — Anthropic Claude provider.
Uses the official `anthropic` Python SDK.
Tool schemas are already in Anthropic's native format, so no conversion needed.
Messages are converted from the OpenAI-style format used internally by aide.
"""
from __future__ import annotations
import json
import logging
import anthropic
from .base import AIProvider, ProviderResponse, ToolCallResult, UsageStats
logger = logging.getLogger(__name__)
DEFAULT_MODEL = "claude-sonnet-4-6"
class AnthropicProvider(AIProvider):
def __init__(self, api_key: str) -> None:
self._client = anthropic.Anthropic(api_key=api_key)
self._async_client = anthropic.AsyncAnthropic(api_key=api_key)
@property
def name(self) -> str:
return "Anthropic"
@property
def default_model(self) -> str:
return DEFAULT_MODEL
# ── Public interface ──────────────────────────────────────────────────────
def chat(
self,
messages: list[dict],
tools: list[dict] | None = None,
system: str = "",
model: str = "",
max_tokens: int = 4096,
) -> ProviderResponse:
params = self._build_params(messages, tools, system, model, max_tokens)
try:
response = self._client.messages.create(**params)
return self._parse_response(response)
except Exception as e:
logger.error(f"Anthropic chat error: {e}")
return ProviderResponse(text=f"Error: {e}", finish_reason="error")
async def chat_async(
self,
messages: list[dict],
tools: list[dict] | None = None,
system: str = "",
model: str = "",
max_tokens: int = 4096,
) -> ProviderResponse:
params = self._build_params(messages, tools, system, model, max_tokens)
try:
response = await self._async_client.messages.create(**params)
return self._parse_response(response)
except Exception as e:
logger.error(f"Anthropic async chat error: {e}")
return ProviderResponse(text=f"Error: {e}", finish_reason="error")
# ── Internal helpers ──────────────────────────────────────────────────────
def _build_params(
self,
messages: list[dict],
tools: list[dict] | None,
system: str,
model: str,
max_tokens: int,
) -> dict:
anthropic_messages = self._convert_messages(messages)
params: dict = {
"model": model or self.default_model,
"messages": anthropic_messages,
"max_tokens": max_tokens,
}
if system:
params["system"] = system
if tools:
# aide tool schemas ARE Anthropic format — pass through directly
params["tools"] = tools
params["tool_choice"] = {"type": "auto"}
return params
def _convert_messages(self, messages: list[dict]) -> list[dict]:
"""
Convert aide's internal message list to Anthropic format.
aide uses an OpenAI-style internal format:
{"role": "user", "content": "..."}
{"role": "assistant", "content": "...", "tool_calls": [...]}
{"role": "tool", "tool_call_id": "...", "content": "..."}
Anthropic requires:
- tool calls embedded in content blocks (tool_use type)
- tool results as user messages with tool_result content blocks
"""
result: list[dict] = []
i = 0
while i < len(messages):
msg = messages[i]
role = msg["role"]
if role == "system":
i += 1
continue # Already handled via system= param
if role == "assistant" and msg.get("tool_calls"):
# Convert assistant tool calls to Anthropic content blocks
blocks: list[dict] = []
if msg.get("content"):
blocks.append({"type": "text", "text": msg["content"]})
for tc in msg["tool_calls"]:
blocks.append({
"type": "tool_use",
"id": tc["id"],
"name": tc["name"],
"input": tc["arguments"],
})
result.append({"role": "assistant", "content": blocks})
elif role == "tool":
# Group consecutive tool results into one user message
tool_results: list[dict] = []
while i < len(messages) and messages[i]["role"] == "tool":
t = messages[i]
tool_results.append({
"type": "tool_result",
"tool_use_id": t["tool_call_id"],
"content": t["content"],
})
i += 1
result.append({"role": "user", "content": tool_results})
continue # i already advanced
else:
# content may be a string (plain text) or a list of blocks (multimodal)
result.append({"role": role, "content": msg.get("content", "")})
i += 1
return result
def _parse_response(self, response) -> ProviderResponse:
text = ""
tool_calls: list[ToolCallResult] = []
for block in response.content:
if block.type == "text":
text += block.text
elif block.type == "tool_use":
tool_calls.append(ToolCallResult(
id=block.id,
name=block.name,
arguments=block.input,
))
usage = UsageStats(
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
) if response.usage else UsageStats()
finish_reason = response.stop_reason or "stop"
if tool_calls:
finish_reason = "tool_use"
return ProviderResponse(
text=text or None,
tool_calls=tool_calls,
usage=usage,
finish_reason=finish_reason,
model=response.model,
)

105
server/providers/base.py Normal file
View File

@@ -0,0 +1,105 @@
"""
providers/base.py — Abstract base class for AI providers.
The interface is designed for aide's tool-use agent loop:
- Tool schemas are in aide's internal format (Anthropic-native)
- Providers are responsible for translating to their wire format
- Responses are normalised into a common ProviderResponse
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@dataclass
class ToolCallResult:
"""A single tool call requested by the model."""
id: str # Unique ID for this call (used in tool result messages)
name: str # Tool name, e.g. "caldav" or "email:send"
arguments: dict # Parsed JSON arguments
@dataclass
class UsageStats:
input_tokens: int = 0
output_tokens: int = 0
@property
def total_tokens(self) -> int:
return self.input_tokens + self.output_tokens
@dataclass
class ProviderResponse:
"""Normalised response from any provider."""
text: str | None # Text content (may be empty when tool calls present)
tool_calls: list[ToolCallResult] = field(default_factory=list)
usage: UsageStats = field(default_factory=UsageStats)
finish_reason: str = "stop" # "stop", "tool_use", "max_tokens", "error"
model: str = ""
images: list[str] = field(default_factory=list) # base64 data URLs from image-gen models
class AIProvider(ABC):
"""
Abstract base for AI providers.
Tool schema format (aide-internal / Anthropic-native):
{
"name": "tool_name",
"description": "What this tool does",
"input_schema": {
"type": "object",
"properties": { ... },
"required": [...]
}
}
Providers translate this to their own wire format internally.
"""
@property
@abstractmethod
def name(self) -> str:
"""Human-readable provider name, e.g. 'Anthropic' or 'OpenRouter'."""
@property
@abstractmethod
def default_model(self) -> str:
"""Default model ID to use when none is specified."""
@abstractmethod
def chat(
self,
messages: list[dict],
tools: list[dict] | None = None,
system: str = "",
model: str = "",
max_tokens: int = 4096,
) -> ProviderResponse:
"""
Synchronous chat completion.
Args:
messages: Conversation history in OpenAI-style format
(role/content pairs, plus tool_call and tool_result messages)
tools: List of tool schemas in aide-internal format (may be None)
system: System prompt text
model: Model ID (uses default_model if empty)
max_tokens: Max tokens in response
Returns:
Normalised ProviderResponse
"""
@abstractmethod
async def chat_async(
self,
messages: list[dict],
tools: list[dict] | None = None,
system: str = "",
model: str = "",
max_tokens: int = 4096,
) -> ProviderResponse:
"""Async variant of chat(). Used by the FastAPI agent loop."""

399
server/providers/models.py Normal file
View File

@@ -0,0 +1,399 @@
"""
providers/models.py — Dynamic model list for all active providers.
Anthropic has no public models API, so current models are hardcoded.
OpenRouter models are fetched from their API and cached for one hour.
Usage:
models, default = await get_available_models()
info = await get_models_info()
"""
from __future__ import annotations
import logging
import time
logger = logging.getLogger(__name__)
# Current Anthropic models (update when new ones ship)
_ANTHROPIC_MODELS = [
"anthropic:claude-opus-4-6",
"anthropic:claude-sonnet-4-6",
"anthropic:claude-haiku-4-5-20251001",
]
_ANTHROPIC_MODEL_INFO = [
{
"id": "anthropic:claude-opus-4-6",
"provider": "anthropic",
"bare_id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"context_length": 200000,
"description": "Anthropic's most powerful model. Best for complex reasoning, nuanced writing, and sophisticated analysis.",
"capabilities": {"vision": True, "tools": True, "online": False, "image_gen": False},
"pricing": {"prompt_per_1m": None, "completion_per_1m": None},
"architecture": {"tokenizer": "claude", "modality": "text+image->text"},
},
{
"id": "anthropic:claude-sonnet-4-6",
"provider": "anthropic",
"bare_id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
"context_length": 200000,
"description": "Best balance of speed and intelligence. Ideal for most tasks requiring strong reasoning with faster response times.",
"capabilities": {"vision": True, "tools": True, "online": False, "image_gen": False},
"pricing": {"prompt_per_1m": None, "completion_per_1m": None},
"architecture": {"tokenizer": "claude", "modality": "text+image->text"},
},
{
"id": "anthropic:claude-haiku-4-5-20251001",
"provider": "anthropic",
"bare_id": "claude-haiku-4-5-20251001",
"name": "Claude Haiku 4.5",
"context_length": 200000,
"description": "Fastest and most compact Claude model. Great for quick tasks, simple Q&A, and high-throughput workloads.",
"capabilities": {"vision": True, "tools": True, "online": False, "image_gen": False},
"pricing": {"prompt_per_1m": None, "completion_per_1m": None},
"architecture": {"tokenizer": "claude", "modality": "text+image->text"},
},
]
# Current OpenAI models (hardcoded — update when new ones ship)
_OPENAI_MODELS = [
"openai:gpt-4o",
"openai:gpt-4o-mini",
"openai:gpt-4-turbo",
"openai:o3-mini",
"openai:gpt-5-image",
]
_OPENAI_MODEL_INFO = [
{
"id": "openai:gpt-4o",
"provider": "openai",
"bare_id": "gpt-4o",
"name": "GPT-4o",
"context_length": 128000,
"description": "OpenAI's flagship model. Multimodal, fast, and highly capable for complex reasoning and generation tasks.",
"capabilities": {"vision": True, "tools": True, "online": False, "image_gen": False},
"pricing": {"prompt_per_1m": 2.50, "completion_per_1m": 10.00},
"architecture": {"tokenizer": "cl100k", "modality": "text+image->text"},
},
{
"id": "openai:gpt-4o-mini",
"provider": "openai",
"bare_id": "gpt-4o-mini",
"name": "GPT-4o mini",
"context_length": 128000,
"description": "Fast and affordable GPT-4o variant. Great for high-throughput tasks that don't require maximum intelligence.",
"capabilities": {"vision": True, "tools": True, "online": False, "image_gen": False},
"pricing": {"prompt_per_1m": 0.15, "completion_per_1m": 0.60},
"architecture": {"tokenizer": "cl100k", "modality": "text+image->text"},
},
{
"id": "openai:gpt-4-turbo",
"provider": "openai",
"bare_id": "gpt-4-turbo",
"name": "GPT-4 Turbo",
"context_length": 128000,
"description": "Previous-generation GPT-4 with 128K context window. Vision and tool use supported.",
"capabilities": {"vision": True, "tools": True, "online": False, "image_gen": False},
"pricing": {"prompt_per_1m": 10.00, "completion_per_1m": 30.00},
"architecture": {"tokenizer": "cl100k", "modality": "text+image->text"},
},
{
"id": "openai:o3-mini",
"provider": "openai",
"bare_id": "o3-mini",
"name": "o3-mini",
"context_length": 200000,
"description": "OpenAI's efficient reasoning model. Excels at STEM tasks with strong tool-use support.",
"capabilities": {"vision": False, "tools": True, "online": False, "image_gen": False},
"pricing": {"prompt_per_1m": 1.10, "completion_per_1m": 4.40},
"architecture": {"tokenizer": "cl100k", "modality": "text->text"},
},
{
"id": "openai:gpt-5-image",
"provider": "openai",
"bare_id": "gpt-5-image",
"name": "GPT-5 Image",
"context_length": 128000,
"description": "GPT-5 with native image generation. Produces high-quality images from text prompts with rich contextual understanding.",
"capabilities": {"vision": True, "tools": False, "online": False, "image_gen": True},
"pricing": {"prompt_per_1m": None, "completion_per_1m": None},
"architecture": {"tokenizer": "cl100k", "modality": "text+image->image+text"},
},
]
_or_raw: list[dict] = [] # full raw objects from OpenRouter /api/v1/models
_or_cache_ts: float = 0.0
_OR_CACHE_TTL = 3600 # seconds
async def _fetch_openrouter_raw(api_key: str) -> list[dict]:
"""Fetch full OpenRouter model objects, with a 1-hour in-memory cache."""
global _or_raw, _or_cache_ts
now = time.monotonic()
if _or_raw and (now - _or_cache_ts) < _OR_CACHE_TTL:
return _or_raw
try:
import httpx
async with httpx.AsyncClient() as client:
r = await client.get(
"https://openrouter.ai/api/v1/models",
headers={"Authorization": f"Bearer {api_key}"},
timeout=10,
)
r.raise_for_status()
data = r.json()
_or_raw = [m for m in data.get("data", []) if m.get("id")]
_or_cache_ts = now
logger.info(f"[models] Fetched {len(_or_raw)} OpenRouter models")
return _or_raw
except Exception as e:
logger.warning(f"[models] Failed to fetch OpenRouter models: {e}")
return _or_raw # return stale cache on error
async def _get_keys(user_id: str | None = None, is_admin: bool = True) -> tuple[str, str, str]:
"""Resolve anthropic + openrouter + openai keys for a user (user setting → global store)."""
from ..database import credential_store, user_settings_store
if user_id and not is_admin:
# Admin may grant a user full access to system keys
use_admin_keys = await user_settings_store.get(user_id, "use_admin_keys")
if not use_admin_keys:
ant_key = await user_settings_store.get(user_id, "anthropic_api_key") or ""
oai_key = await user_settings_store.get(user_id, "openai_api_key") or ""
# Non-admin with no own OR key: fall back to global (free models only)
own_or = await user_settings_store.get(user_id, "openrouter_api_key")
or_key = own_or or await credential_store.get("system:openrouter_api_key") or ""
return ant_key, or_key, oai_key
# Admin, anonymous, or user granted admin key access: full access from global store
ant_key = await credential_store.get("system:anthropic_api_key") or ""
or_key = await credential_store.get("system:openrouter_api_key") or ""
oai_key = await credential_store.get("system:openai_api_key") or ""
return ant_key, or_key, oai_key
def _is_free_openrouter(m: dict) -> bool:
"""Return True if this OpenRouter model is free (pricing.prompt == "0")."""
pricing = m.get("pricing", {})
try:
return float(pricing.get("prompt", "1")) == 0.0 and float(pricing.get("completion", "1")) == 0.0
except (TypeError, ValueError):
return False
async def get_available_models(
user_id: str | None = None,
is_admin: bool = True,
) -> tuple[list[str], str]:
"""
Return (model_list, default_model).
Always auto-builds from active providers:
- Hardcoded Anthropic models if ANTHROPIC_API_KEY is set (and user has access)
- All OpenRouter models (fetched + cached 1h) if OPENROUTER_API_KEY is set
- Non-admin users with no own OR key are limited to free models only
DEFAULT_CHAT_MODEL in .env sets the pre-selected default.
"""
from ..config import settings
from ..database import user_settings_store
ant_key, or_key, oai_key = await _get_keys(user_id=user_id, is_admin=is_admin)
# Determine access restrictions for non-admin users
free_or_only = False
if user_id and not is_admin:
use_admin_keys = await user_settings_store.get(user_id, "use_admin_keys")
if not use_admin_keys:
own_ant = await user_settings_store.get(user_id, "anthropic_api_key")
own_or = await user_settings_store.get(user_id, "openrouter_api_key")
if not own_ant:
ant_key = "" # block Anthropic unless they have their own key
if not own_or and or_key:
free_or_only = True
models: list[str] = []
if ant_key:
models.extend(_ANTHROPIC_MODELS)
if oai_key:
models.extend(_OPENAI_MODELS)
if or_key:
raw = await _fetch_openrouter_raw(or_key)
if free_or_only:
raw = [m for m in raw if _is_free_openrouter(m)]
models.extend(sorted(f"openrouter:{m['id']}" for m in raw))
from ..database import credential_store
if free_or_only:
db_default = await credential_store.get("system:default_chat_model_free") \
or await credential_store.get("system:default_chat_model")
else:
db_default = await credential_store.get("system:default_chat_model")
# Resolve default: DB override → .env → first available model
candidate = db_default or settings.default_chat_model or (models[0] if models else "")
# Ensure the candidate is actually in the model list
default = candidate if candidate in models else (models[0] if models else "")
return models, default
def get_or_output_modalities(bare_model_id: str) -> list[str]:
"""
Return output_modalities for an OpenRouter model from the cached raw API data.
Falls back to ["text"] if not found or cache is empty.
Also detects known image-gen models by ID pattern as a fallback.
"""
for m in _or_raw:
if m.get("id") == bare_model_id:
return m.get("architecture", {}).get("output_modalities") or ["text"]
# Pattern fallback for when cache is cold or model isn't listed
low = bare_model_id.lower()
if any(p in low for p in ("-image", "/flux", "image-gen", "imagen")):
return ["image", "text"]
return ["text"]
async def get_capability_map(
user_id: str | None = None,
is_admin: bool = True,
) -> dict[str, dict]:
"""Return {model_id: {vision, tools, online}} for all available models."""
info = await get_models_info(user_id=user_id, is_admin=is_admin)
return {m["id"]: m.get("capabilities", {}) for m in info}
async def get_models_info(
user_id: str | None = None,
is_admin: bool = True,
) -> list[dict]:
"""
Return rich metadata for all available models, filtered by user access tier.
Anthropic entries use hardcoded info.
OpenRouter entries are derived from the live API response.
"""
from ..config import settings
from ..database import user_settings_store
ant_key, or_key, oai_key = await _get_keys(user_id=user_id, is_admin=is_admin)
free_or_only = False
if user_id and not is_admin:
own_ant = await user_settings_store.get(user_id, "anthropic_api_key")
own_or = await user_settings_store.get(user_id, "openrouter_api_key")
if not own_ant:
ant_key = ""
if not own_or and or_key:
free_or_only = True
results: list[dict] = []
if ant_key:
results.extend(_ANTHROPIC_MODEL_INFO)
if oai_key:
results.extend(_OPENAI_MODEL_INFO)
if or_key:
raw = await _fetch_openrouter_raw(or_key)
if free_or_only:
raw = [m for m in raw if _is_free_openrouter(m)]
for m in raw:
model_id = m.get("id", "")
pricing = m.get("pricing", {})
try:
prompt_per_1m = float(pricing.get("prompt", 0)) * 1_000_000
except (TypeError, ValueError):
prompt_per_1m = None
try:
completion_per_1m = float(pricing.get("completion", 0)) * 1_000_000
except (TypeError, ValueError):
completion_per_1m = None
arch = m.get("architecture", {})
# Vision: OpenRouter returns either a list (new) or a modality string (old)
input_modalities = arch.get("input_modalities") or []
if not input_modalities:
modality_str = arch.get("modality", "")
input_part = modality_str.split("->")[0] if "->" in modality_str else modality_str
input_modalities = [p.strip() for p in input_part.replace("+", " ").split() if p.strip()]
# Tools: field may be named either way depending on API version
supported_params = (
m.get("supported_generation_parameters")
or m.get("supported_parameters")
or []
)
# Online: inherently-online models have "online" in their ID or name,
# or belong to providers whose models are always web-connected
name_lower = (m.get("name") or "").lower()
online = (
"online" in model_id
or model_id.startswith("perplexity/")
or "online" in name_lower
)
out_modalities = arch.get("output_modalities", ["text"])
modality_display = arch.get("modality", "")
if not modality_display and input_modalities:
modality_display = "+".join(input_modalities) + "->" + "+".join(out_modalities)
results.append({
"id": f"openrouter:{model_id}",
"provider": "openrouter",
"bare_id": model_id,
"name": m.get("name") or model_id,
"context_length": m.get("context_length"),
"description": m.get("description") or "",
"capabilities": {
"vision": "image" in input_modalities,
"tools": "tools" in supported_params,
"online": online,
"image_gen": "image" in out_modalities,
},
"pricing": {
"prompt_per_1m": prompt_per_1m,
"completion_per_1m": completion_per_1m,
},
"architecture": {
"tokenizer": arch.get("tokenizer", ""),
"modality": modality_display,
},
})
return results
async def get_access_tier(
user_id: str | None = None,
is_admin: bool = True,
) -> dict:
"""Return access restriction flags for the given user."""
if not user_id or is_admin:
return {"anthropic_blocked": False, "openrouter_free_only": False, "openai_blocked": False}
from ..database import user_settings_store, credential_store
use_admin_keys = await user_settings_store.get(user_id, "use_admin_keys")
if use_admin_keys:
return {"anthropic_blocked": False, "openrouter_free_only": False, "openai_blocked": False}
own_ant = await user_settings_store.get(user_id, "anthropic_api_key")
own_or = await user_settings_store.get(user_id, "openrouter_api_key")
global_or = await credential_store.get("system:openrouter_api_key")
return {
"anthropic_blocked": not bool(own_ant),
"openrouter_free_only": not bool(own_or) and bool(global_or),
"openai_blocked": True, # Non-admins always need their own OpenAI key
}
def invalidate_openrouter_cache() -> None:
"""Force a fresh fetch on the next call (e.g. after an API key change)."""
global _or_cache_ts
_or_cache_ts = 0.0

View File

@@ -0,0 +1,231 @@
"""
providers/openai_provider.py — Direct OpenAI provider.
Uses the official openai SDK pointing at api.openai.com (default base URL).
Tool schema conversion reuses the same Anthropic→OpenAI format translation
as the OpenRouter provider (they share the same wire format).
"""
from __future__ import annotations
import json
import logging
from typing import Any
from openai import OpenAI, AsyncOpenAI
from .base import AIProvider, ProviderResponse, ToolCallResult, UsageStats
logger = logging.getLogger(__name__)
DEFAULT_MODEL = "gpt-4o"
# Models that use max_completion_tokens instead of max_tokens, and don't support
# tool_choice="auto" (reasoning models use implicit tool choice).
_REASONING_MODELS = frozenset({"o1", "o1-mini", "o1-preview"})
def _convert_content_blocks(blocks: list[dict]) -> list[dict]:
"""Convert Anthropic-native content blocks to OpenAI image_url format."""
result = []
for block in blocks:
if block.get("type") == "image":
src = block.get("source", {})
if src.get("type") == "base64":
data_url = f"data:{src['media_type']};base64,{src['data']}"
result.append({"type": "image_url", "image_url": {"url": data_url}})
else:
result.append(block)
return result
class OpenAIProvider(AIProvider):
def __init__(self, api_key: str) -> None:
self._client = OpenAI(api_key=api_key)
self._async_client = AsyncOpenAI(api_key=api_key)
@property
def name(self) -> str:
return "OpenAI"
@property
def default_model(self) -> str:
return DEFAULT_MODEL
# ── Public interface ──────────────────────────────────────────────────────
def chat(
self,
messages: list[dict],
tools: list[dict] | None = None,
system: str = "",
model: str = "",
max_tokens: int = 4096,
) -> ProviderResponse:
params = self._build_params(messages, tools, system, model, max_tokens)
try:
response = self._client.chat.completions.create(**params)
return self._parse_response(response)
except Exception as e:
logger.error(f"OpenAI chat error: {e}")
return ProviderResponse(text=f"Error: {e}", finish_reason="error")
async def chat_async(
self,
messages: list[dict],
tools: list[dict] | None = None,
system: str = "",
model: str = "",
max_tokens: int = 4096,
) -> ProviderResponse:
params = self._build_params(messages, tools, system, model, max_tokens)
try:
response = await self._async_client.chat.completions.create(**params)
return self._parse_response(response)
except Exception as e:
logger.error(f"OpenAI async chat error: {e}")
return ProviderResponse(text=f"Error: {e}", finish_reason="error")
# ── Internal helpers ──────────────────────────────────────────────────────
def _build_params(
self,
messages: list[dict],
tools: list[dict] | None,
system: str,
model: str,
max_tokens: int,
) -> dict:
model = model or self.default_model
openai_messages = self._convert_messages(messages, system, model)
params: dict = {
"model": model,
"messages": openai_messages,
}
is_reasoning = model in _REASONING_MODELS
if is_reasoning:
params["max_completion_tokens"] = max_tokens
else:
params["max_tokens"] = max_tokens
if tools:
params["tools"] = [self._to_openai_tool(t) for t in tools]
if not is_reasoning:
params["tool_choice"] = "auto"
return params
def _convert_messages(self, messages: list[dict], system: str, model: str) -> list[dict]:
"""Convert aide's internal message list to OpenAI format."""
result: list[dict] = []
# Reasoning models (o1, o1-mini) don't support system role — use user role instead
is_reasoning = model in _REASONING_MODELS
if system:
if is_reasoning:
result.append({"role": "user", "content": f"[System instructions]\n{system}"})
else:
result.append({"role": "system", "content": system})
i = 0
while i < len(messages):
msg = messages[i]
role = msg["role"]
if role == "system":
i += 1
continue # Already prepended above
if role == "assistant" and msg.get("tool_calls"):
openai_tool_calls = []
for tc in msg["tool_calls"]:
openai_tool_calls.append({
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": json.dumps(tc["arguments"]),
},
})
out: dict[str, Any] = {"role": "assistant", "tool_calls": openai_tool_calls}
if msg.get("content"):
out["content"] = msg["content"]
result.append(out)
elif role == "tool":
# Group consecutive tool results; collect image blocks for injection
pending_images: list[dict] = []
while i < len(messages) and messages[i]["role"] == "tool":
t = messages[i]
content = t.get("content", "")
if isinstance(content, list):
text = " ".join(b.get("text", "") for b in content if b.get("type") == "text") or "[image]"
pending_images.extend(b for b in content if b.get("type") == "image")
content = text
result.append({"role": "tool", "tool_call_id": t["tool_call_id"], "content": content})
i += 1
if pending_images:
result.append({"role": "user", "content": _convert_content_blocks(pending_images)})
continue # i already advanced
else:
content = msg.get("content", "")
if isinstance(content, list):
content = _convert_content_blocks(content)
result.append({"role": role, "content": content})
i += 1
return result
@staticmethod
def _to_openai_tool(aide_tool: dict) -> dict:
"""Convert aide's Anthropic-native tool schema to OpenAI function-calling format."""
return {
"type": "function",
"function": {
"name": aide_tool["name"],
"description": aide_tool.get("description", ""),
"parameters": aide_tool.get("input_schema", {"type": "object", "properties": {}}),
},
}
def _parse_response(self, response) -> ProviderResponse:
choice = response.choices[0] if response.choices else None
if not choice:
return ProviderResponse(text=None, finish_reason="error")
message = choice.message
text = message.content or None
tool_calls: list[ToolCallResult] = []
if message.tool_calls:
for tc in message.tool_calls:
try:
arguments = json.loads(tc.function.arguments)
except json.JSONDecodeError:
arguments = {"_raw": tc.function.arguments}
tool_calls.append(ToolCallResult(
id=tc.id,
name=tc.function.name,
arguments=arguments,
))
usage = UsageStats()
if response.usage:
usage = UsageStats(
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
)
finish_reason = choice.finish_reason or "stop"
if tool_calls:
finish_reason = "tool_use"
return ProviderResponse(
text=text,
tool_calls=tool_calls,
usage=usage,
finish_reason=finish_reason,
model=response.model,
)

View File

@@ -0,0 +1,306 @@
"""
providers/openrouter_provider.py — OpenRouter provider.
OpenRouter exposes an OpenAI-compatible API, so we use the `openai` Python SDK
with a custom base_url. The X-Title header identifies the app to OpenRouter
(shows as "oAI-Web" in OpenRouter usage logs).
Tool schemas need conversion: oAI-Web uses Anthropic-native format internally,
OpenRouter expects OpenAI function-calling format.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from openai import OpenAI, AsyncOpenAI
from .base import AIProvider, ProviderResponse, ToolCallResult, UsageStats
logger = logging.getLogger(__name__)
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
def _convert_content_blocks(blocks: list[dict]) -> list[dict]:
"""Convert Anthropic-native content blocks to OpenAI image_url / file format."""
result = []
for block in blocks:
btype = block.get("type")
if btype in ("image", "document"):
src = block.get("source", {})
if src.get("type") == "base64":
data_url = f"data:{src['media_type']};base64,{src['data']}"
result.append({"type": "image_url", "image_url": {"url": data_url}})
else:
result.append(block)
return result
class OpenRouterProvider(AIProvider):
def __init__(self, api_key: str, app_name: str = "oAI-Web", app_url: str = "https://mac.oai.pm") -> None:
extra_headers = {
"X-Title": app_name,
"HTTP-Referer": app_url,
}
self._client = OpenAI(
api_key=api_key,
base_url=OPENROUTER_BASE_URL,
default_headers=extra_headers,
)
self._async_client = AsyncOpenAI(
api_key=api_key,
base_url=OPENROUTER_BASE_URL,
default_headers=extra_headers,
)
@property
def name(self) -> str:
return "OpenRouter"
@property
def default_model(self) -> str:
return DEFAULT_MODEL
# ── Public interface ──────────────────────────────────────────────────────
def chat(
self,
messages: list[dict],
tools: list[dict] | None = None,
system: str = "",
model: str = "",
max_tokens: int = 4096,
) -> ProviderResponse:
params = self._build_params(messages, tools, system, model, max_tokens)
try:
response = self._client.chat.completions.create(**params)
return self._parse_response(response)
except Exception as e:
logger.error(f"OpenRouter chat error: {e}")
return ProviderResponse(text=f"Error: {e}", finish_reason="error")
async def chat_async(
self,
messages: list[dict],
tools: list[dict] | None = None,
system: str = "",
model: str = "",
max_tokens: int = 4096,
) -> ProviderResponse:
params = self._build_params(messages, tools, system, model, max_tokens)
try:
response = await self._async_client.chat.completions.create(**params)
return self._parse_response(response)
except Exception as e:
logger.error(f"OpenRouter async chat error: {e}")
return ProviderResponse(text=f"Error: {e}", finish_reason="error")
# ── Internal helpers ──────────────────────────────────────────────────────
def _build_params(
self,
messages: list[dict],
tools: list[dict] | None,
system: str,
model: str,
max_tokens: int,
) -> dict:
effective_model = model or self.default_model
# Detect image-generation models via output_modalities in the OR cache
from .models import get_or_output_modalities
bare_id = effective_model.removeprefix("openrouter:")
out_modalities = get_or_output_modalities(bare_id)
is_image_gen = "image" in out_modalities
openai_messages = self._convert_messages(messages, system)
params: dict = {"model": effective_model, "messages": openai_messages}
if is_image_gen:
# Image-gen models use modalities parameter; max_tokens not applicable
params["modalities"] = out_modalities
else:
params["max_tokens"] = max_tokens
if tools:
params["tools"] = [self._to_openai_tool(t) for t in tools]
params["tool_choice"] = "auto"
return params
def _convert_messages(self, messages: list[dict], system: str) -> list[dict]:
"""
Convert aide's internal message list to OpenAI format.
Prepend system message if provided.
aide internal format uses:
- assistant with "tool_calls": [{"id", "name", "arguments"}]
- role "tool" with "tool_call_id" and "content"
OpenAI format uses:
- assistant with "tool_calls": [{"id", "type": "function", "function": {"name", "arguments"}}]
- role "tool" with "tool_call_id" and "content"
"""
result: list[dict] = []
if system:
result.append({"role": "system", "content": system})
i = 0
while i < len(messages):
msg = messages[i]
role = msg["role"]
if role == "system":
i += 1
continue # Already prepended above
if role == "assistant" and msg.get("tool_calls"):
openai_tool_calls = []
for tc in msg["tool_calls"]:
openai_tool_calls.append({
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": json.dumps(tc["arguments"]),
},
})
out: dict[str, Any] = {"role": "assistant", "tool_calls": openai_tool_calls}
if msg.get("content"):
out["content"] = msg["content"]
result.append(out)
elif role == "tool":
# Group consecutive tool results; collect any image blocks for injection
pending_images: list[dict] = []
while i < len(messages) and messages[i]["role"] == "tool":
t = messages[i]
content = t.get("content", "")
if isinstance(content, list):
text = " ".join(b.get("text", "") for b in content if b.get("type") == "text") or "[image]"
pending_images.extend(b for b in content if b.get("type") == "image")
content = text
result.append({"role": "tool", "tool_call_id": t["tool_call_id"], "content": content})
i += 1
if pending_images:
result.append({"role": "user", "content": _convert_content_blocks(pending_images)})
continue # i already advanced
else:
content = msg.get("content", "")
if isinstance(content, list):
content = _convert_content_blocks(content)
result.append({"role": role, "content": content})
i += 1
return result
@staticmethod
def _to_openai_tool(aide_tool: dict) -> dict:
"""
Convert aide's tool schema (Anthropic-native) to OpenAI function-calling format.
Anthropic format:
{"name": "...", "description": "...", "input_schema": {...}}
OpenAI format:
{"type": "function", "function": {"name": "...", "description": "...", "parameters": {...}}}
"""
return {
"type": "function",
"function": {
"name": aide_tool["name"],
"description": aide_tool.get("description", ""),
"parameters": aide_tool.get("input_schema", {"type": "object", "properties": {}}),
},
}
def _parse_response(self, response) -> ProviderResponse:
choice = response.choices[0] if response.choices else None
if not choice:
return ProviderResponse(text=None, finish_reason="error")
message = choice.message
text = message.content or None
tool_calls: list[ToolCallResult] = []
if message.tool_calls:
for tc in message.tool_calls:
try:
arguments = json.loads(tc.function.arguments)
except json.JSONDecodeError:
arguments = {"_raw": tc.function.arguments}
tool_calls.append(ToolCallResult(
id=tc.id,
name=tc.function.name,
arguments=arguments,
))
usage = UsageStats()
if response.usage:
usage = UsageStats(
input_tokens=response.usage.prompt_tokens,
output_tokens=response.usage.completion_tokens,
)
finish_reason = choice.finish_reason or "stop"
if tool_calls:
finish_reason = "tool_use"
# Extract generated images.
# OpenRouter image structure: {"image_url": {"url": "data:image/png;base64,..."}}
# Two possible locations (both checked; first non-empty wins):
# A. message.images — top-level field in the message (custom OpenRouter format)
# B. message.content — array of content blocks with type "image_url"
images: list[str] = []
def _url_from_img_obj(img) -> str:
"""Extract URL string from an image object in OpenRouter format."""
if isinstance(img, str):
return img
if isinstance(img, dict):
# {"image_url": {"url": "..."}} ← OpenRouter format
inner = img.get("image_url")
if isinstance(inner, dict):
return inner.get("url") or ""
# Fallback: {"url": "..."}
return img.get("url") or ""
# Pydantic model object with image_url attribute
image_url_obj = getattr(img, "image_url", None)
if image_url_obj is not None:
return getattr(image_url_obj, "url", None) or ""
return ""
# A. message.model_extra["images"] (SDK stores unknown fields here)
extra = getattr(message, "model_extra", None) or {}
raw_images = extra.get("images") or getattr(message, "images", None) or []
for img in raw_images:
url = _url_from_img_obj(img)
if url:
images.append(url)
# B. Content as array of blocks: [{"type":"image_url","image_url":{"url":"..."}}]
if not images:
raw_content = message.content
if isinstance(raw_content, list):
for block in raw_content:
if isinstance(block, dict) and block.get("type") == "image_url":
url = (block.get("image_url") or {}).get("url") or ""
if url:
images.append(url)
logger.info("[openrouter] image-gen response: %d image(s), text=%r, extra_keys=%s",
len(images), text[:80] if text else None, list(extra.keys()))
return ProviderResponse(
text=text,
tool_calls=tool_calls,
usage=usage,
finish_reason=finish_reason,
model=response.model,
images=images,
)

View File

@@ -0,0 +1,87 @@
"""
providers/registry.py — Provider factory.
Keys are resolved from:
1. Per-user setting (user_settings table) — if user_id is provided
2. Global credential_store (system:anthropic_api_key / system:openrouter_api_key / system:openai_api_key)
API keys are never read from .env — configure them via Settings → Credentials.
"""
from __future__ import annotations
from .base import AIProvider
async def _resolve_key(provider: str, user_id: str | None = None) -> str:
"""Resolve the API key for a provider: user setting → global credential store."""
from ..database import credential_store, user_settings_store
if user_id:
user_key = await user_settings_store.get(user_id, f"{provider}_api_key")
if user_key:
return user_key
return await credential_store.get(f"system:{provider}_api_key") or ""
async def get_provider(user_id: str | None = None) -> AIProvider:
"""Return the default provider, with keys resolved for the given user."""
from ..config import settings
return await get_provider_for_name(settings.default_provider, user_id=user_id)
async def get_provider_for_name(name: str, user_id: str | None = None) -> AIProvider:
"""Return a provider instance configured with the resolved key."""
key = await _resolve_key(name, user_id=user_id)
if not key:
raise RuntimeError(
f"No API key configured for provider '{name}'. "
"Set it in Settings → General or via environment variable."
)
if name == "anthropic":
from .anthropic_provider import AnthropicProvider
return AnthropicProvider(api_key=key)
elif name == "openrouter":
from .openrouter_provider import OpenRouterProvider
return OpenRouterProvider(api_key=key, app_name="oAI-Web")
elif name == "openai":
from .openai_provider import OpenAIProvider
return OpenAIProvider(api_key=key)
else:
raise RuntimeError(
f"Unknown provider '{name}'. Valid values: 'anthropic', 'openrouter', 'openai'"
)
async def get_provider_for_model(model_str: str, user_id: str | None = None) -> tuple[AIProvider, str]:
"""
Parse a "provider:model" string and return (provider_instance, bare_model_id).
If the model string has no provider prefix, the default provider is used.
Examples:
"anthropic:claude-sonnet-4-6" → (AnthropicProvider, "claude-sonnet-4-6")
"openrouter:openai/gpt-4o" → (OpenRouterProvider, "openai/gpt-4o")
"claude-sonnet-4-6" → (default_provider, "claude-sonnet-4-6")
"""
from ..config import settings
_known = {"anthropic", "openrouter", "openai"}
if ":" in model_str:
prefix, bare = model_str.split(":", 1)
if prefix in _known:
return await get_provider_for_name(prefix, user_id=user_id), bare
# No recognised prefix — use default provider, full string as model ID
return await get_provider_for_name(settings.default_provider, user_id=user_id), model_str
async def get_available_providers(user_id: str | None = None) -> list[str]:
"""Return names of providers that have a valid API key for the given user."""
available = []
if await _resolve_key("anthropic", user_id=user_id):
available.append("anthropic")
if await _resolve_key("openrouter", user_id=user_id):
available.append("openrouter")
if await _resolve_key("openai", user_id=user_id):
available.append("openai")
return available