""" 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"(?= 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}" )