diff --git a/openrouter_auto_router.py b/openrouter_auto_router.py new file mode 100644 index 0000000..789939e --- /dev/null +++ b/openrouter_auto_router.py @@ -0,0 +1,402 @@ +""" +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"(?= 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) or not raw_arguments.strip(): + return {} if isinstance(raw_arguments, str) else None + 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): + """Собирает 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) + elif not payload.get("tools"): + specs = self._extract_tool_specs(tools_map) + if specs: + payload["tools"] = specs + return payload + + 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."} + call_id = tool_call.get("id") + if not isinstance(call_id, str) or not call_id.strip(): + return {"fatal": True, "result": "Ошибка: отсутствует корректный tool_call_id."} + + function = tool_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 _ in range(self.valves.MAX_TOOL_ITERATIONS): + data = await self._request( + self._build_payload(body, model, messages, tools_map), + 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}) + fatal_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 повторился слишком много раз." + fatal_stop = True + break + + execution = await self._execute_tool_call(call, tools_map) + if execution["fatal"]: + stopped_reason = execution["result"] + final_content = content + fatal_stop = True + break + 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 = 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}" + )