Skip to content

feat(studio): local-server discovery + model picker for every provider - #167

Open
Yona-Appletree wants to merge 12 commits into
mainfrom
claude/local-model-discovery-test-eda2eb
Open

feat(studio): local-server discovery + model picker for every provider#167
Yona-Appletree wants to merge 12 commits into
mainfrom
claude/local-model-discovery-test-eda2eb

Conversation

@Yona-Appletree

Copy link
Copy Markdown
Member

Setting up a local model against lightplayer.app dead-ends in ways a browser reports identically: a server answering without CORS headers and a port with nothing listening both surface as the same opaque TypeError. This adds the two affordances that close that gap, plus model discovery for all four providers.

Test connection / Find my server (Custom provider)

Two buttons under the Base URL field. Test probes the address in the field; Find my server tries Ollama :11434, LM Studio :1234, llama.cpp :8080, vLLM :8000 and Jan :1337 concurrently.

The load-bearing trick: every failed probe is retried in no-cors mode. An opaque response resolves whenever the connection was accepted, so opaque success after a normal failure means CORS, and both failing means nothing is there. Diagnoses carry the remedy — the exact OLLAMA_ORIGINS=<this page's origin> ollama serve line, LM Studio's CORS toggle, vLLM's --allowed-origins — plus 401 (wants a key), 404 (base URL likely missing /v1), an empty list (pull a model first), and a served list that lacks the configured model id.

Smaller things that each fix a real dead end:

  • Base URLs normalize (scheme + /v1), so localhost:11434 — the address Ollama prints — works.
  • A dead localhost is retried at 127.0.0.1: Ollama binds IPv4 only, so a name resolving to ::1 is a silent miss.
  • An opaque probe proves a listener, not a product, so the copy never claims "Ollama is running".
  • Port 5000 is not scanned: macOS ControlCenter (AirPlay Receiver) owns it and would hit on every Mac.

Model picker (all four providers)

The Model field gained Browse models ▾. One {"data":[{"id":…}]} parser covers every provider; only the request differs. Anthropic needs the same anthropic-dangerous-direct-browser-access header the agent already sends; OpenRouter's list is public, so it browses before Connect — exactly when a model id is needed; Custom reuses the probe's URL.

  • Long lists get a filter box, short ones don't (and the query only applies while its box is on screen).
  • Rows show published $in/$out per MTok; embedding/speech/image ids are filtered with the count shown, and the field still accepts anything typed.
  • Picking a model also sets the cost-estimate rates from its published pricing — and clears them when the provider publishes none, since leftover rates would silently mis-price the newly chosen model.

Shape

Decisions and copy live in lpa-studio-core (local_model_probe.rs, model_catalog.rs; 30 host tests); the fetches are wasm-only glue in lpa-studio-web. Credentials are read through settings_io rather than the view DTO, which deliberately carries only a masked key.

Validation

just check and just test green locally. 7 new stories cover the discovery and picker states — their baselines are CI's to capture.

Verified live in a browser, not just against fixtures:

  • Anthropic: the real API (dev-settings key) — 11 ids listed correctly.
  • OpenRouter: its real payload is 341 models / 533 KB; captured and replayed through a local CORS server, so parse, sort, prices, filter and the row cap all ran on the genuine catalog. Exactly 11 ids are filtered and all 11 are genuinely image/audio — no false positives.
  • Custom: real Ollama (which returns {"data":null} when nothing is pulled) plus stand-in servers with and without CORS headers.
  • OpenAI: request shape is unit-tested only; no key was available here.

That live pass caught three bugs unit tests could not, all fixed here: a timed-out body read reported as "the response was not JSON", a stale provider's model list surviving a provider switch, and a filter query outliving its own input box.

🤖 Generated with Claude Code

Yona-Appletree and others added 2 commits July 28, 2026 01:28
Setting up a local model against lightplayer.app dead-ends in ways a
browser reports identically: a server answering without CORS headers and
a port with nothing on it both surface as the same opaque TypeError. Two
features close that gap, plus model discovery for all four providers.

Test connection / Find my server (Custom provider)
- Every failed probe is retried in `no-cors` mode. An opaque response
  resolves whenever the connection was accepted, so opaque-success after
  a normal failure means CORS, and both-fail means nothing is there.
- Diagnoses carry the fix: the exact `OLLAMA_ORIGINS=<this origin>`
  command, LM Studio's CORS toggle, vLLM's --allowed-origins, plus 401
  (needs a key), 404 (base URL likely missing /v1), empty list (pull a
  model), and a served list that lacks the configured id.
- Base URLs normalize (scheme + /v1), and a dead `localhost` is retried
  at 127.0.0.1 — Ollama binds IPv4 only, so name resolution to ::1 is a
  silent miss.
- An opaque probe proves a listener, not a product, so the copy never
  claims "Ollama is running". Port 5000 is not scanned: macOS
  ControlCenter owns it and would hit on every Mac.

Model picker (all providers)
- One `{"data":[{"id"}]}` parser; only the request differs per provider.
  Anthropic needs the same anthropic-dangerous-direct-browser-access
  header the agent sends; OpenRouter's list is public, so it browses
  before Connect — which is exactly when a model id is needed.
- Long lists get a filter box, rows show published $/MTok, and embedding
  /speech/image ids are filtered with the count shown (the field still
  accepts anything typed).
- Picking a model also sets the cost-estimate rates from its published
  pricing, and clears them when the provider publishes none — leftover
  rates would silently mis-price the newly chosen model.

Decisions and copy live in lpa-studio-core (30 host tests); the fetches
are wasm-only glue. Credentials come from settings_io rather than the
view DTO, which deliberately carries only a masked key.

Verified live: Anthropic's real API, and OpenRouter's real 341-model /
533 KB payload replayed through a local CORS server (all 11 filtered ids
are genuinely image/audio). That live pass caught three bugs unit tests
could not: a timed-out body read reported as "not JSON", a stale
provider's list surviving a provider switch, and a filter query
outliving its own input box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

CI refreshed the story baselines on this branch

The validate-stories job detected drift and committed the fresh set: 18 changed
in 05dab34.

Review every PNG in the PR's Files changed view (swipe / onion-skin).
Your local branch is now behind — git pull before pushing again.

Story Before After
studio__layout__studio-settings-popover__custom-local-server__lg.png studio__layout__studio-settings-popover__custom-local-server__lg.png@990cfcead8 studio__layout__studio-settings-popover__custom-local-server__lg.png@05dab34dbb
studio__layout__studio-settings-popover__custom-local-server__md.png studio__layout__studio-settings-popover__custom-local-server__md.png@990cfcead8 studio__layout__studio-settings-popover__custom-local-server__md.png@05dab34dbb
studio__layout__studio-settings-popover__custom-local-server__sm.png studio__layout__studio-settings-popover__custom-local-server__sm.png@990cfcead8 studio__layout__studio-settings-popover__custom-local-server__sm.png@05dab34dbb
studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__lg.png studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__lg.png@990cfcead8 studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__lg.png@05dab34dbb
studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__md.png studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__md.png@990cfcead8 studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__md.png@05dab34dbb
studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__sm.png studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__sm.png@990cfcead8 studio__layout__studio-settings-popover__custom-scan-blocked-by-cors__sm.png@05dab34dbb
studio__layout__studio-settings-popover__custom-scan-found-a-server__lg.png studio__layout__studio-settings-popover__custom-scan-found-a-server__lg.png@990cfcead8 studio__layout__studio-settings-popover__custom-scan-found-a-server__lg.png@05dab34dbb
studio__layout__studio-settings-popover__custom-scan-found-a-server__md.png studio__layout__studio-settings-popover__custom-scan-found-a-server__md.png@990cfcead8 studio__layout__studio-settings-popover__custom-scan-found-a-server__md.png@05dab34dbb
10 more file(s)
  • studio__layout__studio-settings-popover__custom-scan-found-a-server__sm.png (changed)
  • studio__layout__studio-settings-popover__custom-scan-found-nothing__lg.png (changed)
  • studio__layout__studio-settings-popover__custom-scan-found-nothing__md.png (changed)
  • studio__layout__studio-settings-popover__custom-scan-found-nothing__sm.png (changed)
  • studio__layout__studio-settings-popover__custom-scan-running__lg.png (changed)
  • studio__layout__studio-settings-popover__custom-scan-running__md.png (changed)
  • studio__layout__studio-settings-popover__custom-scan-running__sm.png (changed)
  • studio__layout__studio-settings-popover__model-fetch-error__lg.png (changed)
  • studio__layout__studio-settings-popover__model-fetch-error__md.png (changed)
  • studio__layout__studio-settings-popover__model-fetch-error__sm.png (changed)

Yona-Appletree and others added 10 commits July 28, 2026 09:40
…scovery-test-eda2eb

# Conflicts:
#	lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__overview__lg.png
#	lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__overview__md.png
#	lp-app/lpa-studio-web/story-images/studio__layout__studio-settings-popover__overview__sm.png
#	lp-app/lpa-studio-web/story-images/studio__node__agent-chat__overview__lg.png
#	lp-app/lpa-studio-web/story-images/studio__node__agent-chat__overview__md.png
#	lp-app/lpa-studio-web/story-images/studio__node__agent-chat__overview__sm.png
The auto-commit path only fires on a push-triggered run, so a branch whose
baselines are stale with nothing new to push — after resolving PNG
conflicts by hand, most notably — had no way to ask CI for a recapture
short of an empty commit.

`CI` now accepts `workflow_dispatch`. The three validate jobs skip
themselves on that event (the branch's code was already validated by the
run that reviewed it), leaving `validate-stories` alone: ~10 minutes, and
it commits any refresh to the selected branch exactly as the PR path
does. `main` is excluded from dispatch auto-commit so a mis-aimed run
cannot push baselines straight to it, and the sticky PR comment stays
PR-only since a dispatch has no PR number.

GitHub only offers the Run-workflow button for workflows on the default
branch, so this becomes usable once it lands on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Alphabetical order made OpenRouter's ~340-model list useless: `ai21/…`
and `aion-labs/…` led, and every model worth choosing was buried.

There is no popularity signal to sort by. `/api/v1/models` carries none,
`?order=top-weekly` is accepted and ignored (byte-identical response),
and the endpoint openrouter.ai's own rankings page uses is CORS-locked to
their origin, so a browser page cannot read it.

The payload does carry quality, though: `benchmarks.artificial_analysis`
publishes coding / agentic / intelligence indices (117 of 341 models),
and `supported_parameters` says whether a model can call tools at all.
Both come from the provider, so nothing here is a list we maintain.

- Order: published coding score first (falling back to agentic, then
  intelligence), then newest by `created`, then id. The head of the
  OpenRouter list becomes claude-opus-5, gpt-5.6-sol, gpt-5.6-terra,
  claude-fable-5, kimi-k3 — the models someone would actually pick.
- Hide models the provider marks tool-incapable: the agent is a
  single-tool loop, so those fail on turn one. 66 of 341 on OpenRouter,
  counted in the hidden line beside the embedding/speech ids. Silence
  about tool support is not a no — only OpenRouter publishes the field.
- Rows show the score, and a legend names the ordering, so a model's
  position is explained rather than mysterious.

Providers that publish neither (Anthropic, OpenAI, local servers) fall
through to id order exactly as before.

Verified against the real 341-model payload replayed through a local CORS
server: 272 offered, 69 hidden, top rows as above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#158 landed model discovery in parallel with this branch: a `/models`
listing in lpa-agent behind an `HttpGetTransport` seam, typed
`ListModelsError`, per-provider fetch state keyed by a credential
fingerprint, and a `<select>` in the settings popover. That is the better
home for it — this branch's version fetched from the web edge and never
reached the store — so the duplicate is deleted rather than merged, and
its four differences are ported onto main's pipeline instead.

Deleted: `settings/model_catalog.rs` (core) and `model_catalog.rs` (web).

Ported into the surviving implementation:

- `data: null` is an empty listing, not a parse failure. Ollama with
  nothing pulled answers exactly that, and serde's `default` covers a
  MISSING field, not a null one — so the commonest local-server state
  read as "model list unavailable". `data` is now required-but-nullable,
  which also keeps the useful opposite signal: a body with no `data` at
  all (Ollama's native `{"models":[…]}`) means the base URL is missing
  its `/v1`, and the probe still says so.
- `ModelInfo` carries the metadata OpenRouter publishes — per-token
  pricing, the Artificial Analysis indices, tool-calling support,
  `created` — so the listing can be ranked and priced without a
  maintained list.
- `model_options` ranks by published coding score, then recency, then id,
  and drops models the provider marks tool-incapable (the agent is a
  single-tool loop) alongside the embedding/speech ids. Listings with no
  scores keep the provider's own order.
- Picking a model adopts its published rates, and clears them when the
  listing prices nothing — now in the store, so it covers the dropdown
  and hand-typed ids alike.

The local-server probe (Test connection / Find my server) is unchanged
apart from reading bodies through the shared discovery parser, so a
diagnosis and the dropdown can never disagree about what a body means.

Baseline PNGs and tailwind.css take main's bytes; CI recaptures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Those stories captured this branch's own model picker, which the merge
of #158 replaced with main's dropdown. Their baselines have no story to
compare against; CI recaptures everything else.
# Conflicts:
#	.github/workflows/pre-merge.yml
…dule

Merging #158 moved the last non-test use of `UiModelOption` out of
settings_store, leaving the top-level import used only by the assertions
in `mod tests` — which reach it through `use super::*` and so kept
compiling under `cargo test` while failing `-D warnings` on the lib.
That is what turned Validate (x64) red on 2026-07-28.

Import it where it is actually used instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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