Files
open-webui/openrouter_auto_router_v2.7.1.py
T

693 lines
29 KiB
Python

"""
title: OpenRouter Auto Router
author: OpenAI
version: 2.8.0
description: Автоматическая маршрутизация OpenRouter с поддержкой tools и прокидыванием usage для Token Tracker.
"""
import asyncio
import inspect
import json
import re
import time
import httpx
from pydantic import BaseModel, Field
class Pipe:
class Valves(BaseModel):
"""Настройки маршрутизатора OpenRouter."""
OPENROUTER_API_KEY: str = Field(default="", description="API-ключ OpenRouter")
BASE_URL: str = Field(
default="https://openrouter.ai/api/v1/chat/completions",
description="Endpoint Chat Completions OpenRouter",
)
DEFAULT_MODEL: str = Field(
default="deepseek/deepseek-v4-flash",
description="Модель для обычных запросов",
)
CODING_MODEL: str = Field(
default="openai/gpt-5.6-luna",
description="Модель для кода, Linux, Docker и Proxmox",
)
VISION_MODEL: str = Field(
default="openai/gpt-5.6-luna",
description="Модель для изображений и скриншотов",
)
COMPLEX_MODEL: str = Field(
default="minimax/minimax-m3:floor",
description="Модель для сложных задач",
)
SEND_TEMPERATURE: bool = Field(default=True, description="Передавать ли temperature модели")
TEMPERATURE: float = Field(default=0.2, ge=0.0, le=2.0, description="Температура генерации")
MAX_TOKENS: int = Field(default=0, ge=0, description="Лимит токенов. 0 — не переопределять")
SHOW_ROUTER_INFO: bool = Field(default=True, description="Показывать выбранный маршрут и модель")
REQUEST_TIMEOUT: int = Field(default=120, gt=0, le=600, description="Таймаут запроса в секундах")
MAX_RETRIES: int = Field(default=2, ge=0, le=10, description="Повторы после сетевых ошибок и HTTP 429")
ENABLE_TOOLS: bool = Field(default=True, description="Разрешить передачу и выполнение tools")
SHOW_TOOL_TRACE: bool = Field(default=False, description="Показывать отладочную информацию о tools")
MAX_TOOL_ITERATIONS: int = Field(default=10, ge=1, le=20, description="Максимум циклов tool calling")
MAX_TOOL_RESULT_CHARS: int = Field(
default=20000,
ge=1000,
le=200000,
description="Максимальный размер результата одного инструмента",
)
def __init__(self):
self.valves = self.Valves()
def pipes(self):
"""Возвращает Pipe как виртуальную модель Open WebUI."""
return [{"id": "auto-router", "name": "🤖 Auto Router — OpenRouter"}]
def _has_image(self, messages):
"""Проверяет наличие изображения в сообщениях."""
for message in messages:
if not isinstance(message, dict):
continue
content = message.get("content")
if not isinstance(content, list):
continue
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") in {"image_url", "input_image"}:
return True
if "image_url" in item or "image" in item:
return True
return False
def _extract_user_text(self, messages):
"""Извлекает текст последнего сообщения пользователя."""
for message in reversed(messages):
if not isinstance(message, dict) or message.get("role") != "user":
continue
content = message.get("content", "")
if isinstance(content, str):
return content
if isinstance(content, list):
return " ".join(
str(item.get("text", ""))
for item in content
if isinstance(item, dict)
and item.get("type") == "text"
and item.get("text")
)
return ""
def _tokenize(self, text):
"""Разбивает текст на слова с поддержкой Unicode."""
return set(re.findall(r"[^\W_]+", text.lower(), flags=re.UNICODE))
def _contains_phrase(self, text, phrase):
"""Проверяет наличие фразы по границам слов."""
return re.search(
rf"(?<!\w){re.escape(phrase.lower())}(?!\w)",
text,
flags=re.UNICODE,
) is not None
def _classify(self, text):
"""Определяет маршрут по ключевым словам."""
text_lower = text.lower()
words = self._tokenize(text_lower)
complex_phrases = {
"спроектируй", "разработай систему", "разработать систему",
"архитектура системы", "рефакторинг проекта", "большой проект",
"проанализируй проект", "анализ проекта", "сложная ошибка",
"сложный баг", "несколько сервисов", "несколько контейнеров",
"полная система", "систему с нуля", "проект с нуля",
"приложение с нуля", "сервис с нуля", "интеграцию всех",
"оптимизируй архитектуру", "system architecture", "system design",
"large project", "large codebase", "refactor project",
"refactor entire", "complex bug", "complex problem", "multi-agent",
"multi service", "multiple services", "distributed system",
"design a system", "build a system", "project from scratch",
"system from scratch", "agentic",
}
if any(self._contains_phrase(text_lower, phrase) for phrase in complex_phrases):
return "complex"
if any(word.startswith("архитектур") for word in words):
return "complex"
coding_words = {
"python", "javascript", "typescript", "java", "rust", "go", "php",
"ruby", "kotlin", "swift", "html", "css", "react", "vue",
"angular", "node", "nextjs", "код", "программа", "программирование",
"скрипт", "функция", "класс", "метод", "библиотека", "api", "sdk",
"алгоритм", "debug", "debugging", "ошибка", "баг", "bug", "traceback",
"exception", "linux", "ubuntu", "debian", "centos", "fedora", "alpine",
"bash", "shell", "zsh", "terminal", "systemctl", "journalctl", "apt",
"yum", "dnf", "ssh", "sudo", "chmod", "chown", "cron", "docker",
"dockerfile", "compose", "container", "контейнер", "контейнеры",
"proxmox", "pve", "lxc", "pct", "qm", "nginx", "прокси", "firewall",
"network", "сеть", "ip", "dns", "ssl", "tls", "сертификат",
"postgresql", "postgres", "mysql", "mariadb", "redis", "mongodb", "sql",
"yaml", "yml", "json", "toml", "git", "github", "gitlab", "commit", "branch",
"gitea",
}
coding_stems = {"программ", "функци", "библиотек", "ошиб", "сертификат"}
coding_phrases = {
"c++", "c#", "docker-compose", "next.js", "написать код", "исправь код",
"перепиши код", "рефакторинг", "stack trace", "error log",
"виртуальная машина", "reverse proxy", "nginx proxy manager", "pull request",
}
if words.intersection(coding_words):
return "coding"
if any(word.startswith(stem) for word in words for stem in coding_stems):
return "coding"
if any(self._contains_phrase(text_lower, phrase) for phrase in coding_phrases):
return "coding"
return "default"
def _select_model(self, messages, user_text):
"""Выбирает модель и тип маршрута."""
if self._has_image(messages):
return self.valves.VISION_MODEL, "vision"
route = self._classify(user_text)
if route == "complex":
return self.valves.COMPLEX_MODEL, "complex"
if route == "coding":
return self.valves.CODING_MODEL, "coding"
return self.valves.DEFAULT_MODEL, "default"
async def _request(self, payload, client):
"""Отправляет запрос в OpenRouter с повторными попытками."""
headers = {
"Authorization": f"Bearer {self.valves.OPENROUTER_API_KEY.strip()}",
"Content-Type": "application/json",
"HTTP-Referer": "http://open-webui",
"X-Title": "Open WebUI Auto Router",
}
last_error = None
for attempt in range(self.valves.MAX_RETRIES + 1):
try:
response = await client.post(
self.valves.BASE_URL,
headers=headers,
json=payload,
)
if response.status_code == 429:
if attempt >= self.valves.MAX_RETRIES:
response.raise_for_status()
retry_after = response.headers.get("Retry-After")
try:
delay = float(retry_after)
except (TypeError, ValueError):
delay = 2 ** attempt
await asyncio.sleep(min(delay, 30))
continue
response.raise_for_status()
return response.json()
except httpx.RequestError as exc:
last_error = exc
if attempt >= self.valves.MAX_RETRIES:
raise
await asyncio.sleep(min(2 ** attempt, 30))
except httpx.HTTPStatusError as exc:
try:
detail = exc.response.text
except Exception:
detail = str(exc)
raise RuntimeError(
f"{exc.response.status_code}: {detail}"
) from exc
if last_error:
raise last_error
raise RuntimeError("Request failed without a captured error")
def _parse_tool_arguments(self, raw_arguments):
"""Разбирает JSON-аргументы вызова инструмента."""
if raw_arguments is None:
return {}
if isinstance(raw_arguments, dict):
return raw_arguments
if not isinstance(raw_arguments, str):
return None
if not raw_arguments.strip():
return {}
try:
arguments = json.loads(raw_arguments)
except json.JSONDecodeError:
return None
return arguments if isinstance(arguments, dict) else None
def _get_tool_callable(self, tool_entry):
"""Извлекает callable из поддерживаемых форматов __tools__."""
if callable(tool_entry):
return tool_entry
if isinstance(tool_entry, dict):
callable_fn = tool_entry.get("callable")
if callable(callable_fn):
return callable_fn
return None
def _get_tool_spec(self, tool_entry):
"""Извлекает schema инструмента из __tools__."""
if not isinstance(tool_entry, dict):
return None
spec = tool_entry.get("spec")
return spec if isinstance(spec, dict) else None
def _extract_tool_specs(self, tools_map):
"""Собирает schema tools в OpenAI-compatible формате."""
if not isinstance(tools_map, dict):
return []
specs = []
for tool_entry in tools_map.values():
spec = self._get_tool_spec(tool_entry)
if not isinstance(spec, dict):
continue
if (
spec.get("type") == "function"
and isinstance(spec.get("function"), dict)
):
specs.append(spec)
continue
name = spec.get("name")
if not isinstance(name, str) or not name.strip():
continue
specs.append({"type": "function", "function": spec})
return specs
def _build_payload(self, body, model, messages, tools_map):
"""Собирает payload для запроса к OpenRouter."""
payload = dict(body)
payload["model"] = model
payload["messages"] = messages
payload["stream"] = False
if self.valves.SEND_TEMPERATURE:
payload["temperature"] = self.valves.TEMPERATURE
if self.valves.MAX_TOKENS > 0:
payload["max_tokens"] = self.valves.MAX_TOKENS
for field in {"user", "reasoning_effort", "metadata", "store"}:
payload.pop(field, None)
if not self.valves.ENABLE_TOOLS:
payload.pop("tools", None)
payload.pop("tool_choice", None)
payload.pop("parallel_tool_calls", None)
elif not payload.get("tools"):
tool_specs = self._extract_tool_specs(tools_map)
if tool_specs:
payload["tools"] = tool_specs
return payload
def _accumulate_usage(self, total_usage, data):
"""Суммирует usage по всем итерациям tool-calling."""
if not isinstance(data, dict):
return
usage = data.get("usage")
if not isinstance(usage, dict):
return
total_usage["prompt_tokens"] += int(usage.get("prompt_tokens", 0) or 0)
total_usage["completion_tokens"] += int(usage.get("completion_tokens", 0) or 0)
total_usage["total_tokens"] += int(usage.get("total_tokens", 0) or 0)
cost = usage.get("cost")
if isinstance(cost, (int, float)):
total_usage["cost"] += float(cost)
prompt_details = usage.get("prompt_tokens_details") or {}
if isinstance(prompt_details, dict):
total_usage["cached_tokens"] += int(
prompt_details.get("cached_tokens", 0) or 0
)
total_usage["cache_write_tokens"] += int(
prompt_details.get("cache_write_tokens", 0) or 0
)
completion_details = usage.get("completion_tokens_details") or {}
if isinstance(completion_details, dict):
total_usage["reasoning_tokens"] += int(
completion_details.get("reasoning_tokens", 0) or 0
)
def _build_usage_payload(self, total_usage):
"""Собирает usage в формате OpenAI chat.completion для Token Tracker."""
return {
"prompt_tokens": total_usage["prompt_tokens"],
"completion_tokens": total_usage["completion_tokens"],
"total_tokens": total_usage["total_tokens"],
"cost": total_usage["cost"],
"prompt_tokens_details": {
"cached_tokens": total_usage["cached_tokens"],
"cache_write_tokens": total_usage["cache_write_tokens"],
},
"completion_tokens_details": {
"reasoning_tokens": total_usage["reasoning_tokens"],
},
}
def _build_router_content(self, answer, route, model):
"""Собирает финальный текст ответа с префиксом маршрутизатора."""
if not self.valves.SHOW_ROUTER_INFO:
return answer
route_names = {
"default": "💰 Обычный запрос",
"coding": "👨‍💻 Код / Linux / Docker",
"vision": "🖼️ Изображение / скриншот",
"complex": "🧠 Сложная задача",
}
route_name = route_names.get(route, route)
return (
f"**🤖 Auto Router** \n"
f"Маршрут: **{route_name}** \n"
f"Модель: `{model}`\n\n"
f"{answer}"
)
async def _run_tool_callable(self, callable_fn, arguments, name):
"""Вызывает sync/async callable и ограничивает результат."""
try:
result = callable_fn(**arguments)
if inspect.isawaitable(result):
result = await result
except TypeError as exc:
result = f"Ошибка вызова `{name}`: неверные аргументы ({exc})."
except Exception as exc:
result = f"Ошибка при выполнении инструмента `{name}`: {exc}"
if isinstance(result, (dict, list)):
try:
result_text = json.dumps(result, ensure_ascii=False)
except (TypeError, ValueError):
result_text = str(result)
else:
result_text = str(result)
if len(result_text) > self.valves.MAX_TOOL_RESULT_CHARS:
result_text = (
result_text[:self.valves.MAX_TOOL_RESULT_CHARS]
+ "\n\n[Результат инструмента обрезан]"
)
return result_text
async def _execute_tool_call(self, tool_call, tools_map):
"""Выполняет tool call. Фатальна только ошибка tool_call_id."""
if not isinstance(tool_call, dict):
return {"fatal": True, "result": "Ошибка: некорректный формат tool call."}
tool_call_id = tool_call.get("id")
if not isinstance(tool_call_id, str) or not tool_call_id.strip():
return {
"fatal": True,
"result": (
"Ошибка: модель вернула tool call без корректного "
"tool_call_id."
),
}
function = tool_call.get("function")
if not isinstance(function, dict):
return {
"fatal": False,
"tool_call_id": tool_call_id,
"name": "unknown",
"arguments": {},
"result": "Ошибка: отсутствует описание функции.",
}
name = function.get("name")
if not isinstance(name, str) or not name.strip():
return {
"fatal": False,
"tool_call_id": tool_call_id,
"name": "unknown",
"arguments": {},
"result": "Ошибка: tool call не содержит имени инструмента.",
}
arguments = self._parse_tool_arguments(function.get("arguments"))
if arguments is None:
return {
"fatal": False,
"tool_call_id": tool_call_id,
"name": name,
"arguments": {},
"result": (
f"Ошибка: инструмент `{name}` получил некорректные "
"JSON-аргументы. Исправь их и повтори вызов."
),
}
if not isinstance(tools_map, dict):
return {
"fatal": False,
"tool_call_id": tool_call_id,
"name": name,
"arguments": arguments,
"result": f"Ошибка: инструмент `{name}` сейчас недоступен.",
}
callable_fn = self._get_tool_callable(tools_map.get(name))
if callable_fn is None:
return {
"fatal": False,
"tool_call_id": tool_call_id,
"name": name,
"arguments": arguments,
"result": (
f"Ошибка: инструмент `{name}` не найден или "
"недоступен в текущем чате."
),
}
return {
"fatal": False,
"tool_call_id": tool_call_id,
"name": name,
"arguments": arguments,
"result": await self._run_tool_callable(
callable_fn, arguments, name
),
}
def _content_to_text(self, content):
"""Преобразует content сообщения в строку."""
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(
str(item.get("text"))
for item in content
if isinstance(item, dict) and item.get("text")
)
if content is None:
return ""
return str(content)
def _format_tool_trace(self, trace):
"""Форматирует историю вызовов инструментов."""
blocks = []
for index, item in enumerate(trace, start=1):
blocks.append(
f"### Инструмент {index}: `{item['name']}`\n\n"
f"**Аргументы:**\n```json\n"
f"{json.dumps(item['arguments'], ensure_ascii=False, indent=2)}\n"
f"```\n\n**Результат:**\n```\n{item['result']}\n```"
)
return "\n\n".join(blocks)
async def pipe(
self,
body: dict,
__user__: dict = None,
__tools__: dict = None,
):
"""Маршрутизирует запрос и выполняет цикл tool calling.
Возвращает OpenAI-совместимый dict формата chat.completion,
чтобы Token Tracker Filter мог извлечь usage (prompt/completion/cost)."""
if not isinstance(body, dict):
return "❌ **Некорректный формат запроса.**"
if not self.valves.OPENROUTER_API_KEY.strip():
return (
"❌ **API-ключ OpenRouter не указан.**\n\n"
"Откройте настройки Pipe → Valves и заполните "
"`OPENROUTER_API_KEY`."
)
original_messages = body.get("messages", [])
if not isinstance(original_messages, list):
return "❌ **Поле `messages` должно быть списком.**"
if not original_messages:
return "❌ **Поле `messages` не должно быть пустым.**"
tools_map = __tools__ if isinstance(__tools__, dict) else None
user_text = self._extract_user_text(original_messages)
model, route = self._select_model(original_messages, user_text)
messages = list(original_messages)
tool_trace = []
final_content = ""
stopped_reason = ""
# Аккумуляторы usage по всем итерациям tool-calling
total_usage = {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
"cost": 0.0,
"reasoning_tokens": 0,
"cached_tokens": 0,
"cache_write_tokens": 0,
}
response_id = f"auto-router-{int(time.time())}"
response_created = int(time.time())
response_model_id = model
timeout = httpx.Timeout(self.valves.REQUEST_TIMEOUT)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
for _ in range(self.valves.MAX_TOOL_ITERATIONS):
payload = self._build_payload(
body, model, messages, tools_map
)
data = await self._request(payload, client)
# Суммируем usage и обновляем метаданные ответа
if isinstance(data, dict):
self._accumulate_usage(total_usage, data)
if isinstance(data.get("id"), str):
response_id = data["id"]
if isinstance(data.get("created"), (int, float)):
response_created = int(data["created"])
if isinstance(data.get("model"), str):
response_model_id = data["model"]
try:
message = data["choices"][0]["message"]
except (KeyError, IndexError, TypeError):
return (
"❌ **OpenRouter вернул некорректный ответ.**\n\n"
f"`{data}`"
)
content_text = self._content_to_text(
message.get("content", "")
)
tool_calls = message.get("tool_calls") or []
if not isinstance(tool_calls, list):
return (
"❌ **OpenRouter вернул некорректный формат "
"`tool_calls`.**\n\n"
f"`{tool_calls}`"
)
if not tool_calls:
final_content = content_text
break
if not self.valves.ENABLE_TOOLS:
stopped_reason = (
"⚠️ Модель запросила инструмент, но tools "
"отключены в настройках Pipe."
)
final_content = content_text
break
if not tools_map:
stopped_reason = (
"⚠️ Модель запросила инструмент, но Open WebUI "
"не передал доступные функции в `__tools__`."
)
final_content = content_text
break
messages.append({
"role": "assistant",
"content": message.get("content"),
"tool_calls": tool_calls,
})
fatal_stop = False
for tool_call in tool_calls:
execution = await self._execute_tool_call(
tool_call, tools_map
)
if execution["fatal"]:
stopped_reason = execution["result"]
final_content = content_text
fatal_stop = True
break
tool_trace.append(execution)
messages.append({
"role": "tool",
"tool_call_id": execution["tool_call_id"],
"name": execution["name"],
"content": execution["result"],
})
if fatal_stop:
break
else:
stopped_reason = (
"⚠️ Достигнут лимит циклов tool calling: "
f"{self.valves.MAX_TOOL_ITERATIONS}."
)
except Exception as error:
return (
"❌ **Запрос к OpenRouter завершился ошибкой.**\n\n"
f"Модель: `{model}`\n\n"
f"Ошибка:\n`{error}`"
)
answer = final_content or ""
if stopped_reason:
answer = f"{answer}\n\n{stopped_reason}" if answer else stopped_reason
if self.valves.SHOW_TOOL_TRACE and tool_trace:
trace_text = self._format_tool_trace(tool_trace)
answer = f"{answer}\n\n---\n\n{trace_text}" if answer else trace_text
content = self._build_router_content(answer, route, model)
# Возвращаем OpenAI chat.completion dict — Token Tracker Filter
# прочитает usage и покажет токены/стоимость/баланс.
return {
"id": response_id,
"object": "chat.completion",
"created": response_created,
"model": response_model_id,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop",
}
],
"usage": self._build_usage_payload(total_usage),
}