Skip to content

feat(llm): Settings picker for LLM provider + model (per-session pinning) - #55

Merged
AhmadHammad21 merged 6 commits into
mainfrom
feat/llm-model-picker
May 30, 2026
Merged

AhmadHammad21 merged 6 commits into
mainfrom
feat/llm-model-picker

Conversation

@AhmadHammad21

@AhmadHammad21 AhmadHammad21 commented May 30, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Replace the _agent singleton with an lru_cache-backed _build_agent(model, key_hash) + resolve_agent(override_source, override_model). Per-request model selection becomes a 2-line lookup; same (model, key) reuses the compiled LangGraph.
  • New GET/PUT /api/settings/llm endpoints (admin-only) backed by app_config["llm_preference"]. UI shows a card on Settings → Agent config with provider + model dropdowns; unconfigured providers are grayed out with a hint about which .env key to add.
  • New sessions only semantic: sessions.model is now preserved on upsert_session conflict, so existing chats keep the LLM they started with. New db.get_session_model(session_id) helper lets the chat router look up the pinned model.
  • CLAUDE_CODE_AUTODETECT is the single kill switch for Claude Code in both the auto-fallback chain and the Settings picker (flipping to false grays out the option in the UI).
  • Custom-model option: each provider's model dropdown ends with "Custom — type a model name…" so users can use unreleased / niche models without a code change. LiteLLM surfaces invalid strings at chat time.
  • Catalog expansion: Claude Opus 4.8, full Claude family across both Claude Code (subscription OAuth) and Anthropic (direct API), new Google Gemini provider (direct gemini/* via GEMINI_API_KEY / GOOGLE_API_KEY), broader OpenRouter selection (Gemma, Gemini, Llama, DeepSeek, Qwen, Grok, Mistral).
  • get_backend_info(pref=...) now reflects the saved pick — Environment tab + LLM card show what new sessions will actually use, not the stale .env default. LLM_MODEL row dropped from the Environment tab (was misleading after the picker overrides it).

Test plan

  • cd apps/backend && uv run pytest — 213 passing (191 pre-existing + 22 new in test_llm/test_picker.py)
  • Manual OSS verify (memory backend) — picked OpenRouter / Claude Code / a custom model; saw new sessions use the new model and existing sessions keep their original (cost card confirms)
  • sqlite + postgres backends (deferred — sessions.model on-conflict change applies equally; tested implicitly via the same save_turn path)
  • product mirror (separate PR — per-org app_config["llm_preference_{org_id}"])

Notes for reviewers

  • The agent build cache is sized 8 — enough headroom for switching across providers without paying the create_deep_agent compile cost twice per (model, key).
  • Custom models do not go through the catalog whitelist; that's intentional. Pre-validation would require a live provider call. See enhancement: model allowed-list / admin policy for the LLM picker #56 (enhancement: model allowed-list) for a future admin-policy gate.

…ons only)

Replaces the single-LLM-at-startup pattern with a runtime picker. Users go to
Settings -> Agent config -> LLM, pick from any provider whose credentials are
present in .env, and save. The next chat session uses the new model; in-flight
chats keep their original model.

Core
- agent/llm/backend.py: resolve_model_and_key now accepts override_source and
  override_model; new available_providers() returns each provider's configured
  status and a curated model list for the UI dropdown.
- agent/llm/preference.py (new): load/save the user pick to app_config under
  "llm_preference" (OSS) or "llm_preference_{org_id}" (product, key reserved).
- agent/core.py: drop the _agent singleton in favor of an lru_cache-backed
  _build_agent(model, key_hash); resolve_agent(...) returns (agent, model_name).
  init_agent(checkpointer) now just stores the checkpointer + warms the cache
  with the deployment default. get_active_model() takes an optional pin.
- agent/db: upsert_session no longer overwrites sessions.model on conflict;
  new get_session_model(session_id) helper lets the chat router learn the
  pinned model for an existing session.

Backend
- api/routers/settings.py: GET /api/settings/llm returns providers + current
  pick + active backend info. PUT /api/settings/llm validates the pick against
  available_providers() before persisting. Admin-only.
- api/routers/chat.py: at chat start, prefer the session's pinned model
  (db.get_session_model); for new sessions, fall back to the saved preference;
  pass through to resolve_agent(). Usage card now labels the per-session model.

Frontend
- pages/SettingsPage.tsx: new LLM card on the Agent config tab — provider
  dropdown (grays out providers whose .env keys are missing), model dropdown,
  Save button. Toast on success/failure. Helper text clarifying "new chats only".

Tests
- test_agent/test_core.py: switched the two timeout tests from patching the
  removed _agent module var to patching get_agent(). 191 pytest green.
Catalog
- OpenRouter: added Claude Haiku, GPT-4-Turbo, Gemini 2.5 Flash + Pro,
  Gemma 3-27B, Llama 3.3 70B, DeepSeek v3, Qwen 2.5 72B, Grok 2,
  Mistral Large.
- New "Google Gemini" provider (direct, gemini/* via GEMINI_API_KEY /
  GOOGLE_API_KEY): gemini-2.5-pro / -flash / -flash-lite.
- Claude Code (subscription): now lists Opus/Sonnet/Haiku so the OAuth
  token can pick a specific Claude model — resolve_model_and_key honors
  the explicit model alongside the detector source.
- OpenAI: added o1-mini.
- Groq: added Mixtral.
- Ollama: added DeepSeek R1.

Settings views
- get_backend_info(pref=) — when a saved LLM preference is present, the
  returned display reflects what NEW sessions will actually use (source,
  model, label "picked in Settings"). Previously it only described the
  .env defaults, so the Environment tab + LLM card showed stale info
  after a user switched models in the UI.
- settings router + init/status router pass the loaded preference through
  to get_backend_info(), so both surfaces update immediately on save.

Tests: 191 pytest green.
Detector
- _MODEL_SLOTS: add claude-opus-4-8 (latest); short alias "opus" now points
  there. Reordered by tier (newest first).
- _DEFAULT_MODEL flipped from sonnet-4-6 to opus-4-8 — when ~/.claude/
  settings.json doesn't name a model, default to the latest Opus rather
  than mid-tier Sonnet.

Picker catalog
- Claude Code (subscription): 7 entries spanning Opus 4.8 / 4.7 / 4.5,
  Sonnet 4.6 / 4.5, Haiku 4.5, and 3.5 Sonnet (legacy fallback).
- Anthropic API: same 7 models, billed via ANTHROPIC_API_KEY instead of
  OAuth — same effective model lineup, different auth path.
- OpenRouter: added openrouter/anthropic/claude-opus-4-8 at the top of
  the Anthropic block.

Tests
- test_model_slot_mapping: bump expected values for the "opus" alias and
  the unknown-slot default (both now Opus 4.8).
The Claude Code branch of available_providers() was overriding the curated
catalog with just the single model from ~/.claude/settings.json — so the UI
dropdown only ever showed one option (whatever the user had Claude Code set
to). Result: users couldn't pick Opus 4.8 / Sonnet / Haiku via the OAuth path
from Settings.

Now: expose the full curated catalog. If ~/.claude/settings.json names a
specific model, promote it to the top of the list so it stays the default.
New apps/backend/tests/test_llm/test_picker.py — 22 focused tests on the
public contract Settings -> LLM relies on. Designed so a contributor
adding a provider or model only has to update the relevant assertion,
and a regression like the recently-fixed "Claude Code dropdown only
shows one model" lands as a named failure here.

Coverage
- available_providers()
  - every catalog entry surfaces
  - unconfigured providers carry a hint about which env var to add
  - Ollama is configured by default (no key needed)
  - Claude Code dropdown lists MULTIPLE models (pins the recent fix)
  - the user's ~/.claude model is promoted to the top of the list
  - CLAUDE_CODE_AUTODETECT=false grays out Claude Code with a clear note
- resolve_model_and_key(override_source, override_model)
  - override_model picks the right provider's env-var key
    (anthropic / openrouter / gemini via GOOGLE_API_KEY)
  - override_source='claude_code' uses the OAuth token + detector model
  - override_source + override_model combines OAuth token with chosen model
  - disabled detector raises a clear RuntimeError (no silent bypass)
  - no overrides + no env => falls to settings.llm_model
- get_backend_info(pref=...)
  - pref with source labels "picked in Settings"
  - pref with model only infers the provider correctly
  - pref=None preserves old env-only behavior
- _provider_of() classification for every catalog provider

Everything mocked: no real ~/.claude, no real env vars. Fast (<1s).
…v tab

Custom model option
- Settings -> Agent config -> LLM: each provider's model dropdown gains a
  "Custom — type a model name…" option at the bottom. Selecting it swaps the
  select for a free-text input prefilled with a provider-prefixed placeholder
  (e.g. "anthropic/claude-…", "openrouter/your-model-name").
- A small "← back to curated list" button restores the dropdown.
- A saved model that isn't in the provider's curated list auto-enters custom
  mode on page load so users can see / edit what's actually saved.

Backend already accepts any model string when the provider is configured, so
this is a UI-only change. LiteLLM surfaces invalid model strings at chat time
(documented behavior — we can't pre-validate without a provider call).

Use cases
- A new release lands (Claude 5, Gemini 3, …) before we update the catalog.
- A niche OpenRouter variant we didn't curate.
- A locally-pulled Ollama tag with a custom name.

Environment tab cleanup
- Drop the LLM_MODEL row. With the picker overriding the .env default per
  session, surfacing the raw env var was actively misleading: users would see
  "openrouter/gemma" while the agent was actually calling "claude-opus-4-8".
  The Agent config LLM card is now the single source of truth for "what model
  am I using?".
@AhmadHammad21
AhmadHammad21 merged commit b96b9fa into main May 30, 2026
2 checks passed
@AhmadHammad21
AhmadHammad21 deleted the feat/llm-model-picker branch May 30, 2026 02:11
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.

1 participant