From 4359ed4feecab112bbe42927ea81cc93a67cb0b3 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Thu, 10 Sep 2026 22:30:44 -0300 Subject: [PATCH 1/4] fix(onboarding): use OpenRouter namespace z-ai/ for Z.AI models (#7514) --- api/onboarding.py | 31 ++++- ...test_issue7514_openrouter_zai_namespace.py | 114 ++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue7514_openrouter_zai_namespace.py diff --git a/api/onboarding.py b/api/onboarding.py index cfff9ea2..f09651c4 100644 --- a/api/onboarding.py +++ b/api/onboarding.py @@ -35,6 +35,34 @@ from api.workspace import get_last_workspace, load_workspaces logger = logging.getLogger(__name__) +# ── OpenRouter namespace translation (#7514) ──────────────────────────────── +# +# ``_FALLBACK_MODELS`` (api/config.py) is authored for the *direct* provider +# endpoints, so its Z.AI entries carry the provider's own ``zai/`` prefix. +# OpenRouter serves the same models under its canonical ``z-ai/`` slug, so +# projecting that list into the OpenRouter setup verbatim handed the wizard +# ids that 404 on openrouter.ai (``zai/glm-5.3`` instead of +# ``z-ai/glm-5.3``). Translate at this projection boundary only — the +# fallback catalog and the direct ``zai`` setup keep their own namespace. +# +# Every other namespace in the fallback list is already OpenRouter-canonical: +# openai/, anthropic/, google/, deepseek/, qwen/, x-ai/, mistralai/, minimax/, +# openrouter/, tencent/, nvidia/, arcee-ai/ — hence a single mapping entry. +_OPENROUTER_NAMESPACE_MAP = {"zai/": "z-ai/"} + + +def _to_openrouter_namespace(model_id: str) -> str: + """Return *model_id* with direct-provider prefixes mapped to OpenRouter's. + + Ids whose namespace already matches OpenRouter (or that carry no + namespace) are returned unchanged. + """ + for direct_prefix, openrouter_prefix in _OPENROUTER_NAMESPACE_MAP.items(): + if model_id.startswith(direct_prefix): + return openrouter_prefix + model_id[len(direct_prefix):] + return model_id + + _SUPPORTED_PROVIDER_SETUPS = { # ── Easy start ────────────────────────────────────────────────────── "openrouter": { @@ -43,7 +71,8 @@ _SUPPORTED_PROVIDER_SETUPS = { "default_model": "anthropic/claude-sonnet-4.6", "requires_base_url": False, "models": [ - {"id": model["id"], "label": model["label"]} for model in _FALLBACK_MODELS + {"id": _to_openrouter_namespace(model["id"]), "label": model["label"]} + for model in _FALLBACK_MODELS ], "category": "easy_start", "quick": True, diff --git a/tests/test_issue7514_openrouter_zai_namespace.py b/tests/test_issue7514_openrouter_zai_namespace.py new file mode 100644 index 00000000..204fec60 --- /dev/null +++ b/tests/test_issue7514_openrouter_zai_namespace.py @@ -0,0 +1,114 @@ +"""Regression tests for #7514: OpenRouter onboarding must serve Z.AI models +under OpenRouter's canonical ``z-ai/`` namespace, not the direct provider's +``zai/`` namespace. + +``_FALLBACK_MODELS`` (api/config.py) is authored for the *direct* provider +endpoints, and its Z.AI entries use that provider's own ``zai/`` prefix. +The OpenRouter onboarding setup reused the list verbatim, so the wizard +offered ids such as ``zai/glm-5.3`` — which 404 on openrouter.ai, where the +canonical slug is ``z-ai/glm-5.3``. + +These tests pin the boundary: + + 1. The ``openrouter`` setup translates ``zai/`` → ``z-ai/`` and never + serves a ``zai/`` id. + 2. The translation is namespace-only: same models, same order, same labels. + 3. The direct ``zai`` setup keeps ``zai/`` (it talks to the Z.AI endpoint, + not to OpenRouter). + 4. ``_FALLBACK_MODELS`` itself is never rewritten — other consumers still + see the direct-provider namespace. +""" + +from __future__ import annotations + +import api.config as config +import api.onboarding as onboarding + + +def _models(provider_id: str) -> list[dict]: + return onboarding._SUPPORTED_PROVIDER_SETUPS[provider_id]["models"] + + +def _ids(provider_id: str) -> list[str]: + return [model["id"] for model in _models(provider_id)] + + +def _expected_openrouter_id(model_id: str) -> str: + """Independent reference implementation of the namespace translation.""" + if model_id.startswith("zai/"): + return "z-ai/" + model_id[len("zai/") :] + return model_id + + +def test_openrouter_setup_never_serves_direct_zai_namespace(): + served = [model_id for model_id in _ids("openrouter") if model_id.startswith("zai/")] + assert served == [], ( + "OpenRouter onboarding served ids from the direct-provider namespace; " + f"these 404 on the OpenRouter API: {served}" + ) + + +def test_openrouter_setup_exposes_z_ai_models(): + ids = _ids("openrouter") + assert "z-ai/glm-5.3" in ids + assert "z-ai/glm-5.3-flash" in ids + + expected = { + _expected_openrouter_id(model["id"]) + for model in config._FALLBACK_MODELS + if model["id"].startswith("zai/") + } + assert expected, "expected the fallback catalog to carry Z.AI models" + assert expected.issubset(set(ids)), ( + "every Z.AI model from the fallback catalog must be offered under the " + f"OpenRouter namespace; missing: {sorted(expected - set(ids))}" + ) + + +def test_openrouter_projection_is_namespace_only(): + """The OpenRouter list stays a 1:1, order-preserving projection.""" + fallback = config._FALLBACK_MODELS + openrouter = _models("openrouter") + + assert len(openrouter) == len(fallback) + for source, projected in zip(fallback, openrouter): + assert projected["label"] == source["label"] + assert projected["id"] == _expected_openrouter_id(source["id"]) + + +def test_direct_zai_setup_keeps_direct_provider_ids(): + """The direct Z.AI setup talks to the Z.AI endpoint (base_url is set on the + setup), so it keeps its own bare provider ids and must not inherit the + OpenRouter translation.""" + ids = _ids("zai") + assert ids == [model["id"] for model in config._PROVIDER_MODELS["zai"]] + assert "glm-5.3" in ids + assert [model_id for model_id in ids if model_id.startswith("z-ai/")] == [] + + +def test_fallback_models_are_untouched(): + ids = [model["id"] for model in config._FALLBACK_MODELS] + assert "zai/glm-5.3" in ids + assert [model_id for model_id in ids if model_id.startswith("z-ai/")] == [] + + +def test_namespace_helper_is_a_noop_for_other_namespaces_and_bare_ids(): + for model_id in ( + "anthropic/claude-sonnet-4.6", + "x-ai/grok-4.20", + "minimax/MiniMax-M3", + "openrouter/elephant-alpha", + "gpt-4o", + ): + assert onboarding._to_openrouter_namespace(model_id) == model_id + assert onboarding._to_openrouter_namespace("zai/glm-5.3") == "z-ai/glm-5.3" + + +def test_setup_catalog_serves_the_translated_namespace(): + """The payload the wizard consumes carries the OpenRouter namespace.""" + catalog = onboarding._build_setup_catalog({}) + entries = {provider["id"]: provider for provider in catalog["providers"]} + ids = [model["id"] for model in entries["openrouter"]["models"]] + + assert [model_id for model_id in ids if model_id.startswith("zai/")] == [] + assert "z-ai/glm-5.3" in ids From 4f109aebbe2c72b9201959952cbaebec42599834 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Thu, 10 Sep 2026 22:31:51 -0300 Subject: [PATCH 2/4] fix(settings): preserve configured aux provider when absent from catalog (#7486) --- static/panels.js | 13 +- tests/test_auxiliary_models_settings.py | 274 ++++++++++++++++++++++++ 2 files changed, 286 insertions(+), 1 deletion(-) diff --git a/static/panels.js b/static/panels.js index c33bcdce..29e11344 100644 --- a/static/panels.js +++ b/static/panels.js @@ -12315,12 +12315,23 @@ function _buildAuxProviderOptions(sel,providers,currentProvider){ autoOpt.value='auto';autoOpt.textContent='auto ('+t('settings_aux_provider_auto')+')'; if(currentProvider==='auto'||!currentProvider) autoOpt.selected=true; sel.appendChild(autoOpt); + let matched=currentProvider==='auto'||!currentProvider; for(const p of providers){ const opt=document.createElement('option'); opt.value=p.slug;opt.textContent=p.name; - if(p.slug===currentProvider) opt.selected=true; + if(p.slug===currentProvider){opt.selected=true;matched=true;} sel.appendChild(opt); } + // The configured provider can be absent from the /api/models catalog (e.g. its + // group exposes no models). Keep it selectable: with no matching option the + // select falls back to its first entry ('auto') and the next Apply would + // persist that, silently discarding the configured value. + if(!matched&¤tProvider){ + const configuredOpt=document.createElement('option'); + configuredOpt.value=currentProvider;configuredOpt.textContent=currentProvider+' (configured)'; + configuredOpt.selected=true; + sel.appendChild(configuredOpt); + } } function _buildAuxModelOptions(sel,provider,providers,currentModel){ diff --git a/tests/test_auxiliary_models_settings.py b/tests/test_auxiliary_models_settings.py index 98c73579..513377ee 100644 --- a/tests/test_auxiliary_models_settings.py +++ b/tests/test_auxiliary_models_settings.py @@ -312,6 +312,280 @@ console.log(JSON.stringify({ "Missing _buildAuxModelOptions() for model dropdown rebuild" ) + @pytest.mark.skipif(NODE is None, reason="node not on PATH") + def test_configured_aux_provider_absent_from_catalog_is_preserved(self): + """#7486: a configured provider missing from /api/models must survive Apply. + + The aux provider /