597 lines
25 KiB
Python
597 lines
25 KiB
Python
"""
|
|
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="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
|
|
|
|
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."""
|
|
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 = ""
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
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}"
|
|
)
|