""" 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}" )