From ee495b900677da64ba44e3c327f4d0067d3fd353 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sat, 25 Jul 2026 16:07:33 -0700 Subject: [PATCH] Add OpenRouter as an OpenAI-compatible reseller provider Descriptor + curated matrix rows + sk-or- key auto-detect (server and GUI). --- coworker/providers/matrix.py | 12 ++++++++++-- coworker/providers/registry.py | 17 +++++++++++++---- surfaces/gui/src/api.ts | 1 + tests/test_provider_verify.py | 1 + tests/test_providers.py | 6 ++++-- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/coworker/providers/matrix.py b/coworker/providers/matrix.py index 9b40cacf..81457fff 100644 --- a/coworker/providers/matrix.py +++ b/coworker/providers/matrix.py @@ -11,8 +11,8 @@ falls back to the conservative heuristics in ``capabilities.py`` at their own ri degraded results. Ids verified against vendor/reseller catalogs on 2026-07-04; refresh the reseller rows when catalogs rotate (they rename on every model generation). -Resellers: Together + Fireworks for now. TODO: add Groq and OpenRouter entries here AND their -descriptors in ``registry.py`` once the current provider surface is tested — deliberately +Resellers: Together + Fireworks + OpenRouter. TODO: add Groq entries here AND its +descriptor in ``registry.py`` once the current provider surface is tested — deliberately deferred to bound how much needs verifying at once. """ @@ -112,6 +112,14 @@ MATRIX: dict[str, ModelEntry] = { "fireworks:accounts/fireworks/models/llama4-maverick-instruct-basic": ModelEntry( "Llama 4 Maverick · via Fireworks" ), + # OpenRouter slugs are lowercase `/` (checked against their catalog + # 2026-07-25); same labs as above, one key for all of them. + "openrouter:z-ai/glm-5.2": ModelEntry("GLM-5.2 · via OpenRouter"), + "openrouter:moonshotai/kimi-k2.6": ModelEntry("Kimi K2.6 · via OpenRouter"), + "openrouter:deepseek/deepseek-v4-pro": ModelEntry("DeepSeek V4 Pro · via OpenRouter"), + "openrouter:meta-llama/llama-4-maverick": ModelEntry( + "Llama 4 Maverick · via OpenRouter" + ), } diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index b5c628c6..a8111285 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -211,7 +211,7 @@ DESCRIPTORS: list[ProviderDescriptor] = [ secret=False, required=False, placeholder="https://…/openai/v1", - help="For Azure OpenAI, OpenRouter, vLLM, or any OpenAI-compliant server. Leave blank for api.openai.com.", + help="For Azure OpenAI, vLLM, or any OpenAI-compliant server. Leave blank for api.openai.com.", ), ], build=_build_openai, @@ -316,9 +316,9 @@ DESCRIPTORS: list[ProviderDescriptor] = [ endpoint_help="Prefilled with the Meta Model API endpoint (public preview, US-only as of 2026-07).", ), # Resellers: many labs' models behind one key, using THEIR model namespaces (the curated - # ids + display labels live in providers/matrix.py). TODO: add Groq and OpenRouter here - # (+ their matrix rows) once the current provider surface is tested — deliberately - # deferred to bound how much needs verifying at once (owner call, 2026-07-04). + # ids + display labels live in providers/matrix.py). TODO: add Groq here (+ its matrix + # rows) once the current provider surface is tested — deliberately deferred to bound + # how much needs verifying at once (owner call, 2026-07-04). _compat( "together", "Together AI", @@ -333,6 +333,13 @@ DESCRIPTORS: list[ProviderDescriptor] = [ recommended_model="accounts/fireworks/models/glm-5p2", env_key="FIREWORKS_API_KEY", ), + _compat( + "openrouter", + "OpenRouter", + base_url="https://openrouter.ai/api/v1", + recommended_model="z-ai/glm-5.2", + env_key="OPENROUTER_API_KEY", + ), ProviderDescriptor( name="ollama", title="Ollama (local models)", @@ -386,6 +393,8 @@ def detect_provider(api_key: str) -> Optional[str]: return None if key.startswith("sk-ant-"): return "anthropic" + if key.startswith("sk-or-"): + return "openrouter" if key.startswith("AIza"): return "gemini" if key.startswith(("sk-", "sk_")): diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index c700cdd0..b659988d 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -1324,6 +1324,7 @@ export function detectProvider(apiKey: string): string | null { const key = (apiKey || "").trim(); if (!key) return null; if (key.startsWith("sk-ant-")) return "anthropic"; + if (key.startsWith("sk-or-")) return "openrouter"; if (key.startsWith("AIza")) return "gemini"; if (key.startsWith("sk-") || key.startsWith("sk_")) return "openai"; return null; diff --git a/tests/test_provider_verify.py b/tests/test_provider_verify.py index c24414e8..8da7fe76 100644 --- a/tests/test_provider_verify.py +++ b/tests/test_provider_verify.py @@ -15,6 +15,7 @@ from coworker.providers import detect_provider, verify_provider_key "key,expected", [ ("sk-ant-api03-abc", "anthropic"), + ("sk-or-v1-abc", "openrouter"), ("AIzaSyAbc123", "gemini"), ("sk-proj-abc", "openai"), ("sk_live_abc", "openai"), diff --git a/tests/test_providers.py b/tests/test_providers.py index 41ebbbd1..e34d1e99 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -381,6 +381,8 @@ def test_matrix_answers_capabilities_for_reseller_ids(): "together:zai-org/GLM-5.2", "together:meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", "fireworks:accounts/fireworks/models/kimi-k2p6", + "openrouter:z-ai/glm-5.2", + "openrouter:meta-llama/llama-4-maverick", ): caps = capabilities_for(mid) assert caps.tools and caps.parallel_tool_calls and caps.streaming @@ -402,12 +404,12 @@ def test_matrix_labels_and_custom_model_fallback(): def test_reseller_descriptors_and_matrix_stay_in_lockstep(): - """Together/Fireworks suggested models derive from the matrix, and each descriptor's + """Reseller suggested models derive from the matrix, and each descriptor's recommended model must be one of them (set_provider's auto-add depends on it).""" from coworker.providers.matrix import models_for_provider from coworker.providers.registry import get_descriptor - for name in ("together", "fireworks"): + for name in ("together", "fireworks", "openrouter"): d = get_descriptor(name) assert d is not None and d.needs_key curated = models_for_provider(name)