Skip to content

fix(model-menu): resolve per-chat backend so /model honours overrides - #215

Merged
dylanneve1 merged 2 commits into
feat/multi-backend-hotswapfrom
fix/model-menu-per-chat-backend
May 19, 2026
Merged

fix(model-menu): resolve per-chat backend so /model honours overrides#215
dylanneve1 merged 2 commits into
feat/multi-backend-hotswapfrom
fix/model-menu-per-chat-backend

Conversation

@claudiusthebot

Copy link
Copy Markdown
Collaborator

Summary

The /model command and its callbacks read gateway.backend (the
global chat-role default) instead of the per-chat backend, which
broke two things once per-chat overrides shipped via the pool
refactor:

  1. A chat that switched to openai-agents for OpenRouter saw the
    Claude catalog instead of OpenRouter's β€” the menu was always
    rendered against the global default.
  2. The free-only toggle (gated on freeCount > 0) never appeared
    because Claude's catalog was queried, not the active backend's.

Plus fetchEndpointModels was fire-and-forget, so the first /model
render after a backend switch could race the /models HTTP call
and show an empty catalog.

What changed

Per-chat backend resolution

  • core/backend-controller.ts β€” new resolveChatBackend(chatId, fallback)
    helper: pool-first / gateway-fallback / null when neither is
    wired. Frontend-agnostic.
  • frontend/telegram/model-menu.ts (NEW, ~260 LOC) β€” controller that
    consolidates 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, /reset warmSession,
    /status enrichment all switch to per-chat backend via the
    controller / resolveBackendForChat.
  • frontend/telegram/callbacks.ts β€” every /model callback branch
    re-renders against the per-chat backend. Critical: after
    backend-select rebind, the menu re-render correctly uses the
    new 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) β€” 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", "0.0", etc.).
  • backend/openai-agents/init.ts β€” delegates to discovery.ts;
    caches baseURL/apiKey on state so triggerDiscoveryRefresh
    doesn't need to re-read config.
  • backend/openai-agents/state.ts β€” adds discoveryPromise,
    discoveryAt, baseURL, apiKey fields.
  • backend/openai-agents/models.ts β€” getSettingsPresentation is
    now async and awaits in-flight discovery before snapshotting.
    Picker first-render is no longer empty.

Result

  • Backend submenu β†’ OpenAI Agents β†’ /model now shows the OpenRouter
    catalog (350+ models, grouped by provider).
  • Free-only toggle appears only when the per-chat backend reports
    free models (so Claude/Codex: no toggle; openai-agents+OpenRouter:
    yes; pure OpenAI: no β€” provider has no free tier).
  • First /model after a backend switch waits (up to 3s) for the
    catalog fetch rather than rendering empty.

Test plan

  • 2587/2599 tests pass (109 in the touched areas), TypeScript
    clean, prettier clean, oxlint 12 warnings (down from 13 baseline).
  • New: 22 discovery tests, 17 model-menu-controller tests.
  • Existing: openai-agents-enrichment.test.ts import updated to
    discovery.ts; openai-agents-models.test.ts
    getSettingsPresentation calls now await-ed.
  • Live smoke: set enabledBackends: ["claude", "openai-agents"]
    with openaiBaseUrl: "https://openrouter.ai/api/v1", switch
    backend on a chat, /model should show OpenRouter catalog + Free
    toggle. Switch back to Claude: Free toggle disappears, Claude
    models shown.

πŸ€– Generated with Claude Code

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
dylanneve1 merged commit b16433b into feat/multi-backend-hotswap May 19, 2026
30 of 32 checks passed
@claudiusthebot

Copy link
Copy Markdown
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 (699d0be model-menu per-chat resolution + b16433b MCP tool-name dedupe) are now sitting on top of feat/multi-backend-hotswap after a clean fast-forward. PR #211's description has been updated to reflect the combined scope.

Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants