From 28772f4ebde8a176fd06a0de20b891bb60daf6ba Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Fri, 31 Jul 2026 00:51:09 -0700 Subject: [PATCH 1/3] fix(openrouter): refresh the curated catalogue against the live API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAI section still led with gpt-5 / gpt-4o / o1 while OpenRouter had moved on to the gpt-5.6 generation, and openai/o1-mini had been delisted upstream entirely — so the /model picker offered a row that fails at request time, the same "offers something you cannot select" shape as the picker bug in #772. Validating the rest against https://openrouter.ai/api/v1/models caught five more dead ids: anthropic/claude-3.5-sonnet, anthropic/claude-3.5-haiku, google/gemini-2.0-flash, meta-llama/llama-3.1-405b-instruct, deepseek/deepseek-v3.2-speciale and x-ai/grok-2. All are replaced with live successors; every id in the catalogue now resolves. Batch (:batch), image, audio and embedding variants stay out — none of them serves an interactive agent turn, and free-text ids are accepted regardless of this list. The list was also duplicated verbatim between PROVIDER_INFO["openrouter"] and OpenRouterProvider._curated_models(). Two lists for one set drift the moment someone edits one, and the halves feed different surfaces — the registry drives login and the picker, the other drives discovery's curation — so the provider now reads the registry. --- src/providers/__init__.py | 59 +++++++-- src/providers/openrouter_provider.py | 53 +++----- tests/providers/test_openrouter_catalog.py | 138 +++++++++++++++++++++ 3 files changed, 204 insertions(+), 46 deletions(-) create mode 100644 tests/providers/test_openrouter_catalog.py diff --git a/src/providers/__init__.py b/src/providers/__init__.py index 5069226f7..fce926361 100644 --- a/src/providers/__init__.py +++ b/src/providers/__init__.py @@ -55,7 +55,15 @@ class ProviderInfo(TypedDict): "default_base_url": "https://api.openai.com/v1", "default_model": "gpt-5.4", "available_models": [ - # GPT-5.5 (flagship; also served by the ChatGPT subscription) + # GPT-5.6 — the current frontier generation. Sol / Terra / Luna are + # durable capability tiers rather than a size ladder: Sol is the + # flagship, Terra balances capability against cost, Luna is the + # cheap high-volume tier. ``gpt-5.6`` is OpenAI's alias for Sol. + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + # GPT-5.5 (also served by the ChatGPT subscription) "gpt-5.5", # GPT-5.4 series "gpt-5.4", @@ -137,36 +145,67 @@ class ProviderInfo(TypedDict): "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", # Anthropic + "anthropic/claude-fable-5", "anthropic/claude-sonnet-4.5", "anthropic/claude-opus-4.1", + "anthropic/claude-opus-4", "anthropic/claude-haiku-4.5", - "anthropic/claude-3.5-sonnet", - "anthropic/claude-3.5-haiku", - # OpenAI + # OpenAI — frontier first. OpenRouter carries ~70 openai/* ids; + # this is the coding-relevant slice. The `:batch` variants (the + # asynchronous Batch API), image/audio/embedding models, and the + # `-chat` tunings are deliberately absent — none of them serves an + # interactive agent turn. Any id OpenRouter lists still works: + # free-text model ids are accepted regardless of this list. + # Sol / Terra / Luna are capability tiers, not a size ladder: + # Sol leads, Terra balances cost, Luna is the cheap high-volume + # tier. Ordered flagship-first to match the rest of the list. + "openai/gpt-5.6-sol-pro", + "openai/gpt-5.6-sol", + "openai/gpt-5.6-terra-pro", + "openai/gpt-5.6-terra", + "openai/gpt-5.6-luna-pro", + "openai/gpt-5.6-luna", + "openai/gpt-5.5-pro", + "openai/gpt-5.5", + "openai/gpt-5.4-pro", + "openai/gpt-5.4", + "openai/gpt-5.4-mini", + "openai/gpt-5.4-nano", + # Coding-specialised + "openai/gpt-5.3-codex", + "openai/gpt-5.2-codex", + "openai/gpt-5.1-codex-max", + # Reasoning (o-series) + "openai/o3-pro", + "openai/o3", + "openai/o4-mini-high", + "openai/o4-mini", + # Previous generation + "openai/gpt-5-pro", "openai/gpt-5", "openai/gpt-5-mini", + "openai/gpt-4.1", "openai/gpt-4o", "openai/gpt-4o-mini", - "openai/o1", - "openai/o1-mini", # Google "google/gemini-2.5-pro", "google/gemini-2.5-flash", - "google/gemini-2.0-flash", + "google/gemini-2.5-flash-lite", # Meta "meta-llama/llama-3.3-70b-instruct", - "meta-llama/llama-3.1-405b-instruct", + "meta-llama/llama-3.1-70b-instruct", # Mistral "mistralai/mistral-large", "mistralai/mixtral-8x22b-instruct", # DeepSeek (V3.x line — V4 is at top of list) "deepseek/deepseek-v3.2", - "deepseek/deepseek-v3.2-speciale", + "deepseek/deepseek-v3.2-exp", "deepseek/deepseek-v3.1-terminus", "deepseek/deepseek-chat-v3.1", "deepseek/deepseek-r1-0528", # xAI - "x-ai/grok-2", + "x-ai/grok-4.5", + "x-ai/grok-4.3", ], }, } diff --git a/src/providers/openrouter_provider.py b/src/providers/openrouter_provider.py index 95df7410f..de62172b3 100644 --- a/src/providers/openrouter_provider.py +++ b/src/providers/openrouter_provider.py @@ -86,39 +86,20 @@ def get_available_models(self) -> list[str]: @staticmethod def _curated_models() -> list[str]: - return [ - # DeepSeek V4 (latest, strongest — top of the list) - "deepseek/deepseek-v4-pro", - "deepseek/deepseek-v4-flash", - # Anthropic - "anthropic/claude-sonnet-4.5", - "anthropic/claude-opus-4.1", - "anthropic/claude-haiku-4.5", - "anthropic/claude-3.5-sonnet", - "anthropic/claude-3.5-haiku", - # OpenAI - "openai/gpt-5", - "openai/gpt-5-mini", - "openai/gpt-4o", - "openai/gpt-4o-mini", - "openai/o1", - "openai/o1-mini", - # Google - "google/gemini-2.5-pro", - "google/gemini-2.5-flash", - "google/gemini-2.0-flash", - # Meta - "meta-llama/llama-3.3-70b-instruct", - "meta-llama/llama-3.1-405b-instruct", - # Mistral - "mistralai/mistral-large", - "mistralai/mixtral-8x22b-instruct", - # DeepSeek (V3.x line — V4 is at top of list) - "deepseek/deepseek-v3.2", - "deepseek/deepseek-v3.2-speciale", - "deepseek/deepseek-v3.1-terminus", - "deepseek/deepseek-chat-v3.1", - "deepseek/deepseek-r1-0528", - # xAI - "x-ai/grok-2", - ] + """The curated catalogue, read from the one place it is defined. + + This used to be a byte-identical second copy of + ``PROVIDER_INFO["openrouter"]["available_models"]``. Two lists meant + for the same set drift the moment someone updates one of them, and the + halves are read by different surfaces — the registry entry feeds + ``login`` and the /model picker, this feeds discovery's curation — so + the drift shows up as a model the picker offers and discovery drops, + or the reverse. + + Imported inside the function, not at module scope: this module is + itself imported lazily by ``get_provider_class``, so a top-level import + of the package that defines that function would be circular. + """ + from . import PROVIDER_INFO + + return list(PROVIDER_INFO["openrouter"]["available_models"]) diff --git a/tests/providers/test_openrouter_catalog.py b/tests/providers/test_openrouter_catalog.py new file mode 100644 index 000000000..ef95b668e --- /dev/null +++ b/tests/providers/test_openrouter_catalog.py @@ -0,0 +1,138 @@ +"""OpenRouter's curated model catalogue. + +The list is a convenience — free-text ids are always accepted — but it is what +``login`` and the /model picker offer, so an id that OpenRouter has delisted +shows up as a row the user can select and then cannot use. That is the same +"offers something you cannot select" shape the picker fix (#772) was about. + +These tests deliberately do NOT hit the network: CI would then fail whenever a +vendor retires a model, which is a fact about the world rather than a defect in +the diff. They pin the structural invariants instead. Re-validating the ids +against the live catalogue is a maintenance step: + + curl -s https://openrouter.ai/api/v1/models \\ + | python3 -c "import json,sys; print('\\n'.join(sorted(m['id'] for m in json.load(sys.stdin)['data'])))" +""" + +from __future__ import annotations + +import pytest + +from src.providers import PROVIDER_INFO +from src.providers.openrouter_provider import OpenRouterProvider + + +CURATED = PROVIDER_INFO["openrouter"]["available_models"] + + +class TestCuratedCatalogueIsSingleSourced: + def test_provider_reads_the_registry_rather_than_a_second_copy(self): + """These were byte-identical duplicates. Two lists for one set drift the + moment someone updates one, and the halves feed different surfaces — + the registry drives login + the picker, this drives discovery's + curation — so drift surfaces as a model one offers and the other drops. + """ + assert OpenRouterProvider._curated_models() == CURATED + + def test_returns_a_copy_so_a_caller_cannot_mutate_the_registry(self): + got = OpenRouterProvider._curated_models() + got.append("mutated/by-caller") + + assert "mutated/by-caller" not in CURATED + assert OpenRouterProvider._curated_models() == CURATED + + +class TestCuratedCatalogueShape: + def test_no_duplicate_ids(self): + assert len(CURATED) == len(set(CURATED)) + + def test_the_default_model_is_one_of_the_offered_ids(self): + assert PROVIDER_INFO["openrouter"]["default_model"] in CURATED + + @pytest.mark.parametrize("model", CURATED) + def test_every_id_is_vendor_qualified(self, model): + """OpenRouter addresses models as ``/``; a bare id is a + copy-paste from another provider's list and 404s at request time.""" + assert model.count("/") == 1, model + vendor, name = model.split("/") + assert vendor and name, model + assert model == model.strip() + + @pytest.mark.parametrize("model", CURATED) + def test_no_batch_variants(self, model): + """``:batch`` ids address the asynchronous Batch API, which does not + answer an interactive agent turn.""" + assert ":batch" not in model, model + + def test_openai_section_leads_with_the_current_frontier(self): + """Guards against the failure this file was added for: the OpenAI block + sat on gpt-5/gpt-4o/o1 long after OpenRouter had moved on, and + ``openai/o1-mini`` had been delisted outright.""" + openai = [m for m in CURATED if m.startswith("openai/")] + + assert openai, "the OpenAI section vanished" + assert openai[0].startswith("openai/gpt-5.6"), openai[0] + # Delisted upstream — re-adding it would offer an unusable row. + assert "openai/o1-mini" not in openai + + def test_the_5_6_tiers_are_ordered_flagship_first(self): + """Sol / Terra / Luna are capability tiers, not a size ladder, and the + alphabetical reading puts them in exactly the wrong order — Luna is the + cheap tier, Sol is the flagship.""" + tiers = [m for m in CURATED if m.startswith("openai/gpt-5.6")] + rank = {"sol": 0, "terra": 1, "luna": 2} + seen = [rank[m.split("gpt-5.6-")[1].removesuffix("-pro")] for m in tiers] + + assert seen == sorted(seen), tiers + + +class TestOpenAIDirectCatalogue: + """The direct OpenAI provider, which is a separate list from OpenRouter's.""" + + DIRECT = PROVIDER_INFO["openai"]["available_models"] + + def test_provider_reads_the_registry_rather_than_a_second_copy(self): + from src.providers.openai_provider import OpenAIProvider + + provider = OpenAIProvider(api_key="x") + + assert provider.get_available_models() == self.DIRECT + assert provider.get_available_models() is not self.DIRECT + + def test_leads_with_the_5_6_generation(self): + assert self.DIRECT[0] == "gpt-5.6" + assert {"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"} <= set(self.DIRECT) + + def test_ids_are_bare_not_openrouter_qualified(self): + """``openai/gpt-5.6-sol`` is OpenRouter's addressing; the direct API + takes the bare id and 404s on the prefixed one.""" + for model in self.DIRECT: + assert "/" not in model, model + + +class TestGpt56ModelConfigs: + @pytest.mark.parametrize( + "model", ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] + ) + def test_carries_the_full_context_window(self, model): + """Without an entry these fell through to the 272K catch-all, firing + auto-compact roughly three quarters of a window early.""" + from src.models.configs import get_model_config + + config = get_model_config(model) + assert config is not None, model + assert config.context_window == 1_048_576, model + + def test_an_unlisted_5_6_variant_inherits_the_5_6_window(self): + from src.models.configs import get_model_config + + assert get_model_config("gpt-5.6-sol-pro").context_window == 1_048_576 + + def test_an_unknown_gpt_id_still_gets_the_conservative_catch_all(self): + """The ordering invariant. ``gpt-5.6``'s prefix base is "gpt", so + placing it above ``gpt-5.5`` would hand every unknown gpt id a 1.05M + window. Over-estimating overflows the context; under-estimating only + compacts early, so the catch-all must stay on the 272K entry.""" + from src.models.configs import get_model_config + + assert get_model_config("gpt-9-unreleased").context_window == 272_000 From 12bac30cde9bde1bdf0752cac84cf91a1273906f Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Fri, 31 Jul 2026 00:51:09 -0700 Subject: [PATCH 2/3] feat(models): GPT-5.6 (Sol / Terra / Luna) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sol, Terra and Luna are durable capability tiers on one generation rather than a size ladder: Sol is the flagship, Terra balances capability against cost, Luna is the cheap high-volume tier, and gpt-5.6 is OpenAI's alias for Sol. Offered by the direct provider and by OpenRouter, which also carries a -pro variant of each tier. Each gets a real ModelConfig (1.05M context / 128K output). Without one they resolved through get_model_config's prefix fallback to the 272K catch-all, firing auto-compact about three quarters of a window early. Placement is load-bearing. The fallback matches on key.rsplit("-", 1)[0], so the bare gpt-5.6 alias has base "gpt" and would become the catch-all for EVERY unknown gpt id if it preceded gpt-5.5 — handing them a 1.05M window. Over-estimating overflows the context; under-estimating only compacts early, so the catch-all stays on the 272K entry and the alias is registered after it. The tier keys (base "gpt-5.6") go first, which is what lets an unlisted variant like gpt-5.6-sol-pro inherit the right window. default_model stays gpt-5.4 — the default is a cost-sensible tier, not the frontier, matching Anthropic defaulting to sonnet-4-6 while listing fable-5 first. SUBSCRIPTION_MODELS is untouched: which models the ChatGPT backend serves is a wire fact to observe, not assume. OpenAIProvider.get_available_models kept a second verbatim copy of the registry list, the same drift hazard as OpenRouter's; it now reads the registry, leaving the subscription branch its own set. --- src/models/configs.py | 38 ++++++++++++++++++++++++++++++++ src/providers/openai_provider.py | 33 +++++++++------------------ 2 files changed, 48 insertions(+), 23 deletions(-) diff --git a/src/models/configs.py b/src/models/configs.py index c1c98656e..6b3450fa9 100644 --- a/src/models/configs.py +++ b/src/models/configs.py @@ -390,12 +390,50 @@ class ModelConfig: # entries so they never take that path. As with the Meta entry above, # max_output_tokens is not sent on the wire for OpenAI providers — its # live effect is the auto-compact output reservation (clamped at 20K). + # GPT-5.6 (Sol / Terra / Luna — three durable capability tiers on one + # generation, 1.05M context each). These keys sit BEFORE "gpt-5.5" + # deliberately: ``get_model_config``'s prefix fallback walks in insertion + # order and each of these has base "gpt-5.6" (rsplit on the last "-"), so + # putting them first is what lets an unlisted variant like + # ``gpt-5.6-sol-pro`` resolve to 1.05M instead of falling through to the + # 272K catch-all. The bare ``gpt-5.6`` alias is deliberately NOT here — + # see below. + "gpt-5.6-sol": ModelConfig( + model_id="gpt-5.6-sol", + display_name="GPT-5.6 Sol", + context_window=1_048_576, + max_output_tokens=128_000, + ), + "gpt-5.6-terra": ModelConfig( + model_id="gpt-5.6-terra", + display_name="GPT-5.6 Terra", + context_window=1_048_576, + max_output_tokens=128_000, + ), + "gpt-5.6-luna": ModelConfig( + model_id="gpt-5.6-luna", + display_name="GPT-5.6 Luna", + context_window=1_048_576, + max_output_tokens=128_000, + ), "gpt-5.5": ModelConfig( model_id="gpt-5.5", display_name="GPT-5.5", context_window=272_000, max_output_tokens=128_000, ), + # ``gpt-5.6`` is OpenAI's alias for Sol. Its base is "gpt" (not + # "gpt-5.6"), so it would become the catch-all for EVERY unknown gpt id if + # it preceded "gpt-5.5" — handing them a 1.05M window. Over-estimating + # overflows the context; under-estimating only compacts early, so the + # catch-all must stay on the 272K entry. Exact lookups are unaffected by + # position: ``get_model_config`` checks for an exact key first. + "gpt-5.6": ModelConfig( + model_id="gpt-5.6", + display_name="GPT-5.6 (Sol)", + context_window=1_048_576, + max_output_tokens=128_000, + ), "gpt-5.4": ModelConfig( model_id="gpt-5.4", display_name="GPT-5.4", diff --git a/src/providers/openai_provider.py b/src/providers/openai_provider.py index eb865e242..0bdf5881c 100644 --- a/src/providers/openai_provider.py +++ b/src/providers/openai_provider.py @@ -153,30 +153,17 @@ def get_available_models(self) -> list[str]: List of model names """ if self._subscription_active: + # A ChatGPT plan serves a SMALLER set than the API key does, so + # this stays its own list — see openai_responses.SUBSCRIPTION_MODELS. return list(SUBSCRIPTION_MODELS) - return [ - # GPT-5.5 (flagship; also the ChatGPT-subscription default family) - "gpt-5.5", - # GPT-5.4 series - "gpt-5.4", - "gpt-5.4-pro", - "gpt-5.4-mini", - "gpt-5.4-nano", - # GPT-5.2 series - "gpt-5.2", - "gpt-5.2-pro", - "gpt-5.2-mini", - "gpt-5.2-nano", - # Codex (coding-specialized) - "gpt-5.3-codex", - "gpt-5.3-codex-spark", - # Legacy GPT-4 series - "gpt-4o", - "gpt-4o-mini", - "gpt-4-turbo", - "gpt-4", - "gpt-3.5-turbo", - ] + # Read the registry rather than keep a second copy. This used to + # duplicate PROVIDER_INFO["openai"]["available_models"] verbatim, and + # the halves feed different surfaces — the registry drives `login` and + # the /model picker, this drives discovery — so an update to one shows + # up as a model one offers and the other drops. + from . import PROVIDER_INFO + + return list(PROVIDER_INFO["openai"]["available_models"]) # ------------------------------------------------------------------ # ChatGPT-subscription path (Responses API against the Codex backend) From b7ff42a4d068e1f68c0477333e8038aac5a48233 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Fri, 31 Jul 2026 00:51:09 -0700 Subject: [PATCH 3/3] docs(changelog): GPT-5.6 + OpenRouter catalogue refresh --- CHANGELOG.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9faff4fd..783cbdd7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,8 +51,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [claude-code-router]: https://ccrdesk.top/en/configuration/fusion-models/ +### Added + +- **GPT-5.6 (Sol / Terra / Luna).** OpenAI's current frontier generation is + three durable capability tiers on one generation rather than a size ladder: + Sol is the flagship, Terra balances capability against cost, Luna is the + cheap high-volume tier, and `gpt-5.6` is OpenAI's alias for Sol. All four are + offered by the direct OpenAI provider and by OpenRouter (which also carries + `-pro` variants of each tier). + + Each carries a real `ModelConfig` (1.05M context / 128K output). Without one + they resolved through the prefix fallback to the 272K catch-all, which fires + auto-compact roughly three quarters of a window early. The bare `gpt-5.6` + alias is deliberately registered *after* `gpt-5.5`: its prefix base is `gpt`, + so listing it first would make it the catch-all for every unknown `gpt` id + and hand them a 1.05M window — over-estimating overflows the context, while + under-estimating only compacts early. + + Not changed: `default_model` stays `gpt-5.4`, matching how Anthropic defaults + to `claude-sonnet-4-6` while listing `claude-fable-5` first — the default is a + cost-sensible tier, not the frontier. The ChatGPT-subscription allow-list + (`SUBSCRIPTION_MODELS`) is also untouched, since which models that backend + serves is a wire fact that has to be observed rather than assumed. + ### Fixed +- **OpenRouter's curated model list offered ids OpenRouter had delisted.** The + OpenAI section still led with `openai/gpt-5` / `openai/gpt-4o` / `openai/o1` + while the gateway had moved on to the `gpt-5.6` family, and + `openai/o1-mini` had been removed upstream entirely — so the /model picker + offered a row that fails at request time, the same "offers something you + cannot select" shape as the picker bug above. Refreshed against + `https://openrouter.ai/api/v1/models`: the OpenAI block now leads with + `gpt-5.6-luna/terra/sol` (and their `-pro` tiers), keeps the codex line and + the o-series, and drops the delisted ids. Batch (`:batch`), image, audio, + and embedding variants are deliberately excluded — none of them serves an + interactive agent turn. + + Validating the rest of the list caught five more dead ids: + `anthropic/claude-3.5-sonnet`, `anthropic/claude-3.5-haiku`, + `google/gemini-2.0-flash`, `meta-llama/llama-3.1-405b-instruct`, + `deepseek/deepseek-v3.2-speciale`, and `x-ai/grok-2`. All are replaced with + live successors; every id in the catalogue now resolves. + + The list had also been duplicated verbatim between + `PROVIDER_INFO["openrouter"]` (which feeds `login` and the picker) and + `OpenRouterProvider._curated_models()` (which feeds discovery's curation). + Two lists for one set drift the moment someone edits one of them, and the + drift reads as a model one surface offers and the other drops, so the + provider now reads the registry. + - **`/model` listed one provider instead of every configured one.** Step 1 of the picker showed a single row — `anthropic · 22 models` — no matter how many providers were set up. `model.options` was a stub: it called the