mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
feat(code): add reasoning effort selector (#4403)
Closes #3491 --- Adds a `/effort` slash command for configuring reasoning effort on the current model. The command resolves provider/model-specific effort levels, applies the selected effort through the existing per-session model-params override path, and rejects unsupported levels for the active model. The bare `/effort` command opens an interactive selector matching the existing modal selector UX. Users can also pass an effort level directly, or clear/reset the per-session effort override. The active effort is shown next to the model in the status label and updates when the effort is set, cleared, or the model is switched. This also fixes provider credential switching so gateway-provisioned custom auth headers, such as `ANTHROPIC_CUSTOM_HEADERS`, are cleared when returning to a provider-native key and endpoint. ## Release note Users can now configure supported reasoning effort levels with `/effort` and see the active effort displayed next to the current model. Switching back to a provider-native credential now clears gateway custom-header auth for supported providers. ## Video https://github.com/user-attachments/assets/4a347939-b5e8-4fb6-bd0d-c50635017593 --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: Mason Daugherty <github@mdrxy.com> Co-authored-by: Mason Daugherty <mason@langchain.dev>
This commit is contained in:
committed by
GitHub
parent
ef637f4a3f
commit
6ee0ac4cca
@@ -8,7 +8,7 @@ Regenerate this file with `make commands-catalog` after changing command names,
|
||||
aliases, descriptions, visibility, or hidden-command metadata.
|
||||
|
||||
|
||||
## Public (31)
|
||||
## Public (32)
|
||||
|
||||
| Command | Aliases | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -20,6 +20,7 @@ aliases, descriptions, visibility, or hidden-command metadata.
|
||||
| `/copy` | | Copy the latest assistant message to clipboard |
|
||||
| `/docs` | | Open the docs |
|
||||
| `/editor` | | Open prompt in an external editor ($EDITOR) |
|
||||
| `/effort` | | Set reasoning effort for the current model |
|
||||
| `/feedback` | | Send feedback or report an issue |
|
||||
| `/force-clear` | | Stop active work, clear the chat, and start a new thread |
|
||||
| `/goal` | | Set a persistent objective by drafting acceptance criteria |
|
||||
|
||||
@@ -1302,6 +1302,43 @@ class DeferredAction:
|
||||
"""Async callable that performs the actual work."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _EffortContext:
|
||||
"""The current model and the effort levels `/effort` can offer for it.
|
||||
|
||||
When the user runs `/effort`, they either pick a reasoning level from a
|
||||
menu or type one. This holds what that requires: the active model and the
|
||||
levels it supports — so the menu lists the right choices and a typed level
|
||||
that the model does not support is rejected instead of silently applied.
|
||||
"""
|
||||
|
||||
spec: str
|
||||
"""Active `provider:model` spec."""
|
||||
|
||||
efforts: tuple[str, ...]
|
||||
"""Reasoning effort labels supported by `spec`."""
|
||||
|
||||
current: str | None
|
||||
"""Effort from the per-session override, or `None` when unset."""
|
||||
|
||||
default: str | None
|
||||
"""Provider default effort for `spec`, or `None` when unknown."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _EffortUnavailable:
|
||||
"""Why `/effort` cannot proceed, as a user-facing message.
|
||||
|
||||
The failure arm of `_resolve_effort_context`, paired with `_EffortContext`.
|
||||
Making it a distinct type — rather than a bare `str` — keeps it nominally
|
||||
separate from any other string a caller handles, so the two arms can never
|
||||
be confused by an unrelated `str` value.
|
||||
"""
|
||||
|
||||
message: str
|
||||
"""User-facing explanation to surface via `AppMessage`."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ThreadHistoryPayload:
|
||||
"""Data returned by `_fetch_thread_history_data`."""
|
||||
@@ -2775,6 +2812,7 @@ class DeepAgentsApp(App):
|
||||
self._chat_input = self.query_one("#input-area", ChatInput)
|
||||
self._sync_status_connection()
|
||||
self._sync_status_queued()
|
||||
self._sync_status_model()
|
||||
self._chat_input.set_cursor_blink(blink=self._cursor_blink_enabled)
|
||||
|
||||
# Apply any skill commands discovered before the widget was mounted
|
||||
@@ -3772,18 +3810,7 @@ class DeepAgentsApp(App):
|
||||
if self._status_bar is None:
|
||||
logger.warning("Status bar not found during server ready transition")
|
||||
else:
|
||||
from deepagents_code.config import settings
|
||||
|
||||
provider = settings.model_provider or ""
|
||||
model = settings.model_name or ""
|
||||
if not provider or not model:
|
||||
logger.warning(
|
||||
"Settings missing model identity at server ready "
|
||||
"(provider=%r, model=%r); status bar will render blank",
|
||||
provider,
|
||||
model,
|
||||
)
|
||||
self._status_bar.set_model(provider=provider, model=model)
|
||||
self._sync_status_model()
|
||||
|
||||
if self._active_mcp_viewer is not None:
|
||||
viewer = self._active_mcp_viewer
|
||||
@@ -9100,7 +9127,7 @@ class DeepAgentsApp(App):
|
||||
await self._mount_message(UserMessage(command))
|
||||
help_body = (
|
||||
"Commands: /quit, /agents, /auth, /clear, /force-clear, "
|
||||
"/copy, /goal, /offload, /editor, "
|
||||
"/copy, /goal, /offload, /editor, /effort, "
|
||||
"/mcp, /model [--model-params JSON] [--default], "
|
||||
"/notifications, /reload, /restart, /rubric, "
|
||||
"/skill:<name>, /remember, "
|
||||
@@ -9330,6 +9357,8 @@ class DeepAgentsApp(App):
|
||||
await self._show_theme_selector()
|
||||
elif cmd == "/notifications":
|
||||
await self._show_notification_settings()
|
||||
elif cmd == "/effort" or cmd.startswith("/effort "):
|
||||
await self._handle_effort_command(command)
|
||||
elif cmd == "/model" or cmd.startswith("/model "):
|
||||
model_arg = None
|
||||
set_default = False
|
||||
@@ -9961,6 +9990,182 @@ class DeepAgentsApp(App):
|
||||
|
||||
return settings.model_provider or None
|
||||
|
||||
def _sync_status_model(self) -> None:
|
||||
"""Update the status bar with the active model and reasoning effort."""
|
||||
from deepagents_code.config import settings
|
||||
from deepagents_code.reasoning_effort import (
|
||||
current_effort_from_model_params,
|
||||
default_effort_for_model,
|
||||
)
|
||||
|
||||
if self._status_bar is None:
|
||||
return
|
||||
provider = settings.model_provider or ""
|
||||
model = settings.model_name or ""
|
||||
if not provider or not model:
|
||||
logger.warning(
|
||||
"Settings missing model identity at status sync "
|
||||
"(provider=%r, model=%r); status bar will render blank",
|
||||
provider,
|
||||
model,
|
||||
)
|
||||
# Identity is blank, so render a uniformly-empty row rather than a
|
||||
# blank model paired with a populated effort suffix.
|
||||
self._status_bar.set_model(provider=provider, model=model, effort="")
|
||||
return
|
||||
spec = self._effective_model_spec()
|
||||
effort = ""
|
||||
if spec:
|
||||
effort = (
|
||||
current_effort_from_model_params(spec, self._model_params_override)
|
||||
or default_effort_for_model(spec)
|
||||
or ""
|
||||
)
|
||||
self._status_bar.set_model(provider=provider, model=model, effort=effort)
|
||||
|
||||
def _resolve_effort_context(self) -> _EffortContext | _EffortUnavailable:
|
||||
"""Resolve the active model spec and its supported reasoning efforts.
|
||||
|
||||
Returns:
|
||||
An `_EffortContext` on success, or an `_EffortUnavailable` carrying
|
||||
a user-facing message when no model is configured or the model
|
||||
does not support reasoning effort. The two arms are distinct
|
||||
types, so callers discriminate with
|
||||
`isinstance(..., _EffortUnavailable)`.
|
||||
"""
|
||||
from deepagents_code.reasoning_effort import (
|
||||
current_effort_from_model_params,
|
||||
default_effort_for_model,
|
||||
supported_efforts_for_model,
|
||||
)
|
||||
|
||||
spec = self._effective_model_spec()
|
||||
if not spec:
|
||||
return _EffortUnavailable(
|
||||
"No model is configured yet. Run `/model` to choose one."
|
||||
)
|
||||
efforts = supported_efforts_for_model(spec)
|
||||
if not efforts:
|
||||
return _EffortUnavailable(
|
||||
f"Reasoning effort is not configurable for {spec}."
|
||||
)
|
||||
return _EffortContext(
|
||||
spec=spec,
|
||||
efforts=efforts,
|
||||
current=current_effort_from_model_params(spec, self._model_params_override),
|
||||
default=default_effort_for_model(spec),
|
||||
)
|
||||
|
||||
async def _handle_effort_command(self, command: str) -> None:
|
||||
"""Set or select reasoning effort for the current model.
|
||||
|
||||
Args:
|
||||
command: The raw `/effort` slash command.
|
||||
"""
|
||||
raw = command.strip()[len("/effort") :].strip().lower()
|
||||
if not raw:
|
||||
await self._show_effort_selector(command)
|
||||
return
|
||||
|
||||
await self._mount_message(UserMessage(command))
|
||||
await self._set_effort_override(raw)
|
||||
|
||||
async def _show_effort_selector(self, command: str) -> None:
|
||||
"""Open the reasoning effort selector for the current model.
|
||||
|
||||
Args:
|
||||
command: The raw `/effort` slash command.
|
||||
"""
|
||||
from deepagents_code.widgets.effort_selector import EffortSelectorScreen
|
||||
|
||||
context = self._resolve_effort_context()
|
||||
if isinstance(context, _EffortUnavailable):
|
||||
await self._mount_message(UserMessage(command))
|
||||
await self._mount_message(AppMessage(context.message))
|
||||
return
|
||||
|
||||
screen = EffortSelectorScreen(
|
||||
model_spec=context.spec,
|
||||
efforts=context.efforts,
|
||||
current_effort=context.current,
|
||||
default_effort=context.default,
|
||||
)
|
||||
|
||||
async def apply_effort(effort: str) -> None:
|
||||
try:
|
||||
await self._set_effort_override(effort)
|
||||
except Exception:
|
||||
# The interactive path applies the effort in a background
|
||||
# worker, so a failure would otherwise die silently there with
|
||||
# no confirmation and no error for the user.
|
||||
logger.exception("Failed to apply reasoning effort %r", effort)
|
||||
await self._mount_message(
|
||||
ErrorMessage(f"Failed to apply reasoning effort {effort!r}."),
|
||||
)
|
||||
|
||||
def handle_result(result: str | None) -> None:
|
||||
if result is not None:
|
||||
self.run_worker(
|
||||
apply_effort(result),
|
||||
exclusive=False,
|
||||
group="effort-selection",
|
||||
)
|
||||
if self._chat_input:
|
||||
self._chat_input.focus_input()
|
||||
|
||||
self.push_screen(screen, handle_result)
|
||||
|
||||
async def _set_effort_override(self, effort: str) -> None:
|
||||
"""Apply a reasoning effort override to the current model params.
|
||||
|
||||
Args:
|
||||
effort: Effort label or clear/reset token.
|
||||
"""
|
||||
from deepagents_code.reasoning_effort import (
|
||||
merge_effort_model_params,
|
||||
model_params_for_effort,
|
||||
without_effort_model_params,
|
||||
)
|
||||
|
||||
context = self._resolve_effort_context()
|
||||
if isinstance(context, _EffortUnavailable):
|
||||
await self._mount_message(AppMessage(context.message))
|
||||
return
|
||||
spec = context.spec
|
||||
|
||||
if effort in {"clear", "--clear", "reset"}:
|
||||
had_override = context.current is not None
|
||||
self._model_params_override = without_effort_model_params(
|
||||
self._model_params_override
|
||||
)
|
||||
self._sync_status_model()
|
||||
message = (
|
||||
f"Reasoning effort override cleared for {spec}."
|
||||
if had_override
|
||||
else f"No reasoning effort override was set for {spec}."
|
||||
)
|
||||
await self._mount_message(AppMessage(message))
|
||||
return
|
||||
|
||||
params = model_params_for_effort(spec, effort)
|
||||
if params is None:
|
||||
supported = ", ".join(context.efforts)
|
||||
await self._mount_message(
|
||||
ErrorMessage(
|
||||
f"Unsupported reasoning effort {effort!r} for {spec}. "
|
||||
f"Supported efforts: {supported}",
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
self._model_params_override = merge_effort_model_params(
|
||||
self._model_params_override, params
|
||||
)
|
||||
self._sync_status_model()
|
||||
await self._mount_message(
|
||||
AppMessage(f"Reasoning effort for {spec} set to {effort}."),
|
||||
)
|
||||
|
||||
async def _run_agent_task(
|
||||
self,
|
||||
message: str,
|
||||
@@ -15454,6 +15659,7 @@ class DeepAgentsApp(App):
|
||||
# prior per-session override.
|
||||
self._model_override = current
|
||||
self._model_params_override = extra_kwargs
|
||||
self._sync_status_model()
|
||||
params_suffix = _format_model_params(extra_kwargs)
|
||||
if announce_unchanged:
|
||||
message = f"Already using {current}{params_suffix}"
|
||||
@@ -15498,11 +15704,7 @@ class DeepAgentsApp(App):
|
||||
self._model_override = display
|
||||
self._model_params_override = extra_kwargs
|
||||
|
||||
if self._status_bar:
|
||||
self._status_bar.set_model(
|
||||
provider=settings.model_provider or "",
|
||||
model=settings.model_name or "",
|
||||
)
|
||||
self._sync_status_model()
|
||||
|
||||
self._last_model_unchanged_message = None
|
||||
params_suffix = _format_model_params(extra_kwargs)
|
||||
|
||||
@@ -118,6 +118,13 @@ COMMANDS: tuple[SlashCommand, ...] = (
|
||||
description="Open prompt in an external editor ($EDITOR)",
|
||||
bypass_tier=BypassTier.QUEUED,
|
||||
),
|
||||
SlashCommand(
|
||||
name="/effort",
|
||||
description="Set reasoning effort for the current model",
|
||||
bypass_tier=BypassTier.QUEUED,
|
||||
hidden_keywords="reasoning thinking level",
|
||||
argument_hint="[none|low|medium|high|xhigh|max|clear]",
|
||||
),
|
||||
SlashCommand(
|
||||
name="/mcp",
|
||||
description="Manage MCP servers and authentication",
|
||||
|
||||
@@ -742,6 +742,9 @@ OPTIONAL_AUTH_ENV: dict[str, str] = {"ollama": "OLLAMA_API_KEY"}
|
||||
PROVIDER_HOST_ENV: dict[str, str] = {"ollama": "OLLAMA_HOST"}
|
||||
"""Provider-specific env vars that can point a local provider at a remote host."""
|
||||
|
||||
PROVIDER_CUSTOM_HEADERS_ENV: dict[str, str] = {"anthropic": "ANTHROPIC_CUSTOM_HEADERS"}
|
||||
"""Provider SDK env vars that inject custom request headers (e.g. gateway auth)."""
|
||||
|
||||
OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434"
|
||||
"""Default endpoint assumed when no `base_url` or `OLLAMA_HOST` is configured."""
|
||||
|
||||
@@ -2170,6 +2173,10 @@ def _apply_stored_base_url(provider: str, base_url: str | None) -> None:
|
||||
clears every name when `base_url` is `None` (reset to the provider
|
||||
default). See `apply_stored_credentials` for the pairing rationale.
|
||||
|
||||
When switching to a provider-native key (no `base_url`), also clears the
|
||||
provider's custom-headers env var (e.g. `ANTHROPIC_CUSTOM_HEADERS`) so a
|
||||
gateway-provisioned auth header isn't sent to the native endpoint.
|
||||
|
||||
Args:
|
||||
provider: Provider name.
|
||||
base_url: The stored endpoint, or `None` to reset to the default.
|
||||
@@ -2183,12 +2190,56 @@ def _apply_stored_base_url(provider: str, base_url: str | None) -> None:
|
||||
names.add(canonical)
|
||||
if not names:
|
||||
return
|
||||
configured_base_url_survives = _configured_base_url_survives_env_clear(provider)
|
||||
for name in names:
|
||||
if base_url and name == canonical:
|
||||
os.environ[name] = base_url
|
||||
else:
|
||||
os.environ.pop(name, None)
|
||||
|
||||
# A provider SDK's custom-header env var (e.g. `ANTHROPIC_CUSTOM_HEADERS`)
|
||||
# injects headers into every request. A gateway-provisioned environment
|
||||
# often sets it to `X-Api-Key: <gateway-key>`, which overrides the SDK's
|
||||
# own `api_key`-derived header. When switching to a provider-native key
|
||||
# (no stored `base_url`), that header must also be cleared — otherwise the
|
||||
# gateway key is sent to the native endpoint and rejected.
|
||||
custom_headers_env = PROVIDER_CUSTOM_HEADERS_ENV.get(provider)
|
||||
if custom_headers_env and not base_url:
|
||||
if not configured_base_url_survives:
|
||||
if os.environ.pop(custom_headers_env, None) is not None:
|
||||
# Log the env var name only — never its value, which carries
|
||||
# auth headers. Surfaces the removal for the user who set a
|
||||
# header deliberately for the native endpoint and later wonders
|
||||
# where it went.
|
||||
logger.info(
|
||||
"Cleared %s while applying a provider-native %s key",
|
||||
custom_headers_env,
|
||||
provider,
|
||||
)
|
||||
elif os.environ.get(custom_headers_env) is not None:
|
||||
# A provider base URL still routes (config or a prefixed env var),
|
||||
# so the custom-header env is deliberately kept. Log the name only —
|
||||
# never the value — so the retention is observable when a user later
|
||||
# wonders why a gateway header is still in effect after applying a
|
||||
# native key.
|
||||
logger.debug(
|
||||
"Kept %s: a %s base URL is still configured",
|
||||
custom_headers_env,
|
||||
provider,
|
||||
)
|
||||
|
||||
|
||||
def _configured_base_url_survives_env_clear(provider: str) -> bool:
|
||||
"""Return whether endpoint config still routes after plain env cleanup."""
|
||||
config = ModelConfig.load()
|
||||
provider_cfg = config.providers.get(provider)
|
||||
if provider_cfg and provider_cfg.get("base_url"):
|
||||
return True
|
||||
for env_var in get_base_url_env_vars(provider):
|
||||
if os.environ.get(f"{_ENV_PREFIX}{env_var}"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def warn_on_split_credential_source(provider: str) -> None:
|
||||
"""Log when a provider's key and endpoint resolve from different env tiers.
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
"""Provider-specific reasoning effort support for `/effort`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeAlias, get_args
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from deepagents_code.model_config import CODEX_PROVIDER, ModelSpec
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EffortLabel: TypeAlias = Literal["none", "low", "medium", "high", "xhigh", "max"]
|
||||
"""Closed vocabulary of effort labels across all supported providers.
|
||||
|
||||
Typing the per-provider tuples with this alias catches typos in the vocabulary
|
||||
at check time. It does not express the deeper invariant that a label must be
|
||||
supported by a *specific* model — that is enforced at runtime by
|
||||
`supported_efforts_for_model`.
|
||||
|
||||
This vocabulary is also hand-duplicated as display text in the `/effort`
|
||||
`argument_hint` (`command_registry.py`) and in `COMMANDS.md`; those are not
|
||||
type-checked against this alias, so update them in lockstep when it changes.
|
||||
"""
|
||||
|
||||
ReasoningProvider: TypeAlias = Literal[
|
||||
"anthropic", "fireworks", "google_genai", "openai", "openai_codex"
|
||||
]
|
||||
"""Provider identifiers that support model-specific reasoning effort controls.
|
||||
|
||||
Values must stay byte-identical to the provider strings from `ModelSpec.parse`
|
||||
used throughout `model_config.py` (e.g. `CODEX_PROVIDER`).
|
||||
"""
|
||||
|
||||
|
||||
class ReasoningProviderConfig(NamedTuple):
|
||||
"""Provider-specific reasoning effort behavior."""
|
||||
|
||||
supported_efforts: Callable[[str], tuple[EffortLabel, ...]]
|
||||
"""Return supported effort labels for a lowercased model name."""
|
||||
|
||||
default_effort: Callable[[str], EffortLabel | None]
|
||||
"""Return the provider default effort for a lowercased model name, if known."""
|
||||
|
||||
model_params: Callable[[str], dict[str, Any]]
|
||||
"""Translate an effort label into provider-specific model params."""
|
||||
|
||||
current_effort: Callable[[dict[str, Any]], str | None]
|
||||
"""Read the configured effort label from provider-specific model params."""
|
||||
|
||||
|
||||
OPENAI_EFFORTS: tuple[EffortLabel, ...] = ("none", "low", "medium", "high", "xhigh")
|
||||
"""OpenAI GPT-5 effort labels for `reasoning.effort`.
|
||||
|
||||
See https://platform.openai.com/docs/guides/reasoning.
|
||||
"""
|
||||
|
||||
ANTHROPIC_EFFORTS: tuple[EffortLabel, ...] = ("low", "medium", "high", "xhigh", "max")
|
||||
"""Anthropic `output_config.effort` labels for Opus 4.7+ and Sonnet 5.
|
||||
|
||||
See https://platform.claude.com/docs/en/build-with-claude/effort.
|
||||
"""
|
||||
|
||||
ANTHROPIC_EFFORTS_NO_XHIGH: tuple[EffortLabel, ...] = ("low", "medium", "high", "max")
|
||||
"""Anthropic effort labels for Opus 4.6 and Sonnet 4.6.
|
||||
|
||||
These models predate `xhigh`; Sonnet 4.5 rejects `effort` entirely.
|
||||
See https://platform.claude.com/docs/en/build-with-claude/effort.
|
||||
"""
|
||||
|
||||
ANTHROPIC_EFFORTS_NO_MAX: tuple[EffortLabel, ...] = ("low", "medium", "high")
|
||||
"""Anthropic effort labels for Opus 4.5.
|
||||
|
||||
Opus 4.5 predates both `max` (Opus 4.6+) and `xhigh` (Opus 4.7+).
|
||||
See https://platform.claude.com/docs/en/build-with-claude/effort.
|
||||
"""
|
||||
|
||||
GOOGLE_EFFORTS: tuple[EffortLabel, ...] = ("low", "medium", "high")
|
||||
"""Gemini `thinking_level` labels.
|
||||
|
||||
Applied to every `gemini-3*` model (the gate in `_classify_reasoning_provider`),
|
||||
including Gemini 3 Pro/Flash, 3.1 Pro, and 3.5 Flash — all accept
|
||||
low/medium/high. `minimal` is Flash-Lite / original-Pro territory, neither of
|
||||
which is offered here. See https://ai.google.dev/gemini-api/docs/thinking.
|
||||
"""
|
||||
|
||||
FIREWORKS_REASONING_EFFORTS: tuple[EffortLabel, ...] = (
|
||||
"none",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
)
|
||||
"""Fireworks `reasoning_effort` labels for DeepSeek V4 Pro.
|
||||
|
||||
See https://docs.fireworks.ai/guides/reasoning.
|
||||
"""
|
||||
|
||||
FIREWORKS_KIMI_EFFORTS: tuple[EffortLabel, ...] = ("low", "medium", "high")
|
||||
"""Fireworks `reasoning_effort` labels for Kimi K2 models.
|
||||
|
||||
See https://docs.fireworks.ai/guides/reasoning.
|
||||
"""
|
||||
|
||||
FIREWORKS_GLM_EFFORTS: tuple[EffortLabel, ...] = ("none", "high", "max")
|
||||
"""Fireworks `reasoning_effort` labels for GLM 5 models.
|
||||
|
||||
See https://docs.fireworks.ai/guides/reasoning.
|
||||
"""
|
||||
|
||||
_REASONING_KEYS: frozenset[str] = frozenset(
|
||||
{"effort", "reasoning", "reasoning_effort", "thinking", "thinking_level"}
|
||||
)
|
||||
"""Runtime config keys that may already carry provider reasoning settings."""
|
||||
|
||||
|
||||
def _openai_supported_efforts(_model: str) -> tuple[EffortLabel, ...]:
|
||||
"""Return OpenAI reasoning effort levels."""
|
||||
return OPENAI_EFFORTS
|
||||
|
||||
|
||||
def _openai_default_effort(model: str) -> EffortLabel | None:
|
||||
"""Return the OpenAI default reasoning effort when known."""
|
||||
# Only gpt-5.5 documents `medium` as its default; other/newer gpt-5 variants
|
||||
# fall through to `None` until their default is confirmed against
|
||||
# https://platform.openai.com/docs/guides/reasoning.
|
||||
return "medium" if model.startswith("gpt-5.5") else None
|
||||
|
||||
|
||||
def _openai_model_params(effort: str) -> dict[str, Any]:
|
||||
"""Return OpenAI reasoning params for an effort label."""
|
||||
if effort == "none":
|
||||
return {"reasoning": {"effort": "none"}}
|
||||
return {"reasoning": {"effort": effort, "summary": "auto"}}
|
||||
|
||||
|
||||
def _openai_current_effort(model_params: dict[str, Any]) -> str | None:
|
||||
"""Read the OpenAI reasoning effort from model params.
|
||||
|
||||
Returns:
|
||||
The configured effort label, or `None` when unset.
|
||||
"""
|
||||
reasoning = model_params.get("reasoning")
|
||||
if isinstance(reasoning, dict):
|
||||
value = reasoning.get("effort")
|
||||
if value is not None and not isinstance(value, str):
|
||||
# Present but mistyped (e.g. a hand-edited config or bad
|
||||
# `--model-params` JSON). Discard it, but log the *type* — never the
|
||||
# value — so the drop is greppable instead of silently read as "no
|
||||
# effort set" while the malformed param still ships on the wire.
|
||||
logger.warning(
|
||||
"Ignoring non-str OpenAI reasoning.effort of type %s",
|
||||
type(value).__name__,
|
||||
)
|
||||
return value if isinstance(value, str) else None
|
||||
if reasoning is not None:
|
||||
logger.warning(
|
||||
"Ignoring OpenAI reasoning params of unexpected type %s",
|
||||
type(reasoning).__name__,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _has_version(model: str, token: str) -> bool:
|
||||
"""Return whether `model` carries version `token` not followed by a digit.
|
||||
|
||||
A plain substring test would match a longer version by accident — e.g.
|
||||
`"opus-4-1" in "claude-opus-4-16"` is true. Anchoring on a non-digit
|
||||
boundary keeps `opus-4-1` from matching a future `opus-4-16` while still
|
||||
matching a dated suffix like `opus-4-1-20250805`. `token` is always a
|
||||
hardcoded constant, but `re.escape` keeps the match literal regardless.
|
||||
"""
|
||||
return re.search(rf"{re.escape(token)}(?!\d)", model) is not None
|
||||
|
||||
|
||||
def _anthropic_supported_efforts(model: str) -> tuple[EffortLabel, ...]:
|
||||
"""Return the effort levels an Anthropic model accepts.
|
||||
|
||||
Args:
|
||||
model: Lowercased Anthropic model name (e.g. `claude-opus-4-8`).
|
||||
|
||||
Returns:
|
||||
Supported effort labels, or an empty tuple when the model does not
|
||||
accept `effort` (e.g. Sonnet 4.5).
|
||||
"""
|
||||
if model.startswith("claude-opus-"):
|
||||
if _has_version(model, "opus-4-0") or _has_version(model, "opus-4-1"):
|
||||
# Opus 4.0/4.1 predate reasoning effort entirely.
|
||||
return ()
|
||||
if _has_version(model, "opus-4-5"):
|
||||
# Opus 4.5 predates both `max` (4.6+) and `xhigh` (4.7+).
|
||||
return ANTHROPIC_EFFORTS_NO_MAX
|
||||
# Opus 4.6 predates `xhigh`; 4.7+ (and newer, unrecognized versions)
|
||||
# get the full range.
|
||||
return (
|
||||
ANTHROPIC_EFFORTS_NO_XHIGH
|
||||
if _has_version(model, "opus-4-6")
|
||||
else ANTHROPIC_EFFORTS
|
||||
)
|
||||
if model.startswith("claude-sonnet-"):
|
||||
if (
|
||||
_has_version(model, "sonnet-4-0")
|
||||
or _has_version(model, "sonnet-4-1")
|
||||
or _has_version(model, "sonnet-4-5")
|
||||
):
|
||||
# Sonnet 4.0/4.1 predate effort; Sonnet 4.5 rejects it.
|
||||
return ()
|
||||
# Sonnet 4.6 predates `xhigh`; Sonnet 5 (and newer) get the full range.
|
||||
return (
|
||||
ANTHROPIC_EFFORTS_NO_XHIGH
|
||||
if _has_version(model, "sonnet-4-6")
|
||||
else ANTHROPIC_EFFORTS
|
||||
)
|
||||
return ()
|
||||
|
||||
|
||||
def _anthropic_default_effort(model: str) -> EffortLabel | None:
|
||||
"""Return the Anthropic default reasoning effort when known."""
|
||||
return "high" if _anthropic_supported_efforts(model) else None
|
||||
|
||||
|
||||
def _anthropic_model_params(effort: str) -> dict[str, Any]:
|
||||
"""Return Anthropic reasoning params for an effort label."""
|
||||
return {
|
||||
"thinking": {"type": "adaptive", "display": "summarized"},
|
||||
"effort": effort,
|
||||
}
|
||||
|
||||
|
||||
def _anthropic_current_effort(model_params: dict[str, Any]) -> str | None:
|
||||
"""Read the Anthropic reasoning effort from model params.
|
||||
|
||||
Returns:
|
||||
The configured effort label, or `None` when unset.
|
||||
"""
|
||||
value = model_params.get("effort")
|
||||
if value is not None and not isinstance(value, str):
|
||||
logger.warning(
|
||||
"Ignoring non-str Anthropic effort of type %s", type(value).__name__
|
||||
)
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _google_supported_efforts(_model: str) -> tuple[EffortLabel, ...]:
|
||||
"""Return Gemini thinking levels."""
|
||||
return GOOGLE_EFFORTS
|
||||
|
||||
|
||||
def _google_default_effort(model: str) -> EffortLabel | None:
|
||||
"""Return the Gemini default thinking level when known."""
|
||||
if model.startswith("gemini-3.5-flash"):
|
||||
return "medium"
|
||||
if model.startswith(("gemini-3.1-pro", "gemini-3-flash", "gemini-3-pro")):
|
||||
return "high"
|
||||
return None
|
||||
|
||||
|
||||
def _google_model_params(effort: str) -> dict[str, Any]:
|
||||
"""Return Gemini thinking params for an effort label."""
|
||||
return {"thinking_level": effort}
|
||||
|
||||
|
||||
def _google_current_effort(model_params: dict[str, Any]) -> str | None:
|
||||
"""Read the Gemini thinking level from model params.
|
||||
|
||||
Returns:
|
||||
The configured effort label, or `None` when unset.
|
||||
"""
|
||||
value = model_params.get("thinking_level")
|
||||
if value is not None and not isinstance(value, str):
|
||||
logger.warning(
|
||||
"Ignoring non-str Gemini thinking_level of type %s",
|
||||
type(value).__name__,
|
||||
)
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _fireworks_supported_efforts(model: str) -> tuple[EffortLabel, ...]:
|
||||
"""Return Fireworks reasoning effort levels for a model."""
|
||||
if "kimi-k2" in model:
|
||||
return FIREWORKS_KIMI_EFFORTS
|
||||
if "glm-5" in model:
|
||||
return FIREWORKS_GLM_EFFORTS
|
||||
if "deepseek-v4-pro" in model:
|
||||
return FIREWORKS_REASONING_EFFORTS
|
||||
return ()
|
||||
|
||||
|
||||
def _fireworks_default_effort(model: str) -> EffortLabel | None:
|
||||
"""Return the Fireworks default reasoning effort when known."""
|
||||
if "deepseek-v4-pro" in model:
|
||||
return "high"
|
||||
if "glm-5p2" in model:
|
||||
return "max"
|
||||
return None
|
||||
|
||||
|
||||
def _fireworks_model_params(effort: str) -> dict[str, Any]:
|
||||
"""Return Fireworks reasoning params for an effort label."""
|
||||
return {"model_kwargs": {"reasoning_effort": effort}}
|
||||
|
||||
|
||||
def _fireworks_current_effort(model_params: dict[str, Any]) -> str | None:
|
||||
"""Read the Fireworks reasoning effort from model params.
|
||||
|
||||
Returns:
|
||||
The configured effort label, or `None` when unset.
|
||||
"""
|
||||
kwargs = model_params.get("model_kwargs")
|
||||
if isinstance(kwargs, dict):
|
||||
value = kwargs.get("reasoning_effort")
|
||||
if value is not None and not isinstance(value, str):
|
||||
logger.warning(
|
||||
"Ignoring non-str Fireworks reasoning_effort of type %s",
|
||||
type(value).__name__,
|
||||
)
|
||||
return value if isinstance(value, str) else None
|
||||
# A non-dict `model_kwargs` is a legitimate shape here (it may hold
|
||||
# unrelated params, or be preserved verbatim by `without_effort_model_params`),
|
||||
# so treat it as "no effort configured" without warning.
|
||||
return None
|
||||
|
||||
|
||||
_OPENAI_CONFIG = ReasoningProviderConfig(
|
||||
supported_efforts=_openai_supported_efforts,
|
||||
default_effort=_openai_default_effort,
|
||||
model_params=_openai_model_params,
|
||||
current_effort=_openai_current_effort,
|
||||
)
|
||||
"""Shared config for OpenAI-compatible GPT-5 reasoning providers.
|
||||
|
||||
`openai` and `openai_codex` use different provider names so model selection can
|
||||
route to the right client, but `/effort` maps both to the same reasoning params.
|
||||
"""
|
||||
|
||||
_PROVIDER_CONFIGS: dict[ReasoningProvider, ReasoningProviderConfig] = {
|
||||
"openai": _OPENAI_CONFIG,
|
||||
"openai_codex": _OPENAI_CONFIG,
|
||||
"anthropic": ReasoningProviderConfig(
|
||||
supported_efforts=_anthropic_supported_efforts,
|
||||
default_effort=_anthropic_default_effort,
|
||||
model_params=_anthropic_model_params,
|
||||
current_effort=_anthropic_current_effort,
|
||||
),
|
||||
"google_genai": ReasoningProviderConfig(
|
||||
supported_efforts=_google_supported_efforts,
|
||||
default_effort=_google_default_effort,
|
||||
model_params=_google_model_params,
|
||||
current_effort=_google_current_effort,
|
||||
),
|
||||
"fireworks": ReasoningProviderConfig(
|
||||
supported_efforts=_fireworks_supported_efforts,
|
||||
default_effort=_fireworks_default_effort,
|
||||
model_params=_fireworks_model_params,
|
||||
current_effort=_fireworks_current_effort,
|
||||
),
|
||||
}
|
||||
"""Provider-specific reasoning effort behavior keyed by `ModelSpec` provider."""
|
||||
|
||||
if set(_PROVIDER_CONFIGS) != set(get_args(ReasoningProvider)): # pragma: no cover
|
||||
# `_classify_reasoning_provider` only ever returns members of the
|
||||
# `ReasoningProvider` vocabulary, and `_reasoning_config` indexes
|
||||
# `_PROVIDER_CONFIGS` with the result — so the two must stay in lockstep or
|
||||
# that lookup raises `KeyError` at runtime. Fail loudly at import instead.
|
||||
msg = "_PROVIDER_CONFIGS keys must match the ReasoningProvider vocabulary"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def _classify_reasoning_provider(provider: str, model: str) -> ReasoningProvider | None:
|
||||
"""Classify provider/model parts into a reasoning-capable provider.
|
||||
|
||||
Returns:
|
||||
The registry key for supported reasoning models, or `None` otherwise.
|
||||
"""
|
||||
model_lower = model.lower()
|
||||
if provider == "openai" and model_lower.startswith("gpt-5"):
|
||||
return "openai"
|
||||
if provider == CODEX_PROVIDER and model_lower.startswith("gpt-5"):
|
||||
return "openai_codex"
|
||||
if provider == "anthropic" and model_lower.startswith(
|
||||
("claude-opus-", "claude-sonnet-")
|
||||
):
|
||||
return "anthropic"
|
||||
if provider == "google_genai" and model_lower.startswith("gemini-3"):
|
||||
return "google_genai"
|
||||
if provider == "fireworks" and model_lower.startswith("accounts/fireworks/models/"):
|
||||
return "fireworks"
|
||||
return None
|
||||
|
||||
|
||||
def _reasoning_config(model_spec: str) -> tuple[ReasoningProviderConfig, str] | None:
|
||||
"""Return provider config and lowercased model when reasoning is supported."""
|
||||
parsed = ModelSpec.try_parse(model_spec)
|
||||
if parsed is None:
|
||||
return None
|
||||
provider = _classify_reasoning_provider(parsed.provider, parsed.model)
|
||||
if provider is None:
|
||||
return None
|
||||
return _PROVIDER_CONFIGS[provider], parsed.model.lower()
|
||||
|
||||
|
||||
def supported_efforts_for_model(model_spec: str | None) -> tuple[str, ...]:
|
||||
"""Return reasoning efforts supported by `model_spec`.
|
||||
|
||||
Returns plain `str` labels rather than `EffortLabel`: this is the public
|
||||
boundary where the label vocabulary is intentionally dropped, since the
|
||||
values flow straight to the UI.
|
||||
|
||||
Args:
|
||||
model_spec: `provider:model` spec for the active model.
|
||||
|
||||
Returns:
|
||||
Supported effort labels, or an empty tuple when the model is unsupported.
|
||||
"""
|
||||
if not model_spec:
|
||||
return ()
|
||||
context = _reasoning_config(model_spec)
|
||||
if context is None:
|
||||
return ()
|
||||
config, model = context
|
||||
efforts = config.supported_efforts(model)
|
||||
if not efforts:
|
||||
# A recognized reasoning provider that yields no configurable efforts
|
||||
# usually means the model-version heuristics need updating for a newer
|
||||
# release. Log at info so the maintenance gap is visible at default
|
||||
# verbosity rather than silently reporting "not configurable".
|
||||
logger.info("No configurable reasoning efforts for %s", model_spec)
|
||||
return efforts
|
||||
|
||||
|
||||
def default_effort_for_model(model_spec: str | None) -> str | None:
|
||||
"""Return the documented default reasoning effort when known.
|
||||
|
||||
Returns a plain `str` rather than `EffortLabel`: like
|
||||
`supported_efforts_for_model`, this is the public boundary where the label
|
||||
vocabulary is intentionally dropped, since every caller treats the value as
|
||||
display text.
|
||||
|
||||
Args:
|
||||
model_spec: `provider:model` spec for the active model.
|
||||
|
||||
Returns:
|
||||
The provider default effort label, or `None` when the default is unknown.
|
||||
"""
|
||||
if not model_spec:
|
||||
return None
|
||||
context = _reasoning_config(model_spec)
|
||||
if context is None:
|
||||
return None
|
||||
config, model = context
|
||||
return config.default_effort(model)
|
||||
|
||||
|
||||
def model_params_for_effort(model_spec: str, effort: str) -> dict[str, Any] | None:
|
||||
"""Translate an effort label into provider-specific model params.
|
||||
|
||||
Args:
|
||||
model_spec: `provider:model` spec for the active model.
|
||||
effort: Effort label accepted by `supported_efforts_for_model`.
|
||||
|
||||
Returns:
|
||||
Model params to merge into the per-session override, or `None` when the
|
||||
model/effort pair is unsupported.
|
||||
"""
|
||||
context = _reasoning_config(model_spec)
|
||||
if context is None:
|
||||
return None
|
||||
config, model = context
|
||||
if effort not in config.supported_efforts(model):
|
||||
return None
|
||||
return config.model_params(effort)
|
||||
|
||||
|
||||
def current_effort_from_model_params(
|
||||
model_spec: str | None, model_params: dict[str, Any] | None
|
||||
) -> str | None:
|
||||
"""Read the configured effort from model params when present.
|
||||
|
||||
Args:
|
||||
model_spec: `provider:model` spec for the active model.
|
||||
model_params: Per-session model params.
|
||||
|
||||
Returns:
|
||||
The configured effort, or `None` when no recognized effort override is set.
|
||||
"""
|
||||
if not model_spec or not model_params:
|
||||
return None
|
||||
context = _reasoning_config(model_spec)
|
||||
if context is None:
|
||||
return None
|
||||
config, _ = context
|
||||
return config.current_effort(model_params)
|
||||
|
||||
|
||||
def merge_effort_model_params(
|
||||
existing: dict[str, Any] | None, effort_params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Merge effort params into existing per-session model params.
|
||||
|
||||
Args:
|
||||
existing: Current per-session model params.
|
||||
effort_params: Params returned by `model_params_for_effort`.
|
||||
|
||||
Returns:
|
||||
A new merged dictionary preserving unrelated nested `model_kwargs`.
|
||||
"""
|
||||
merged = dict(existing) if existing else {}
|
||||
for key, value in effort_params.items():
|
||||
if key == "model_kwargs" and isinstance(value, dict):
|
||||
current = merged.get("model_kwargs")
|
||||
base = dict(current) if isinstance(current, dict) else {}
|
||||
base.update(value)
|
||||
merged["model_kwargs"] = base
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def without_effort_model_params(
|
||||
existing: dict[str, Any] | None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Remove known effort params while preserving unrelated model params.
|
||||
|
||||
Args:
|
||||
existing: Current per-session model params.
|
||||
|
||||
Returns:
|
||||
A cleaned dictionary, or `None` when no params remain.
|
||||
"""
|
||||
if not existing:
|
||||
return None
|
||||
# Exclude `model_kwargs` from the comprehension and rebuild it below.
|
||||
# Leaving it here would retain a stale `reasoning_effort` when the cleaned
|
||||
# nested dict ends up empty — the empty-check would then skip the overwrite
|
||||
# and the original (still-populated) copy would survive.
|
||||
cleaned = {
|
||||
key: (dict(value) if isinstance(value, dict) else value)
|
||||
for key, value in existing.items()
|
||||
if key not in _REASONING_KEYS and key != "model_kwargs"
|
||||
}
|
||||
kwargs = existing.get("model_kwargs")
|
||||
if isinstance(kwargs, dict):
|
||||
model_kwargs = {k: v for k, v in kwargs.items() if k != "reasoning_effort"}
|
||||
if model_kwargs:
|
||||
cleaned["model_kwargs"] = model_kwargs
|
||||
elif kwargs is not None:
|
||||
cleaned["model_kwargs"] = kwargs
|
||||
return cleaned or None
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Interactive reasoning effort selector for `/effort`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Vertical
|
||||
from textual.content import Content
|
||||
from textual.css.query import NoMatches
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import OptionList, Static
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import ComposeResult
|
||||
|
||||
from deepagents_code import theme
|
||||
from deepagents_code.config import get_glyphs, is_ascii_mode
|
||||
|
||||
|
||||
class EffortSelectorScreen(ModalScreen[str | None]):
|
||||
"""Modal dialog for selecting a reasoning effort level."""
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("escape", "cancel", "Cancel", show=False),
|
||||
Binding("tab", "cursor_down", "Next", show=False, priority=True),
|
||||
Binding("shift+tab", "cursor_up", "Previous", show=False, priority=True),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
EffortSelectorScreen {
|
||||
align: center middle;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
EffortSelectorScreen > Vertical {
|
||||
width: 54;
|
||||
max-width: 90%;
|
||||
height: auto;
|
||||
max-height: 80%;
|
||||
background: $surface;
|
||||
border: solid $primary;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
EffortSelectorScreen .effort-selector-title {
|
||||
text-style: bold;
|
||||
color: $primary;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
EffortSelectorScreen .effort-selector-subtitle {
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
EffortSelectorScreen OptionList {
|
||||
height: auto;
|
||||
max-height: 10;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
EffortSelectorScreen .effort-selector-help {
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
margin-top: 1;
|
||||
text-align: center;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
model_spec: str,
|
||||
efforts: tuple[str, ...],
|
||||
current_effort: str | None = None,
|
||||
default_effort: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize the effort selector.
|
||||
|
||||
Args:
|
||||
model_spec: Active `provider:model` spec.
|
||||
efforts: Supported effort labels for `model_spec`.
|
||||
current_effort: Current per-session effort override, if any.
|
||||
default_effort: Provider default effort for `model_spec`, if known.
|
||||
"""
|
||||
super().__init__()
|
||||
self._model_spec = model_spec
|
||||
self._efforts = efforts
|
||||
self._current_effort = current_effort
|
||||
self._default_effort = default_effort
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the screen layout.
|
||||
|
||||
Yields:
|
||||
Widgets for the effort selector UI.
|
||||
"""
|
||||
glyphs = get_glyphs()
|
||||
options = [
|
||||
Option(self._format_label(effort), id=effort) for effort in self._efforts
|
||||
]
|
||||
highlighted_effort = self._current_effort or self._default_effort
|
||||
try:
|
||||
highlighted = self._efforts.index(highlighted_effort)
|
||||
except ValueError:
|
||||
highlighted = 0
|
||||
help_text = (
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab switch"
|
||||
f" {glyphs.bullet} Enter select"
|
||||
f" {glyphs.bullet} Esc cancel"
|
||||
)
|
||||
with Vertical():
|
||||
yield Static("Select Reasoning Effort", classes="effort-selector-title")
|
||||
yield Static(self._model_spec, classes="effort-selector-subtitle")
|
||||
option_list = OptionList(*options, id="effort-options")
|
||||
option_list.highlighted = highlighted
|
||||
yield option_list
|
||||
yield Static(help_text, classes="effort-selector-help")
|
||||
|
||||
def _format_label(self, effort: str) -> Content:
|
||||
"""Render an effort label with a current marker.
|
||||
|
||||
Args:
|
||||
effort: Effort label.
|
||||
|
||||
Returns:
|
||||
Styled option label.
|
||||
"""
|
||||
markers = []
|
||||
if effort == self._current_effort:
|
||||
markers.append("current")
|
||||
if effort == self._default_effort:
|
||||
markers.append("default")
|
||||
if markers:
|
||||
suffix = ", ".join(markers)
|
||||
return Content.from_markup(
|
||||
"$effort [dim]($suffix)[/dim]", effort=effort, suffix=suffix
|
||||
)
|
||||
return Content.from_markup("$effort", effort=effort)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Apply ASCII border if needed."""
|
||||
if is_ascii_mode():
|
||||
container = self.query_one(Vertical)
|
||||
colors = theme.get_theme_colors(self)
|
||||
container.styles.border = ("ascii", colors.success)
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
"""Dismiss with the selected effort.
|
||||
|
||||
Args:
|
||||
event: The option selected event.
|
||||
"""
|
||||
effort = event.option.id
|
||||
# Every option is built with a non-empty `id` (the effort label), so
|
||||
# this is always truthy today. Guard anyway: a future id-less option
|
||||
# (e.g. a separator) would otherwise dismiss with `None`, which reads
|
||||
# as a cancel — a silent no-op rather than a clearly-impossible branch.
|
||||
if effort is not None:
|
||||
self.dismiss(effort)
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
"""Cancel without changing effort."""
|
||||
self.dismiss(None)
|
||||
|
||||
def action_cursor_down(self) -> None:
|
||||
"""Move the option list cursor down."""
|
||||
option_list = self._option_list()
|
||||
if option_list is not None:
|
||||
option_list.action_cursor_down()
|
||||
|
||||
def action_cursor_up(self) -> None:
|
||||
"""Move the option list cursor up."""
|
||||
option_list = self._option_list()
|
||||
if option_list is not None:
|
||||
option_list.action_cursor_up()
|
||||
|
||||
def _option_list(self) -> OptionList | None:
|
||||
"""Return the option list if it is mounted."""
|
||||
try:
|
||||
return self.query_one("#effort-options", OptionList)
|
||||
except NoMatches:
|
||||
return None
|
||||
@@ -48,10 +48,16 @@ class ModelLabel(Widget):
|
||||
When the full `provider:model` text doesn't fit, the provider is dropped
|
||||
first. If the bare model name still doesn't fit, it is left-truncated
|
||||
with a leading ellipsis so the most distinctive tail stays visible.
|
||||
|
||||
When a reasoning effort is set, its label is appended to the model and
|
||||
participates in the same ladder: the effort suffix is preserved (with the
|
||||
model left-truncated to make room) and is only dropped once even the
|
||||
left-truncated model plus effort cannot fit.
|
||||
"""
|
||||
|
||||
provider: reactive[str] = reactive("", layout=True)
|
||||
model: reactive[str] = reactive("", layout=True)
|
||||
effort: reactive[str] = reactive("", layout=True)
|
||||
|
||||
def _clean_model(self) -> str:
|
||||
"""Strip the provider's registered prefix so the status bar stays compact.
|
||||
@@ -68,6 +74,19 @@ class ModelLabel(Widget):
|
||||
return name[len(prefix) :]
|
||||
return name
|
||||
|
||||
def _with_effort(self, text: str) -> str:
|
||||
"""Append the reasoning effort label when one is set.
|
||||
|
||||
Args:
|
||||
text: Base model display text.
|
||||
|
||||
Returns:
|
||||
Model display text with the effort suffix (a per-session override or
|
||||
the provider default) when one is present, else
|
||||
`text` unchanged.
|
||||
"""
|
||||
return f"{text} {self.effort}" if self.effort else text
|
||||
|
||||
def get_content_width(self, container: Size, viewport: Size) -> int: # noqa: ARG002
|
||||
"""Return the intrinsic width so `width: auto` works.
|
||||
|
||||
@@ -82,7 +101,7 @@ class ModelLabel(Widget):
|
||||
return 0
|
||||
model = self._clean_model()
|
||||
full = f"{self.provider}:{model}" if self.provider else model
|
||||
return len(full)
|
||||
return len(self._with_effort(full))
|
||||
|
||||
def render(self) -> RenderResult:
|
||||
"""Render the model label with width-aware truncation.
|
||||
@@ -95,8 +114,15 @@ class ModelLabel(Widget):
|
||||
return ""
|
||||
model = self._clean_model()
|
||||
full = f"{self.provider}:{model}" if self.provider else model
|
||||
if len(full) <= width:
|
||||
return Content(full)
|
||||
full_with_effort = self._with_effort(full)
|
||||
model_with_effort = self._with_effort(model)
|
||||
if len(full_with_effort) <= width:
|
||||
return Content(full_with_effort)
|
||||
if len(model_with_effort) <= width:
|
||||
return Content(model_with_effort)
|
||||
if self.effort and width > len(self.effort) + 2:
|
||||
model_width = width - len(self.effort) - 1
|
||||
return Content(f"\u2026{model[-(model_width - 1) :]} {self.effort}")
|
||||
if len(model) <= width:
|
||||
return Content(model)
|
||||
if width > 1:
|
||||
@@ -585,13 +611,16 @@ class StatusBar(Horizontal):
|
||||
except NoMatches:
|
||||
return
|
||||
|
||||
def set_model(self, *, provider: str, model: str) -> None:
|
||||
def set_model(self, *, provider: str, model: str, effort: str = "") -> None:
|
||||
"""Update the model display text.
|
||||
|
||||
Args:
|
||||
provider: Model provider name (e.g., `'anthropic'`).
|
||||
model: Model name (e.g., `'claude-sonnet-4-5'`).
|
||||
effort: Reasoning effort label to display (per-session override or
|
||||
provider default), or empty when none applies.
|
||||
"""
|
||||
label = self.query_one("#model-display", ModelLabel)
|
||||
label.provider = provider
|
||||
label.model = model
|
||||
label.effort = effort
|
||||
|
||||
@@ -48,6 +48,7 @@ _TIPS: dict[str, int] = {
|
||||
"Use /mcp login <server> to authenticate MCP OAuth servers without leaving the TUI": 1, # noqa: E501
|
||||
"Use /remember to save learnings from this conversation": 1,
|
||||
"Use /model to switch models mid-conversation": 2,
|
||||
"Use /effort high to change the current model's reasoning effort": 1,
|
||||
"Press ctrl+x to compose prompts in your external editor": 1,
|
||||
"Press ctrl+u to delete to the start of the line in the chat input": 1,
|
||||
"Use /skill:<name> to invoke a skill directly": 1,
|
||||
|
||||
@@ -248,6 +248,41 @@ class TestInitialPromptOnMount:
|
||||
|
||||
assert submitted == [("code-review", "review this diff", None)]
|
||||
|
||||
async def test_on_mount_infers_status_bar_default_effort(self) -> None:
|
||||
"""Initial status sync should show default effort before server ready."""
|
||||
from deepagents_code.widgets.status import StatusBar
|
||||
|
||||
app = DeepAgentsApp(thread_id="thread-123")
|
||||
chat = MagicMock()
|
||||
chat.styles = SimpleNamespace(scrollbar_size_vertical=None)
|
||||
status_bar = MagicMock(spec=StatusBar)
|
||||
chat_input = MagicMock(spec=ChatInput)
|
||||
|
||||
def query_one(selector: object, *_args: object) -> object:
|
||||
if selector == "#chat":
|
||||
return chat
|
||||
if selector == "#status-bar":
|
||||
return status_bar
|
||||
if selector == "#input-area":
|
||||
return chat_input
|
||||
raise NoMatches(str(selector))
|
||||
|
||||
app.query_one = MagicMock(side_effect=query_one) # ty: ignore
|
||||
app.call_after_refresh = MagicMock() # ty: ignore
|
||||
app.run_worker = MagicMock(side_effect=_closing_run_worker_mock) # ty: ignore
|
||||
|
||||
with (
|
||||
patch("deepagents_code.config.settings") as mock_settings,
|
||||
patch("asyncio.create_task", side_effect=_closing_run_worker_mock),
|
||||
):
|
||||
mock_settings.model_provider = "openai"
|
||||
mock_settings.model_name = "gpt-5.5"
|
||||
await app.on_mount()
|
||||
|
||||
status_bar.set_model.assert_called_once_with(
|
||||
provider="openai", model="gpt-5.5", effort="medium"
|
||||
)
|
||||
|
||||
async def test_server_ready_refreshes_status_bar_model(self) -> None:
|
||||
"""ServerReady should push current settings into the StatusBar model display.
|
||||
|
||||
@@ -280,7 +315,7 @@ class TestInitialPromptOnMount:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
status_bar.set_model.assert_called_once_with(
|
||||
provider="anthropic", model="claude-opus-4-7"
|
||||
provider="anthropic", model="claude-opus-4-7", effort="high"
|
||||
)
|
||||
|
||||
async def test_server_ready_warns_when_status_bar_missing(
|
||||
@@ -345,7 +380,7 @@ class TestInitialPromptOnMount:
|
||||
# Still calls set_model with the falsy-coerced strings so the widget
|
||||
# doesn't render stale state — but emits a warning so the misconfig
|
||||
# isn't invisible.
|
||||
status_bar.set_model.assert_called_once_with(provider="", model="")
|
||||
status_bar.set_model.assert_called_once_with(provider="", model="", effort="")
|
||||
assert any(
|
||||
"Settings missing model identity" in record.message
|
||||
for record in caplog.records
|
||||
|
||||
@@ -390,6 +390,146 @@ class TestStoredCredentials:
|
||||
assert apply_stored_credentials("google_genai") is True
|
||||
assert "GOOGLE_GEMINI_BASE_URL" not in os.environ
|
||||
|
||||
def test_apply_stored_credentials_blank_base_url_clears_anthropic_custom_headers(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A stored Anthropic key with no base_url clears `ANTHROPIC_CUSTOM_HEADERS`.
|
||||
|
||||
The Anthropic SDK reads `ANTHROPIC_CUSTOM_HEADERS` and injects the
|
||||
headers into every request. A gateway-provisioned environment sets
|
||||
this to `X-Api-Key: <gateway-key>`, which overrides the SDK's own
|
||||
`api_key`-derived header. When switching to a personal key, the
|
||||
custom header must also be cleared or the gateway key is sent to
|
||||
the provider's native endpoint and rejected.
|
||||
"""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
from deepagents_code.model_config import apply_stored_credentials
|
||||
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.setenv(
|
||||
"ANTHROPIC_BASE_URL", "https://gateway.smith.langchain.com/anthropic"
|
||||
)
|
||||
monkeypatch.setenv("ANTHROPIC_CUSTOM_HEADERS", "X-Api-Key: lsv2_sk_gateway_key")
|
||||
auth_store.set_stored_key("anthropic", "sk-ant-personal")
|
||||
|
||||
assert apply_stored_credentials("anthropic") is True
|
||||
assert os.environ["ANTHROPIC_API_KEY"] == "sk-ant-personal"
|
||||
assert "ANTHROPIC_BASE_URL" not in os.environ
|
||||
assert "ANTHROPIC_API_URL" not in os.environ
|
||||
assert "ANTHROPIC_CUSTOM_HEADERS" not in os.environ
|
||||
|
||||
def test_apply_stored_credentials_with_base_url_preserves_anthropic_custom_headers(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A stored Anthropic key *with* a base_url preserves custom headers.
|
||||
|
||||
When the user stores a gateway endpoint in `/auth`, the custom
|
||||
headers env var should be left in place — it carries the gateway
|
||||
auth header that the gateway expects.
|
||||
"""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
from deepagents_code.model_config import apply_stored_credentials
|
||||
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.setenv("ANTHROPIC_CUSTOM_HEADERS", "X-Api-Key: lsv2_sk_gateway_key")
|
||||
auth_store.set_stored_key(
|
||||
"anthropic",
|
||||
"lsv2_sk_gateway_key",
|
||||
base_url="https://gateway.smith.langchain.com/anthropic",
|
||||
)
|
||||
|
||||
assert apply_stored_credentials("anthropic") is True
|
||||
assert (
|
||||
os.environ["ANTHROPIC_BASE_URL"]
|
||||
== "https://gateway.smith.langchain.com/anthropic"
|
||||
)
|
||||
assert (
|
||||
os.environ["ANTHROPIC_CUSTOM_HEADERS"] == "X-Api-Key: lsv2_sk_gateway_key"
|
||||
)
|
||||
|
||||
def test_apply_stored_credentials_config_base_url_preserves_anthropic_headers(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A config-routed Anthropic gateway keeps its custom headers."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store, model_config
|
||||
from deepagents_code.model_config import apply_stored_credentials, clear_caches
|
||||
|
||||
config_path = tmp_path / "config.toml"
|
||||
config_path.write_text("""
|
||||
[models.providers.anthropic]
|
||||
base_url = "https://configured.gateway.example/anthropic"
|
||||
models = ["claude-sonnet-4-5"]
|
||||
""")
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.setenv(
|
||||
"ANTHROPIC_BASE_URL", "https://stale.gateway.example/anthropic"
|
||||
)
|
||||
monkeypatch.setenv("ANTHROPIC_CUSTOM_HEADERS", "X-Api-Key: lsv2_sk_gateway_key")
|
||||
auth_store.set_stored_key("anthropic", "sk-ant-personal")
|
||||
|
||||
with patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path):
|
||||
clear_caches()
|
||||
assert apply_stored_credentials("anthropic") is True
|
||||
assert (
|
||||
model_config.ModelConfig.load().get_base_url("anthropic")
|
||||
== "https://configured.gateway.example/anthropic"
|
||||
)
|
||||
|
||||
assert os.environ["ANTHROPIC_API_KEY"] == "sk-ant-personal"
|
||||
assert "ANTHROPIC_BASE_URL" not in os.environ
|
||||
assert "ANTHROPIC_API_URL" not in os.environ
|
||||
assert (
|
||||
os.environ["ANTHROPIC_CUSTOM_HEADERS"] == "X-Api-Key: lsv2_sk_gateway_key"
|
||||
)
|
||||
|
||||
def test_apply_stored_credentials_prefixed_base_url_preserves_anthropic_headers(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A scoped Anthropic endpoint override keeps its gateway headers."""
|
||||
import os
|
||||
|
||||
from deepagents_code import auth_store
|
||||
from deepagents_code.model_config import apply_stored_credentials
|
||||
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.setenv(
|
||||
"ANTHROPIC_BASE_URL", "https://stale.gateway.example/anthropic"
|
||||
)
|
||||
monkeypatch.setenv(
|
||||
"DEEPAGENTS_CODE_ANTHROPIC_BASE_URL",
|
||||
"https://scoped.gateway.example/anthropic",
|
||||
)
|
||||
monkeypatch.setenv("ANTHROPIC_CUSTOM_HEADERS", "X-Api-Key: lsv2_sk_gateway_key")
|
||||
auth_store.set_stored_key("anthropic", "sk-ant-personal")
|
||||
|
||||
assert apply_stored_credentials("anthropic") is True
|
||||
|
||||
assert os.environ["ANTHROPIC_API_KEY"] == "sk-ant-personal"
|
||||
assert "ANTHROPIC_BASE_URL" not in os.environ
|
||||
assert "ANTHROPIC_API_URL" not in os.environ
|
||||
assert (
|
||||
os.environ["DEEPAGENTS_CODE_ANTHROPIC_BASE_URL"]
|
||||
== "https://scoped.gateway.example/anthropic"
|
||||
)
|
||||
assert (
|
||||
os.environ["ANTHROPIC_CUSTOM_HEADERS"] == "X-Api-Key: lsv2_sk_gateway_key"
|
||||
)
|
||||
|
||||
def test_apply_stored_credentials_clears_config_base_url_env(
|
||||
self,
|
||||
fake_state_dir: Path, # noqa: ARG002
|
||||
|
||||
@@ -329,6 +329,36 @@ class TestModelSwitchNoOp:
|
||||
# Stable, key-sorted JSON in the echoed suffix.
|
||||
assert 'with model params {"num_ctx": 16384, "temperature": 0.2}' in message
|
||||
|
||||
async def test_same_model_with_new_params_refreshes_status_effort(self) -> None:
|
||||
"""Same-model param updates should refresh the status bar effort."""
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app._status_bar = Mock() # ty: ignore
|
||||
app._agent = _make_remote_agent()
|
||||
|
||||
settings.model_name = "gpt-5.5"
|
||||
settings.model_provider = "openai"
|
||||
|
||||
with patch(
|
||||
"deepagents_code.model_config.get_provider_auth_status",
|
||||
return_value=ProviderAuthStatus(
|
||||
state=ProviderAuthState.CONFIGURED,
|
||||
provider="openai",
|
||||
env_var="OPENAI_API_KEY",
|
||||
source=ProviderAuthSource.ENV,
|
||||
),
|
||||
):
|
||||
await app._switch_model(
|
||||
"openai:gpt-5.5",
|
||||
extra_kwargs={"reasoning": {"effort": "low", "summary": "auto"}},
|
||||
)
|
||||
|
||||
app._status_bar.set_model.assert_called_once_with( # ty: ignore[unresolved-attribute]
|
||||
provider="openai",
|
||||
model="gpt-5.5",
|
||||
effort="low",
|
||||
)
|
||||
|
||||
async def test_same_model_without_params_clears_prior_override(self) -> None:
|
||||
"""Re-selecting the same model with no params must clear stale params.
|
||||
|
||||
|
||||
@@ -0,0 +1,618 @@
|
||||
"""Tests for `/effort` reasoning effort handling."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Coroutine, Iterator
|
||||
from typing import get_args
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from textual.app import App
|
||||
from textual.widgets import OptionList
|
||||
|
||||
from deepagents_code.app import DeepAgentsApp
|
||||
from deepagents_code.command_registry import COMMANDS
|
||||
from deepagents_code.config import settings
|
||||
from deepagents_code.reasoning_effort import (
|
||||
EffortLabel,
|
||||
current_effort_from_model_params,
|
||||
default_effort_for_model,
|
||||
merge_effort_model_params,
|
||||
model_params_for_effort,
|
||||
supported_efforts_for_model,
|
||||
without_effort_model_params,
|
||||
)
|
||||
from deepagents_code.widgets.effort_selector import EffortSelectorScreen
|
||||
from deepagents_code.widgets.messages import ErrorMessage
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_settings() -> Iterator[None]:
|
||||
original_name = settings.model_name
|
||||
original_provider = settings.model_provider
|
||||
yield
|
||||
settings.model_name = original_name
|
||||
settings.model_provider = original_provider
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model_spec", "efforts"),
|
||||
[
|
||||
("openai:gpt-5.5", ("none", "low", "medium", "high", "xhigh")),
|
||||
("openai_codex:gpt-5.5", ("none", "low", "medium", "high", "xhigh")),
|
||||
# A generic (non-5.5) gpt-5 still gets the full OpenAI range.
|
||||
("openai:gpt-5.4", ("none", "low", "medium", "high", "xhigh")),
|
||||
("anthropic:claude-opus-4-8", ("low", "medium", "high", "xhigh", "max")),
|
||||
# Opus 4.7 is the first version documented for the full range; assert the
|
||||
# named boundary directly rather than relying on 4.8 to exercise it.
|
||||
("anthropic:claude-opus-4-7", ("low", "medium", "high", "xhigh", "max")),
|
||||
("anthropic:claude-opus-4-6", ("low", "medium", "high", "max")),
|
||||
("anthropic:claude-opus-4-5", ("low", "medium", "high")),
|
||||
("anthropic:claude-sonnet-5", ("low", "medium", "high", "xhigh", "max")),
|
||||
("anthropic:claude-sonnet-4-6", ("low", "medium", "high", "max")),
|
||||
("anthropic:claude-sonnet-4-5", ()),
|
||||
# Models that predate reasoning effort must report no configurable
|
||||
# efforts rather than falling through to the full range.
|
||||
("anthropic:claude-opus-4-1", ()),
|
||||
("anthropic:claude-opus-4-0", ()),
|
||||
("anthropic:claude-sonnet-4-0", ()),
|
||||
# A dated snapshot of a predating version still predates: the version
|
||||
# anchor tolerates a trailing `-<date>` suffix.
|
||||
("anthropic:claude-opus-4-1-20250805", ()),
|
||||
# Version matching is anchored on a non-digit boundary, so a
|
||||
# hypothetical future double-digit minor is NOT misread as the
|
||||
# single-digit version it prefixes (`opus-4-1` must not match
|
||||
# `opus-4-16`); it falls through to the full range instead.
|
||||
("anthropic:claude-opus-4-16", ("low", "medium", "high", "xhigh", "max")),
|
||||
("google_genai:gemini-3.5-flash", ("low", "medium", "high")),
|
||||
("google_genai:gemini-3.1-pro-preview", ("low", "medium", "high")),
|
||||
(
|
||||
"fireworks:accounts/fireworks/models/deepseek-v4-pro",
|
||||
("none", "low", "medium", "high", "xhigh", "max"),
|
||||
),
|
||||
(
|
||||
"fireworks:accounts/fireworks/models/kimi-k2p7-code",
|
||||
("low", "medium", "high"),
|
||||
),
|
||||
("fireworks:accounts/fireworks/models/glm-5p2", ("none", "high", "max")),
|
||||
# Recognized provider, wrong model family: the per-provider prefix
|
||||
# guards in `_classify_reasoning_provider` (and the Fireworks family
|
||||
# check) must reject these rather than fall through to an effort set.
|
||||
("openai:gpt-4o", ()),
|
||||
("openai_codex:gpt-4o", ()),
|
||||
("anthropic:claude-3-5-haiku-latest", ()),
|
||||
("google_genai:gemini-2.5-flash", ()),
|
||||
("fireworks:accounts/fireworks/models/llama-v3p1-70b-instruct", ()),
|
||||
],
|
||||
)
|
||||
def test_supported_efforts_for_model(model_spec: str, efforts: tuple[str, ...]) -> None:
|
||||
assert supported_efforts_for_model(model_spec) == efforts
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model_spec", "default"),
|
||||
[
|
||||
("openai:gpt-5.5", "medium"),
|
||||
("openai_codex:gpt-5.5", "medium"),
|
||||
# Only gpt-5.5 has a documented default; other gpt-5 variants are None.
|
||||
("openai:gpt-5.4", None),
|
||||
("anthropic:claude-opus-4-8", "high"),
|
||||
("anthropic:claude-opus-4-7", "high"),
|
||||
("anthropic:claude-sonnet-4-6", "high"),
|
||||
("anthropic:claude-sonnet-4-5", None),
|
||||
("anthropic:claude-opus-4-1", None),
|
||||
("google_genai:gemini-3.5-flash", "medium"),
|
||||
("google_genai:gemini-3.1-pro-preview", "high"),
|
||||
("google_genai:gemini-3-pro", "high"),
|
||||
("google_genai:gemini-3-flash", "high"),
|
||||
("fireworks:accounts/fireworks/models/deepseek-v4-pro", "high"),
|
||||
("fireworks:accounts/fireworks/models/glm-5p2", "max"),
|
||||
("fireworks:accounts/fireworks/models/kimi-k2p7-code", None),
|
||||
("ollama:llama3.1", None),
|
||||
],
|
||||
)
|
||||
def test_default_effort_for_model(model_spec: str, default: str | None) -> None:
|
||||
assert default_effort_for_model(model_spec) == default
|
||||
|
||||
|
||||
def test_model_params_for_effort_maps_provider_kwargs() -> None:
|
||||
assert model_params_for_effort("openai:gpt-5.5", "high") == {
|
||||
"reasoning": {"effort": "high", "summary": "auto"}
|
||||
}
|
||||
assert model_params_for_effort("anthropic:claude-opus-4-8", "xhigh") == {
|
||||
"thinking": {"type": "adaptive", "display": "summarized"},
|
||||
"effort": "xhigh",
|
||||
}
|
||||
assert model_params_for_effort("google_genai:gemini-3.5-flash", "low") == {
|
||||
"thinking_level": "low"
|
||||
}
|
||||
assert model_params_for_effort(
|
||||
"fireworks:accounts/fireworks/models/deepseek-v4-pro", "max"
|
||||
) == {"model_kwargs": {"reasoning_effort": "max"}}
|
||||
|
||||
|
||||
def test_model_params_for_effort_rejects_unsupported_effort() -> None:
|
||||
assert (
|
||||
model_params_for_effort(
|
||||
"fireworks:accounts/fireworks/models/kimi-k2p7-code", "max"
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert model_params_for_effort("ollama:llama3.1", "high") is None
|
||||
|
||||
|
||||
def test_merge_and_clear_effort_model_params_preserves_unrelated_params() -> None:
|
||||
merged = merge_effort_model_params(
|
||||
{"temperature": 0.2, "model_kwargs": {"top_p": 0.9}},
|
||||
{"model_kwargs": {"reasoning_effort": "high"}},
|
||||
)
|
||||
|
||||
assert merged == {
|
||||
"temperature": 0.2,
|
||||
"model_kwargs": {"top_p": 0.9, "reasoning_effort": "high"},
|
||||
}
|
||||
assert (
|
||||
current_effort_from_model_params(
|
||||
"fireworks:accounts/fireworks/models/deepseek-v4-pro", merged
|
||||
)
|
||||
== "high"
|
||||
)
|
||||
assert without_effort_model_params(merged) == {
|
||||
"temperature": 0.2,
|
||||
"model_kwargs": {"top_p": 0.9},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model_spec", "model_params"),
|
||||
[
|
||||
# `reasoning` present but not a dict (e.g. a bare-string override).
|
||||
("openai:gpt-5.5", {"reasoning": "high"}),
|
||||
# `reasoning.effort` present but not a str.
|
||||
("openai:gpt-5.5", {"reasoning": {"effort": 5}}),
|
||||
("anthropic:claude-opus-4-8", {"effort": 5}),
|
||||
("google_genai:gemini-3.5-flash", {"thinking_level": 5}),
|
||||
(
|
||||
"fireworks:accounts/fireworks/models/deepseek-v4-pro",
|
||||
{"model_kwargs": {"reasoning_effort": 5}},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_current_effort_warns_on_malformed_params(
|
||||
model_spec: str,
|
||||
model_params: dict[str, object],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A present-but-mistyped effort is discarded *and* logged, not silent.
|
||||
|
||||
Reading it as plain `None` would let the status bar show the provider
|
||||
default while the malformed param still ships on the wire — the two would
|
||||
disagree with no trace. The reader must warn (type only, never the value).
|
||||
"""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert current_effort_from_model_params(model_spec, model_params) is None
|
||||
assert any(record.levelno == logging.WARNING for record in caplog.records)
|
||||
|
||||
|
||||
def test_current_effort_non_dict_model_kwargs_is_silent() -> None:
|
||||
"""A non-dict `model_kwargs` is a legit shape and must not warn."""
|
||||
# No caplog assertion for silence: the value simply reads as "no effort".
|
||||
assert (
|
||||
current_effort_from_model_params(
|
||||
"fireworks:accounts/fireworks/models/deepseek-v4-pro",
|
||||
{"model_kwargs": "raw"},
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_effort_argument_hint_covers_effort_vocabulary() -> None:
|
||||
"""The `/effort` argument hint must list every `EffortLabel` plus a reset.
|
||||
|
||||
The label vocabulary is hand-duplicated into the command's `argument_hint`
|
||||
(and `COMMANDS.md`), none of which is type-checked against `EffortLabel`.
|
||||
This pins the hint so a new label can't silently drift out of the hint text.
|
||||
"""
|
||||
effort_command = next(cmd for cmd in COMMANDS if cmd.name == "/effort")
|
||||
hint = effort_command.argument_hint
|
||||
assert hint is not None
|
||||
tokens = set(hint.strip("[]").split("|"))
|
||||
assert set(get_args(EffortLabel)) <= tokens
|
||||
# At least one reset token (handled by `_set_effort_override`) is offered.
|
||||
assert tokens & {"clear", "--clear", "reset"}
|
||||
|
||||
|
||||
async def test_effort_command_sets_current_model_params() -> None:
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
settings.model_provider = "openai"
|
||||
settings.model_name = "gpt-5.5"
|
||||
|
||||
await app._handle_effort_command("/effort high")
|
||||
|
||||
assert app._model_params_override == {
|
||||
"reasoning": {"effort": "high", "summary": "auto"}
|
||||
}
|
||||
assert app._mount_message.await_count == 2 # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
async def test_effort_command_without_args_opens_selector() -> None:
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app.push_screen = Mock() # ty: ignore
|
||||
app._model_params_override = {"reasoning": {"effort": "medium"}}
|
||||
settings.model_provider = "openai"
|
||||
settings.model_name = "gpt-5.5"
|
||||
|
||||
await app._handle_effort_command("/effort")
|
||||
|
||||
app.push_screen.assert_called_once() # ty: ignore[unresolved-attribute]
|
||||
screen = app.push_screen.call_args.args[0] # ty: ignore[unresolved-attribute]
|
||||
assert isinstance(screen, EffortSelectorScreen)
|
||||
assert screen._model_spec == "openai:gpt-5.5"
|
||||
assert screen._efforts == ("none", "low", "medium", "high", "xhigh")
|
||||
assert screen._current_effort == "medium"
|
||||
assert screen._default_effort == "medium"
|
||||
app._mount_message.assert_not_awaited() # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
async def test_effort_command_clear_removes_only_effort_params() -> None:
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app._model_params_override = {
|
||||
"temperature": 0.2,
|
||||
"reasoning": {"effort": "high", "summary": "auto"},
|
||||
"reasoning_effort": "high",
|
||||
}
|
||||
settings.model_provider = "openai"
|
||||
settings.model_name = "gpt-5.5"
|
||||
|
||||
await app._handle_effort_command("/effort clear")
|
||||
|
||||
assert app._model_params_override == {"temperature": 0.2}
|
||||
|
||||
|
||||
async def test_effort_command_updates_status_bar_effort() -> None:
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app._status_bar = Mock() # ty: ignore
|
||||
settings.model_provider = "openai"
|
||||
settings.model_name = "gpt-5.5"
|
||||
|
||||
await app._handle_effort_command("/effort xhigh")
|
||||
|
||||
app._status_bar.set_model.assert_called_once_with( # ty: ignore[unresolved-attribute]
|
||||
provider="openai",
|
||||
model="gpt-5.5",
|
||||
effort="xhigh",
|
||||
)
|
||||
|
||||
|
||||
async def test_effort_command_clear_refreshes_status_bar_to_default() -> None:
|
||||
"""Clearing an override refreshes the status bar to the reverted effort."""
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app._status_bar = Mock() # ty: ignore
|
||||
app._model_params_override = {"reasoning": {"effort": "high", "summary": "auto"}}
|
||||
settings.model_provider = "openai"
|
||||
settings.model_name = "gpt-5.5"
|
||||
|
||||
await app._handle_effort_command("/effort clear")
|
||||
|
||||
# gpt-5.5's documented default is `medium`; the bar reverts to it once the
|
||||
# `high` override is gone. A dropped `_sync_status_model()` call in the
|
||||
# clear branch would leave the stale `high` suffix and fail this.
|
||||
app._status_bar.set_model.assert_called_once_with( # ty: ignore[unresolved-attribute]
|
||||
provider="openai",
|
||||
model="gpt-5.5",
|
||||
effort="medium",
|
||||
)
|
||||
|
||||
|
||||
async def test_effort_command_rejects_unsupported_effort() -> None:
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
settings.model_provider = "fireworks"
|
||||
settings.model_name = "accounts/fireworks/models/kimi-k2p7-code"
|
||||
|
||||
await app._handle_effort_command("/effort max")
|
||||
|
||||
assert app._model_params_override is None
|
||||
assert app._mount_message.await_count == 2 # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token", ["clear", "--clear", "reset"])
|
||||
async def test_effort_command_clear_aliases(token: str) -> None:
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app._model_params_override = {
|
||||
"temperature": 0.2,
|
||||
"reasoning": {"effort": "high", "summary": "auto"},
|
||||
}
|
||||
settings.model_provider = "openai"
|
||||
settings.model_name = "gpt-5.5"
|
||||
|
||||
await app._handle_effort_command(f"/effort {token}")
|
||||
|
||||
assert app._model_params_override == {"temperature": 0.2}
|
||||
|
||||
|
||||
async def test_effort_selector_reports_no_model_configured() -> None:
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app.push_screen = Mock() # ty: ignore
|
||||
settings.model_provider = None
|
||||
settings.model_name = None
|
||||
|
||||
await app._handle_effort_command("/effort")
|
||||
|
||||
app.push_screen.assert_not_called() # ty: ignore[unresolved-attribute]
|
||||
assert app._mount_message.await_count == 2 # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
async def test_effort_command_reports_not_configurable_model() -> None:
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
settings.model_provider = "anthropic"
|
||||
settings.model_name = "claude-sonnet-4-5"
|
||||
|
||||
await app._handle_effort_command("/effort high")
|
||||
|
||||
assert app._model_params_override is None
|
||||
assert app._mount_message.await_count == 2 # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
async def test_effort_selector_not_configurable_model_skips_screen() -> None:
|
||||
"""Bare `/effort` on a non-configurable model reports instead of opening.
|
||||
|
||||
The typed-arg path is covered separately; this guards the *selector* arm so
|
||||
a regression can't push the modal for a model that supports no efforts.
|
||||
"""
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app.push_screen = Mock() # ty: ignore
|
||||
settings.model_provider = "anthropic"
|
||||
settings.model_name = "claude-sonnet-4-5"
|
||||
|
||||
await app._handle_effort_command("/effort")
|
||||
|
||||
app.push_screen.assert_not_called() # ty: ignore[unresolved-attribute]
|
||||
# Echoed UserMessage + the "not configurable" AppMessage.
|
||||
assert app._mount_message.await_count == 2 # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
async def test_set_effort_override_guards_non_configurable_model() -> None:
|
||||
"""`_set_effort_override` re-checks configurability before applying.
|
||||
|
||||
The selector path applies effort in a worker scheduled after the model was
|
||||
resolved, so the sink re-resolves the context to guard against the model
|
||||
becoming non-configurable in between.
|
||||
"""
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
settings.model_provider = "anthropic"
|
||||
settings.model_name = "claude-sonnet-4-5"
|
||||
|
||||
await app._set_effort_override("high")
|
||||
|
||||
assert app._model_params_override is None
|
||||
# Single AppMessage — the direct sink does not echo a UserMessage.
|
||||
app._mount_message.assert_awaited_once() # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
async def test_effort_selector_result_applies_and_refocuses() -> None:
|
||||
"""Choosing an effort schedules the apply worker and restores input focus."""
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app.push_screen = Mock() # ty: ignore
|
||||
app._set_effort_override = AsyncMock() # ty: ignore
|
||||
app._chat_input = Mock() # ty: ignore
|
||||
scheduled: list[tuple[Coroutine[object, object, None], dict[str, object]]] = []
|
||||
app.run_worker = Mock( # ty: ignore
|
||||
side_effect=lambda coro, **kwargs: scheduled.append((coro, kwargs))
|
||||
)
|
||||
settings.model_provider = "openai"
|
||||
settings.model_name = "gpt-5.5"
|
||||
|
||||
await app._handle_effort_command("/effort")
|
||||
handle_result = app.push_screen.call_args.args[1] # ty: ignore[unresolved-attribute]
|
||||
|
||||
handle_result("high")
|
||||
|
||||
assert scheduled[0][1]["group"] == "effort-selection"
|
||||
app._chat_input.focus_input.assert_called_once() # ty: ignore[unresolved-attribute]
|
||||
|
||||
# Running the scheduled worker coroutine applies the chosen effort.
|
||||
await scheduled[0][0]
|
||||
app._set_effort_override.assert_awaited_once_with( # ty: ignore[unresolved-attribute]
|
||||
"high"
|
||||
)
|
||||
|
||||
|
||||
async def test_effort_selector_cancel_refocuses_without_applying() -> None:
|
||||
"""Dismissing the selector refocuses input and schedules no work."""
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app.push_screen = Mock() # ty: ignore
|
||||
app._chat_input = Mock() # ty: ignore
|
||||
app.run_worker = Mock() # ty: ignore
|
||||
settings.model_provider = "openai"
|
||||
settings.model_name = "gpt-5.5"
|
||||
|
||||
await app._handle_effort_command("/effort")
|
||||
handle_result = app.push_screen.call_args.args[1] # ty: ignore[unresolved-attribute]
|
||||
|
||||
handle_result(None)
|
||||
|
||||
app.run_worker.assert_not_called() # ty: ignore[unresolved-attribute]
|
||||
app._chat_input.focus_input.assert_called_once() # ty: ignore[unresolved-attribute]
|
||||
|
||||
|
||||
async def test_effort_selector_apply_failure_reports_error(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A failure applying the selected effort logs and surfaces an error.
|
||||
|
||||
The worker running `apply_effort` is not covered by the app's worker-state
|
||||
error net, so the callback catches, logs, and mounts an `ErrorMessage`
|
||||
itself — otherwise the failure would die silently in the background.
|
||||
"""
|
||||
app = DeepAgentsApp()
|
||||
app._mount_message = AsyncMock() # ty: ignore
|
||||
app.push_screen = Mock() # ty: ignore
|
||||
app._set_effort_override = AsyncMock( # ty: ignore
|
||||
side_effect=RuntimeError("boom")
|
||||
)
|
||||
app._chat_input = Mock() # ty: ignore
|
||||
scheduled: list[Coroutine[object, object, None]] = []
|
||||
app.run_worker = Mock( # ty: ignore
|
||||
side_effect=lambda coro, **_kwargs: scheduled.append(coro)
|
||||
)
|
||||
settings.model_provider = "openai"
|
||||
settings.model_name = "gpt-5.5"
|
||||
|
||||
await app._handle_effort_command("/effort")
|
||||
handle_result = app.push_screen.call_args.args[1] # ty: ignore[unresolved-attribute]
|
||||
handle_result("high")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
await scheduled[0]
|
||||
|
||||
assert any(
|
||||
"Failed to apply reasoning effort" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
mounted = app._mount_message.await_args.args[0] # ty: ignore[unresolved-attribute]
|
||||
assert isinstance(mounted, ErrorMessage)
|
||||
|
||||
|
||||
def test_without_effort_clears_anthropic_thinking_and_effort() -> None:
|
||||
effort_params = model_params_for_effort("anthropic:claude-opus-4-8", "xhigh")
|
||||
assert effort_params is not None
|
||||
params = merge_effort_model_params({"temperature": 0.3}, effort_params)
|
||||
assert params["effort"] == "xhigh"
|
||||
assert "thinking" in params
|
||||
assert without_effort_model_params(params) == {"temperature": 0.3}
|
||||
|
||||
|
||||
def test_without_effort_clears_google_thinking_level() -> None:
|
||||
effort_params = model_params_for_effort("google_genai:gemini-3.5-flash", "low")
|
||||
assert effort_params is not None
|
||||
assert without_effort_model_params(effort_params) is None
|
||||
|
||||
|
||||
def test_without_effort_clears_top_level_openai_reasoning_effort() -> None:
|
||||
cleaned = without_effort_model_params(
|
||||
{"reasoning_effort": "high", "temperature": 0.1}
|
||||
)
|
||||
assert cleaned == {"temperature": 0.1}
|
||||
|
||||
|
||||
def test_without_effort_preserves_non_dict_model_kwargs() -> None:
|
||||
"""A non-dict `model_kwargs` is preserved verbatim while effort keys drop."""
|
||||
cleaned = without_effort_model_params(
|
||||
{"model_kwargs": "raw", "temperature": 0.1, "effort": "high"}
|
||||
)
|
||||
assert cleaned == {"model_kwargs": "raw", "temperature": 0.1}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model_spec", "effort"),
|
||||
[
|
||||
("openai:gpt-5.5", "none"),
|
||||
("openai:gpt-5.5", "high"),
|
||||
("anthropic:claude-opus-4-8", "xhigh"),
|
||||
("google_genai:gemini-3.5-flash", "low"),
|
||||
("fireworks:accounts/fireworks/models/deepseek-v4-pro", "max"),
|
||||
],
|
||||
)
|
||||
def test_effort_params_round_trip_clears_to_none(model_spec: str, effort: str) -> None:
|
||||
"""The clear-set must strip exactly what `model_params_for_effort` writes."""
|
||||
effort_params = model_params_for_effort(model_spec, effort)
|
||||
assert effort_params is not None
|
||||
merged = merge_effort_model_params(None, effort_params)
|
||||
assert without_effort_model_params(merged) is None
|
||||
|
||||
|
||||
class _EffortSelectorHost(App[None]):
|
||||
"""Minimal host app for mounting `EffortSelectorScreen` in tests."""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_effort", "default_effort", "expected_index"),
|
||||
[("medium", "low", 2), (None, "medium", 2), (None, None, 0), ("bogus", None, 0)],
|
||||
)
|
||||
async def test_effort_selector_highlights_current(
|
||||
current_effort: str | None, default_effort: str | None, expected_index: int
|
||||
) -> None:
|
||||
app = _EffortSelectorHost()
|
||||
async with app.run_test() as pilot:
|
||||
await app.push_screen(
|
||||
EffortSelectorScreen(
|
||||
model_spec="openai:gpt-5.5",
|
||||
efforts=("none", "low", "medium", "high", "xhigh"),
|
||||
current_effort=current_effort,
|
||||
default_effort=default_effort,
|
||||
)
|
||||
)
|
||||
await pilot.pause()
|
||||
option_list = app.screen.query_one("#effort-options", OptionList)
|
||||
assert option_list.highlighted == expected_index
|
||||
|
||||
|
||||
async def test_effort_selector_enter_selects_highlighted() -> None:
|
||||
app = _EffortSelectorHost()
|
||||
async with app.run_test() as pilot:
|
||||
results: list[str | None] = []
|
||||
await app.push_screen(
|
||||
EffortSelectorScreen(
|
||||
model_spec="openai:gpt-5.5",
|
||||
efforts=("low", "medium", "high"),
|
||||
current_effort="low",
|
||||
),
|
||||
results.append,
|
||||
)
|
||||
await pilot.pause()
|
||||
app.screen.query_one("#effort-options", OptionList).focus()
|
||||
await pilot.pause()
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
assert results == ["low"]
|
||||
|
||||
|
||||
async def test_effort_selector_escape_cancels() -> None:
|
||||
app = _EffortSelectorHost()
|
||||
async with app.run_test() as pilot:
|
||||
results: list[str | None] = []
|
||||
await app.push_screen(
|
||||
EffortSelectorScreen(
|
||||
model_spec="openai:gpt-5.5",
|
||||
efforts=("low", "high"),
|
||||
current_effort=None,
|
||||
),
|
||||
results.append,
|
||||
)
|
||||
await pilot.pause()
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert results == [None]
|
||||
|
||||
|
||||
def test_effort_selector_format_label_marks_current_and_default() -> None:
|
||||
screen = EffortSelectorScreen(
|
||||
model_spec="openai:gpt-5.5",
|
||||
efforts=("low", "high"),
|
||||
current_effort="high",
|
||||
default_effort="low",
|
||||
)
|
||||
assert "(current)" in str(screen._format_label("high"))
|
||||
assert "(default)" in str(screen._format_label("low"))
|
||||
|
||||
|
||||
def test_effort_selector_format_label_combines_current_default() -> None:
|
||||
screen = EffortSelectorScreen(
|
||||
model_spec="openai:gpt-5.5",
|
||||
efforts=("low", "high"),
|
||||
current_effort="high",
|
||||
default_effort="high",
|
||||
)
|
||||
assert "(current, default)" in str(screen._format_label("high"))
|
||||
@@ -416,6 +416,62 @@ class TestModelLabelPrefixStripping:
|
||||
rendered = str(label.render())
|
||||
assert "openai:gpt-5.5" in rendered
|
||||
|
||||
async def test_effort_suffix_rendered(self) -> None:
|
||||
"""Active reasoning effort should be shown next to the model."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
bar = pilot.app.query_one("#status-bar", StatusBar)
|
||||
bar.set_model(provider="openai", model="gpt-5.5", effort="xhigh")
|
||||
await pilot.pause()
|
||||
label = pilot.app.query_one("#model-display", ModelLabel)
|
||||
assert str(label.render()) == "openai:gpt-5.5 xhigh"
|
||||
|
||||
async def test_effort_suffix_survives_provider_drop(self) -> None:
|
||||
"""When narrow, provider is dropped before the effort label."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
label = pilot.app.query_one("#model-display", ModelLabel)
|
||||
label.provider = "openai"
|
||||
label.model = "gpt-5.5"
|
||||
label.effort = "xhigh"
|
||||
label.styles.width = 18
|
||||
await pilot.pause()
|
||||
assert str(label.render()) == "gpt-5.5 xhigh"
|
||||
|
||||
async def test_effort_suffix_left_truncates_model(self) -> None:
|
||||
"""Overflowing model text is left-truncated while the effort stays."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
label = pilot.app.query_one("#model-display", ModelLabel)
|
||||
label.provider = "openai"
|
||||
label.model = "gpt-5.5-turbo-preview"
|
||||
label.effort = "high"
|
||||
label.styles.width = 15
|
||||
await pilot.pause()
|
||||
width = label.content_size.width
|
||||
rendered = str(label.render())
|
||||
# Starts with an ellipsis (left-truncated) yet retains the effort
|
||||
# label — the branch that keeps effort while dropping model chars.
|
||||
assert rendered.startswith("…")
|
||||
assert rendered.endswith(" high")
|
||||
assert "openai:" not in rendered
|
||||
assert len(rendered) <= width
|
||||
|
||||
async def test_effort_suffix_dropped_when_only_bare_model_fits(self) -> None:
|
||||
"""In the narrow window where effort can't fit, the bare model wins.
|
||||
|
||||
When the width is too small for even the left-truncated `model effort`
|
||||
form but still fits the bare model, the effort suffix is dropped rather
|
||||
than the model — the last rung before ellipsis truncation.
|
||||
"""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
label = pilot.app.query_one("#model-display", ModelLabel)
|
||||
label.provider = ""
|
||||
label.model = "o1"
|
||||
label.effort = "medium"
|
||||
# padding 0 2 -> content width = 6: too narrow for "o1 medium" (9)
|
||||
# and below len("medium") + 2, but wide enough for bare "o1".
|
||||
label.styles.width = 10
|
||||
await pilot.pause()
|
||||
assert str(label.render()) == "o1"
|
||||
|
||||
async def test_no_provider_no_stripping(self) -> None:
|
||||
"""Without a provider, the model name is passed through unchanged."""
|
||||
async with StatusBarApp().run_test() as pilot:
|
||||
@@ -540,8 +596,9 @@ class TestConnectionIndicator:
|
||||
await pilot.pause()
|
||||
indicator = pilot.app.query_one("#connection-indicator", Static)
|
||||
rendered = str(indicator.render())
|
||||
assert get_glyphs().spinner_frames[0] in rendered
|
||||
assert "Reconnecting" in rendered
|
||||
frame, _, label = rendered.partition(" ")
|
||||
assert frame in get_glyphs().spinner_frames
|
||||
assert label == "Reconnecting"
|
||||
|
||||
async def test_unmount_stops_spinner(self) -> None:
|
||||
"""Leaving the DOM must stop the timer so it can't tick detached."""
|
||||
|
||||
Reference in New Issue
Block a user