Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 55 additions & 25 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7271,7 +7271,16 @@ def _build_provider_select(self):
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
Expand All @@ -7280,31 +7289,45 @@ def _build_model_select(self, provider_slug: str):
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"
Expand Down Expand Up @@ -7369,8 +7392,15 @@ async def _on_provider_selected(self, interaction: discord.Interaction):

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(
Expand Down
157 changes: 157 additions & 0 deletions tests/gateway/test_discord_model_picker_partition.py
Original file line number Diff line number Diff line change
@@ -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