Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
910 changes: 910 additions & 0 deletions docs/plans/2026-09-20-web-settings-model-section.md

Large diffs are not rendered by default.

256 changes: 256 additions & 0 deletions docs/specs/2026-09-20-web-settings-model-section-acceptance.md

Large diffs are not rendered by default.

602 changes: 602 additions & 0 deletions docs/specs/2026-09-20-web-settings-model-section-design.md

Large diffs are not rendered by default.

52 changes: 50 additions & 2 deletions i18n/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -3279,6 +3279,10 @@
"en": "No models under this account",
"zh": "这个账号下还没有模型"
},
"gui.picker.empty_kind": {
"en": "No {kind} model added here yet",
"zh": "这一家还没有添加{kind}模型"
},
"gui.picker.manage": {
"en": "Manage models & accounts...",
"zh": "管理模型与账号…"
Expand Down Expand Up @@ -6276,8 +6280,12 @@
"zh": "用量"
},
"gui.settings.nav.model": {
"en": "Models",
"zh": "模型"
"en": "Model settings",
"zh": "模型配置"
},
"gui.settings.nav.provider": {
"en": "Model providers",
"zh": "模型服务商"
},
"gui.settings.nav.skills": {
"en": "Skills",
Expand Down Expand Up @@ -6579,6 +6587,10 @@
"en": "Paste an API key",
"zh": "粘贴 API Key"
},
"gui.settings.providers.pick_one": {
"en": "Choose a provider on the left",
"zh": "在左边选一家服务商"
},
"gui.settings.providers.key_first": {
"en": "Enter the API key first",
"zh": "先填入 API Key"
Expand Down Expand Up @@ -6615,6 +6627,18 @@
"en": "Disconnect",
"zh": "断开"
},
"gui.settings.providers.disconnect_key": {
"en": "Disconnect and clear the key",
"zh": "断开并清除密钥"
},
"gui.settings.providers.disconnect_auth": {
"en": "Disconnect authorisation",
"zh": "断开授权"
},
"gui.settings.providers.disconnect_local": {
"en": "Disconnect this server",
"zh": "断开这台服务"
},
"gui.settings.providers.connection": {
"en": "Connection",
"zh": "连接"
Expand Down Expand Up @@ -6671,10 +6695,30 @@
"en": "added",
"zh": "已添加"
},
"gui.settings.providers.add_model": {
"en": "Add model",
"zh": "添加模型"
},
"gui.settings.providers.showing_added": {
"en": "showing {n} · added {m}",
"zh": "显示 {n} 个 · 已添加 {m}"
},
"gui.settings.providers.fetching": {
"en": "Fetching the model list from {name}",
"zh": "正在从 {name} 获取模型列表"
},
"gui.settings.providers.filter_direct": {
"en": "Direct vendors",
"zh": "直连厂商"
},
"gui.settings.providers.filter_gateway": {
"en": "Aggregators",
"zh": "聚合网关"
},
"gui.settings.providers.filter_oauth": {
"en": "Browser sign-in",
"zh": "浏览器授权"
},
"gui.settings.providers.no_list": {
"en": "This vendor has no model list endpoint; type a model id",
"zh": "这家没有模型列表接口,直接输入模型 id"
Expand Down Expand Up @@ -7175,6 +7219,10 @@
"en": "Overrides the default",
"zh": "覆盖默认"
},
"gui.settings.providers.base_reset": {
"en": "Reset",
"zh": "恢复默认"
},
"gui.settings.providers.key_optional_ph": {
"en": "API key, if the server wants one",
"zh": "API Key(服务要求时填)"
Expand Down
4 changes: 3 additions & 1 deletion raven/rpc/methods/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ def _model_labels(slug: str, models: "list[str]", *, section: Any = _UNLOADED) -
"""
from raven.providers.catalog import describe
from raven.providers.rates import resolve_context_window
from raven.providers.registry_data import kind_of

overlays = _configured_overlays(slug, section=section)
out: dict[str, dict[str, Any]] = {}
Expand All @@ -251,7 +252,7 @@ def _model_labels(slug: str, models: "list[str]", *, section: Any = _UNLOADED) -
window = resolve_context_window(model, allow_fetch=False)
if not (row.described or row.tagged or window):
continue
entry: dict[str, Any] = {"label": row.label}
entry: dict[str, Any] = {"label": row.label, "kind": kind_of(row.capabilities, row.output_modalities)}
if row.description:
entry["description"] = row.description
if row.capabilities:
Expand Down Expand Up @@ -397,6 +398,7 @@ def _build_provider_entry(
"protocols": protocols,
"protocol_overrides": overrides,
"total_models": len(models),
"gateway": bool(spec and spec.is_gateway),
# "An address must be supplied" -- the gate's answer, not the shape's:
# an endpoint-credential spec that ships a usable default (custom's
# localhost gateway) runs on a bare key, and the picker must not
Expand Down
7 changes: 7 additions & 0 deletions raven/rpc/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1558,6 +1558,9 @@ class ModelLabel(_Strict):
capabilities: list[str] = Field(default_factory=list)
input_modalities: list[str] = Field(default_factory=list)
output_modalities: list[str] = Field(default_factory=list)
#: 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.
kind: Literal["text", "image", "audio", "video", "embedding", "reranker"]
#: Tokens the model reads in one request. Resolved from the tables that also
#: route, never from the display registry -- a window sizes trimming, so the
#: number a picker shows has to be the number a request is sized with. None
Expand Down Expand Up @@ -1613,6 +1616,10 @@ class ModelOptionProvider(_Strict):
extra_headers: dict[str, str] = Field(default_factory=dict)
total_models: int
needs_api_base: bool
#: The registry's ``is_gateway``: resells other vendors' models under
#: vendor/model ids. The catalogue's filter reads it; no client can derive
#: it from a slug.
gateway: bool = False
#: Addresses to pick between, empty for the providers that have only one.
#: A row that states these is drawn with the list in place of a host field.
platforms: list[ModelOptionPlatform] = Field(default_factory=list)
Expand Down
19 changes: 18 additions & 1 deletion rpc-schema/openrpc.json
Original file line number Diff line number Diff line change
Expand Up @@ -10651,6 +10651,10 @@
"needs_api_base": {
"type": "boolean"
},
"gateway": {
"type": "boolean",
"description": "The registry's is_gateway: resells other vendors' models under vendor/model ids. The catalogue's gateway filter reads this; absent means false."
},
"platforms": {
"type": "array",
"items": {
Expand Down Expand Up @@ -10744,7 +10748,8 @@
"additionalProperties": false,
"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.",
"required": [
"label"
"label",
"kind"
],
"properties": {
"label": {
Expand Down Expand Up @@ -10774,6 +10779,18 @@
"type": "string"
}
},
"kind": {
"type": "string",
"enum": [
"text",
"image",
"audio",
"video",
"embedding",
"reranker"
],
"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."
},
"context_window": {
"type": "integer",
"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."
Expand Down
31 changes: 30 additions & 1 deletion tests/test_provider_registry_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,15 @@
import pytest

from raven.providers import registry_data
from raven.providers.registry_data import CAPABILITIES, MODALITIES, curated_for, provider_metadata, row_for
from raven.providers.registry_data import (
CAPABILITIES,
MODALITIES,
curated_for,
inferred_tags,
kind_of,
provider_metadata,
row_for,
)


def test_a_reseller_reads_the_vendors_row_without_carrying_a_copy() -> None:
Expand Down Expand Up @@ -430,3 +438,24 @@ def test_a_model_tagged_from_its_endpoint_reaches_the_same_bucket_as_a_catalogue
assert kind_of(["video-generation"], []) == "video"
# Reading pictures is still something a text model does.
assert kind_of(["image-recognition"], ["text"]) == "text"


# The same nine rows, with the same kinds, sit in ui-web/src/features/model/types.test.ts:
# the page copies inferred_tags' two patterns for a typed id's chip, and this
# table is what keeps the copy honest.
NAME_GUESSES = [
("openai/text-embedding-3-small", "embedding"),
("BAAI/bge-reranker-v2-m3", "reranker"),
("my-team/bge-reranker-custom", "reranker"),
("jina-embeddings-v3", "embedding"),
("bge-m3", "embedding"),
("gte-large", "embedding"),
("rerank-co/gpt-4", "text"),
("google/gemini-2.5-flash-image", "text"),
("deepseek-v4-pro", "text"),
]


@pytest.mark.parametrize(("model_id", "kind"), NAME_GUESSES)
def test_kind_of_a_name_alone(model_id: str, kind: str) -> None:
assert kind_of(inferred_tags(model_id), ()) == kind
26 changes: 25 additions & 1 deletion tests/test_rpc_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,30 @@ async def test_options_current_provider_derived_from_model(fake_home: Path) -> N
assert _entry(result, "anthropic")["is_current"] is True


async def test_options_rows_carry_the_gateway_flag(fake_home: Path) -> None:
_write_config(fake_home, {"agents": {"defaults": {"model": "anthropic/claude-sonnet-4-5"}}})
result = await model_options({})
assert _entry(result, "openrouter")["gateway"] is True
assert _entry(result, "custom")["gateway"] is True
assert _entry(result, "anthropic")["gateway"] is False


async def test_model_labels_carry_a_kind(fake_home: Path) -> None:
# openrouter with a key and one configured model whose name is the only
# thing that says what it is
_write_config(
fake_home,
{
"agents": {"defaults": {"model": "openrouter/anthropic/claude-sonnet-4-5"}},
"providers": {"openrouter": {"apiKey": "sk-or-xxx", "models": ["openai/text-embedding-3-small"]}},
},
)
result = await model_options({})
labels = _entry(result, "openrouter")["model_labels"]
assert labels["openai/text-embedding-3-small"]["kind"] == "embedding"
assert all("kind" in v for v in labels.values())


async def test_options_oauth_provider_warning_and_auth_type(fake_home: Path) -> None:
_write_config(fake_home, {"agents": {"defaults": {"model": "anthropic/claude-sonnet-4-5"}}})
result = await model_options({})
Expand Down Expand Up @@ -1351,7 +1375,7 @@ async def test_a_user_written_overlay_reaches_the_picker(fake_home: Path) -> Non
)
entry = _entry(await model_options({}), "hosted_vllm")
label = (entry.get("model_labels") or {}).get("hosted-vllm/my-finetune-v3")
assert label == {"label": "Our finetune", "description": "tuned on tickets"}
assert label == {"label": "Our finetune", "description": "tuned on tickets", "kind": "text"}


async def test_the_picker_gets_the_tags_it_draws_as_icons(fake_home: Path) -> None:
Expand Down
7 changes: 7 additions & 0 deletions ui-tui/src/gatewayTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,13 +378,20 @@ export interface ModelOptionProvider {
picker's offer -- config plus a curated shortlist plus a catalogue -- which
is what the TUI picker draws; a surface managing the list reads this one. */
configured_models?: string[]
/* Resells other vendors' models under `vendor/model` ids (the registry's
`is_gateway`). The web settings page filters its catalogue on it; here it
is declared so the drift check stays honest about what the wire sends. */
gateway?: boolean
model_labels?: Record<
string,
{
capabilities?: string[]
context_window?: number
description?: string
input_modalities?: string[]
/* Which bucket a model list files this under, from what it writes
(`registry_data.kind_of`): reading pictures leaves a model in `text`. */
kind: 'audio' | 'embedding' | 'image' | 'reranker' | 'text' | 'video'
label: string
output_modalities?: string[]
}
Expand Down
Loading
Loading