Initial commit
This commit is contained in:
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,
|
||||
)
|
||||
Reference in New Issue
Block a user