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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 67 additions & 25 deletions libs/cli/deepagents_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1327,10 +1327,58 @@ def apply_to_settings(self) -> None:
settings.model_context_limit = self.context_limit


def _apply_profile_overrides(
model: BaseChatModel,
overrides: dict[str, Any],
model_name: str,
*,
label: str,
raise_on_failure: bool = False,
) -> None:
"""Merge `overrides` into `model.profile`.

If the model already has a dict profile, overrides are layered on top
so existing keys (e.g., `tool_calling`) are preserved unchanged.

Args:
model: The chat model whose profile will be updated.
overrides: Key/value pairs to merge into the profile.
model_name: Model name used in log/error messages.
label: Human-readable source label for messages
(e.g., `"config.toml"`, `"CLI --profile-override"`).
raise_on_failure: When `True`, raise `ModelConfigError` instead
of logging a warning if assignment fails.

Raises:
ModelConfigError: If `raise_on_failure` is `True` and the model
rejects profile assignment.
"""
logger.debug("Applying %s profile overrides: %s", label, overrides)
profile = getattr(model, "profile", None)
merged = {**profile, **overrides} if isinstance(profile, dict) else overrides
try:
model.profile = merged # type: ignore[union-attr]
except (AttributeError, TypeError, ValueError) as exc:
if raise_on_failure:
msg = (
f"Could not apply {label} to model '{model_name}': {exc}. "
f"The model may not support profile assignment."
)
raise ModelConfigError(msg) from exc
logger.warning(
"Could not apply %s profile overrides to model '%s': %s. "
"Overrides will be ignored.",
label,
model_name,
exc,
)


def create_model(
model_spec: str | None = None,
*,
extra_kwargs: dict[str, Any] | None = None,
profile_overrides: dict[str, Any] | None = None,
) -> ModelResult:
"""Create a chat model.

Expand All @@ -1349,6 +1397,9 @@ def create_model(
extra_kwargs: Additional kwargs to pass to the model constructor.

These take highest priority, overriding values from the config file.
profile_overrides: Extra profile fields from `--profile-override`.

Merged on top of config file profile overrides (CLI wins).

Returns:
A `ModelResult` containing the model and its metadata.
Expand Down Expand Up @@ -1412,35 +1463,26 @@ def create_model(

# Apply profile overrides from config.toml (e.g., max_input_tokens)
if provider:
profile_overrides = config.get_profile_overrides(
config_profile_overrides = config.get_profile_overrides(
provider, model_name=model_name
)
if profile_overrides:
logger.debug(
"Applying profile overrides for '%s' (provider '%s'): %s",
if config_profile_overrides:
_apply_profile_overrides(
model,
config_profile_overrides,
model_name,
provider,
profile_overrides,
label=f"config.toml (provider '{provider}')",
)
# Intentionally over-defensive
profile = getattr(model, "profile", None)
if isinstance(profile, dict):
# Copy original profile and overlay config overrides on top.
# Duplicate keys use the override value; keys only in the
# original (e.g., tool_calling) are preserved unchanged.
merged = {**profile, **profile_overrides}
else:
merged = profile_overrides
try:
model.profile = merged # type: ignore[union-attr]
except (AttributeError, TypeError, ValueError) as exc:
logger.warning(
"Could not apply profile overrides to model '%s' "
"(provider '%s'): %s. Overrides will be ignored.",
model_name,
provider,
exc,
)

# CLI --profile-override takes highest priority (on top of config.toml)
if profile_overrides:
_apply_profile_overrides(
model,
profile_overrides,
model_name,
label="CLI --profile-override",
raise_on_failure=True,
)

# Extract context limit from model profile (if available)
context_limit: int | None = None
Expand Down
38 changes: 37 additions & 1 deletion libs/cli/deepagents_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,14 @@ def __call__(
"These take priority, overriding config file values.",
)

parser.add_argument(
"--profile-override",
metavar="JSON",
help="Override model profile fields as a JSON string "
"(e.g., '{\"max_input_tokens\": 4096}'). "
"Merged on top of config file profile overrides.",
)

parser.add_argument(
"--default-model",
metavar="MODEL",
Expand Down Expand Up @@ -434,6 +442,7 @@ async def run_textual_cli_async(
sandbox_setup: str | None = None,
model_name: str | None = None,
model_params: dict[str, Any] | None = None,
profile_override: dict[str, Any] | None = None,
thread_id: str | None = None,
is_resumed: bool = False,
initial_prompt: str | None = None,
Expand All @@ -452,6 +461,9 @@ async def run_textual_cli_async(
model_params: Extra kwargs from `--model-params` to pass to the model.

These override config file values.
profile_override: Extra profile fields from `--profile-override`.

Merged on top of config file profile overrides.
thread_id: Thread ID to use (new or resumed)
is_resumed: Whether this is a resumed session
initial_prompt: Optional prompt to auto-submit when session starts
Expand All @@ -469,7 +481,11 @@ async def run_textual_cli_async(
from deepagents_cli.tools import fetch_url, http_request, web_search

try:
result = create_model(model_name, extra_kwargs=model_params)
result = create_model(
model_name,
extra_kwargs=model_params,
profile_overrides=profile_override,
)
except ModelConfigError as e:
from deepagents_cli.app import AppResult

Expand Down Expand Up @@ -743,6 +759,24 @@ def cli_main() -> None:
)
sys.exit(1)

profile_override: dict[str, Any] | None = None
raw_profile = getattr(args, "profile_override", None)
if raw_profile:
try:
profile_override = json.loads(raw_profile)
except json.JSONDecodeError as e:
console.print(
"[bold red]Error:[/bold red] "
f"--profile-override is not valid JSON: {e}"
)
sys.exit(1)
if not isinstance(profile_override, dict):
console.print(
"[bold red]Error:[/bold red] "
"--profile-override must be a JSON object"
)
sys.exit(1)

apply_stdin_pipe(args)

if (args.quiet or args.no_stream) and not args.non_interactive_message:
Expand Down Expand Up @@ -877,6 +911,7 @@ def cli_main() -> None:
assistant_id=args.agent,
model_name=getattr(args, "model", None),
model_params=model_params,
profile_override=profile_override,
sandbox_type=args.sandbox,
sandbox_id=args.sandbox_id,
sandbox_setup=getattr(args, "sandbox_setup", None),
Expand Down Expand Up @@ -976,6 +1011,7 @@ def cli_main() -> None:
sandbox_setup=getattr(args, "sandbox_setup", None),
model_name=getattr(args, "model", None),
model_params=model_params,
profile_override=profile_override,
thread_id=thread_id,
is_resumed=is_resumed,
initial_prompt=getattr(args, "initial_prompt", None),
Expand Down
10 changes: 9 additions & 1 deletion libs/cli/deepagents_cli/non_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ async def run_non_interactive(
sandbox_id: str | None = None,
sandbox_setup: str | None = None,
*,
profile_override: dict[str, Any] | None = None,
quiet: bool = False,
stream: bool = True,
) -> int:
Expand Down Expand Up @@ -617,6 +618,9 @@ async def run_non_interactive(
sandbox_id: Optional existing sandbox ID to reuse.
sandbox_setup: Optional path to setup script to run in the sandbox
after creation.
profile_override: Extra profile fields from `--profile-override`.

Merged on top of config file profile overrides.
quiet: When `True`, all console output (headers, status messages,
tool notifications, HITL decisions, errors) is redirected to
stderr so that only the agent's response text appears on stdout.
Expand All @@ -633,7 +637,11 @@ async def run_non_interactive(
# uses _write_text() -> sys.stdout directly.
console = Console(stderr=True) if quiet else Console()
try:
result = create_model(model_name, extra_kwargs=model_params)
result = create_model(
model_name,
extra_kwargs=model_params,
profile_overrides=profile_override,
)
except ModelConfigError as e:
console.print(f"[bold red]Error:[/bold red] {e}")
return 1
Expand Down
116 changes: 115 additions & 1 deletion libs/cli/tests/unit_tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,12 +566,126 @@ def test_profile_override_logs_warning_on_frozen_model(
result = create_model("anthropic:claude-sonnet-4-5")

assert any(
"Could not apply profile overrides" in r.message for r in caplog.records
"Could not apply" in r.message and "profile overrides" in r.message
for r in caplog.records
)
# Falls back to original profile extraction
assert result.context_limit == 200000


class TestCreateModelCLIProfileOverrides:
"""Tests for CLI --profile-override in create_model."""

@patch("langchain.chat_models.init_chat_model")
def test_cli_profile_override_sets_context_limit(
self, mock_init_chat_model: Mock, tmp_path: Path
) -> None:
"""CLI profile override for max_input_tokens flows to context_limit."""
config_path = tmp_path / "config.toml"
config_path.write_text("") # empty config
mock_model = Mock()
mock_model.profile = {"max_input_tokens": 200000, "tool_calling": True}
mock_init_chat_model.return_value = mock_model

clear_caches()
with patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path):
result = create_model(
"anthropic:claude-sonnet-4-5",
profile_overrides={"max_input_tokens": 4096},
)

assert result.context_limit == 4096

@patch("langchain.chat_models.init_chat_model")
def test_cli_profile_override_beats_config_toml(
self, mock_init_chat_model: Mock, tmp_path: Path
) -> None:
"""CLI --profile-override wins over config.toml profile."""
config_path = tmp_path / "config.toml"
config_path.write_text("""
[models.providers.anthropic.profile]
max_input_tokens = 8192
""")
mock_model = Mock()
mock_model.profile = {"max_input_tokens": 200000, "tool_calling": True}
mock_init_chat_model.return_value = mock_model

clear_caches()
with patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path):
result = create_model(
"anthropic:claude-sonnet-4-5",
profile_overrides={"max_input_tokens": 4096},
)

# CLI (4096) beats config.toml (8192)
assert result.context_limit == 4096

@patch("langchain.chat_models.init_chat_model")
def test_cli_profile_override_preserves_other_keys(
self, mock_init_chat_model: Mock, tmp_path: Path
) -> None:
"""CLI override merges into profile without dropping other keys."""
config_path = tmp_path / "config.toml"
config_path.write_text("")
mock_model = Mock()
mock_model.profile = {"max_input_tokens": 200000, "tool_calling": True}
mock_init_chat_model.return_value = mock_model

clear_caches()
with patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path):
create_model(
"anthropic:claude-sonnet-4-5",
profile_overrides={"max_input_tokens": 4096},
)

assert mock_model.profile == {"max_input_tokens": 4096, "tool_calling": True}

@patch("langchain.chat_models.init_chat_model")
def test_cli_profile_override_on_model_without_profile(
self, mock_init_chat_model: Mock, tmp_path: Path
) -> None:
"""CLI override applied even when model has no profile attr."""
config_path = tmp_path / "config.toml"
config_path.write_text("")
mock_model = Mock(spec=["invoke"])
mock_init_chat_model.return_value = mock_model

clear_caches()
with patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path):
result = create_model(
"anthropic:claude-sonnet-4-5",
profile_overrides={"max_input_tokens": 4096},
)

assert result.context_limit == 4096

@patch("langchain.chat_models.init_chat_model")
def test_cli_profile_override_raises_on_frozen_model(
self,
mock_init_chat_model: Mock,
tmp_path: Path,
) -> None:
"""CLI --profile-override raises when model rejects assignment."""
config_path = tmp_path / "config.toml"
config_path.write_text("")
mock_model = Mock()
type(mock_model).profile = property(
fget=lambda _: {"max_input_tokens": 200000},
fset=lambda _, __: (_ for _ in ()).throw(AttributeError("frozen")),
)
mock_init_chat_model.return_value = mock_model

clear_caches()
with (
patch.object(model_config, "DEFAULT_CONFIG_PATH", config_path),
pytest.raises(ModelConfigError, match="Could not apply CLI"),
):
create_model(
"anthropic:claude-sonnet-4-5",
profile_overrides={"max_input_tokens": 4096},
)


class TestParseShellAllowList:
"""Test parsing shell allow-list strings."""

Expand Down
Loading
Loading