diff --git a/openrouter_auto_router_v2.7.1.py b/openrouter_auto_router_v2.7.1.py deleted file mode 100644 index add1fc8..0000000 --- a/openrouter_auto_router_v2.7.1.py +++ /dev/null @@ -1,693 +0,0 @@ -""" -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