From 9e10756f6e2b99003c7746e53e0b31641d776eac Mon Sep 17 00:00:00 2001 From: host Date: Tue, 11 Aug 2026 19:10:00 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B8=D1=82=D1=8C=20op?= =?UTF-8?q?enrouter=5Fauto=5Frouter.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openrouter_auto_router.py | 415 -------------------------------------- 1 file changed, 415 deletions(-) delete mode 100644 openrouter_auto_router.py diff --git a/openrouter_auto_router.py b/openrouter_auto_router.py deleted file mode 100644 index a5b5188..0000000 --- a/openrouter_auto_router.py +++ /dev/null @@ -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"(?= 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}" - )