Удалить openrouter_auto_router.py

This commit is contained in:
2026-08-11 19:10:00 +03:00
parent 03f115f751
commit 9e10756f6e
-415
View File
@@ -1,415 +0,0 @@
"""
title: OpenRouter Auto Router
author: OpenAI
version: 2.7.1
description: Автоматическая маршрутизация OpenRouter с поддержкой tools.
"""
import asyncio
import inspect
import json
import re
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="xiaomi/mimo-v2.5:floor", description="Модель для кода, Linux, Docker и Proxmox")
VISION_MODEL: str = Field(default="xiaomi/mimo-v2.5:floor", 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=5, ge=1, le=20, description="Максимум циклов tool calling")
MAX_TOOL_RESULT_CHARS: int = Field(default=20000, ge=1000, le=200000, description="Максимальный размер результата инструмента")
MAX_SAME_TOOL_CALLS: int = Field(default=2, ge=1, le=5, 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, re.UNICODE) is not None
def _classify(self, text):
"""Определяет маршрут по ключевым словам."""
lowered = text.lower()
words = self._tokenize(lowered)
complex_phrases = {
"спроектируй", "разработай систему", "разработать систему",
"архитектура системы", "рефакторинг проекта", "большой проект",
"проанализируй проект", "анализ проекта", "сложная ошибка",
"сложный баг", "несколько сервисов", "несколько контейнеров",
"полная система", "систему с нуля", "проект с нуля",
"приложение с нуля", "сервис с нуля", "интеграцию всех",
"оптимизируй архитектуру", "system architecture", "system design",
"large project", "large codebase", "refactor project", "refactor entire",
"complex bug", "complex problem", "multi-agent", "multiple services",
"distributed system", "design a system", "build a system", "agentic",
}
if any(self._contains_phrase(lowered, item) for item 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",
}
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 {"программ", "функци", "библиотек", "ошиб", "сертификат"}):
return "coding"
if any(self._contains_phrase(lowered, item) for item 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, entry):
"""Извлекает callable из прямого или словарного формата __tools__."""
if callable(entry):
return entry
if isinstance(entry, dict) and callable(entry.get("callable")):
return entry["callable"]
return None
def _extract_tool_specs(self, tools_map):
"""Преобразует плоские schema tools в OpenAI-compatible формат."""
if not isinstance(tools_map, dict):
return []
result = []
for entry in tools_map.values():
if not isinstance(entry, dict) or not isinstance(entry.get("spec"), dict):
continue
spec = entry["spec"]
if spec.get("type") == "function" and isinstance(spec.get("function"), dict):
result.append(spec)
elif isinstance(spec.get("name"), str) and spec["name"].strip():
result.append({"type": "function", "function": spec})
return result
def _build_payload(self, body, model, messages, tools_map, tool_iteration=0):
"""Собирает payload для OpenRouter."""
payload = dict(body)
payload.update({"model": model, "messages": messages, "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:
for field in {"tools", "tool_choice", "parallel_tool_calls"}:
payload.pop(field, None)
else:
if not payload.get("tools"):
specs = self._extract_tool_specs(tools_map)
if specs:
payload["tools"] = specs
# После первого tool call модель должна иметь возможность
# завершить ответ обычным текстом, даже если Open WebUI
# передал tool_choice="required" или фиксированный tool.
if tool_iteration > 0:
payload["tool_choice"] = "auto"
return payload
async def _execute_tool_call(self, call, tools_map):
"""Выполняет tool call; фатальна только ошибка tool_call_id."""
if not isinstance(call, dict):
return {"fatal": True, "result": "Ошибка: некорректный формат tool call."}
call_id = call.get("id")
if not isinstance(call_id, str) or not call_id.strip():
return {"fatal": True, "result": "Ошибка: отсутствует корректный tool_call_id."}
function = call.get("function")
if not isinstance(function, dict):
return {"fatal": False, "tool_call_id": 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": call_id, "name": "unknown", "arguments": {}, "result": "Ошибка: отсутствует имя инструмента."}
arguments = self._parse_tool_arguments(function.get("arguments"))
if arguments is None:
return {"fatal": False, "tool_call_id": call_id, "name": name, "arguments": {}, "result": f"Ошибка: инструмент `{name}` получил некорректные JSON-аргументы. Исправь их и повтори вызов."}
callable_fn = self._get_tool_callable(tools_map.get(name) if isinstance(tools_map, dict) else None)
if callable_fn is None:
return {"fatal": False, "tool_call_id": call_id, "name": name, "arguments": arguments, "result": f"Ошибка: инструмент `{name}` не найден или недоступен."}
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 = json.dumps(result, ensure_ascii=False)
except (TypeError, ValueError):
result = str(result)
else:
result = str(result)
if len(result) > self.valves.MAX_TOOL_RESULT_CHARS:
result = result[:self.valves.MAX_TOOL_RESULT_CHARS] + "\n\n[Результат инструмента обрезан]"
return {"fatal": False, "tool_call_id": call_id, "name": name, "arguments": arguments, "result": result}
def _content_to_text(self, content):
"""Преобразует content ответа в строку."""
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(str(x.get("text")) for x in content if isinstance(x, dict) and x.get("text"))
return "" if content is None else str(content)
def _format_tool_trace(self, trace):
"""Форматирует отладочную историю tools."""
return "\n\n".join(
f"### Инструмент {i}: `{item['name']}`\n\n"
f"Аргументы:\n```json\n{json.dumps(item['arguments'], ensure_ascii=False, indent=2)}\n```\n\n"
f"Результат:\n```\n{item['result']}\n```"
for i, item in enumerate(trace, 1)
)
async def pipe(self, body: dict, __user__: dict = None, __tools__: dict = None):
"""Маршрутизирует запрос и выполняет цикл tool calling."""
if not isinstance(body, dict):
return "❌ **Некорректный формат запроса.**"
if not self.valves.OPENROUTER_API_KEY.strip():
return "❌ **API-ключ OpenRouter не указан.**"
original_messages = body.get("messages", [])
if not isinstance(original_messages, list) or 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)
trace = []
repeated = {}
final_content = ""
stopped_reason = ""
timeout = httpx.Timeout(self.valves.REQUEST_TIMEOUT)
try:
async with httpx.AsyncClient(timeout=timeout) as client:
for iteration in range(self.valves.MAX_TOOL_ITERATIONS):
payload = self._build_payload(
body, model, messages, tools_map, iteration
)
data = await self._request(payload, client)
try:
message = data["choices"][0]["message"]
except (KeyError, IndexError, TypeError):
return f"❌ **Некорректный ответ OpenRouter.**\n\n`{data}`"
content = self._content_to_text(message.get("content", ""))
tool_calls = message.get("tool_calls") or []
if not isinstance(tool_calls, list):
return f"❌ **Некорректный формат `tool_calls`.**\n\n`{tool_calls}`"
if not tool_calls:
final_content = content
break
if not self.valves.ENABLE_TOOLS or not tools_map:
stopped_reason = "⚠️ Модель запросила tools, но они недоступны в текущем чате."
final_content = content
break
messages.append({"role": "assistant", "content": message.get("content"), "tool_calls": tool_calls})
stop = False
for call in tool_calls:
function = call.get("function", {}) if isinstance(call, dict) else {}
key = json.dumps(
{"name": function.get("name"), "arguments": function.get("arguments")},
ensure_ascii=False,
sort_keys=True,
)
repeated[key] = repeated.get(key, 0) + 1
if repeated[key] > self.valves.MAX_SAME_TOOL_CALLS:
stopped_reason = "⚠️ Один и тот же tool call повторился слишком много раз."
stop = True
break
execution = await self._execute_tool_call(call, tools_map)
if execution["fatal"]:
stopped_reason = execution["result"]
final_content = content
stop = True
break
trace.append(execution)
messages.append({
"role": "tool",
"tool_call_id": execution["tool_call_id"],
"name": execution["name"],
"content": execution["result"],
})
if stop:
break
else:
stopped_reason = f"⚠️ Достигнут лимит циклов tool calling: {self.valves.MAX_TOOL_ITERATIONS}."
except Exception as error:
return f"❌ **Запрос к OpenRouter завершился ошибкой.**\n\nМодель: `{model}`\n\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 trace:
trace_text = self._format_tool_trace(trace)
answer = f"{answer}\n\n---\n\n{trace_text}" if answer else trace_text
if not self.valves.SHOW_ROUTER_INFO:
return answer
route_names = {
"default": "💰 Обычный запрос",
"coding": "👨‍💻 Код / Linux / Docker",
"vision": "🖼️ Изображение / скриншот",
"complex": "🧠 Сложная задача",
}
return (
f"**🤖 Auto Router** \n"
f"Маршрут: **{route_names.get(route, route)}** \n"
f"Модель: `{model}`\n\n{answer}"
)