From c15200dc286b66abce3f1bcf09b298dc06b8539d Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sun, 14 Jun 2026 18:49:02 +0000 Subject: [PATCH] fix(cli): label OpenRouter prompts and shortlist mainstream models Label each OpenRouter model prompt by mode (quick/deep) like the other providers, so the two consecutive selections are distinguishable. Populate the dropdown with the newest models from mainstream chat providers rather than the universal-newest (which surfaced niche/experimental releases); Custom ID still reaches anything. Cancelled required prompts now exit cleanly instead of crashing, and the output-language prompt falls back to English. --- cli/utils.py | 89 ++++++++++++++----- tests/test_openrouter_model_select.py | 122 ++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 tests/test_openrouter_model_select.py diff --git a/cli/utils.py b/cli/utils.py index bdca55a45..da8524d2d 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -196,6 +196,19 @@ def select_research_depth() -> int: return choice +# Mainstream OpenRouter chat-LLM provider namespaces. We surface the newest +# models from these rather than the universal-newest, which is dominated by +# niche/experimental releases. These are the general-purpose chat providers; +# more enterprise/specialised namespaces (nvidia, cohere, amazon, ...) tend to +# ship research/safety variants as their newest, so they're left out of the +# shortlist. Provider names are stable (unlike model IDs), so this rarely needs +# touching; anything not here is still reachable via Custom ID. +_OPENROUTER_MAINSTREAM = { + "openai", "anthropic", "google", "deepseek", "qwen", "mistralai", + "meta-llama", "x-ai", "z-ai", "minimax", "moonshotai", +} + + def _fetch_openrouter_models() -> list[tuple[str, str]]: """Fetch available models from the OpenRouter API.""" import requests @@ -203,21 +216,54 @@ def _fetch_openrouter_models() -> list[tuple[str, str]]: resp = requests.get("https://openrouter.ai/api/v1/models", timeout=10) resp.raise_for_status() models = resp.json().get("data", []) + # Newest first so the top-N shown really is the latest available — the + # API currently returns this order, but sort explicitly so the prompt's + # "latest available" label holds regardless of response ordering. + models.sort(key=lambda m: m.get("created") or 0, reverse=True) return [(m.get("name") or m["id"], m["id"]) for m in models] except Exception as e: console.print(f"\n[yellow]Could not fetch OpenRouter models: {e}[/yellow]") return [] -def select_openrouter_model() -> str: - """Select an OpenRouter model from the newest available, or enter a custom ID.""" - models = _fetch_openrouter_models() +def _require_text(message: str, hint: str) -> str: + """Prompt for a required value; exit cleanly if the user cancels. - choices = [questionary.Choice(name, value=mid) for name, mid in models[:5]] + ``questionary.text(...).ask()`` returns None on Ctrl-C/Esc; mirror the + exit-on-cancel behavior of the other required selections so a cancelled + prompt never returns an empty model/deployment that would fail downstream. + """ + response = questionary.text( + message, + validate=lambda x: len(x.strip()) > 0 or hint, + ).ask() + if response is None: + console.print("\n[red]Cancelled. Exiting...[/red]") + exit(1) + return response.strip() + + +def select_openrouter_model(mode: str) -> str: + """Select an OpenRouter model from the newest available, or enter a custom ID. + + ``mode`` ("quick"/"deep") labels the prompt so the two consecutive + OpenRouter selections are distinguishable, like the other providers (#1000). + """ + models = _fetch_openrouter_models() # newest first + # Prefer the newest from mainstream providers so the shortlist isn't crowded + # out by niche/experimental releases; fall back to all if none match. + mainstream = [ + (name, mid) for name, mid in models + if not mid.startswith("~") # skip variant/alias duplicate routes + and mid.split("/", 1)[0] in _OPENROUTER_MAINSTREAM + ] + top = (mainstream or models)[:5] + + choices = [questionary.Choice(name, value=mid) for name, mid in top] choices.append(questionary.Choice("Custom model ID", value="custom")) choice = questionary.select( - "Select OpenRouter Model (latest available):", + f"Select Your [{mode.title()}-Thinking] OpenRouter Model (latest available):", choices=choices, instruction="\n- Use arrow keys to navigate\n- Press Enter to select", style=questionary.Style([ @@ -227,33 +273,32 @@ def select_openrouter_model() -> str: ]), ).ask() - if choice is None or choice == "custom": - return questionary.text( + if choice is None: + console.print("\n[red]No model selected. Exiting...[/red]") + exit(1) + if choice == "custom": + return _require_text( "Enter OpenRouter model ID (e.g. google/gemma-4-26b-a4b-it):", - validate=lambda x: len(x.strip()) > 0 or "Please enter a model ID.", - ).ask().strip() - + "Please enter a model ID.", + ) return choice def _prompt_custom_model_id() -> str: """Prompt user to type a custom model ID.""" - return questionary.text( - "Enter model ID:", - validate=lambda x: len(x.strip()) > 0 or "Please enter a model ID.", - ).ask().strip() + return _require_text("Enter model ID:", "Please enter a model ID.") def _select_model(provider: str, mode: str) -> str: """Select a model for the given provider and mode (quick/deep).""" if provider.lower() == "openrouter": - return select_openrouter_model() + return select_openrouter_model(mode) if provider.lower() == "azure": - return questionary.text( + return _require_text( f"Enter Azure deployment name ({mode}-thinking):", - validate=lambda x: len(x.strip()) > 0 or "Please enter a deployment name.", - ).ask().strip() + "Please enter a deployment name.", + ) choice = questionary.select( f"Select Your [{mode.title()}-Thinking LLM Engine]:", @@ -630,10 +675,14 @@ def ask_output_language() -> str: ]), ).ask() + # Output language has a sensible default, so a cancel falls back to English + # rather than exiting the run (unlike the required model/provider prompts). + if choice is None: + return "English" if choice == "custom": - return questionary.text( + return (questionary.text( "Enter language name (e.g. Turkish, Vietnamese, Thai, Indonesian):", validate=lambda x: len(x.strip()) > 0 or "Please enter a language name.", - ).ask().strip() + ).ask() or "").strip() or "English" return choice diff --git a/tests/test_openrouter_model_select.py b/tests/test_openrouter_model_select.py new file mode 100644 index 000000000..7ca10f07e --- /dev/null +++ b/tests/test_openrouter_model_select.py @@ -0,0 +1,122 @@ +"""OpenRouter model selection: prompts are labeled by mode (#1000); required +prompts exit cleanly on cancel; the output-language prompt defaults to English +on cancel; and the OpenRouter list is newest-first.""" + +from unittest import mock + +import pytest + +from cli import utils + + +def _asks(value): + return mock.Mock(ask=mock.Mock(return_value=value)) + + +@pytest.mark.unit +class TestOpenRouterPromptLabel: + @pytest.mark.parametrize("mode,label", [("quick", "Quick-Thinking"), ("deep", "Deep-Thinking")]) + def test_prompt_states_the_mode(self, mode, label): + captured = {} + + def fake_select(message, **kwargs): + captured["message"] = message + return _asks("openrouter/some-model") + + with mock.patch.object(utils, "_fetch_openrouter_models", + return_value=[("Some Model", "openrouter/some-model")]), \ + mock.patch.object(utils.questionary, "select", side_effect=fake_select): + out = utils.select_openrouter_model(mode) + + assert label in captured["message"] + assert out == "openrouter/some-model" + + +@pytest.mark.unit +class TestOpenRouterLatestFirst: + def test_models_sorted_newest_first(self): + payload = {"data": [ + {"id": "old/model", "name": "Old", "created": 1000}, + {"id": "new/model", "name": "New", "created": 3000}, + {"id": "mid/model", "name": "Mid", "created": 2000}, + ]} + resp = mock.Mock() + resp.json.return_value = payload + resp.raise_for_status = mock.Mock() + with mock.patch("requests.get", return_value=resp): + out = utils._fetch_openrouter_models() + assert [mid for _, mid in out] == ["new/model", "mid/model", "old/model"] + + +@pytest.mark.unit +class TestMainstreamFilter: + def test_dropdown_prefers_mainstream_over_niche(self): + # _fetch returns newest-first; the shortlist should drop niche namespaces. + models = [ + ("Fusion", "openrouter/fusion"), + ("Niche", "nex-agi/nex-n2-pro:free"), + ("Claude", "anthropic/claude-x"), + ("GPT", "openai/gpt-x"), + ] + captured = {} + + def fake_select(message, **kwargs): + captured["values"] = [c.value for c in kwargs["choices"]] + return _asks("anthropic/claude-x") + + with mock.patch.object(utils, "_fetch_openrouter_models", return_value=models), \ + mock.patch.object(utils.questionary, "select", side_effect=fake_select): + utils.select_openrouter_model("quick") + + assert "anthropic/claude-x" in captured["values"] + assert "openai/gpt-x" in captured["values"] + assert "openrouter/fusion" not in captured["values"] + assert "nex-agi/nex-n2-pro:free" not in captured["values"] + assert "custom" in captured["values"] # escape hatch preserved + + def test_falls_back_to_all_when_no_mainstream(self): + models = [("Niche", "nex-agi/x"), ("Other", "thedrummer/y")] + captured = {} + + def fake_select(message, **kwargs): + captured["values"] = [c.value for c in kwargs["choices"]] + return _asks("nex-agi/x") + + with mock.patch.object(utils, "_fetch_openrouter_models", return_value=models), \ + mock.patch.object(utils.questionary, "select", side_effect=fake_select): + utils.select_openrouter_model("deep") + + assert "nex-agi/x" in captured["values"] # fallback keeps the list usable + + +@pytest.mark.unit +class TestCancelExitsCleanly: + def test_dropdown_cancel_exits(self): + with mock.patch.object(utils, "_fetch_openrouter_models", return_value=[]), \ + mock.patch.object(utils.questionary, "select", return_value=_asks(None)), \ + pytest.raises(SystemExit): + utils.select_openrouter_model("quick") + + def test_custom_id_cancel_exits(self): + with mock.patch.object(utils, "_fetch_openrouter_models", return_value=[]), \ + mock.patch.object(utils.questionary, "select", return_value=_asks("custom")), \ + mock.patch.object(utils.questionary, "text", return_value=_asks(None)), \ + pytest.raises(SystemExit): + utils.select_openrouter_model("deep") + + def test_prompt_custom_model_id_cancel_exits(self): + with mock.patch.object(utils.questionary, "text", return_value=_asks(None)), \ + pytest.raises(SystemExit): + utils._prompt_custom_model_id() + + +@pytest.mark.unit +class TestLanguageDefaultsToEnglish: + def test_select_cancel_defaults_english(self): + with mock.patch.object(utils.questionary, "select", return_value=_asks(None)): + assert utils.ask_output_language() == "English" + + def test_custom_language_cancel_defaults_english(self): + with mock.patch.object(utils.questionary, "select", return_value=_asks("custom")), \ + mock.patch.object(utils.questionary, "text", return_value=_asks(None)): + assert utils.ask_output_language() == "English"