From ab3e2f563b7b7cf36723b3fdffedf41e05b76779 Mon Sep 17 00:00:00 2001 From: Epic Date: Sat, 25 Jul 2026 00:49:11 +0000 Subject: [PATCH] fix(discord): render provider model lists >25 options across multiple select menus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_), 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). --- plugins/platforms/discord/adapter.py | 80 ++++++--- .../test_discord_model_picker_partition.py | 157 ++++++++++++++++++ 2 files changed, 212 insertions(+), 25 deletions(-) create mode 100644 tests/gateway/test_discord_model_picker_partition.py diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index ad1a6ab119..71d6f0c06d 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -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 ` directly*" if total > shown else "" await interaction.response.edit_message( diff --git a/tests/gateway/test_discord_model_picker_partition.py b/tests/gateway/test_discord_model_picker_partition.py new file mode 100644 index 0000000000..53b0a52738 --- /dev/null +++ b/tests/gateway/test_discord_model_picker_partition.py @@ -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