Skip to content

Commit d46f36a

Browse files
gloryfromcaclaude
andcommitted
feat(ui-web): split model providers from model settings, one picker (#565)
## Summary The settings dialog's Model page answered two questions at once -- which accounts this install holds, and which model each job runs on -- and it listed the connected providers only, so the fifty-odd vendors Raven supports were reachable through a dropdown inside an "add" form and nowhere else. This splits the page in two, gives every model a kind, and merges the two model pickers into one. **Model providers** is a two-column page. The left column is every provider `model.options` returns (fifty-five on a stock registry, `hosted_vllm` and `custom` among them) with a search box and six filters -- connected, direct vendors, aggregators, browser sign-in, local -- connected rows first and each carrying its vendor mark. The right column is that provider's connection, beside the list rather than paged into. **Model settings** keeps the roles card alone, and each of its eleven slots offers its own kind: the embedding slot lists embedding models, image lists image models, and a chat model appears in neither. **Adding a model** is a popover under its button. It fetches the vendor's list, groups a gateway's ids by vendor, counts the kinds present over the current search result and filters to the one pressed, and writes one model per click rather than collecting ticks for a confirming press. An id the vendor does not list is typed into the same box with a chip saying what kind it is. **One picker.** `components/ModelPicker` (164 lines, the roles card's) is deleted; the roles card opens the composer's. What an opening lists is now an `Offer`: a kind, the providers the caller allows, a title, the pair it marks, and what a pick means. ### Two decisions worth reading **The kind narrows each provider's column, never the provider list.** A tester reported that a provider with a working key but no model list built vanished from the composer's picker, and the model the chip named -- set by onboarding, never "added" -- was in no column to be marked. So a provider with nothing of the offered kind is listed with a count of zero and a column that says so plus a row to type an id into; and the model a slot or the conversation currently holds is listed in its provider's column even when that column does not carry it. Verified on a real host: with `agents.defaults.model` set to an id absent from `providers.deepseek.models`, the picker opens on DeepSeek and lists it first, marked, and writes nothing. **One classifier, in Python.** `registry_data.kind_of` files a model by what it writes, and `ModelLabel.kind` carries that answer to the page; nothing in TypeScript derives a kind from capabilities or modalities. The one exception is the chip on a typed id, which guesses from the name -- a translation of `inferred_tags`, with a nine-row table asserted on both sides so the copy cannot drift. ### Backend Two read-only, additive fields on `model.options`: `ModelLabel.kind` and `ModelOptionProvider.gateway` (the registry's `is_gateway`, which no client can derive from a slug). No new method, no new config key. Contract first: `openrpc.json`, then pydantic, then the handlers, then `npm run gen`. ### Not in this change The three navigation entries the prototype also adds (channels, cron, memory); the prototype's OAuth pane, which tells the reader to run a CLI command -- the browser device flow stays; backend enforcement of kinds, which remains a filter the page applies; the onboarding wizard, which keeps rendering `providers/Providers.tsx` untouched; the TUI. ## Type - [ ] Fix - [x] Feature - [ ] Docs - [ ] CI / tooling - [ ] Refactor - [ ] Other ## Verification Rebased onto `refactor/ui_web_architecture` at 6062b36 and re-run after the rebase. ``` uv run pytest tests/test_rpc_model.py tests/test_provider_registry_data.py tests/test_rpc_schema_match.py -q 630 passed in 12.44s (cd ui-web && npx vitest run) Test Files 186 passed (186) Tests 2552 passed (2552) (cd ui-web && npx tsc --noEmit && npm run gen:check && npm run build && python3 build.py) generated.ts matches the contract (189 methods) boot-snapshot: OK (250 nodes match golden) boot-snapshot: OK (251 nodes match golden) make check-source-language; make check-large-files; npm run lint:i18n --prefix ui-tui i18n: generated catalogue is up to date uv run ruff check raven tests All checks passed! ``` Mutation checks on the two new backend fields: commenting out the `"gateway"` line fails `test_options_rows_carry_the_gateway_flag` on `KeyError: 'gateway'`; commenting out the `"kind"` key fails `test_model_labels_carry_a_kind` on `KeyError: 'kind'`. Restored, `git diff` clean. Driven on a real host (`raven serve` on its own home and port 18899, real keys for DeepSeek / OpenRouter / Gemini, a real browser): - Model providers draws 55 rows in two columns of 286px and 462px, the three connected ones first with a status dot, DeepSeek carrying the default badge, 54 vendor marks and one lettered tile (`custom`). - The embedding slot's picker lists all three connected providers with counts 1 / 0 / 0 and exactly one model. It is at the body, not inside the dialog, at z-index 46 over the veil's 40. - The add-model popover against OpenRouter's live catalogue: 534 models, kind tabs reading all 534 / text 445 / image 52 / embedding 33 / audio 4, five vendor groups, the four seeded models checked. Typing `my-team/bge-custom` offers it with an "embedding" chip, which is what `inferred_tags` answers for that name. - The composer's chip lists the same three providers with OpenRouter counted 1: its embedding, reranker and image models are not offered for chat. - [x] Relevant tests pass locally - [x] Relevant lint / type checks pass locally - [ ] User-facing docs or screenshots are updated when needed ## Risk Composer users see two changes: embedding and reranker models leave the model chip's picker, and typing an id there now adds it to the provider before switching. Settings users learn two navigation entries where there was one; `settingsTab.id` values written elsewhere still land on a model page, since the roles page keeps the id `model`. A model the registry classifies wrongly -- an embedding model with no `embedding` tag whose name the two patterns miss -- is invisible to the embedding slot until an overlay states its kind. The add-model popover's chip is the way to state it. Rollback: the two wire fields are additive and optional on the reader's side; the section split is a data change in `SECTIONS`; the deleted picker is one `git revert` away. One gate pin moved: `fixture-shape`'s `UNSENT` list loses its six `model_labels` entries, because the offline fixtures now send them (a page that filters by kind needs a kind offline too). The gate's own message asks for exactly this. - [x] Security impact considered - [x] Backward compatibility considered - [x] Rollback path is clear for risky changes ## Related Issues N/A --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 333e689 commit d46f36a

51 files changed

Lines changed: 3729 additions & 385 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/plans/2026-09-20-web-settings-model-section.md‎

Lines changed: 910 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/specs/2026-09-20-web-settings-model-section-acceptance.md‎

Lines changed: 256 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/specs/2026-09-20-web-settings-model-section-design.md‎

Lines changed: 602 additions & 0 deletions
Large diffs are not rendered by default.

‎i18n/messages.json‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3355,6 +3355,10 @@
33553355
"en": "No models under this account",
33563356
"zh": "这个账号下还没有模型"
33573357
},
3358+
"gui.picker.empty_kind": {
3359+
"en": "No {kind} model added here yet",
3360+
"zh": "这一家还没有添加{kind}模型"
3361+
},
33583362
"gui.picker.manage": {
33593363
"en": "Manage models & accounts...",
33603364
"zh": "管理模型与账号…"
@@ -6420,8 +6424,12 @@
64206424
"zh": "用量"
64216425
},
64226426
"gui.settings.nav.model": {
6423-
"en": "Models",
6424-
"zh": "模型"
6427+
"en": "Model settings",
6428+
"zh": "模型配置"
6429+
},
6430+
"gui.settings.nav.provider": {
6431+
"en": "Model providers",
6432+
"zh": "模型服务商"
64256433
},
64266434
"gui.settings.nav.skills": {
64276435
"en": "Skills",
@@ -6723,6 +6731,10 @@
67236731
"en": "Paste an API key",
67246732
"zh": "粘贴 API Key"
67256733
},
6734+
"gui.settings.providers.pick_one": {
6735+
"en": "Choose a provider on the left",
6736+
"zh": "在左边选一家服务商"
6737+
},
67266738
"gui.settings.providers.key_first": {
67276739
"en": "Enter the API key first",
67286740
"zh": "先填入 API Key"
@@ -6759,6 +6771,18 @@
67596771
"en": "Disconnect",
67606772
"zh": "断开"
67616773
},
6774+
"gui.settings.providers.disconnect_key": {
6775+
"en": "Disconnect and clear the key",
6776+
"zh": "断开并清除密钥"
6777+
},
6778+
"gui.settings.providers.disconnect_auth": {
6779+
"en": "Disconnect authorisation",
6780+
"zh": "断开授权"
6781+
},
6782+
"gui.settings.providers.disconnect_local": {
6783+
"en": "Disconnect this server",
6784+
"zh": "断开这台服务"
6785+
},
67626786
"gui.settings.providers.connection": {
67636787
"en": "Connection",
67646788
"zh": "连接"
@@ -6815,10 +6839,30 @@
68156839
"en": "added",
68166840
"zh": "已添加"
68176841
},
6842+
"gui.settings.providers.add_model": {
6843+
"en": "Add model",
6844+
"zh": "添加模型"
6845+
},
6846+
"gui.settings.providers.showing_added": {
6847+
"en": "showing {n} · added {m}",
6848+
"zh": "显示 {n} 个 · 已添加 {m}"
6849+
},
68186850
"gui.settings.providers.fetching": {
68196851
"en": "Fetching the model list from {name}",
68206852
"zh": "正在从 {name} 获取模型列表"
68216853
},
6854+
"gui.settings.providers.filter_direct": {
6855+
"en": "Direct vendors",
6856+
"zh": "直连厂商"
6857+
},
6858+
"gui.settings.providers.filter_gateway": {
6859+
"en": "Aggregators",
6860+
"zh": "聚合网关"
6861+
},
6862+
"gui.settings.providers.filter_oauth": {
6863+
"en": "Browser sign-in",
6864+
"zh": "浏览器授权"
6865+
},
68226866
"gui.settings.providers.no_list": {
68236867
"en": "This vendor has no model list endpoint; type a model id",
68246868
"zh": "这家没有模型列表接口,直接输入模型 id"
@@ -7319,6 +7363,10 @@
73197363
"en": "Overrides the default",
73207364
"zh": "覆盖默认"
73217365
},
7366+
"gui.settings.providers.base_reset": {
7367+
"en": "Reset",
7368+
"zh": "恢复默认"
7369+
},
73227370
"gui.settings.providers.key_optional_ph": {
73237371
"en": "API key, if the server wants one",
73247372
"zh": "API Key(服务要求时填)"

‎raven/rpc/methods/model.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ def _model_labels(slug: str, models: "list[str]", *, section: Any = _UNLOADED) -
243243
"""
244244
from raven.providers.catalog import describe
245245
from raven.providers.rates import resolve_context_window
246+
from raven.providers.registry_data import kind_of
246247

247248
overlays = _configured_overlays(slug, section=section)
248249
out: dict[str, dict[str, Any]] = {}
@@ -251,7 +252,7 @@ def _model_labels(slug: str, models: "list[str]", *, section: Any = _UNLOADED) -
251252
window = resolve_context_window(model, allow_fetch=False)
252253
if not (row.described or row.tagged or window):
253254
continue
254-
entry: dict[str, Any] = {"label": row.label}
255+
entry: dict[str, Any] = {"label": row.label, "kind": kind_of(row.capabilities, row.output_modalities)}
255256
if row.description:
256257
entry["description"] = row.description
257258
if row.capabilities:
@@ -397,6 +398,7 @@ def _build_provider_entry(
397398
"protocols": protocols,
398399
"protocol_overrides": overrides,
399400
"total_models": len(models),
401+
"gateway": bool(spec and spec.is_gateway),
400402
# "An address must be supplied" -- the gate's answer, not the shape's:
401403
# an endpoint-credential spec that ships a usable default (custom's
402404
# localhost gateway) runs on a bare key, and the picker must not

‎raven/rpc/models.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1574,6 +1574,9 @@ class ModelLabel(_Strict):
15741574
capabilities: list[str] = Field(default_factory=list)
15751575
input_modalities: list[str] = Field(default_factory=list)
15761576
output_modalities: list[str] = Field(default_factory=list)
1577+
#: The bucket a model list files this model under, from what it writes
1578+
#: (``registry_data.kind_of``): a model that reads images is still text.
1579+
kind: Literal["text", "image", "audio", "video", "embedding", "reranker"]
15771580
#: Tokens the model reads in one request. Resolved from the tables that also
15781581
#: route, never from the display registry -- a window sizes trimming, so the
15791582
#: number a picker shows has to be the number a request is sized with. None
@@ -1629,6 +1632,10 @@ class ModelOptionProvider(_Strict):
16291632
extra_headers: dict[str, str] = Field(default_factory=dict)
16301633
total_models: int
16311634
needs_api_base: bool
1635+
#: The registry's ``is_gateway``: resells other vendors' models under
1636+
#: vendor/model ids. The catalogue's filter reads it; no client can derive
1637+
#: it from a slug.
1638+
gateway: bool = False
16321639
#: Addresses to pick between, empty for the providers that have only one.
16331640
#: A row that states these is drawn with the list in place of a host field.
16341641
platforms: list[ModelOptionPlatform] = Field(default_factory=list)

‎rpc-schema/openrpc.json‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10875,6 +10875,10 @@
1087510875
"needs_api_base": {
1087610876
"type": "boolean"
1087710877
},
10878+
"gateway": {
10879+
"type": "boolean",
10880+
"description": "The registry's is_gateway: resells other vendors' models under vendor/model ids. The catalogue's gateway filter reads this; absent means false."
10881+
},
1087810882
"platforms": {
1087910883
"type": "array",
1088010884
"items": {
@@ -10968,7 +10972,8 @@
1096810972
"additionalProperties": false,
1096910973
"description": "How a model reads to a person, and what it can do. Absent for a model the registry knows nothing about -- one released since the bundled files, or served by a local deployment -- in which case the id is all there is to show. An empty tag list means nothing is published, not that the model cannot: a surface renders absence as no icon, never as a denial.",
1097010974
"required": [
10971-
"label"
10975+
"label",
10976+
"kind"
1097210977
],
1097310978
"properties": {
1097410979
"label": {
@@ -10998,6 +11003,18 @@
1099811003
"type": "string"
1099911004
}
1100011005
},
11006+
"kind": {
11007+
"type": "string",
11008+
"enum": [
11009+
"text",
11010+
"image",
11011+
"audio",
11012+
"video",
11013+
"embedding",
11014+
"reranker"
11015+
],
11016+
"description": "The bucket a model list files this model under, from what it writes (registry_data.kind_of): a model that reads images is still text. A model with no label entry is text."
11017+
},
1100111018
"context_window": {
1100211019
"type": "integer",
1100311020
"description": "Tokens the model reads in one request, from the tables that also route rather than from the display registry -- the number shown has to be the number a request is sized with. Absent where no such table names the model."

‎tests/test_provider_registry_data.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@
1313
import pytest
1414

1515
from raven.providers import registry_data
16-
from raven.providers.registry_data import CAPABILITIES, MODALITIES, curated_for, provider_metadata, row_for
16+
from raven.providers.registry_data import (
17+
CAPABILITIES,
18+
MODALITIES,
19+
curated_for,
20+
inferred_tags,
21+
kind_of,
22+
provider_metadata,
23+
row_for,
24+
)
1725

1826

1927
def test_a_reseller_reads_the_vendors_row_without_carrying_a_copy() -> None:
@@ -430,3 +438,24 @@ def test_a_model_tagged_from_its_endpoint_reaches_the_same_bucket_as_a_catalogue
430438
assert kind_of(["video-generation"], []) == "video"
431439
# Reading pictures is still something a text model does.
432440
assert kind_of(["image-recognition"], ["text"]) == "text"
441+
442+
443+
# The same nine rows, with the same kinds, sit in ui-web/src/features/model/types.test.ts:
444+
# the page copies inferred_tags' two patterns for a typed id's chip, and this
445+
# table is what keeps the copy honest.
446+
NAME_GUESSES = [
447+
("openai/text-embedding-3-small", "embedding"),
448+
("BAAI/bge-reranker-v2-m3", "reranker"),
449+
("my-team/bge-reranker-custom", "reranker"),
450+
("jina-embeddings-v3", "embedding"),
451+
("bge-m3", "embedding"),
452+
("gte-large", "embedding"),
453+
("rerank-co/gpt-4", "text"),
454+
("google/gemini-2.5-flash-image", "text"),
455+
("deepseek-v4-pro", "text"),
456+
]
457+
458+
459+
@pytest.mark.parametrize(("model_id", "kind"), NAME_GUESSES)
460+
def test_kind_of_a_name_alone(model_id: str, kind: str) -> None:
461+
assert kind_of(inferred_tags(model_id), ()) == kind

‎tests/test_rpc_model.py‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,30 @@ async def test_options_current_provider_derived_from_model(fake_home: Path) -> N
141141
assert _entry(result, "anthropic")["is_current"] is True
142142

143143

144+
async def test_options_rows_carry_the_gateway_flag(fake_home: Path) -> None:
145+
_write_config(fake_home, {"agents": {"defaults": {"model": "anthropic/claude-sonnet-4-5"}}})
146+
result = await model_options({})
147+
assert _entry(result, "openrouter")["gateway"] is True
148+
assert _entry(result, "custom")["gateway"] is True
149+
assert _entry(result, "anthropic")["gateway"] is False
150+
151+
152+
async def test_model_labels_carry_a_kind(fake_home: Path) -> None:
153+
# openrouter with a key and one configured model whose name is the only
154+
# thing that says what it is
155+
_write_config(
156+
fake_home,
157+
{
158+
"agents": {"defaults": {"model": "openrouter/anthropic/claude-sonnet-4-5"}},
159+
"providers": {"openrouter": {"apiKey": "sk-or-xxx", "models": ["openai/text-embedding-3-small"]}},
160+
},
161+
)
162+
result = await model_options({})
163+
labels = _entry(result, "openrouter")["model_labels"]
164+
assert labels["openai/text-embedding-3-small"]["kind"] == "embedding"
165+
assert all("kind" in v for v in labels.values())
166+
167+
144168
async def test_options_oauth_provider_warning_and_auth_type(fake_home: Path) -> None:
145169
_write_config(fake_home, {"agents": {"defaults": {"model": "anthropic/claude-sonnet-4-5"}}})
146170
result = await model_options({})
@@ -1351,7 +1375,7 @@ async def test_a_user_written_overlay_reaches_the_picker(fake_home: Path) -> Non
13511375
)
13521376
entry = _entry(await model_options({}), "hosted_vllm")
13531377
label = (entry.get("model_labels") or {}).get("hosted-vllm/my-finetune-v3")
1354-
assert label == {"label": "Our finetune", "description": "tuned on tickets"}
1378+
assert label == {"label": "Our finetune", "description": "tuned on tickets", "kind": "text"}
13551379

13561380

13571381
async def test_the_picker_gets_the_tags_it_draws_as_icons(fake_home: Path) -> None:

‎ui-tui/src/gatewayTypes.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,13 +378,20 @@ export interface ModelOptionProvider {
378378
picker's offer -- config plus a curated shortlist plus a catalogue -- which
379379
is what the TUI picker draws; a surface managing the list reads this one. */
380380
configured_models?: string[]
381+
/* Resells other vendors' models under `vendor/model` ids (the registry's
382+
`is_gateway`). The web settings page filters its catalogue on it; here it
383+
is declared so the drift check stays honest about what the wire sends. */
384+
gateway?: boolean
381385
model_labels?: Record<
382386
string,
383387
{
384388
capabilities?: string[]
385389
context_window?: number
386390
description?: string
387391
input_modalities?: string[]
392+
/* Which bucket a model list files this under, from what it writes
393+
(`registry_data.kind_of`): reading pictures leaves a model in `text`. */
394+
kind: 'audio' | 'embedding' | 'image' | 'reranker' | 'text' | 'video'
388395
label: string
389396
output_modalities?: string[]
390397
}

0 commit comments

Comments
 (0)