diff --git a/openrouter_auto_router_v2.7.2.py b/openrouter_auto_router_v2.7.2.py deleted file mode 100644 index 0f284da..0000000 --- a/openrouter_auto_router_v2.7.2.py +++ /dev/null @@ -1,745 +0,0 @@ -""" -title: OpenRouter Auto Router -author: OpenAI -version: 2.7.2 -description: Автоматическая маршрутизация OpenRouter с поддержкой tools и встроенным показом расхода токенов через __event_emitter__. -""" - -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="Максимальный размер результата одного инструмента", - ) - - # --- Новое в v2.7.2 --- - SHOW_USAGE_STATUS: bool = Field( - default=True, - description="Показывать расход токенов и стоимость под сообщением", - ) - USAGE_STATUS_DETAILED: bool = Field( - default=False, - description="Подробный расход (reasoning / cached / cache_write) — если их присылает провайдер", - ) - - 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 _format_usage_status(self, usage, model, elapsed, iterations): - """Форматирует строку статуса расхода токенов.""" - parts = [] - - # Токены (всегда показываем, если есть) - total = usage["total_tokens"] - if total > 0: - token_str = ( - f"🪙 {total:,}t" - f" (📥{usage['prompt_tokens']:,}/📤{usage['completion_tokens']:,})" - ) - - if self.valves.USAGE_STATUS_DETAILED: - if usage["reasoning_tokens"] > 0: - token_str += f" (🧠{usage['reasoning_tokens']:,})" - cache_parts = [] - if usage["cached_tokens"] > 0: - cache_parts.append(f"{usage['cached_tokens']:,}r") - if usage["cache_write_tokens"] > 0: - cache_parts.append(f"{usage['cache_write_tokens']:,}w") - if cache_parts: - token_str += f" (💾{'/'.join(cache_parts)})" - - parts.append(token_str) - else: - parts.append("⚠️ Tokens: N/A") - - # Стоимость (если провайдер её прислал) - cost = usage["cost"] - if cost > 0: - if cost < 0.0001: - cost_str = f"{cost:.8f}".rstrip("0").rstrip(".") - else: - cost_str = f"{cost:.6f}".rstrip("0").rstrip(".") - parts.append(f"💸 ${cost_str}") - else: - parts.append("💸 N/A") - - # Время - if elapsed > 0: - parts.append(f"⏱️ {elapsed:.1f}s") - - # Итерации tool calling - if iterations > 1: - parts.append(f"🔄 {iterations} итераций") - - # Модель (короткое имя) - short_model = model.split("/")[-1] if "/" in model else model - parts.append(f"🤖 {short_model}") - - return " · ".join(parts) - - 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, - __event_emitter__=None, - ): - """Маршрутизирует запрос и выполняет цикл tool calling. - - Возвращает строку — расход показывается через __event_emitter__.""" - 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 по всем итерациям - total_usage = { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - "cost": 0.0, - "reasoning_tokens": 0, - "cached_tokens": 0, - "cache_write_tokens": 0, - } - - start_time = time.time() - iterations = 0 - - # Показываем «считаю» статус в начале - if self.valves.SHOW_USAGE_STATUS and callable(__event_emitter__): - try: - await __event_emitter__( - { - "type": "status", - "data": { - "description": "⏳ Обрабатываю запрос...", - "done": False, - }, - } - ) - except Exception: - pass - - timeout = httpx.Timeout(self.valves.REQUEST_TIMEOUT) - - try: - async with httpx.AsyncClient(timeout=timeout) as client: - for _ in range(self.valves.MAX_TOOL_ITERATIONS): - iterations += 1 - payload = self._build_payload( - body, model, messages, tools_map - ) - data = await self._request(payload, client) - - # Суммируем usage - self._accumulate_usage(total_usage, data) - - 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}`" - ) - - elapsed = time.time() - start_time - - 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 - - # Эмитим статус с расходом (та же механика, что в Token Tracker) - if self.valves.SHOW_USAGE_STATUS and callable(__event_emitter__): - try: - status_text = self._format_usage_status( - total_usage, model, elapsed, iterations - ) - await __event_emitter__( - { - "type": "status", - "data": {"description": status_text, "done": True}, - } - ) - except Exception: - # Никогда не ломаем основной ответ из-за статус-эмиссии - pass - - 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}" - )