From 908293f02398c253ab6eb3a59c6b23f5c048547d Mon Sep 17 00:00:00 2001 From: host Date: Wed, 12 Aug 2026 18:17:01 +0300 Subject: [PATCH] =?UTF-8?q?v2.7.2=20=E2=80=94=20=D0=B2=D0=BE=D0=B7=D0=B2?= =?UTF-8?q?=D1=80=D0=B0=D1=82=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20+?= =?UTF-8?q?=20=D1=80=D0=B0=D1=81=D1=85=D0=BE=D0=B4=20=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B7=20=5F=5Fevent=5Femitter=5F=5F=20(=D0=B7=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B0=20Token=20Tracker=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=88=D0=B5=D0=B3=D0=BE=20Pipe)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openrouter_auto_router_v2.7.2.py | 745 +++++++++++++++++++++++++++++++ 1 file changed, 745 insertions(+) create mode 100644 openrouter_auto_router_v2.7.2.py diff --git a/openrouter_auto_router_v2.7.2.py b/openrouter_auto_router_v2.7.2.py new file mode 100644 index 0000000..91e0dca --- /dev/null +++ b/openrouter_auto_router_v2.7.2.py @@ -0,0 +1,745 @@ +""" +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}" + )