fix(model-menu): resolve per-chat backend so /model honours overrides - #215
Merged
dylanneve1 merged 2 commits intoMay 19, 2026
Merged
Conversation
The /model command and its callbacks read `gateway.backend` β the
global chat-role backend β which broke two things once per-chat
backend overrides shipped via the pool refactor:
1. A chat that switched to `openai-agents` to use OpenRouter saw
the *Claude* catalog in /model instead of OpenRouter's, because
the menu was always rendered against the global default.
2. The free-only toggle, gated on `freeCount > 0`, never appeared
because the global default's catalog was queried β Claude has no
free models, so `freeCount` was always 0.
Plus `fetchEndpointModels` was fire-and-forget, so the very first
/model render after a backend switch could race the `/models` HTTP
call and show an empty catalog before the network returned.
Changes
βββββββ
* `src/core/backend-controller.ts` β add `resolveChatBackend(chatId,
fallback)`: pool-first / gateway-fallback / `null` when neither is
wired. Frontend-agnostic; replaces the ad-hoc `gateway?.backend`
reads everywhere they used to live.
* `src/frontend/telegram/model-menu.ts` (NEW, 260 LOC) β controller
that consolidates the /model menu, browse, and backend-submenu
flows. Always resolves the per-chat backend via the new core
helper. Pure functions; no Telegram types leak in.
* `src/frontend/telegram/commands.ts` β `/model`, `/reset` warmSession,
and `/status` enrichment all switch to per-chat backend.
* `src/frontend/telegram/callbacks.ts` β every `/model` callback
branch (select, browse, nav, backend submenu) re-renders against
the per-chat backend. Specifically: after a `backend-select` rebind,
the menu re-render correctly uses the *new* backend's catalog
instead of the stale closure-captured one.
* `src/frontend/discord/{commands,callbacks}.ts` β same treatment for
/model, /settings, /status, and warmSession.
* `src/backend/openai-agents/discovery.ts` (NEW, 248 LOC) β extracted
endpoint-model discovery. `startDiscovery` stashes the in-flight
promise on state; `awaitDiscovery(timeoutMs)` lets the picker wait
briefly on first render so the catalog isn't empty just because
the HTTP call hasn't returned. `refreshDiscovery` for explicit
retries. Free-pricing detection is now string-or-number tolerant
(vLLM ships numeric 0, OpenRouter ships string "0").
* `src/backend/openai-agents/init.ts` β delegates fetch to
`discovery.ts`; caches `baseURL`/`apiKey` on state so
`triggerDiscoveryRefresh` can retry without re-reading config.
* `src/backend/openai-agents/state.ts` β adds `discoveryPromise`,
`discoveryAt`, `baseURL`, `apiKey` fields.
* `src/backend/openai-agents/models.ts` β `getSettingsPresentation`
awaits in-flight discovery before snapshotting.
Tests
βββββ
* `openai-agents-discovery.test.ts` (NEW, 22 cases) β discovery
lifecycle, soft-timeout, idempotence, free-pricing variants.
* `telegram-model-menu-controller.test.ts` (NEW, 17 cases) β per-chat
backend resolution, free-toggle gating off the per-chat catalog,
filter/page/provider propagation, override <-> default round trip.
* `openai-agents-enrichment.test.ts` β import path updated to point
at the new discovery module.
* `openai-agents-models.test.ts` β `getSettingsPresentation` calls
now `await`-ed (it's async; previously sync-wrapped in Promise.resolve).
2587 tests pass (109 in the touched areas), TypeScript clean,
prettier clean, no new oxlint warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OpenAI Agents SDK throws `Duplicate tool names found across MCP servers: cancel_scheduled` (and similar) when two MCP servers expose the same tool name. Talon legitimately ships colliding names β Telegram frontend's `cancel_scheduled` (cancel a scheduled message) and the email plugin's `cancel_scheduled` (cancel a scheduled email) β because they're scoped to different domains. Fix: install a shared first-claimer `toolFilter` on every MCPServerStdio. Iteration order in `mcpServers` determines priority β frontend tools first, then Brave Search, then plugins. Whichever server lists a colliding tool name first keeps it; subsequent servers have that name silently dropped from their visible toolset. Closure-owned Map persists for the bundle's lifetime, so a re-`listTools()` re-applies the same ownership idempotently. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dylanneve1
merged commit May 19, 2026
b16433b
into
feat/multi-backend-hotswap
30 of 32 checks passed
8 tasks
Collaborator
Author
|
Folded into #211 β both PRs were the same multi-backend work artificially split, per Dylan's ask to keep it as a single PR. The two commits from this branch ( Closing. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The
/modelcommand and its callbacks readgateway.backend(theglobal chat-role default) instead of the per-chat backend, which
broke two things once per-chat overrides shipped via the pool
refactor:
openai-agentsfor OpenRouter saw theClaude catalog instead of OpenRouter's β the menu was always
rendered against the global default.
freeCount > 0) never appearedbecause Claude's catalog was queried, not the active backend's.
Plus
fetchEndpointModelswas fire-and-forget, so the first /modelrender after a backend switch could race the
/modelsHTTP calland show an empty catalog.
What changed
Per-chat backend resolution
core/backend-controller.tsβ newresolveChatBackend(chatId, fallback)helper: pool-first / gateway-fallback /
nullwhen neither iswired. Frontend-agnostic.
frontend/telegram/model-menu.ts(NEW, ~260 LOC) β controller thatconsolidates main-menu, browse, and backend-submenu flows. Always
resolves per-chat backend. Pure-function shape; no Telegram types
leak in.
frontend/telegram/commands.tsβ/model,/resetwarmSession,/statusenrichment all switch to per-chat backend via thecontroller /
resolveBackendForChat.frontend/telegram/callbacks.tsβ every/modelcallback branchre-renders against the per-chat backend. Critical: after
backend-selectrebind, the menu re-render correctly uses thenew backend's catalog instead of the stale closure-captured one.
frontend/discord/{commands,callbacks}.tsβ same treatment for/model, /settings, /status, and warmSession.
OpenRouter discovery awaitability
backend/openai-agents/discovery.ts(NEW, ~248 LOC) β extractedendpoint-model discovery.
startDiscoverystashes the in-flightpromise on state;
awaitDiscovery(timeoutMs)lets the picker waitbriefly on first render so the catalog isn't empty just because the
HTTP call hasn't returned.
refreshDiscoveryfor explicit retries.Free-pricing detection is now string-or-number tolerant (vLLM ships
numeric 0, OpenRouter ships string
"0","0.0", etc.).backend/openai-agents/init.tsβ delegates todiscovery.ts;caches
baseURL/apiKeyon state sotriggerDiscoveryRefreshdoesn't need to re-read config.
backend/openai-agents/state.tsβ addsdiscoveryPromise,discoveryAt,baseURL,apiKeyfields.backend/openai-agents/models.tsβgetSettingsPresentationisnow
asyncand awaits in-flight discovery before snapshotting.Picker first-render is no longer empty.
Result
catalog (350+ models, grouped by provider).
free models (so Claude/Codex: no toggle; openai-agents+OpenRouter:
yes; pure OpenAI: no β provider has no free tier).
catalog fetch rather than rendering empty.
Test plan
clean, prettier clean, oxlint 12 warnings (down from 13 baseline).
openai-agents-enrichment.test.tsimport updated todiscovery.ts;openai-agents-models.test.tsgetSettingsPresentationcalls nowawait-ed.enabledBackends: ["claude", "openai-agents"]with
openaiBaseUrl: "https://openrouter.ai/api/v1", switchbackend on a chat, /model should show OpenRouter catalog + Free
toggle. Switch back to Claude: Free toggle disappears, Claude
models shown.
π€ Generated with Claude Code