mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
feat(code): show model name instead of spec in switcher (#4460)
The `/model` switcher now shows just the model name under each provider header instead of the full `provider:model` string. --- In the `/model` selector (`libs/code`), provider-grouped rows rendered the full `provider:model` spec even though each row sits under a header that already names the provider. This shows just the model name for those rows, reducing redundancy. A `show_provider_prefix` flag is threaded through the label helpers and persisted on each `ModelOption` so incremental relabels in `_move_selection` stay consistent with the full rebuild. The pinned "Recent" section keeps the full spec since it has no provider header, and all selection/matching logic still keys off the full spec. Made by [Open SWE](https://openswe.vercel.app/agents/9a970f12-372e-9bdb-1fd6-57126025f5c2) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -438,6 +438,13 @@ class ProviderConfig(TypedDict, total=False):
|
||||
capitalization.
|
||||
"""
|
||||
|
||||
short_name: str
|
||||
"""Compact brand label for space-constrained UI (e.g. the `/model` Recent
|
||||
tag), where the full `display_name` — which may carry a parenthetical
|
||||
qualifier like `"OpenAI Codex (ChatGPT login)"` — is too long. Optional;
|
||||
when unset, callers fall back to `display_name`.
|
||||
"""
|
||||
|
||||
api_key_url: str
|
||||
"""Provider page where users can create or manage API keys.
|
||||
|
||||
@@ -2430,6 +2437,15 @@ class ModelConfig:
|
||||
display_name,
|
||||
)
|
||||
|
||||
short_name = cast("object", provider.get("short_name"))
|
||||
if short_name is not None and not isinstance(short_name, str):
|
||||
logger.warning(
|
||||
"Provider '%s' has non-string 'short_name' value %r "
|
||||
"(expected a string). Falling back to the display name.",
|
||||
name,
|
||||
short_name,
|
||||
)
|
||||
|
||||
api_key_url = cast("object", provider.get("api_key_url"))
|
||||
if api_key_url is not None and not isinstance(api_key_url, str):
|
||||
logger.warning(
|
||||
@@ -2618,6 +2634,19 @@ class ModelConfig:
|
||||
name = provider.get("display_name") if provider else None
|
||||
return name if isinstance(name, str) else None
|
||||
|
||||
def get_provider_short_name(self, provider_name: str) -> str | None:
|
||||
"""Get the configured compact brand name for a provider.
|
||||
|
||||
Args:
|
||||
provider_name: The provider to look up.
|
||||
|
||||
Returns:
|
||||
Compact brand name if configured, None otherwise.
|
||||
"""
|
||||
provider = self.providers.get(provider_name)
|
||||
name = provider.get("short_name") if provider else None
|
||||
return name if isinstance(name, str) else None
|
||||
|
||||
def get_provider_api_key_url(self, provider_name: str) -> str | None:
|
||||
"""Get the configured API-key management URL for a provider.
|
||||
|
||||
|
||||
@@ -102,6 +102,20 @@ PROVIDER_DISPLAY_NAMES: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
PROVIDER_SHORT_NAMES: dict[str, str] = {
|
||||
# Only providers whose `PROVIDER_DISPLAY_NAMES` label is too verbose for a
|
||||
# compact tag need an entry here; everything else falls back to the display
|
||||
# name, which is already short.
|
||||
"openai_codex": "OpenAI Codex",
|
||||
}
|
||||
"""Compact brand labels for space-constrained UI (e.g. the `/model` Recent tag).
|
||||
|
||||
Sparse companion to `PROVIDER_DISPLAY_NAMES`: an entry exists only when the full
|
||||
display name carries a parenthetical qualifier that reads badly inside a tag
|
||||
(e.g. `"OpenAI Codex (ChatGPT login)"`). Resolved via `provider_short_name`.
|
||||
"""
|
||||
|
||||
|
||||
PROVIDER_API_KEY_URLS: dict[str, str] = {
|
||||
"anthropic": "https://platform.claude.com/login?returnTo=%2Fsettings%2Fkeys",
|
||||
"baseten": "https://docs.baseten.co/organization/api-keys",
|
||||
@@ -141,8 +155,14 @@ def _is_safe_acquisition_url(url: str) -> bool:
|
||||
return urlsplit(url).scheme in {"http", "https"}
|
||||
|
||||
|
||||
def _provider_display_name(provider: str, config: ModelConfig | None = None) -> str:
|
||||
"""Return a human-readable provider label for auth UI.
|
||||
def provider_display_name(provider: str, config: ModelConfig | None = None) -> str:
|
||||
"""Return a human-readable provider label.
|
||||
|
||||
Shared by the auth UI and the model selector so a provider is labeled
|
||||
identically in both. (The install prompt reuses the underlying
|
||||
`PROVIDER_DISPLAY_NAMES` map directly rather than this function, to avoid an
|
||||
event-loop config read, so a user-configured `display_name` won't surface
|
||||
there.)
|
||||
|
||||
Resolution order: a configured `display_name`, then the built-in
|
||||
`PROVIDER_DISPLAY_NAMES` map, then a title-cased form of the provider key.
|
||||
@@ -160,6 +180,29 @@ def _provider_display_name(provider: str, config: ModelConfig | None = None) ->
|
||||
) or PROVIDER_DISPLAY_NAMES.get(provider, provider.replace("_", " ").title())
|
||||
|
||||
|
||||
def provider_short_name(provider: str, config: ModelConfig | None = None) -> str:
|
||||
"""Return a compact brand label for a provider.
|
||||
|
||||
For space-constrained UI (e.g. the `/model` Recent tag). Resolution order:
|
||||
a configured `short_name`, then the built-in `PROVIDER_SHORT_NAMES` map,
|
||||
then the full `provider_display_name` (which is already short for providers
|
||||
without a parenthetical qualifier).
|
||||
|
||||
Args:
|
||||
provider: Provider config key.
|
||||
config: Parsed model config, if already loaded by the caller.
|
||||
|
||||
Returns:
|
||||
Compact brand label, falling back to the display name when none is set.
|
||||
"""
|
||||
model_config = config or ModelConfig.load()
|
||||
return (
|
||||
model_config.get_provider_short_name(provider)
|
||||
or PROVIDER_SHORT_NAMES.get(provider)
|
||||
or provider_display_name(provider, model_config)
|
||||
)
|
||||
|
||||
|
||||
def _auth_status_for(provider: str) -> ProviderAuthStatus:
|
||||
"""Resolve the credential readiness of a provider or non-model service.
|
||||
|
||||
@@ -558,7 +601,7 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
|
||||
Widgets that make up the auth prompt modal.
|
||||
"""
|
||||
glyphs = get_glyphs()
|
||||
provider_label = _provider_display_name(self._provider, self._config)
|
||||
provider_label = provider_display_name(self._provider, self._config)
|
||||
with Vertical():
|
||||
# Tag the title with `(stored)` so the user knows a replacement
|
||||
# (or the `Ctrl+D delete` affordance shown in the help line) is
|
||||
@@ -801,7 +844,7 @@ class AuthPromptScreen(ModalScreen[AuthResult]):
|
||||
url = configured_url or PROVIDER_API_KEY_URLS.get(
|
||||
self._provider, _PROVIDERS_DOCS_URL
|
||||
)
|
||||
provider = _provider_display_name(self._provider, config)
|
||||
provider = provider_display_name(self._provider, config)
|
||||
label = (
|
||||
f"{provider} key page"
|
||||
if configured_url or self._provider in PROVIDER_API_KEY_URLS
|
||||
@@ -1532,7 +1575,7 @@ class AuthManagerScreen(ModalScreen[None]):
|
||||
Returns:
|
||||
A composed `Content` with the provider label and a status badge.
|
||||
"""
|
||||
name = _provider_display_name(provider)
|
||||
name = provider_display_name(provider)
|
||||
if not installed:
|
||||
return Content.assemble(
|
||||
Content.styled(name, "dim"),
|
||||
|
||||
@@ -195,7 +195,7 @@ class InstallProviderConfirmScreen(ModalScreen[bool]):
|
||||
# Reuse the auth UI's curated labels (e.g. `google_genai` -> "Google
|
||||
# Gemini") so the title reads naturally, falling back to a title-cased
|
||||
# provider key. Avoids the event-loop config read in
|
||||
# `_provider_display_name`, which is overkill for a static title.
|
||||
# `provider_display_name`, which is overkill for a static title.
|
||||
from deepagents_code.widgets.auth import PROVIDER_DISPLAY_NAMES
|
||||
|
||||
provider = PROVIDER_DISPLAY_NAMES.get(
|
||||
|
||||
@@ -30,6 +30,7 @@ from deepagents_code.model_config import (
|
||||
CODEX_PROVIDER,
|
||||
ModelConfig,
|
||||
ModelProfileEntry,
|
||||
ModelSpec,
|
||||
ProviderAuthState,
|
||||
ProviderAuthStatus,
|
||||
clear_default_model,
|
||||
@@ -63,66 +64,73 @@ from the per-provider sections below.
|
||||
"""
|
||||
|
||||
|
||||
_RECOMMENDED_MODELS: frozenset[str] = frozenset(
|
||||
{
|
||||
"anthropic:claude-opus-4-7",
|
||||
"anthropic:claude-opus-4-8",
|
||||
"anthropic:claude-sonnet-5",
|
||||
"baseten:deepseek-ai/DeepSeek-V4-Pro",
|
||||
"baseten:moonshotai/Kimi-K2.7-Code",
|
||||
"baseten:nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B",
|
||||
"baseten:zai-org/GLM-5.2",
|
||||
"fireworks:accounts/fireworks/models/deepseek-v4-pro",
|
||||
"fireworks:accounts/fireworks/models/glm-5p2",
|
||||
"fireworks:accounts/fireworks/models/kimi-k2p7-code",
|
||||
"fireworks:accounts/fireworks/models/minimax-m3",
|
||||
"fireworks:accounts/fireworks/models/qwen3p7-plus",
|
||||
"google_genai:gemini-3.5-flash",
|
||||
"google_genai:gemini-3.1-pro-preview",
|
||||
"ollama:deepseek-v4-flash:cloud",
|
||||
"ollama:deepseek-v4-pro:cloud",
|
||||
"ollama:glm-5.2:cloud",
|
||||
"ollama:kimi-k2.7-code:cloud",
|
||||
"ollama:minimax-m3:cloud",
|
||||
"openai:gpt-5.4",
|
||||
"openai:gpt-5.4-mini",
|
||||
"openai:gpt-5.4-pro",
|
||||
"openai:gpt-5.5",
|
||||
"openai:gpt-5.5-pro",
|
||||
"openai_codex:gpt-5.2",
|
||||
"openai_codex:gpt-5.3-codex",
|
||||
"openai_codex:gpt-5.4",
|
||||
"openai_codex:gpt-5.4-mini",
|
||||
"openai_codex:gpt-5.5",
|
||||
"openrouter:anthropic/claude-opus-4.6",
|
||||
"openrouter:anthropic/claude-opus-4.7",
|
||||
"openrouter:anthropic/claude-opus-4.7-fast",
|
||||
"openrouter:anthropic/claude-opus-4.8",
|
||||
"openrouter:anthropic/claude-sonnet-5",
|
||||
"openrouter:deepseek/deepseek-v4-flash",
|
||||
"openrouter:deepseek/deepseek-v4-flash:free",
|
||||
"openrouter:deepseek/deepseek-v4-pro",
|
||||
"openrouter:google/gemini-3.5-flash",
|
||||
"openrouter:google/gemini-3.1-pro-preview",
|
||||
"openrouter:moonshotai/kimi-k2.7-code",
|
||||
"openrouter:nvidia/nemotron-3-ultra-550b-a55b",
|
||||
"openrouter:openai/gpt-5.4",
|
||||
"openrouter:openai/gpt-5.4-mini",
|
||||
"openrouter:openai/gpt-5.4-pro",
|
||||
"openrouter:openai/gpt-5.5",
|
||||
"openrouter:openai/gpt-5.5-pro",
|
||||
"openrouter:openrouter/fusion",
|
||||
"openrouter:qwen/qwen3.7-plus",
|
||||
"openrouter:z-ai/glm-5.2",
|
||||
}
|
||||
)
|
||||
"""Hand-curated frontier-tier models promoted across the UI.
|
||||
_RECOMMENDED_MODELS: dict[str, str] = {
|
||||
"anthropic:claude-opus-4-7": "Claude Opus 4.7",
|
||||
"anthropic:claude-opus-4-8": "Claude Opus 4.8",
|
||||
"anthropic:claude-sonnet-5": "Claude Sonnet 5",
|
||||
"baseten:deepseek-ai/DeepSeek-V4-Pro": "DeepSeek V4 Pro",
|
||||
"baseten:moonshotai/Kimi-K2.7-Code": "Kimi K2.7 Code",
|
||||
"baseten:nvidia/NVIDIA-Nemotron-3-Ultra-550B-A55B": "Nemotron 3 Ultra 550B A55B",
|
||||
"baseten:zai-org/GLM-5.2": "GLM 5.2",
|
||||
"fireworks:accounts/fireworks/models/deepseek-v4-pro": "DeepSeek V4 Pro",
|
||||
"fireworks:accounts/fireworks/models/glm-5p2": "GLM 5.2",
|
||||
"fireworks:accounts/fireworks/models/kimi-k2p7-code": "Kimi K2.7 Code",
|
||||
"fireworks:accounts/fireworks/models/minimax-m3": "MiniMax-M3",
|
||||
"fireworks:accounts/fireworks/models/qwen3p7-plus": "Qwen 3.7 Plus",
|
||||
"google_genai:gemini-3.5-flash": "Gemini 3.5 Flash",
|
||||
"google_genai:gemini-3.1-pro-preview": "Gemini 3.1 Pro Preview",
|
||||
"ollama:deepseek-v4-flash:cloud": "DeepSeek V4 Flash",
|
||||
"ollama:deepseek-v4-pro:cloud": "DeepSeek V4 Pro",
|
||||
"ollama:glm-5.2:cloud": "GLM 5.2",
|
||||
"ollama:kimi-k2.7-code:cloud": "Kimi K2.7 Code",
|
||||
"ollama:minimax-m3:cloud": "MiniMax-M3",
|
||||
"openai:gpt-5.4": "GPT-5.4",
|
||||
"openai:gpt-5.4-mini": "GPT-5.4 mini",
|
||||
"openai:gpt-5.4-pro": "GPT-5.4 Pro",
|
||||
"openai:gpt-5.5": "GPT-5.5",
|
||||
"openai:gpt-5.5-pro": "GPT-5.5 Pro",
|
||||
"openai_codex:gpt-5.2": "GPT-5.2",
|
||||
"openai_codex:gpt-5.3-codex": "GPT-5.3 Codex",
|
||||
"openai_codex:gpt-5.4": "GPT-5.4",
|
||||
"openai_codex:gpt-5.4-mini": "GPT-5.4 mini",
|
||||
"openai_codex:gpt-5.5": "GPT-5.5",
|
||||
"openrouter:anthropic/claude-opus-4.6": "Claude Opus 4.6",
|
||||
"openrouter:anthropic/claude-opus-4.7": "Claude Opus 4.7",
|
||||
"openrouter:anthropic/claude-opus-4.7-fast": "Claude Opus 4.7 Fast",
|
||||
"openrouter:anthropic/claude-opus-4.8": "Claude Opus 4.8",
|
||||
"openrouter:anthropic/claude-sonnet-5": "Claude Sonnet 5",
|
||||
"openrouter:deepseek/deepseek-v4-flash": "DeepSeek V4 Flash",
|
||||
"openrouter:deepseek/deepseek-v4-flash:free": "DeepSeek V4 Flash (free)",
|
||||
"openrouter:deepseek/deepseek-v4-pro": "DeepSeek V4 Pro",
|
||||
"openrouter:google/gemini-3.5-flash": "Gemini 3.5 Flash",
|
||||
"openrouter:google/gemini-3.1-pro-preview": "Gemini 3.1 Pro Preview",
|
||||
"openrouter:moonshotai/kimi-k2.7-code": "Kimi K2.7 Code",
|
||||
"openrouter:nvidia/nemotron-3-ultra-550b-a55b": "Nemotron 3 Ultra 550B A55B",
|
||||
"openrouter:openai/gpt-5.4": "GPT-5.4",
|
||||
"openrouter:openai/gpt-5.4-mini": "GPT-5.4 mini",
|
||||
"openrouter:openai/gpt-5.4-pro": "GPT-5.4 Pro",
|
||||
"openrouter:openai/gpt-5.5": "GPT-5.5",
|
||||
"openrouter:openai/gpt-5.5-pro": "GPT-5.5 Pro",
|
||||
"openrouter:openrouter/fusion": "OpenRouter Fusion",
|
||||
"openrouter:qwen/qwen3.7-plus": "Qwen 3.7 Plus",
|
||||
"openrouter:z-ai/glm-5.2": "GLM 5.2",
|
||||
}
|
||||
"""Hand-curated frontier-tier models promoted across the UI, mapped to a
|
||||
human-readable display name.
|
||||
|
||||
Used by the onboarding picker (`curated=True`) and by the in-`/model`
|
||||
"Recommended only" toggle (Ctrl+R). Same model IDs may appear under multiple
|
||||
providers (e.g. Kimi-K2.7-Code via `baseten`, `fireworks`, `ollama`, and
|
||||
`openrouter`) and are listed under each provider intentionally so the user
|
||||
can pick whichever provider they have credentials for.
|
||||
"Recommended only" toggle (Ctrl+R). Membership tests and iteration operate on
|
||||
the spec keys; the names are a display fallback for `_get_model_display_name`
|
||||
when a provider package (and thus its profile `name`) is not installed — the
|
||||
common case for uninstalled recommendations and onboarding, where the raw
|
||||
model id (e.g. `accounts/fireworks/models/kimi-k2p7-code`) would otherwise
|
||||
show. When a profile is available its upstream `name` wins, so these stay a
|
||||
safety net rather than a second source of truth.
|
||||
|
||||
Same model IDs may appear under multiple providers (e.g. Kimi K2.7 Code via
|
||||
`baseten`, `fireworks`, `ollama`, and `openrouter`) and are listed under each
|
||||
provider intentionally so the user can pick whichever provider they have
|
||||
credentials for.
|
||||
"""
|
||||
|
||||
|
||||
@@ -159,6 +167,7 @@ class ModelOption(Static):
|
||||
*,
|
||||
auth_status: ProviderAuthStatus | None = None,
|
||||
classes: str = "",
|
||||
show_provider: bool = True,
|
||||
) -> None:
|
||||
"""Initialize a model option.
|
||||
|
||||
@@ -170,10 +179,18 @@ class ModelOption(Static):
|
||||
index: The index of this option in the filtered list.
|
||||
auth_status: Provider auth/readiness status.
|
||||
classes: CSS classes for styling.
|
||||
show_provider: Whether the row appends a dim `(provider)` tag after
|
||||
the model name. `True` for the cross-provider "Recent" section,
|
||||
which has no provider header to disambiguate the same model
|
||||
offered by multiple providers; `False` for provider-grouped
|
||||
rows where the header already names the provider. Persisted on
|
||||
the widget so incremental relabels in `_move_selection`
|
||||
reproduce the same display.
|
||||
"""
|
||||
super().__init__(label, classes=classes)
|
||||
self.model_spec = model_spec
|
||||
self.index = index
|
||||
self.show_provider = show_provider
|
||||
self.auth_status = auth_status or ProviderAuthStatus(
|
||||
state=ProviderAuthState.UNKNOWN,
|
||||
provider=provider,
|
||||
@@ -582,9 +599,9 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
)
|
||||
|
||||
# Seeded from the discovered models; a recommended spec already
|
||||
# surfaced here is skipped below. Recommended specs are unique (a
|
||||
# frozenset iterated once), so this entry guard is the only dedup
|
||||
# needed and the set never has to grow inside the loop.
|
||||
# surfaced here is skipped below. Recommended specs are unique (dict
|
||||
# keys iterated once), so this entry guard is the only dedup needed
|
||||
# and the set never has to grow inside the loop.
|
||||
existing_specs = {spec for spec, _ in all_models}
|
||||
installed_recommended: list[tuple[str, str]] = []
|
||||
uninstalled_recommended: list[tuple[str, str]] = []
|
||||
@@ -837,11 +854,38 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
tokens = query.split()
|
||||
search_models = self._all_models if self._curated else self._unfiltered_models
|
||||
|
||||
# Match against what the user actually sees, not just the raw spec: the
|
||||
# friendly model name and provider label are folded into the search
|
||||
# haystack so e.g. "Opus 4.8" finds `anthropic:claude-opus-4-8` (whose
|
||||
# spec, with hyphens, the "4.8" token can't subsequence-match) and
|
||||
# "OpenAI Codex" finds the codex rows. The spec stays in the haystack so
|
||||
# existing muscle-memory queries keep working.
|
||||
from deepagents_code.widgets.auth import provider_display_name
|
||||
|
||||
config = ModelConfig.load()
|
||||
provider_labels: dict[str, str] = {}
|
||||
|
||||
# Resolve the display labels up front, *outside* the try below. That
|
||||
# fallback exists for `Matcher` choking on edge-case input; folding
|
||||
# label resolution into it would misattribute an error from
|
||||
# `_get_model_display_name`/`provider_display_name` to the matcher and
|
||||
# silently drop the user's filter. Any failure here should surface, not
|
||||
# masquerade as "no matches".
|
||||
haystacks: list[tuple[str, str, str]] = []
|
||||
for spec, provider in search_models:
|
||||
label = provider_labels.get(provider)
|
||||
if label is None:
|
||||
label = provider_display_name(provider, config)
|
||||
provider_labels[provider] = label
|
||||
haystacks.append(
|
||||
(spec, provider, f"{spec} {self._get_model_display_name(spec)} {label}")
|
||||
)
|
||||
|
||||
try:
|
||||
matchers = [Matcher(token, case_sensitive=False) for token in tokens]
|
||||
scored: list[tuple[float, str, str]] = []
|
||||
for spec, provider in search_models:
|
||||
scores = [m.match(spec) for m in matchers]
|
||||
for spec, provider, haystack in haystacks:
|
||||
scores = [m.match(haystack) for m in matchers]
|
||||
if all(s > 0 for s in scores):
|
||||
scored.append((min(scores), spec, provider))
|
||||
except Exception:
|
||||
@@ -1106,9 +1150,18 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
selected_widget = widget
|
||||
flat_index += 1
|
||||
|
||||
# Resolve friendly provider labels via the shared helper so headers
|
||||
# match the `/auth` and install UIs (e.g. `openai_codex` renders as
|
||||
# "OpenAI Codex (ChatGPT login)"). Load config once; the helper reads a
|
||||
# user-configured `display_name` before the built-in map.
|
||||
from deepagents_code.widgets.auth import provider_display_name
|
||||
|
||||
config = ModelConfig.load()
|
||||
|
||||
for provider, model_entries in by_provider.items():
|
||||
# Provider header; auth/readiness indicator appended only when non-empty.
|
||||
auth_status = auth_statuses[provider]
|
||||
provider_label = provider_display_name(provider, config)
|
||||
if provider in self._install_extras:
|
||||
auth_indicator = self._install_indicator()
|
||||
else:
|
||||
@@ -1116,13 +1169,13 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
if auth_indicator:
|
||||
header_content = Content.from_markup(
|
||||
"[bold]$provider[/bold] [dim]$auth[/dim]",
|
||||
provider=provider,
|
||||
provider=provider_label,
|
||||
auth=auth_indicator,
|
||||
)
|
||||
else:
|
||||
header_content = Content.from_markup(
|
||||
"[bold]$provider[/bold]",
|
||||
provider=provider,
|
||||
provider=provider_label,
|
||||
)
|
||||
all_widgets.append(Static(header_content, classes="model-provider-header"))
|
||||
|
||||
@@ -1137,7 +1190,11 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
classes += " model-option-current"
|
||||
|
||||
label = self._build_option_label(
|
||||
model_spec, provider, auth_status, selected=is_selected
|
||||
model_spec,
|
||||
provider,
|
||||
auth_status,
|
||||
selected=is_selected,
|
||||
show_provider=False,
|
||||
)
|
||||
widget = ModelOption(
|
||||
label=label,
|
||||
@@ -1146,6 +1203,7 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
index=flat_index,
|
||||
auth_status=auth_status,
|
||||
classes=classes,
|
||||
show_provider=False,
|
||||
)
|
||||
all_widgets.append(widget)
|
||||
self._option_widgets.append(widget)
|
||||
@@ -1199,9 +1257,16 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
auth_status: ProviderAuthStatus,
|
||||
*,
|
||||
selected: bool,
|
||||
show_provider: bool = True,
|
||||
) -> Content:
|
||||
"""Build a model-option label from the current screen state.
|
||||
|
||||
Every row shows the model's human-readable name (via
|
||||
`_get_model_display_name`). The cross-provider "Recent" section
|
||||
(`show_provider=True`) additionally appends a dim `(provider)` tag,
|
||||
since it has no provider header to disambiguate the same model offered
|
||||
by multiple providers.
|
||||
|
||||
Centralizes the per-row flag derivation (current/default/status/
|
||||
`install_required`) shared by the full rebuild in `_update_display`
|
||||
and the incremental relabel in `_move_selection`, so the two paths
|
||||
@@ -1215,10 +1280,18 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
install-required set.
|
||||
auth_status: Provider auth/readiness status for the row.
|
||||
selected: Whether this row is the highlighted one.
|
||||
show_provider: Whether to append the dim `(provider)` tag — `True`
|
||||
for Recent rows, `False` for provider-grouped rows. The tag
|
||||
uses the compact brand label (`provider_short_name`).
|
||||
|
||||
Returns:
|
||||
Styled `Content` label.
|
||||
"""
|
||||
provider_label: str | None = None
|
||||
if show_provider:
|
||||
from deepagents_code.widgets.auth import provider_short_name
|
||||
|
||||
provider_label = provider_short_name(provider)
|
||||
return self._format_option_label(
|
||||
model_spec,
|
||||
selected=selected,
|
||||
@@ -1227,6 +1300,8 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
is_default=model_spec == self._default_spec,
|
||||
status=self._get_model_status(model_spec),
|
||||
install_required=provider in self._install_extras,
|
||||
display_name=self._get_model_display_name(model_spec),
|
||||
provider_label=provider_label,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -1239,6 +1314,8 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
is_default: bool = False,
|
||||
status: str | None = None,
|
||||
install_required: bool = False,
|
||||
display_name: str | None = None,
|
||||
provider_label: str | None = None,
|
||||
) -> Content:
|
||||
"""Build the display label for a model option.
|
||||
|
||||
@@ -1254,6 +1331,16 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
install_required: Whether the provider's integration package is not
|
||||
installed; renders the spec dimmed since selecting it prompts
|
||||
an install rather than switching immediately.
|
||||
display_name: Text to show in place of the full `model_spec`. When
|
||||
`None`, the full spec is shown. Both the Recent and
|
||||
provider-grouped rows pass the model's human-readable name (see
|
||||
`_get_model_display_name`).
|
||||
provider_label: When set (and `display_name` is given), appends a
|
||||
dim ` (provider)` tag after the name — used by the
|
||||
cross-provider Recent section, which has no provider header to
|
||||
disambiguate the same model across providers. `None` for
|
||||
provider-grouped rows. Ignored when `display_name` is `None`,
|
||||
since the raw spec already embeds the provider.
|
||||
|
||||
Returns:
|
||||
Styled Content label.
|
||||
@@ -1261,19 +1348,28 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
colors = theme.get_theme_colors()
|
||||
glyphs = get_glyphs()
|
||||
cursor = f"{glyphs.cursor} " if selected else " "
|
||||
display = model_spec if display_name is None else display_name
|
||||
# When selected, skip the inline primary color — CSS already flips the
|
||||
# row to ($primary bg, $background fg). Keep `bold` so the default
|
||||
# emphasis survives both states.
|
||||
if install_required and not selected:
|
||||
spec = Content.styled(model_spec, "dim")
|
||||
spec = Content.styled(display, "dim")
|
||||
elif auth_status.blocks_start:
|
||||
spec = Content.styled(model_spec, colors.warning)
|
||||
spec = Content.styled(display, colors.warning)
|
||||
elif is_default and selected:
|
||||
spec = Content.styled(model_spec, "bold")
|
||||
spec = Content.styled(display, "bold")
|
||||
elif is_default:
|
||||
spec = Content.styled(model_spec, f"bold {colors.primary}")
|
||||
spec = Content.styled(display, f"bold {colors.primary}")
|
||||
else:
|
||||
spec = Content(model_spec)
|
||||
spec = Content(display)
|
||||
# Dim provider tag disambiguates Recent rows (no provider header).
|
||||
# Styled like `(current)` — always dim, so it survives row selection.
|
||||
# Requires a friendly `display_name`: tagging the raw spec (which
|
||||
# already embeds the provider) would print the provider twice.
|
||||
if provider_label and display_name is not None:
|
||||
provider_tag = Content.styled(f" ({provider_label})", "dim")
|
||||
else:
|
||||
provider_tag = Content("")
|
||||
suffix = Content.styled(" (current)", "dim") if current else Content("")
|
||||
if is_default and selected:
|
||||
default_suffix = Content.styled(" (default)", "bold")
|
||||
@@ -1287,7 +1383,9 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
status_suffix = Content.styled(f" ({status})", colors.warning)
|
||||
else:
|
||||
status_suffix = Content("")
|
||||
return Content.assemble(cursor, spec, suffix, default_suffix, status_suffix)
|
||||
return Content.assemble(
|
||||
cursor, spec, provider_tag, suffix, default_suffix, status_suffix
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _format_footer(
|
||||
@@ -1422,6 +1520,40 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
return None
|
||||
return profile.get("status")
|
||||
|
||||
def _get_model_display_name(self, model_spec: str) -> str:
|
||||
"""Resolve the friendly display name for a model spec.
|
||||
|
||||
Used by every row (provider-grouped and the cross-provider Recent
|
||||
section) and folded into the search haystack. Prefers the profile's
|
||||
human-readable `name` (e.g. `'Claude Sonnet 5'`), which reads better
|
||||
than the raw model id. When no profile is loaded — the case for
|
||||
uninstalled recommendations and onboarding — falls back to the
|
||||
hardcoded name in `_RECOMMENDED_MODELS`, then the model portion of the
|
||||
spec, then the spec itself.
|
||||
|
||||
Args:
|
||||
model_spec: The `provider:model` string.
|
||||
|
||||
Returns:
|
||||
The display name for the row.
|
||||
"""
|
||||
entry = self._profiles.get(model_spec)
|
||||
if entry:
|
||||
profile = entry.get("profile")
|
||||
# `profile` originates from provider packages, so guard its type
|
||||
# rather than trusting the schema before `.get`.
|
||||
if isinstance(profile, dict):
|
||||
name = profile.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
return name
|
||||
recommended = _RECOMMENDED_MODELS.get(model_spec)
|
||||
if recommended:
|
||||
return recommended
|
||||
parsed = ModelSpec.try_parse(model_spec)
|
||||
# `parsed.model` can be empty for a malformed spec like `provider:`;
|
||||
# fall back to the raw spec rather than rendering a blank row.
|
||||
return parsed.model if parsed and parsed.model else model_spec
|
||||
|
||||
def _update_footer(self) -> None:
|
||||
"""Update the detail footer for the currently highlighted model."""
|
||||
footer = self.query_one("#model-detail-footer", Static)
|
||||
@@ -1461,6 +1593,7 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
old_widget.provider,
|
||||
old_widget.auth_status,
|
||||
selected=False,
|
||||
show_provider=old_widget.show_provider,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1473,6 +1606,7 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
new_widget.provider,
|
||||
new_widget.auth_status,
|
||||
selected=True,
|
||||
show_provider=new_widget.show_provider,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ from deepagents_code.widgets.auth import (
|
||||
AuthPromptScreen,
|
||||
AuthResult,
|
||||
_is_safe_acquisition_url,
|
||||
_provider_display_name,
|
||||
provider_display_name,
|
||||
provider_short_name,
|
||||
)
|
||||
from deepagents_code.widgets.codex_auth import CodexAuthScreen
|
||||
|
||||
@@ -203,23 +204,46 @@ class TestAuthPromptScreen:
|
||||
|
||||
def test_display_name_falls_back_to_title_cased_key(self) -> None:
|
||||
"""Unmapped provider keys degrade to a readable title-cased label."""
|
||||
assert _provider_display_name("acme_gateway") == "Acme Gateway"
|
||||
assert provider_display_name("acme_gateway") == "Acme Gateway"
|
||||
|
||||
def test_config_display_name_overrides_builtin_map(self) -> None:
|
||||
"""A configured `display_name` wins over the built-in label.
|
||||
|
||||
Pins the resolution order (config > built-in map) so reordering the
|
||||
`or` chain in `_provider_display_name` would fail a test instead of
|
||||
`or` chain in `provider_display_name` would fail a test instead of
|
||||
silently shadowing user config.
|
||||
"""
|
||||
config = model_config.ModelConfig(
|
||||
providers={"openai": {"display_name": "Custom OpenAI"}}
|
||||
)
|
||||
assert _provider_display_name("openai", config) == "Custom OpenAI"
|
||||
assert provider_display_name("openai", config) == "Custom OpenAI"
|
||||
# Without the override, resolution falls through to the built-in map.
|
||||
empty = model_config.ModelConfig()
|
||||
builtin_label = PROVIDER_DISPLAY_NAMES["openai"]
|
||||
assert _provider_display_name("openai", empty) == builtin_label
|
||||
assert provider_display_name("openai", empty) == builtin_label
|
||||
|
||||
def test_short_name_uses_builtin_brand_map(self) -> None:
|
||||
"""A provider with a verbose display name gets its compact brand."""
|
||||
empty = model_config.ModelConfig()
|
||||
assert provider_short_name("openai_codex", empty) == "OpenAI Codex"
|
||||
|
||||
def test_short_name_falls_back_to_display_name(self) -> None:
|
||||
"""Providers without a brand override reuse the display name."""
|
||||
empty = model_config.ModelConfig()
|
||||
assert provider_short_name("openai", empty) == PROVIDER_DISPLAY_NAMES["openai"]
|
||||
# Unmapped keys degrade through display-name resolution to title case.
|
||||
assert provider_short_name("acme_gateway", empty) == "Acme Gateway"
|
||||
|
||||
def test_config_short_name_overrides_builtin_map(self) -> None:
|
||||
"""A configured `short_name` wins over the built-in brand map.
|
||||
|
||||
Pins the resolution order (config > built-in map > display name) so
|
||||
reordering the `or` chain in `provider_short_name` fails a test.
|
||||
"""
|
||||
config = model_config.ModelConfig(
|
||||
providers={"openai_codex": {"short_name": "Codex"}}
|
||||
)
|
||||
assert provider_short_name("openai_codex", config) == "Codex"
|
||||
|
||||
def test_is_safe_acquisition_url_rejects_non_http_schemes(self) -> None:
|
||||
"""Only http/https URLs are eligible to render as clickable links."""
|
||||
|
||||
@@ -1507,6 +1507,7 @@ api_key_env = "OPENAI_API_KEY"
|
||||
config_path.write_text("""
|
||||
[models.providers.my_gateway]
|
||||
display_name = "My Gateway"
|
||||
short_name = "Gateway"
|
||||
api_key_url = "https://gateway.example/keys"
|
||||
models = ["my-model"]
|
||||
api_key_env = "MY_GATEWAY_API_KEY"
|
||||
@@ -1514,11 +1515,13 @@ api_key_env = "MY_GATEWAY_API_KEY"
|
||||
config = ModelConfig.load(config_path)
|
||||
|
||||
assert config.get_provider_display_name("my_gateway") == "My Gateway"
|
||||
assert config.get_provider_short_name("my_gateway") == "Gateway"
|
||||
assert (
|
||||
config.get_provider_api_key_url("my_gateway")
|
||||
== "https://gateway.example/keys"
|
||||
)
|
||||
assert config.get_provider_display_name("missing") is None
|
||||
assert config.get_provider_short_name("missing") is None
|
||||
assert config.get_provider_api_key_url("missing") is None
|
||||
|
||||
def test_ignores_non_string_provider_api_key_url(self, tmp_path):
|
||||
@@ -1547,8 +1550,21 @@ api_key_env = "MY_GATEWAY_API_KEY"
|
||||
|
||||
assert config.get_provider_display_name("my_gateway") is None
|
||||
|
||||
def test_ignores_non_string_provider_short_name(self, tmp_path):
|
||||
"""Non-string provider short names fall back to the display name."""
|
||||
config_path = tmp_path / "config.toml"
|
||||
config_path.write_text("""
|
||||
[models.providers.my_gateway]
|
||||
short_name = 123
|
||||
models = ["my-model"]
|
||||
api_key_env = "MY_GATEWAY_API_KEY"
|
||||
""")
|
||||
config = ModelConfig.load(config_path)
|
||||
|
||||
assert config.get_provider_short_name("my_gateway") is None
|
||||
|
||||
def test_warns_on_non_string_provider_metadata(self, tmp_path, caplog):
|
||||
"""Malformed `display_name`/`api_key_url` warn at load, matching siblings.
|
||||
"""Malformed `display_name`/`short_name`/`api_key_url` warn at load.
|
||||
|
||||
Surfaces the misconfiguration as a diagnostic instead of silently
|
||||
dropping it, consistent with the `enabled`/`class_path` validation.
|
||||
@@ -1557,6 +1573,7 @@ api_key_env = "MY_GATEWAY_API_KEY"
|
||||
config_path.write_text("""
|
||||
[models.providers.my_gateway]
|
||||
display_name = 123
|
||||
short_name = 456
|
||||
api_key_url = ["not", "a", "string"]
|
||||
models = ["my-model"]
|
||||
api_key_env = "MY_GATEWAY_API_KEY"
|
||||
@@ -1566,6 +1583,7 @@ api_key_env = "MY_GATEWAY_API_KEY"
|
||||
|
||||
messages = [r.getMessage() for r in caplog.records]
|
||||
assert any("non-string 'display_name'" in m for m in messages)
|
||||
assert any("non-string 'short_name'" in m for m in messages)
|
||||
assert any("non-string 'api_key_url'" in m for m in messages)
|
||||
|
||||
def test_loads_custom_base_url(self, tmp_path):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for ModelSelectorScreen."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar
|
||||
from unittest.mock import MagicMock
|
||||
@@ -23,13 +23,22 @@ from deepagents_code.widgets.model_selector import ModelSelectorScreen
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _seed_provider_credentials(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
def _seed_provider_credentials(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> Iterator[None]:
|
||||
"""Seed credentials so dismissal tests aren't blocked by missing keys.
|
||||
|
||||
The selector now opens an auth prompt when the highlighted provider
|
||||
has no key. Most tests in this file just want to assert dismissal
|
||||
behavior, so we seed env vars for the providers their fixtures use
|
||||
and redirect the credential store into a clean temp dir.
|
||||
|
||||
Also redirects `DEFAULT_CONFIG_PATH` to a (nonexistent) temp file and
|
||||
clears the process-wide config cache. The selector now resolves provider
|
||||
labels via `ModelConfig.load()`, which defaults to the developer's real
|
||||
`~/.deepagents/config.toml`; a local `display_name`/`short_name` override
|
||||
would otherwise flip label assertions. This keeps the suite hermetic.
|
||||
"""
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
@@ -40,6 +49,15 @@ def _seed_provider_credentials(monkeypatch: pytest.MonkeyPatch, tmp_path: Path)
|
||||
monkeypatch.setattr(
|
||||
"deepagents_code.model_config.DEFAULT_STATE_DIR", tmp_path / ".state"
|
||||
)
|
||||
from deepagents_code import model_config
|
||||
|
||||
monkeypatch.setattr(model_config, "DEFAULT_CONFIG_PATH", tmp_path / "config.toml")
|
||||
# The cache is a module global that monkeypatch can't undo; clear it before
|
||||
# and after so neither a prior test's real config nor this fixture's empty
|
||||
# config leaks across tests.
|
||||
model_config.clear_caches()
|
||||
yield
|
||||
model_config.clear_caches()
|
||||
|
||||
|
||||
_FILTER_TEST_MODELS: list[tuple[str, str]] = [
|
||||
@@ -548,6 +566,94 @@ class TestRecentModelsSection:
|
||||
]
|
||||
assert not any("Recent" in h for h in headers)
|
||||
|
||||
async def test_provider_header_uses_friendly_name(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Provider headers render the friendly label, not the raw config key."""
|
||||
from deepagents_code.widgets import model_selector
|
||||
|
||||
monkeypatch.setattr(
|
||||
model_selector,
|
||||
"get_available_models",
|
||||
lambda: {"openai_codex": ["gpt-5.5"]},
|
||||
)
|
||||
monkeypatch.setattr(model_selector, "load_recent_models", list)
|
||||
|
||||
app = ModelSelectorTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
screen = ModelSelectorScreen()
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
|
||||
headers = [
|
||||
str(h.content)
|
||||
for h in screen.query(".model-provider-header").results(Static)
|
||||
]
|
||||
assert any("OpenAI Codex (ChatGPT login)" in h for h in headers)
|
||||
assert not any("openai_codex" in h for h in headers)
|
||||
|
||||
async def test_recent_row_shows_name_and_provider_tag(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Recent rows show the friendly name plus a brand `(provider)` tag."""
|
||||
from deepagents_code.widgets import model_selector
|
||||
|
||||
monkeypatch.setattr(
|
||||
model_selector,
|
||||
"get_available_models",
|
||||
lambda: {"openai": ["gpt-5.5"]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
model_selector,
|
||||
"load_recent_models",
|
||||
lambda: ["openai:gpt-5.5"],
|
||||
)
|
||||
|
||||
app = ModelSelectorTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
screen = ModelSelectorScreen()
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
|
||||
recent = screen._option_widgets[0]
|
||||
assert recent.model_spec == "openai:gpt-5.5"
|
||||
assert recent.show_provider
|
||||
text = str(recent.content)
|
||||
assert "GPT-5.5" in text
|
||||
# No brand override for openai: falls back to the display name.
|
||||
assert "(OpenAI)" in text
|
||||
assert "openai:gpt-5.5" not in text
|
||||
|
||||
async def test_recent_row_uses_short_brand_over_verbose_display_name(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The Recent tag uses the compact brand, not the verbose auth label."""
|
||||
from deepagents_code.widgets import model_selector
|
||||
|
||||
monkeypatch.setattr(
|
||||
model_selector,
|
||||
"get_available_models",
|
||||
lambda: {"openai_codex": ["gpt-5.5"]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
model_selector,
|
||||
"load_recent_models",
|
||||
lambda: ["openai_codex:gpt-5.5"],
|
||||
)
|
||||
|
||||
app = ModelSelectorTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
screen = ModelSelectorScreen()
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
|
||||
recent = screen._option_widgets[0]
|
||||
assert recent.model_spec == "openai_codex:gpt-5.5"
|
||||
text = str(recent.content)
|
||||
assert "(OpenAI Codex)" in text
|
||||
# The verbose auth label must not leak into the compact tag.
|
||||
assert "ChatGPT login" not in text
|
||||
|
||||
async def test_recent_entries_appear_in_provider_section_too(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
@@ -1224,6 +1330,69 @@ class TestModelSelectorFuzzyMatching:
|
||||
f"'claude sonnet' should match claude-sonnet models. Got: {specs}"
|
||||
)
|
||||
|
||||
def test_fuzzy_matches_friendly_name_dotted_version(self) -> None:
|
||||
"""A dotted version in the friendly name matches where the spec can't.
|
||||
|
||||
`anthropic:claude-opus-4-7` hyphenates its version, so the "4.7" token
|
||||
cannot subsequence-match the spec. The friendly name "Claude Opus 4.7"
|
||||
(from the recommended list) supplies the dotted form.
|
||||
"""
|
||||
screen = _model_selector_for_filtering()
|
||||
screen._filter_text = "opus 4.7"
|
||||
screen._update_filtered_list()
|
||||
|
||||
specs = [spec for spec, _ in screen._filtered_models]
|
||||
assert "anthropic:claude-opus-4-7" in specs, (
|
||||
f"friendly name should let 'opus 4.7' match. Got: {specs}"
|
||||
)
|
||||
|
||||
def test_fuzzy_dotted_version_needs_friendly_name(self) -> None:
|
||||
"""Negative control: without the friendly name, "4.7" can't match the spec.
|
||||
|
||||
Pins that the previous test passes because of the folded-in name, not
|
||||
some incidental spec match — guarding the friendly-name search feature.
|
||||
"""
|
||||
screen = _model_selector_for_filtering()
|
||||
|
||||
# Neutralize the friendly name (return the hyphenated model portion, as
|
||||
# the raw spec already carries) so "4.7" has no dotted form to match.
|
||||
screen._get_model_display_name = ( # ty: ignore[invalid-assignment]
|
||||
lambda spec: spec.split(":", 1)[-1]
|
||||
)
|
||||
screen._filter_text = "opus 4.7"
|
||||
screen._update_filtered_list()
|
||||
|
||||
specs = [spec for spec, _ in screen._filtered_models]
|
||||
assert "anthropic:claude-opus-4-7" not in specs
|
||||
|
||||
def test_fuzzy_matches_provider_friendly_label(self) -> None:
|
||||
"""The provider display label — not just the key — is searchable.
|
||||
|
||||
Searches "chatgpt", which appears in neither the spec
|
||||
(`openai_codex:gpt-5.2`), the friendly model name ("GPT-5.2"), nor the
|
||||
provider key (`openai_codex`) — only in the resolved display label
|
||||
"OpenAI Codex (ChatGPT login)". So a match proves the provider-label
|
||||
branch of the haystack is doing the work; there is no other source for
|
||||
it. Guards against the label term being silently dropped.
|
||||
"""
|
||||
screen = _model_selector_for_filtering()
|
||||
# Inject a codex row locally rather than mutate the shared fixture that
|
||||
# other filter tests count on.
|
||||
codex = ("openai_codex:gpt-5.2", "openai_codex")
|
||||
for models in (
|
||||
screen._unfiltered_models,
|
||||
screen._all_models,
|
||||
screen._filtered_models,
|
||||
):
|
||||
models.append(codex)
|
||||
screen._filter_text = "chatgpt"
|
||||
screen._update_filtered_list()
|
||||
|
||||
specs = [spec for spec, _ in screen._filtered_models]
|
||||
assert specs == ["openai_codex:gpt-5.2"], (
|
||||
f"'chatgpt' should match only via the provider label. Got: {specs}"
|
||||
)
|
||||
|
||||
async def test_tab_noop_when_no_matches(self) -> None:
|
||||
"""Tab should do nothing when filter matches no models."""
|
||||
app = ModelSelectorTestApp()
|
||||
@@ -1845,6 +2014,70 @@ class TestFormatOptionLabel:
|
||||
# Not dimmed when selected; the missing-creds warning color applies.
|
||||
assert DARK_COLORS.warning in label.markup
|
||||
|
||||
def test_uses_display_name_when_provided(self) -> None:
|
||||
"""Provider-grouped rows render the profile name, not the full spec."""
|
||||
label = ModelSelectorScreen._format_option_label(
|
||||
"anthropic:claude-sonnet-4-5",
|
||||
selected=False,
|
||||
current=False,
|
||||
auth_status=ProviderAuthStatus(
|
||||
state=ProviderAuthState.CONFIGURED,
|
||||
provider="anthropic",
|
||||
source=ProviderAuthSource.ENV,
|
||||
),
|
||||
display_name="Claude Sonnet 4.5",
|
||||
)
|
||||
assert "Claude Sonnet 4.5" in label.plain
|
||||
assert "anthropic:claude-sonnet-4-5" not in label.plain
|
||||
|
||||
def test_shows_full_spec_when_no_display_name(self) -> None:
|
||||
"""With no display_name the full spec is shown (static-method default)."""
|
||||
label = ModelSelectorScreen._format_option_label(
|
||||
"anthropic:claude-sonnet-4-5",
|
||||
selected=False,
|
||||
current=False,
|
||||
auth_status=ProviderAuthStatus(
|
||||
state=ProviderAuthState.CONFIGURED,
|
||||
provider="anthropic",
|
||||
source=ProviderAuthSource.ENV,
|
||||
),
|
||||
)
|
||||
assert "anthropic:claude-sonnet-4-5" in label.plain
|
||||
|
||||
def test_appends_provider_tag_for_recent(self) -> None:
|
||||
"""Recent rows show the name plus a `(provider)` tag, not the raw spec."""
|
||||
label = ModelSelectorScreen._format_option_label(
|
||||
"openai:gpt-5.5",
|
||||
selected=False,
|
||||
current=False,
|
||||
auth_status=ProviderAuthStatus(
|
||||
state=ProviderAuthState.CONFIGURED,
|
||||
provider="openai",
|
||||
source=ProviderAuthSource.ENV,
|
||||
),
|
||||
display_name="GPT-5.5",
|
||||
provider_label="openai",
|
||||
)
|
||||
assert "GPT-5.5" in label.plain
|
||||
assert "(openai)" in label.plain
|
||||
assert "openai:gpt-5.5" not in label.plain
|
||||
|
||||
def test_no_provider_tag_without_label(self) -> None:
|
||||
"""Provider-grouped rows omit the `(provider)` tag."""
|
||||
label = ModelSelectorScreen._format_option_label(
|
||||
"openai:gpt-5.5",
|
||||
selected=False,
|
||||
current=False,
|
||||
auth_status=ProviderAuthStatus(
|
||||
state=ProviderAuthState.CONFIGURED,
|
||||
provider="openai",
|
||||
source=ProviderAuthSource.ENV,
|
||||
),
|
||||
display_name="GPT-5.5",
|
||||
)
|
||||
assert "GPT-5.5" in label.plain
|
||||
assert "(openai)" not in label.plain
|
||||
|
||||
|
||||
class TestFormatAuthIndicator:
|
||||
"""Tests for provider auth indicator labels."""
|
||||
@@ -1987,6 +2220,101 @@ class TestGetModelStatus:
|
||||
assert screen._get_model_status("anthropic:model") is None
|
||||
|
||||
|
||||
class TestGetModelDisplayName:
|
||||
"""Tests for _get_model_display_name resolution."""
|
||||
|
||||
def test_returns_profile_name_when_present(self) -> None:
|
||||
"""The human-readable profile name wins over the raw model id."""
|
||||
screen = ModelSelectorScreen.__new__(ModelSelectorScreen)
|
||||
screen._profiles = {
|
||||
"anthropic:claude-sonnet-4-5": ModelProfileEntry(
|
||||
profile={"name": "Claude Sonnet 4.5"},
|
||||
overridden_keys=frozenset(),
|
||||
),
|
||||
}
|
||||
assert (
|
||||
screen._get_model_display_name("anthropic:claude-sonnet-4-5")
|
||||
== "Claude Sonnet 4.5"
|
||||
)
|
||||
|
||||
def test_falls_back_to_model_id_when_no_name(self) -> None:
|
||||
"""A profile without a `name` key falls back to the model portion."""
|
||||
screen = ModelSelectorScreen.__new__(ModelSelectorScreen)
|
||||
screen._profiles = {
|
||||
"anthropic:claude-sonnet-4-5": ModelProfileEntry(
|
||||
profile={"max_input_tokens": 200000},
|
||||
overridden_keys=frozenset(),
|
||||
),
|
||||
}
|
||||
assert (
|
||||
screen._get_model_display_name("anthropic:claude-sonnet-4-5")
|
||||
== "claude-sonnet-4-5"
|
||||
)
|
||||
|
||||
def test_uses_recommended_name_when_no_profile(self) -> None:
|
||||
"""Uninstalled recommendations use the hardcoded name, not the raw id."""
|
||||
from deepagents_code.widgets import model_selector
|
||||
|
||||
screen = ModelSelectorScreen.__new__(ModelSelectorScreen)
|
||||
screen._profiles = {}
|
||||
spec = "fireworks:accounts/fireworks/models/kimi-k2p7-code"
|
||||
assert spec in model_selector._RECOMMENDED_MODELS
|
||||
assert screen._get_model_display_name(spec) == "Kimi K2.7 Code"
|
||||
|
||||
def test_profile_name_wins_over_recommended_name(self) -> None:
|
||||
"""A loaded profile's `name` takes precedence over the hardcoded one."""
|
||||
from deepagents_code.widgets import model_selector
|
||||
|
||||
spec = "openai:gpt-5.5"
|
||||
assert spec in model_selector._RECOMMENDED_MODELS
|
||||
screen = ModelSelectorScreen.__new__(ModelSelectorScreen)
|
||||
screen._profiles = {
|
||||
spec: ModelProfileEntry(
|
||||
profile={"name": "GPT-5.5 (from profile)"},
|
||||
overridden_keys=frozenset(),
|
||||
),
|
||||
}
|
||||
assert screen._get_model_display_name(spec) == "GPT-5.5 (from profile)"
|
||||
|
||||
def test_falls_back_to_model_id_when_not_recommended(self) -> None:
|
||||
"""A non-recommended spec absent from profiles falls back to the model."""
|
||||
screen = ModelSelectorScreen.__new__(ModelSelectorScreen)
|
||||
screen._profiles = {}
|
||||
assert (
|
||||
screen._get_model_display_name("openai:some-unlisted-model")
|
||||
== "some-unlisted-model"
|
||||
)
|
||||
|
||||
def test_ignores_empty_name(self) -> None:
|
||||
"""An empty `name` string falls back rather than rendering blank."""
|
||||
screen = ModelSelectorScreen.__new__(ModelSelectorScreen)
|
||||
screen._profiles = {
|
||||
"openai:some-unlisted-model": ModelProfileEntry(
|
||||
profile={"name": ""},
|
||||
overridden_keys=frozenset(),
|
||||
),
|
||||
}
|
||||
assert (
|
||||
screen._get_model_display_name("openai:some-unlisted-model")
|
||||
== "some-unlisted-model"
|
||||
)
|
||||
|
||||
def test_preserves_colon_in_model_id_fallback(self) -> None:
|
||||
"""Only the leading `provider:` is stripped in the fallback path."""
|
||||
screen = ModelSelectorScreen.__new__(ModelSelectorScreen)
|
||||
screen._profiles = {}
|
||||
assert (
|
||||
screen._get_model_display_name("ollama:some-model:cloud")
|
||||
== "some-model:cloud"
|
||||
)
|
||||
|
||||
def test_returns_bare_spec_unchanged(self) -> None:
|
||||
"""A spec without a `provider:` prefix is returned as-is."""
|
||||
screen = ModelSelectorScreen.__new__(ModelSelectorScreen)
|
||||
screen._profiles = {}
|
||||
assert screen._get_model_display_name("gpt-5.5") == "gpt-5.5"
|
||||
|
||||
|
||||
class TestModelDetailFooter:
|
||||
"""Tests for the model detail footer in the selector."""
|
||||
|
||||
@@ -2289,7 +2617,7 @@ class TestModelSelectorInstallRouting:
|
||||
monkeypatch.setattr(
|
||||
model_selector,
|
||||
"_RECOMMENDED_MODELS",
|
||||
frozenset({installed_spec, uninstalled_spec}),
|
||||
{installed_spec: "GLM 5.2", uninstalled_spec: "DeepSeek V4 Pro"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
model_selector,
|
||||
@@ -2744,7 +3072,17 @@ enabled = false
|
||||
# Recents render first; index 0 is the Recent-section install row.
|
||||
recent_install = screen._option_widgets[0]
|
||||
assert recent_install.model_spec == install_spec
|
||||
assert "dim" in recent_install.content.markup
|
||||
# Check the model NAME's dim specifically: the Recent row always
|
||||
# carries a dim `(Baseten)` provider tag, so a bare "dim" substring
|
||||
# search can't tell install-required dimming from the tag. The name
|
||||
# is wrapped in `[dim]...` only when install-required and unselected.
|
||||
name_dim = "[dim]Kimi K2.7 Code"
|
||||
# The provider tag disambiguates the cross-provider Recent row and
|
||||
# must survive `_move_selection`'s incremental relabel — which
|
||||
# re-derives the label from the widget's persisted `show_provider`.
|
||||
provider_tag = "(Baseten)"
|
||||
assert name_dim in recent_install.content.markup
|
||||
assert provider_tag in recent_install.content.markup
|
||||
# `_update_display` keeps the openai row highlighted (rendered
|
||||
# order: recent install, openai, provider-group install).
|
||||
assert screen._selected_index == 1
|
||||
@@ -2753,9 +3091,12 @@ enabled = false
|
||||
screen._move_selection(-1)
|
||||
await pilot.pause()
|
||||
assert screen._selected_index == 0
|
||||
assert "dim" not in recent_install.content.markup
|
||||
assert name_dim not in recent_install.content.markup
|
||||
# The tag persists regardless of selection (it's the relabel path).
|
||||
assert provider_tag in recent_install.content.markup
|
||||
screen._move_selection(1)
|
||||
await pilot.pause()
|
||||
assert screen._selected_index == 1
|
||||
|
||||
assert "dim" in recent_install.content.markup
|
||||
assert name_dim in recent_install.content.markup
|
||||
assert provider_tag in recent_install.content.markup
|
||||
|
||||
Reference in New Issue
Block a user