""" 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}" )