fix(discord): render provider model lists >25 options across multiple select menus
The Discord /model picker built a single discord.ui.Select filled with models[:25], silently dropping any models beyond the first 25. Discord caps a single select at 25 options but allows up to 5 component rows, so partition the list across up to 3 select menus (25 each; Back/Cancel use the other 2) instead of truncating. This fixes providers like Nous (curated list + Portal recommendations exceed 25) whose tail — including free-tier :free Portal picks — was previously clipped on Discord while showing fine in the Portal UI / CLI. - _build_model_select: slice into <=25-option chunks, one select per chunk (custom_id model_model_select_<i>), all via _on_model_selected. Multi-row menus get a (n/total) placeholder suffix. - _on_provider_selected: 'N more available' count reflects models actually rendered across the partitioned menus. - Add regression test covering the 37-model Nous case (no truncation/dupes, per-menu 25 cap holds).
This commit is contained in:
@@ -9224,7 +9224,16 @@ def _define_discord_view_classes() -> None:
|
||||
self.add_item(cancel_btn)
|
||||
|
||||
def _build_model_select(self, provider_slug: str):
|
||||
"""Build the model dropdown for a specific provider."""
|
||||
"""Build the model dropdown(s) for a specific provider.
|
||||
|
||||
Discord caps each ``discord.ui.Select`` at 25 options and a View at
|
||||
5 action rows. We keep 2 rows for Back/Cancel, so partition the
|
||||
model list across up to 3 select menus (75 slots) instead of
|
||||
truncating at 25. This matters for providers like Nous whose
|
||||
curated + Portal free-recommendation list exceeds 25 entries — the
|
||||
tail (typically the ``:free`` Portal picks) was previously dropped
|
||||
on Discord, so free-tier models never surfaced there.
|
||||
"""
|
||||
self.clear_items()
|
||||
provider = next(
|
||||
(p for p in self.providers if p["slug"] == provider_slug), None
|
||||
@@ -9233,31 +9242,45 @@ def _define_discord_view_classes() -> None:
|
||||
return
|
||||
|
||||
models = provider.get("models", [])
|
||||
options = []
|
||||
for model_id in models[:25]:
|
||||
short = model_id.split("/")[-1] if "/" in model_id else model_id
|
||||
options.append(
|
||||
discord.SelectOption(
|
||||
label=_truncate_discord_component_text(
|
||||
short,
|
||||
_DISCORD_SELECT_FIELD_LIMIT,
|
||||
),
|
||||
value=_truncate_discord_component_text(
|
||||
model_id,
|
||||
_DISCORD_SELECT_FIELD_LIMIT,
|
||||
),
|
||||
)
|
||||
)
|
||||
if not options:
|
||||
if not models:
|
||||
return
|
||||
|
||||
select = discord.ui.Select(
|
||||
placeholder=f"Choose a model from {provider.get('name', provider_slug)}...",
|
||||
options=options,
|
||||
custom_id="model_model_select",
|
||||
)
|
||||
select.callback = self._on_model_selected
|
||||
self.add_item(select)
|
||||
# Slice the model list into chunks of <= 25 across (up to) 3 select
|
||||
# rows. Stash the full partitioned list so the count message in
|
||||
# _on_provider_selected can report how many are actually shown.
|
||||
chunks = [
|
||||
models[i : i + 25] for i in range(0, len(models), 25)
|
||||
][:3]
|
||||
self._model_chunks = chunks
|
||||
|
||||
total_rows = len(chunks) + 2 # + Back/Cancel buttons
|
||||
placeholder_base = f"Choose a model from {provider.get('name', provider_slug)}"
|
||||
for idx, chunk in enumerate(chunks):
|
||||
options = []
|
||||
for model_id in chunk:
|
||||
short = model_id.split("/")[-1] if "/" in model_id else model_id
|
||||
options.append(
|
||||
discord.SelectOption(
|
||||
label=_truncate_discord_component_text(
|
||||
short,
|
||||
_DISCORD_SELECT_FIELD_LIMIT,
|
||||
),
|
||||
value=_truncate_discord_component_text(
|
||||
model_id,
|
||||
_DISCORD_SELECT_FIELD_LIMIT,
|
||||
),
|
||||
)
|
||||
)
|
||||
suffix = f" ({idx + 1}/{len(chunks)})" if len(chunks) > 1 else ""
|
||||
select = discord.ui.Select(
|
||||
placeholder=f"{placeholder_base}{suffix}...",
|
||||
options=options,
|
||||
custom_id=f"model_model_select_{idx}",
|
||||
)
|
||||
# All model selects resolve through the same handler — the
|
||||
# selected value is the model id, identical across rows.
|
||||
select.callback = self._on_model_selected
|
||||
self.add_item(select)
|
||||
|
||||
back_btn = discord.ui.Button(
|
||||
label="◀ Back", style=discord.ButtonStyle.grey, custom_id="model_back"
|
||||
@@ -9322,8 +9345,15 @@ def _define_discord_view_classes() -> None:
|
||||
|
||||
self._build_model_select(provider_slug)
|
||||
|
||||
# `shown` counts models actually rendered across the partitioned
|
||||
# select menus (up to 3×25 = 75); older code hard-capped at 25 and
|
||||
# silently dropped the tail (e.g. Nous `:free` Portal picks).
|
||||
total = provider.get("total_models", 0) if provider else 0
|
||||
shown = min(len(provider.get("models", [])), 25) if provider else 0
|
||||
chunks = getattr(self, "_model_chunks", None)
|
||||
if chunks is not None:
|
||||
shown = sum(len(c) for c in chunks)
|
||||
else:
|
||||
shown = min(len(provider.get("models", [])), 25) if provider else 0
|
||||
extra = f"\n*{total - shown} more available — type `/model <name>` directly*" if total > shown else ""
|
||||
|
||||
await interaction.response.edit_message(
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Regression test: Discord /model picker must surface ALL models for a
|
||||
provider whose list exceeds 25 entries (e.g. Nous curated + Portal free
|
||||
recommendations), not silently truncate the tail at 25 options.
|
||||
|
||||
Discord caps a single Select at 25 options and a View at 5 action rows. The
|
||||
picker keeps 2 rows for Back/Cancel, so it must partition the model list
|
||||
across up to 3 select menus. This is what makes free-tier ``:free`` Portal
|
||||
picks (appended after the curated list) appear on Discord — they previously
|
||||
fell off the 25-option cliff.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from gateway.platforms.base import utf16_len
|
||||
from plugins.platforms.discord.adapter import ModelPickerView
|
||||
|
||||
|
||||
def _all_options(view: "ModelPickerView"):
|
||||
"""Flatten every model select menu's options into (label, value).
|
||||
|
||||
Detect selects by their ``model_model_select*`` custom_id (and presence of
|
||||
``.options``) rather than class name — the discord mock in conftest uses a
|
||||
``_FakeSelect`` class, while the real library uses ``discord.ui.Select``.
|
||||
"""
|
||||
out = []
|
||||
for child in view.children:
|
||||
custom_id = getattr(child, "custom_id", "")
|
||||
if isinstance(custom_id, str) and custom_id.startswith("model_model_select"):
|
||||
out.extend((opt.label, opt.value) for opt in getattr(child, "options", []))
|
||||
return out
|
||||
|
||||
|
||||
def test_nous_free_models_render_across_partitioned_selects():
|
||||
# 37 models: 32 curated + 5 free Portal recommendations appended at the tail
|
||||
# (the real-world shape that was getting clipped at 25 on Discord).
|
||||
models = [
|
||||
"anthropic/claude-fable-5",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-sonnet-5",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
"openai/gpt-5.6-sol",
|
||||
"openai/gpt-5.6-sol-pro",
|
||||
"openai/gpt-5.6-terra",
|
||||
"openai/gpt-5.6-terra-pro",
|
||||
"openai/gpt-5.6-luna",
|
||||
"openai/gpt-5.6-luna-pro",
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.5-pro",
|
||||
"openai/gpt-5.4-mini",
|
||||
"google/gemini-3-pro-preview",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"google/gemini-3.5-flash",
|
||||
"x-ai/grok-4.5",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
"qwen/qwen3.7-max",
|
||||
"qwen/qwen3.7-plus",
|
||||
"qwen/qwen3.6-35b-a3b",
|
||||
"moonshotai/kimi-k2.6",
|
||||
"moonshotai/kimi-k2.7-code",
|
||||
"minimax/minimax-m3",
|
||||
"z-ai/glm-5.2",
|
||||
"z-ai/glm-5.1",
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
"tencent/hy3",
|
||||
"stepfun/step-3.7-flash",
|
||||
"nvidia/nemotron-3-super-120b-a12b",
|
||||
"sakana/fugu-ultra",
|
||||
# --- Portal free recommendations (the tail that was dropped) ---
|
||||
"tencent/hy3:free",
|
||||
"poolside/laguna-s-2.1:free",
|
||||
"inclusionai/ling-3.0-flash:free",
|
||||
"stepfun/step-3.7-flash:free",
|
||||
"poolside/laguna-xs-2.1:free",
|
||||
]
|
||||
assert len(models) == 37
|
||||
|
||||
view = ModelPickerView(
|
||||
providers=[
|
||||
{
|
||||
"slug": "nous",
|
||||
"name": "Nous Portal",
|
||||
"models": list(models),
|
||||
"total_models": len(models),
|
||||
"is_current": True,
|
||||
}
|
||||
],
|
||||
current_model="tencent/hy3",
|
||||
current_provider="nous",
|
||||
session_key="session-1",
|
||||
on_model_selected=lambda *a, **k: None,
|
||||
allowed_user_ids={"123"},
|
||||
)
|
||||
view._selected_provider = "nous"
|
||||
view._build_model_select("nous")
|
||||
|
||||
# The tail must render — this is the regression that was failing before
|
||||
# multi-select partitioning.
|
||||
rendered = [v for _label, v in _all_options(view)]
|
||||
assert "tencent/hy3:free" in rendered
|
||||
assert "stepfun/step-3.7-flash:free" in rendered
|
||||
assert "poolside/laguna-s-2.1:free" in rendered
|
||||
assert "inclusionai/ling-3.0-flash:free" in rendered
|
||||
assert "poolside/laguna-xs-2.1:free" in rendered
|
||||
|
||||
# Every model must appear exactly once (no truncation, no dupes).
|
||||
assert sorted(rendered) == sorted(models)
|
||||
assert len(rendered) == 37
|
||||
|
||||
# 37 models => 2 select rows of 25 + 12, plus Back/Cancel = 4 action rows.
|
||||
select_rows = [
|
||||
c for c in view.children
|
||||
if isinstance(getattr(c, "custom_id", ""), str)
|
||||
and c.custom_id.startswith("model_model_select")
|
||||
]
|
||||
assert len(select_rows) == 2
|
||||
assert len(select_rows[0].options) == 25
|
||||
assert len(select_rows[1].options) == 12
|
||||
|
||||
# Each option still respects Discord's 100-char utf16 field limit.
|
||||
for _label, value in _all_options(view):
|
||||
assert utf16_len(value) <= 100
|
||||
|
||||
# No single select exceeds Discord's 25-option hard cap.
|
||||
for sel in select_rows:
|
||||
assert len(sel.options) <= 25
|
||||
|
||||
|
||||
def test_small_provider_single_select_unchanged():
|
||||
"""A <25-model provider still renders as a single select menu."""
|
||||
models = [f"m/{i}" for i in range(10)]
|
||||
view = ModelPickerView(
|
||||
providers=[
|
||||
{
|
||||
"slug": "emoji",
|
||||
"name": "Emoji",
|
||||
"models": models,
|
||||
"total_models": len(models),
|
||||
"is_current": False,
|
||||
}
|
||||
],
|
||||
current_model="m/0",
|
||||
current_provider="emoji",
|
||||
session_key="session-1",
|
||||
on_model_selected=lambda *a, **k: None,
|
||||
allowed_user_ids={"123"},
|
||||
)
|
||||
view._selected_provider = "emoji"
|
||||
view._build_model_select("emoji")
|
||||
|
||||
select_rows = [
|
||||
c for c in view.children
|
||||
if isinstance(getattr(c, "custom_id", ""), str)
|
||||
and c.custom_id.startswith("model_model_select")
|
||||
]
|
||||
assert len(select_rows) == 1
|
||||
assert len(select_rows[0].options) == 10
|
||||
Reference in New Issue
Block a user