Initial commit
This commit is contained in:
1
server/providers/__init__.py
Normal file
1
server/providers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# aide providers package
|
||||
BIN
server/providers/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
server/providers/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
server/providers/__pycache__/anthropic_provider.cpython-314.pyc
Normal file
BIN
server/providers/__pycache__/anthropic_provider.cpython-314.pyc
Normal file
Binary file not shown.
BIN
server/providers/__pycache__/base.cpython-314.pyc
Normal file
BIN
server/providers/__pycache__/base.cpython-314.pyc
Normal file
Binary file not shown.
BIN
server/providers/__pycache__/registry.cpython-314.pyc
Normal file
BIN
server/providers/__pycache__/registry.cpython-314.pyc
Normal file
Binary file not shown.
181
server/providers/anthropic_provider.py
Normal file
181
server/providers/anthropic_provider.py
Normal 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
105
server/providers/base.py
Normal 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
399
server/providers/models.py
Normal 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
|
||||
231
server/providers/openai_provider.py
Normal file
231
server/providers/openai_provider.py
Normal 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,
|
||||
)
|
||||
306
server/providers/openrouter_provider.py
Normal file
306
server/providers/openrouter_provider.py
Normal 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,
|
||||
)
|
||||
87
server/providers/registry.py
Normal file
87
server/providers/registry.py
Normal 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
|
||||
Reference in New Issue
Block a user