diff --git a/openrouter_auto_router.py b/openrouter_auto_router.py new file mode 100644 index 0000000..add1fc8 --- /dev/null +++ b/openrouter_auto_router.py @@ -0,0 +1,693 @@ +""" +title: OpenRouter Auto Router +author: OpenAI +version: 2.8.0 +description: Автоматическая маршрутизация OpenRouter с поддержкой tools и прокидыванием usage для Token Tracker. +""" + +import asyncio +import inspect +import json +import re +import time + +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 + + def _accumulate_usage(self, total_usage, data): + """Суммирует usage по всем итерациям tool-calling.""" + if not isinstance(data, dict): + return + + usage = data.get("usage") + if not isinstance(usage, dict): + return + + total_usage["prompt_tokens"] += int(usage.get("prompt_tokens", 0) or 0) + total_usage["completion_tokens"] += int(usage.get("completion_tokens", 0) or 0) + total_usage["total_tokens"] += int(usage.get("total_tokens", 0) or 0) + + cost = usage.get("cost") + if isinstance(cost, (int, float)): + total_usage["cost"] += float(cost) + + prompt_details = usage.get("prompt_tokens_details") or {} + if isinstance(prompt_details, dict): + total_usage["cached_tokens"] += int( + prompt_details.get("cached_tokens", 0) or 0 + ) + total_usage["cache_write_tokens"] += int( + prompt_details.get("cache_write_tokens", 0) or 0 + ) + + completion_details = usage.get("completion_tokens_details") or {} + if isinstance(completion_details, dict): + total_usage["reasoning_tokens"] += int( + completion_details.get("reasoning_tokens", 0) or 0 + ) + + def _build_usage_payload(self, total_usage): + """Собирает usage в формате OpenAI chat.completion для Token Tracker.""" + return { + "prompt_tokens": total_usage["prompt_tokens"], + "completion_tokens": total_usage["completion_tokens"], + "total_tokens": total_usage["total_tokens"], + "cost": total_usage["cost"], + "prompt_tokens_details": { + "cached_tokens": total_usage["cached_tokens"], + "cache_write_tokens": total_usage["cache_write_tokens"], + }, + "completion_tokens_details": { + "reasoning_tokens": total_usage["reasoning_tokens"], + }, + } + + def _build_router_content(self, answer, route, model): + """Собирает финальный текст ответа с префиксом маршрутизатора.""" + 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}" + ) + + 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. + + Возвращает OpenAI-совместимый dict формата chat.completion, + чтобы Token Tracker Filter мог извлечь usage (prompt/completion/cost).""" + 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 = "" + + # Аккумуляторы usage по всем итерациям tool-calling + total_usage = { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + "cost": 0.0, + "reasoning_tokens": 0, + "cached_tokens": 0, + "cache_write_tokens": 0, + } + response_id = f"auto-router-{int(time.time())}" + response_created = int(time.time()) + response_model_id = model + + 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) + + # Суммируем usage и обновляем метаданные ответа + if isinstance(data, dict): + self._accumulate_usage(total_usage, data) + if isinstance(data.get("id"), str): + response_id = data["id"] + if isinstance(data.get("created"), (int, float)): + response_created = int(data["created"]) + if isinstance(data.get("model"), str): + response_model_id = data["model"] + + 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 + + content = self._build_router_content(answer, route, model) + + # Возвращаем OpenAI chat.completion dict — Token Tracker Filter + # прочитает usage и покажет токены/стоимость/баланс. + return { + "id": response_id, + "object": "chat.completion", + "created": response_created, + "model": response_model_id, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": self._build_usage_payload(total_usage), + } \ No newline at end of file