mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-06-16 21:06:15 +03:00
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.
This commit is contained in:
89
cli/utils.py
89
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
|
||||
|
||||
122
tests/test_openrouter_model_select.py
Normal file
122
tests/test_openrouter_model_select.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user