From 6233c9210b7680ed328eb920aeff168b158b8c0a Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Fri, 1 May 2026 23:35:44 +0800 Subject: [PATCH] add openrouter LLM support --- README.md | 8 +- condor/acp/pydantic_ai_client.py | 81 ++++++++- frontend/package-lock.json | 246 +++++++++++++++------------ handlers/agents/__init__.py | 211 ++++++++++++++++++++++- handlers/agents/_shared.py | 9 +- handlers/agents/menu.py | 70 +++++++- handlers/agents/openrouter_models.py | 119 +++++++++++++ 7 files changed, 618 insertions(+), 126 deletions(-) create mode 100644 handlers/agents/openrouter_models.py diff --git a/README.md b/README.md index 6fb1e802..61a63fc3 100644 --- a/README.md +++ b/README.md @@ -157,9 +157,15 @@ Preferences are automatically saved and persist across sessions: ```bash TELEGRAM_TOKEN=your_bot_token ADMIN_USER_ID=123456789 -OPENAI_API_KEY=sk-... # Optional, for AI features +OPENAI_API_KEY=sk-... # Optional, for AI features +OPENROUTER_API_KEY=sk-or-... # Optional, unlocks the OpenRouter LLM picker ``` +> **OpenRouter:** Add `OPENROUTER_API_KEY` to `.env`, then in `/agent → Change LLM` +> select **OpenRouter — Pick Model**. The picker fetches the live catalog and shows +> only models that support tool-calling. Get a key at +> [openrouter.ai/keys](https://openrouter.ai/keys). + ### `config.yml` (auto-created on first run) ```yaml servers: diff --git a/condor/acp/pydantic_ai_client.py b/condor/acp/pydantic_ai_client.py index 70de76ad..7783d59d 100644 --- a/condor/acp/pydantic_ai_client.py +++ b/condor/acp/pydantic_ai_client.py @@ -51,7 +51,7 @@ def _infer_tool_filter_mode(model_name: str) -> str: model_lower = model_name.lower() # Cloud providers always get full access (they're powerful enough) - if any(provider in model_lower for provider in ["openai:", "anthropic:", "groq:", "google:"]): + if any(provider in model_lower for provider in ["openai:", "anthropic:", "groq:", "google:", "openrouter:"]): log.info("Auto-detected cloud provider → tool_filter_mode=full") return "full" @@ -92,12 +92,13 @@ def _infer_tool_filter_mode(model_name: str) -> str: # Model prefix → pydantic-ai model string mapping # Users set agent_key like "ollama:llama3.1:70b" or "openai:gpt-4o" # which maps directly to pydantic-ai model identifiers. -PYDANTIC_AI_PREFIXES = frozenset({"ollama", "openai", "groq", "anthropic", "google", "lmstudio"}) +PYDANTIC_AI_PREFIXES = frozenset({"ollama", "openai", "groq", "anthropic", "google", "lmstudio", "openrouter"}) -# Default base URLs for local model providers +# Default base URLs for local model providers and OpenRouter DEFAULT_BASE_URLS: dict[str, str] = { "ollama": "http://localhost:11434/v1", "lmstudio": "http://localhost:1234/v1", + "openrouter": "https://openrouter.ai/api/v1", } @@ -120,6 +121,7 @@ class PydanticAIClient: - "openai:my-model" → with base_url, uses any OpenAI-compatible API (LM Studio, vLLM, etc.) - "groq:llama-3.3-70b-versatile" → uses Groq cloud - "anthropic:claude-sonnet-4-6" → uses Anthropic API + - "openrouter:anthropic/claude-sonnet-4-5" → uses OpenRouter (requires OPENROUTER_API_KEY) """ def __init__( @@ -151,10 +153,13 @@ def _build_model(self) -> Any: environment variables like OLLAMA_BASE_URL. Resolution: - - ollama:model → OpenAI-compat at localhost:11434/v1 (or custom base_url) - - lmstudio:model → OpenAI-compat at localhost:1234/v1 (or custom base_url) - - openai:model → OpenAI API (or custom base_url for vLLM, etc.) - - groq/anthropic → standard pydantic-ai resolution + - ollama:model → OpenAI-compat at localhost:11434/v1 (or custom base_url) + - lmstudio:model → OpenAI-compat at localhost:1234/v1 (or custom base_url) + - openrouter:model → OpenAI-compat at https://openrouter.ai/api/v1, + requires OPENROUTER_API_KEY; model id must be + explicit (e.g. "openrouter:anthropic/claude-sonnet-4-5"). + - openai:model → OpenAI API (or custom base_url for vLLM, etc.) + - groq/anthropic → standard pydantic-ai resolution """ from pydantic_ai.models.openai import OpenAIModel from pydantic_ai.providers.openai import OpenAIProvider @@ -162,6 +167,26 @@ def _build_model(self) -> Any: prefix, _, model_id = self.model_name.partition(":") base_url = self.base_url + # OpenRouter: OpenAI-compatible cloud gateway, requires API key. + # Handled before the generic DEFAULT_BASE_URLS branch because that branch + # uses api_key="not-needed", which OpenRouter rejects. + if prefix == "openrouter": + if not model_id: + raise RuntimeError( + "OpenRouter requires an explicit model id, e.g. " + "'openrouter:openai/gpt-4o' or 'openrouter:anthropic/claude-sonnet-4-5'." + ) + api_key = os.environ.get("OPENROUTER_API_KEY") + if not api_key: + raise RuntimeError( + "OPENROUTER_API_KEY is not set. Add it to your .env to use openrouter:* models." + ) + provider = OpenAIProvider( + base_url=base_url or DEFAULT_BASE_URLS["openrouter"], + api_key=api_key, + ) + return OpenAIModel(model_id, provider=provider) + # Local providers: always use OpenAI-compatible endpoint with default URL if prefix in DEFAULT_BASE_URLS: base_url = base_url or DEFAULT_BASE_URLS[prefix] @@ -415,5 +440,45 @@ async def prompt_stream(self, text: str) -> AsyncIterator[ACPEvent]: yield PromptDone(stop_reason="timeout") except Exception as e: log.exception("PydanticAI prompt error: %s", e) - yield TextChunk(text=f"(error: {e})") + yield TextChunk(text=self._format_error(e)) yield PromptDone(stop_reason="error") + + def _format_error(self, e: Exception) -> str: + """Translate provider HTTP errors into actionable user-facing text. + + Falls back to the raw exception string for anything we don't recognize. + """ + try: + from pydantic_ai.exceptions import ModelHTTPError + except ImportError: + return f"(error: {e})" + + if not isinstance(e, ModelHTTPError): + return f"(error: {e})" + + is_openrouter = self.model_name.startswith("openrouter:") + status = getattr(e, "status_code", None) + + if is_openrouter and status == 402: + return ( + "OpenRouter rejected the request: insufficient credits.\n\n" + "Either top up at https://openrouter.ai/settings/credits, or " + "switch to a free model with /agent → Change LLM → OpenRouter " + "→ Enter model manually → openrouter/free." + ) + if is_openrouter and status == 401: + return ( + "OpenRouter rejected the API key (401). Check OPENROUTER_API_KEY " + "in your .env and confirm the key is on the account that holds your credits." + ) + if is_openrouter and status == 429: + return ( + "OpenRouter rate-limited the request (429). Free models share a " + "tighter quota — wait a moment and retry, or switch to a paid model." + ) + if is_openrouter and status and 500 <= status < 600: + return ( + f"OpenRouter upstream error ({status}). The selected provider may " + "be down — try again, or switch models with /agent → Change LLM." + ) + return f"(error: {e})" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e8690fcc..41ba477d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -400,21 +400,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -423,9 +423,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -745,26 +745,28 @@ "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", "dev": true, "license": "MIT", "funding": { @@ -772,9 +774,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", "cpu": [ "arm64" ], @@ -789,9 +791,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", "cpu": [ "arm64" ], @@ -806,9 +808,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", "cpu": [ "x64" ], @@ -823,9 +825,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz", - "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", "cpu": [ "x64" ], @@ -840,9 +842,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz", - "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", "cpu": [ "arm" ], @@ -857,13 +859,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -874,13 +879,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -891,13 +899,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -908,13 +919,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -925,13 +939,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz", - "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -942,13 +959,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz", - "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -959,9 +979,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz", - "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", "cpu": [ "arm64" ], @@ -976,9 +996,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz", - "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", "cpu": [ "wasm32" ], @@ -986,16 +1006,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", "cpu": [ "arm64" ], @@ -1010,9 +1032,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz", - "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", "cpu": [ "x64" ], @@ -1591,9 +1613,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1796,9 +1818,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2932,9 +2954,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -3068,9 +3090,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "dev": true, "funding": [ { @@ -3186,14 +3208,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz", - "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.11" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" @@ -3202,27 +3224,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", - "@rolldown/binding-darwin-x64": "1.0.0-rc.11", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz", - "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", "dev": true, "license": "MIT" }, @@ -3335,14 +3357,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -3472,17 +3494,17 @@ } }, "node_modules/vite": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz", - "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.11", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -3499,7 +3521,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", diff --git a/handlers/agents/__init__.py b/handlers/agents/__init__.py index a213177f..6facf4d7 100644 --- a/handlers/agents/__init__.py +++ b/handlers/agents/__init__.py @@ -3,7 +3,7 @@ import logging import shutil -from telegram import Update +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ContextTypes from handlers import clear_all_input_states @@ -123,7 +123,25 @@ async def agent_callback_handler( await _handle_settings(update, context) elif action.startswith("set_llm:"): llm_key = action.split(":", 1)[1] - await _handle_set_llm(update, context, llm_key) + # OpenRouter sentinel -> open the model picker instead of setting directly + if llm_key == "openrouter:": + await _handle_openrouter_picker(update, context, page=0) + else: + await _handle_set_llm(update, context, llm_key) + elif action.startswith("or_page:"): + page = int(action.split(":", 1)[1]) + await _handle_openrouter_picker(update, context, page=page) + elif action.startswith("or_pick:"): + idx = int(action.split(":", 1)[1]) + await _handle_openrouter_pick(update, context, idx) + elif action == "or_type": + await _handle_openrouter_type_prompt(update, context) + elif action == "or_type_confirm": + await _handle_openrouter_type_confirm(update, context) + elif action == "or_type_cancel": + await _handle_openrouter_type_cancel(update, context) + elif action == "or_noop": + pass # page indicator button — do nothing # Session management elif action == "stop": @@ -268,6 +286,190 @@ async def _handle_set_llm( ) +async def _handle_openrouter_picker( + update: Update, context: ContextTypes.DEFAULT_TYPE, page: int +) -> None: + """Show paginated OpenRouter model picker. + + Fetches the model list (cached for 1h), filters to tool-calling models, and + stores the resolved list in user_data so or_pick:N can resolve the index. + """ + import os + + from .menu import _openrouter_picker_keyboard + from .openrouter_models import fetch_models + + query = update.callback_query + + # Surface a hint instead of silently presenting an unusable picker + if not os.environ.get("OPENROUTER_API_KEY"): + await query.message.edit_text( + "OPENROUTER_API_KEY is not set. Add it to your .env to use OpenRouter models, " + "then restart the bot.\n\nGet a key at https://openrouter.ai/keys" + ) + return + + if page == 0: + await query.message.edit_text("Loading OpenRouter models...") + + try: + models = await fetch_models() + except Exception as e: + log.exception("Failed to fetch OpenRouter models") + await query.message.edit_text(f"Failed to fetch OpenRouter models: {e}") + return + + if not models: + await query.message.edit_text( + "No OpenRouter models available. Check your network or try again later." + ) + return + + # Cache for or_pick:N to resolve. Models list is sorted deterministically and + # cached for an hour, so the index is stable across paging within a session. + context.user_data["_openrouter_models"] = models + + current = context.user_data.get("agent_llm", "") + current_slug = ( + current.split(":", 1)[1] + if current.startswith("openrouter:") and current != "openrouter:" + else None + ) + + keyboard = _openrouter_picker_keyboard(models, page=page, current_slug=current_slug) + await query.message.edit_text( + f"Select an OpenRouter model ({len(models)} with tool-calling support):", + reply_markup=keyboard, + ) + + +async def _handle_openrouter_pick( + update: Update, context: ContextTypes.DEFAULT_TYPE, idx: int +) -> None: + """Resolve picker index → set agent_llm to 'openrouter:'.""" + query = update.callback_query + models = context.user_data.get("_openrouter_models") or [] + if not models or idx < 0 or idx >= len(models): + await query.message.edit_text( + "Selection expired. Reopen the picker via Change LLM." + ) + return + + model = models[idx] + agent_key = f"openrouter:{model.slug}" + context.user_data["agent_llm"] = agent_key + + pricing = "" + if model.prompt_price or model.completion_price: + pricing = ( + f"\nPricing: ${model.prompt_price:.2f}/M input, " + f"${model.completion_price:.2f}/M output" + ) + + await query.message.edit_text( + f"LLM set to OpenRouter — {model.name}.\n" + f"Slug: {model.slug}{pricing}\n\n" + "New sessions will use this model. Use /agent to continue." + ) + + +async def _handle_openrouter_type_prompt( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Arm slug-input mode: next text message is parsed as an OpenRouter slug.""" + query = update.callback_query + context.user_data["_openrouter_typing_slug"] = True + await query.message.edit_text( + "Send the OpenRouter model slug as a message.\n" + "Example: anthropic/claude-sonnet-4.5\n\n" + "Send /cancel to abort." + ) + + +async def _resolve_openrouter_typed_slug( + update: Update, context: ContextTypes.DEFAULT_TYPE, text: str +) -> None: + """Validate a typed slug; on match, prompt confirmation.""" + from .openrouter_models import fetch_models, find_model_by_slug + + slug = text.strip() + if slug.lower() in ("/cancel", "cancel"): + await update.message.reply_text("Cancelled. Use /agent to continue.") + return + + try: + models = await fetch_models() + except Exception as e: + log.exception("Failed to fetch OpenRouter models") + await update.message.reply_text(f"Failed to fetch OpenRouter models: {e}") + return + + model = find_model_by_slug(models, slug) + if not model: + # Re-arm so the user can retype without hunting for the button again + context.user_data["_openrouter_typing_slug"] = True + await update.message.reply_text( + f"No tool-calling OpenRouter model matches '{slug}'.\n" + "The slug must be exact (e.g. anthropic/claude-sonnet-4.5).\n" + "Try again, or send /cancel." + ) + return + + context.user_data["_openrouter_typed_slug"] = model.slug + + pricing = "" + if model.prompt_price or model.completion_price: + pricing = ( + f"\nPricing: ${model.prompt_price:.2f}/M input, " + f"${model.completion_price:.2f}/M output" + ) + + keyboard = InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton( + "Use this model", callback_data="agent:or_type_confirm" + ), + InlineKeyboardButton( + "Cancel", callback_data="agent:or_type_cancel" + ), + ] + ] + ) + await update.message.reply_text( + f"Use OpenRouter — {model.name}?\nSlug: {model.slug}{pricing}", + reply_markup=keyboard, + ) + + +async def _handle_openrouter_type_confirm( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Apply the typed slug as the active agent_llm.""" + query = update.callback_query + slug = context.user_data.pop("_openrouter_typed_slug", None) + if not slug: + await query.message.edit_text( + "Selection expired. Reopen the picker via Change LLM." + ) + return + + context.user_data["agent_llm"] = f"openrouter:{slug}" + await query.message.edit_text( + f"LLM set to OpenRouter — {slug}.\n\n" + "New sessions will use this model. Use /agent to continue." + ) + + +async def _handle_openrouter_type_cancel( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Discard the typed slug without changing the active LLM.""" + query = update.callback_query + context.user_data.pop("_openrouter_typed_slug", None) + await query.message.edit_text("Cancelled. Use /agent to continue.") + + async def _handle_stop(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Stop the active agent session.""" query = update.callback_query @@ -586,6 +788,11 @@ async def agent_message_handler( await _do_compact_from_message(update, context, text) return + # Handle typed OpenRouter slug input + if context.user_data.pop("_openrouter_typing_slug", None): + await _resolve_openrouter_typed_slug(update, context, text) + return + # Backward compat mode = context.user_data.get("agent_mode", DEFAULT_MODE) if mode == "trading": diff --git a/handlers/agents/_shared.py b/handlers/agents/_shared.py index 43e43743..6eea7c7c 100644 --- a/handlers/agents/_shared.py +++ b/handlers/agents/_shared.py @@ -13,6 +13,9 @@ "codex": {"label": "ChatGPT Codex"}, "ollama:": {"label": "Ollama — Default Model"}, "lmstudio:": {"label": "LM Studio — Default Model"}, + # Sentinel — clicking this opens the OpenRouter model picker (handlers/agents/menu.py). + # The actual stored agent_llm becomes "openrouter:" once the user picks a model. + "openrouter:": {"label": "OpenRouter — Pick Model"}, } DEFAULT_AGENT = "claude-code" @@ -124,9 +127,13 @@ - Pydantic AI (local): "ollama:llama3.1", "ollama:qwen3:32b", \ "ollama:qwen2.5:72b", "ollama:deepseek-r1:32b", "lmstudio:" - Pydantic AI (cloud): "openai:gpt-4o", "groq:llama-3.3-70b-versatile" +- OpenRouter (cloud, unified gateway): "openrouter:openai/gpt-4o", \ +"openrouter:anthropic/claude-sonnet-4-5", "openrouter:meta-llama/llama-3.3-70b-instruct". \ +Requires OPENROUTER_API_KEY in .env. Honors model_base_url for self-hosted proxies. - Custom endpoint: use "openai:" + model_base_url in config -Default URLs (no config needed): Ollama=localhost:11434, LM Studio=localhost:1234. \ +Default URLs (no config needed): Ollama=localhost:11434, LM Studio=localhost:1234, \ +OpenRouter=https://openrouter.ai/api/v1. \ Override with model_base_url in config if running on a different host/port. GENERIC vs SPECIFIC STRATEGIES: diff --git a/handlers/agents/menu.py b/handlers/agents/menu.py index f79e12bc..7d10f106 100644 --- a/handlers/agents/menu.py +++ b/handlers/agents/menu.py @@ -43,11 +43,23 @@ def _mode_selection_keyboard() -> InlineKeyboardMarkup: def _settings_keyboard(current_llm: str) -> InlineKeyboardMarkup: - """Build LLM picker keyboard.""" + """Build LLM picker keyboard. + + The current selection is marked with a bullet. If the user has previously + picked an OpenRouter model (agent_llm starts with "openrouter:"), the + "openrouter:" sentinel row matches and shows the slug they picked. + """ keyboard = [] for key, info in AGENT_OPTIONS.items(): label = info["label"] - if key == current_llm: + # Treat any "openrouter:" as matching the sentinel "openrouter:" row + is_current = key == current_llm or ( + key == "openrouter:" and current_llm.startswith("openrouter:") and current_llm != "openrouter:" + ) + if is_current and current_llm.startswith("openrouter:") and current_llm != "openrouter:": + slug = current_llm.split(":", 1)[1] + label = f"• OpenRouter — {slug}" + elif is_current: label = f"• {label}" keyboard.append( [InlineKeyboardButton(label, callback_data=f"agent:set_llm:{key}")] @@ -56,6 +68,60 @@ def _settings_keyboard(current_llm: str) -> InlineKeyboardMarkup: return InlineKeyboardMarkup(keyboard) +# OpenRouter picker pagination +OPENROUTER_PAGE_SIZE = 8 + + +def _openrouter_picker_keyboard( + models: list, page: int, current_slug: str | None +) -> InlineKeyboardMarkup: + """Paginated keyboard for picking an OpenRouter model. + + `models` is a list of OpenRouterModel; we reference each by its index in this + list so callback_data stays well under Telegram's 64-byte cap regardless of + slug length. + """ + from .openrouter_models import format_button_label + + if not models: + return InlineKeyboardMarkup( + [[InlineKeyboardButton("Back", callback_data="agent:settings")]] + ) + + total_pages = (len(models) + OPENROUTER_PAGE_SIZE - 1) // OPENROUTER_PAGE_SIZE + page = max(0, min(page, total_pages - 1)) + start = page * OPENROUTER_PAGE_SIZE + end = min(start + OPENROUTER_PAGE_SIZE, len(models)) + + keyboard: list[list[InlineKeyboardButton]] = [ + [InlineKeyboardButton("Enter model manually", callback_data="agent:or_type")], + ] + for idx in range(start, end): + m = models[idx] + label = format_button_label(m) + if current_slug and m.slug == current_slug: + label = f"• {label}" + keyboard.append( + [InlineKeyboardButton(label, callback_data=f"agent:or_pick:{idx}")] + ) + + nav_row: list[InlineKeyboardButton] = [] + if page > 0: + nav_row.append( + InlineKeyboardButton("‹ Prev", callback_data=f"agent:or_page:{page - 1}") + ) + nav_row.append( + InlineKeyboardButton(f"{page + 1}/{total_pages}", callback_data="agent:or_noop") + ) + if page < total_pages - 1: + nav_row.append( + InlineKeyboardButton("Next ›", callback_data=f"agent:or_page:{page + 1}") + ) + keyboard.append(nav_row) + keyboard.append([InlineKeyboardButton("Back", callback_data="agent:settings")]) + return InlineKeyboardMarkup(keyboard) + + def _no_session_keyboard(mode: str) -> InlineKeyboardMarkup: """Build keyboard when no session is active.""" rows = [ diff --git a/handlers/agents/openrouter_models.py b/handlers/agents/openrouter_models.py new file mode 100644 index 00000000..698ec2e8 --- /dev/null +++ b/handlers/agents/openrouter_models.py @@ -0,0 +1,119 @@ +"""OpenRouter model catalog fetcher. + +Hits GET https://openrouter.ai/api/v1/models (public, unauthenticated) and +filters to entries that advertise `tools` in their supported_parameters. +Condor's whole architecture depends on tool-calling, so models without it +are hidden from the picker. + +Results are cached for an hour so paginating the picker doesn't refetch. +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass + +import aiohttp + +log = logging.getLogger(__name__) + +OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models" +CACHE_TTL_SECONDS = 3600 + + +@dataclass(frozen=True) +class OpenRouterModel: + slug: str # e.g. "anthropic/claude-sonnet-4-5" + name: str # human-friendly name from the API + context_length: int # tokens + prompt_price: float # USD per 1M input tokens, 0 if free + completion_price: float + + +_cache: tuple[float, list[OpenRouterModel]] | None = None + + +def _parse_price(value: object) -> float: + """OpenRouter returns prices as strings in USD per token. Convert to per-1M.""" + try: + return float(value) * 1_000_000 # type: ignore[arg-type] + except (TypeError, ValueError): + return 0.0 + + +def _model_supports_tools(entry: dict) -> bool: + params = entry.get("supported_parameters") or [] + return "tools" in params + + +async def fetch_models(force_refresh: bool = False) -> list[OpenRouterModel]: + """Return the list of OpenRouter models that support tool-calling. + + Sorted by provider then model id for predictable pagination. + """ + global _cache + + if not force_refresh and _cache is not None: + cached_at, models = _cache + if time.monotonic() - cached_at < CACHE_TTL_SECONDS: + return models + + timeout = aiohttp.ClientTimeout(total=10) + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(OPENROUTER_MODELS_URL) as resp: + resp.raise_for_status() + payload = await resp.json() + except Exception as e: + log.warning("Failed to fetch OpenRouter models: %s", e) + if _cache is not None: + return _cache[1] # serve stale cache on failure + return [] + + raw = payload.get("data") or [] + models: list[OpenRouterModel] = [] + for entry in raw: + if not isinstance(entry, dict): + continue + slug = entry.get("id") + if not isinstance(slug, str) or not slug or slug.startswith("~"): + continue # skip canonical aliases like "~anthropic/claude-haiku-latest" + if not _model_supports_tools(entry): + continue + pricing = entry.get("pricing") or {} + models.append( + OpenRouterModel( + slug=slug, + name=str(entry.get("name") or slug), + context_length=int(entry.get("context_length") or 0), + prompt_price=_parse_price(pricing.get("prompt")), + completion_price=_parse_price(pricing.get("completion")), + ) + ) + + models.sort(key=lambda m: m.slug.lower()) + _cache = (time.monotonic(), models) + log.info("Fetched %d OpenRouter models with tool support", len(models)) + return models + + +def format_button_label(model: OpenRouterModel) -> str: + """Short label for inline keyboard buttons. Telegram max ~30 chars looks good.""" + label = model.name or model.slug + if len(label) > 38: + label = label[:35] + "..." + return label + + +def find_model_by_slug( + models: list[OpenRouterModel], slug: str +) -> OpenRouterModel | None: + """Case-insensitive exact-match lookup by slug.""" + target = slug.strip().lower() + if not target: + return None + for m in models: + if m.slug.lower() == target: + return m + return None