chore: import upstream snapshot with attribution
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
"""Slash command /model package exports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from surfaces.interactive_shell.command_registry.model.command import (
|
||||
COMMANDS,
|
||||
parse_model_set_args,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.model.switching import (
|
||||
restore_default_model,
|
||||
switch_llm_provider,
|
||||
switch_reasoning_model,
|
||||
switch_toolcall_model,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"COMMANDS",
|
||||
"parse_model_set_args",
|
||||
"restore_default_model",
|
||||
"switch_llm_provider",
|
||||
"switch_reasoning_model",
|
||||
"switch_toolcall_model",
|
||||
]
|
||||
@@ -0,0 +1,403 @@
|
||||
"""Slash command /model and interactive provider/model menus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
import surfaces.interactive_shell.command_registry.repl_data as repl_data
|
||||
from surfaces.interactive_shell.command_registry.model.switching import (
|
||||
_provider_allows_custom_models,
|
||||
restore_default_model,
|
||||
switch_llm_provider,
|
||||
switch_reasoning_model,
|
||||
switch_toolcall_model,
|
||||
)
|
||||
from surfaces.interactive_shell.command_registry.types import SlashCommand
|
||||
from surfaces.interactive_shell.runtime import Session
|
||||
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING, render_models_table
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import (
|
||||
CRUMB_SEP,
|
||||
repl_choose_one,
|
||||
repl_section_break,
|
||||
repl_tty_interactive,
|
||||
)
|
||||
|
||||
_ROOT = "/model" # breadcrumb root label
|
||||
|
||||
|
||||
def _provider_menu_choices() -> list[tuple[str, str]]:
|
||||
from surfaces.cli.wizard.config import SUPPORTED_PROVIDERS
|
||||
|
||||
current_provider = (os.getenv("LLM_PROVIDER", "anthropic") or "anthropic").strip().lower()
|
||||
options: list[tuple[str, str]] = []
|
||||
for provider in SUPPORTED_PROVIDERS:
|
||||
suffix = "*" if provider.value == current_provider else ""
|
||||
options.append((provider.value, f"{provider.value}{suffix}"))
|
||||
return options
|
||||
|
||||
|
||||
def _reasoning_model_menu_choices(provider: object) -> list[tuple[str, str]]:
|
||||
model_options = list(getattr(provider, "models", ()))
|
||||
choices: list[tuple[str, str]] = [
|
||||
("__provider_default__", "provider default (one step)"),
|
||||
]
|
||||
for option in model_options:
|
||||
value = str(getattr(option, "value", ""))
|
||||
display = value if value else "cli-default"
|
||||
choices.append((value, display))
|
||||
if _provider_allows_custom_models(provider):
|
||||
choices.append(("__custom__", "custom model ID"))
|
||||
return choices
|
||||
|
||||
|
||||
def _toolcall_model_menu_choices(provider: object) -> list[tuple[str, str]]:
|
||||
model_options = list(getattr(provider, "models", ()))
|
||||
choices: list[tuple[str, str]] = [
|
||||
("__keep__", "keep"),
|
||||
("__match_reasoning__", "match-reasoning"),
|
||||
]
|
||||
for option in model_options:
|
||||
value = str(getattr(option, "value", ""))
|
||||
display = value if value else "cli-default"
|
||||
choices.append((value, display))
|
||||
if _provider_allows_custom_models(provider):
|
||||
choices.append(("__custom__", "custom model ID"))
|
||||
return choices
|
||||
|
||||
|
||||
def _prompt_custom_model_id(console: Console, provider_value: str = "provider") -> str | None:
|
||||
"""Prompt the user to type a custom model ID."""
|
||||
console.print()
|
||||
console.print(
|
||||
f"[{DIM}]Enter a model ID for {escape(provider_value)}. "
|
||||
"The provider will validate availability when OpenSRE sends a request.[/]"
|
||||
)
|
||||
try:
|
||||
value = console.input(f"[{HIGHLIGHT}]model ID> [/]").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
return value if value else None
|
||||
|
||||
|
||||
def _interactive_set_provider(console: Console) -> bool | None:
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
|
||||
crumb_set = f"{_ROOT}{CRUMB_SEP}set"
|
||||
while True:
|
||||
provider_value = repl_choose_one(
|
||||
title="LLM provider",
|
||||
breadcrumb=crumb_set,
|
||||
choices=_provider_menu_choices(),
|
||||
)
|
||||
if provider_value is None:
|
||||
return None
|
||||
provider = PROVIDER_BY_VALUE.get(provider_value)
|
||||
if provider is None:
|
||||
return False
|
||||
|
||||
crumb_model = f"{crumb_set}{CRUMB_SEP}{provider_value}"
|
||||
while True:
|
||||
reasoning_choice = repl_choose_one(
|
||||
title="reasoning model",
|
||||
breadcrumb=crumb_model,
|
||||
choices=_reasoning_model_menu_choices(provider),
|
||||
)
|
||||
if reasoning_choice is None:
|
||||
break
|
||||
|
||||
if reasoning_choice == "__custom__":
|
||||
custom = _prompt_custom_model_id(console, provider.value)
|
||||
if custom is None:
|
||||
continue
|
||||
reasoning_choice = custom
|
||||
|
||||
model_choice = (
|
||||
None if reasoning_choice == "__provider_default__" else str(reasoning_choice)
|
||||
)
|
||||
toolcall_model: str | None = None
|
||||
# Default reasoning: switch provider + default reasoning only — do not
|
||||
# prompt for toolcall (matches non-interactive `/model set <provider>`).
|
||||
if provider.toolcall_model_env and reasoning_choice != "__provider_default__":
|
||||
crumb_tc = f"{crumb_model}{CRUMB_SEP}toolcall"
|
||||
while True:
|
||||
toolcall_value = repl_choose_one(
|
||||
title="toolcall model",
|
||||
breadcrumb=crumb_tc,
|
||||
choices=_toolcall_model_menu_choices(provider),
|
||||
)
|
||||
if toolcall_value is None:
|
||||
return None
|
||||
if toolcall_value == "__keep__":
|
||||
break
|
||||
if toolcall_value == "__match_reasoning__":
|
||||
toolcall_model = model_choice or provider.default_model
|
||||
break
|
||||
if toolcall_value == "__custom__":
|
||||
custom_tc = _prompt_custom_model_id(console, provider.value)
|
||||
if custom_tc is None:
|
||||
continue
|
||||
toolcall_model = custom_tc
|
||||
break
|
||||
toolcall_model = str(toolcall_value)
|
||||
break
|
||||
|
||||
return switch_llm_provider(
|
||||
provider.value,
|
||||
console,
|
||||
model=model_choice,
|
||||
toolcall_model=toolcall_model,
|
||||
)
|
||||
|
||||
|
||||
def _interactive_restore_provider(console: Console) -> bool | None:
|
||||
provider_value = repl_choose_one(
|
||||
title="LLM provider",
|
||||
breadcrumb=f"{_ROOT}{CRUMB_SEP}restore",
|
||||
choices=_provider_menu_choices(),
|
||||
)
|
||||
if provider_value is None:
|
||||
return None
|
||||
return restore_default_model(provider_value, console)
|
||||
|
||||
|
||||
def _interactive_set_toolcall(console: Console) -> bool | None:
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
|
||||
crumb_tc = f"{_ROOT}{CRUMB_SEP}toolcall"
|
||||
provider_value = repl_choose_one(
|
||||
title="LLM provider",
|
||||
breadcrumb=crumb_tc,
|
||||
choices=_provider_menu_choices(),
|
||||
)
|
||||
if provider_value is None:
|
||||
return None
|
||||
provider = PROVIDER_BY_VALUE.get(provider_value)
|
||||
if provider is None:
|
||||
return False
|
||||
if not provider.toolcall_model_env:
|
||||
console.print(
|
||||
f"[{WARNING}]provider {provider.value} does not expose a separate "
|
||||
"toolcall model[/] — nothing to set."
|
||||
)
|
||||
return False
|
||||
model_value = repl_choose_one(
|
||||
title="toolcall model",
|
||||
breadcrumb=f"{crumb_tc}{CRUMB_SEP}{provider_value}",
|
||||
choices=_toolcall_model_menu_choices(provider),
|
||||
)
|
||||
if model_value is None:
|
||||
return None
|
||||
if model_value == "__keep__":
|
||||
console.print("[dim]toolcall model left unchanged.[/dim]")
|
||||
return True
|
||||
if model_value == "__match_reasoning__":
|
||||
reasoning = (os.getenv(provider.model_env, "") or "").strip() or provider.default_model
|
||||
return switch_toolcall_model(reasoning, console, provider_name=provider.value)
|
||||
if model_value == "__custom__":
|
||||
custom_tc = _prompt_custom_model_id(console, provider.value)
|
||||
if custom_tc is None:
|
||||
return None
|
||||
model_value = custom_tc
|
||||
return switch_toolcall_model(str(model_value), console, provider_name=provider.value)
|
||||
|
||||
|
||||
def _interactive_model_menu(session: Session, console: Console) -> bool:
|
||||
while True:
|
||||
action = repl_choose_one(
|
||||
title="Select Model and Effort",
|
||||
breadcrumb=f"{_ROOT}",
|
||||
choices=[
|
||||
("show", "show"),
|
||||
("set", "set"),
|
||||
("restore", "restore"),
|
||||
("toolcall", "toolcall"),
|
||||
("done", "done"),
|
||||
],
|
||||
)
|
||||
if action is None or action == "done":
|
||||
return True
|
||||
if action == "show":
|
||||
repl_section_break(console)
|
||||
render_models_table(console, repl_data.load_llm_settings())
|
||||
repl_section_break(console)
|
||||
continue
|
||||
if action == "set":
|
||||
switched = _interactive_set_provider(console)
|
||||
if switched is None:
|
||||
continue
|
||||
if not switched:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
repl_section_break(console)
|
||||
continue
|
||||
return True
|
||||
if action == "restore":
|
||||
restored = _interactive_restore_provider(console)
|
||||
if restored is None:
|
||||
continue
|
||||
if not restored:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
repl_section_break(console)
|
||||
continue
|
||||
return True
|
||||
if action == "toolcall":
|
||||
switched = _interactive_set_toolcall(console)
|
||||
if switched is None:
|
||||
continue
|
||||
if not switched:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
repl_section_break(console)
|
||||
continue
|
||||
return True
|
||||
|
||||
|
||||
def parse_model_set_args(args: list[str]) -> tuple[str, str | None, str | None]:
|
||||
"""Parse `set <provider> [reasoning_model] [--toolcall-model <m>]`.
|
||||
|
||||
``args`` is the slice after the ``set``/``use``/``switch`` keyword.
|
||||
|
||||
Raises :class:`ValueError` with a user-facing message when the input is
|
||||
malformed.
|
||||
"""
|
||||
if not args:
|
||||
raise ValueError("missing provider name")
|
||||
|
||||
provider = args[0]
|
||||
reasoning_model: str | None = None
|
||||
toolcall_model: str | None = None
|
||||
|
||||
i = 1
|
||||
while i < len(args):
|
||||
token = args[i]
|
||||
if token == "--toolcall-model":
|
||||
if i + 1 >= len(args):
|
||||
raise ValueError("missing value for --toolcall-model")
|
||||
toolcall_model = args[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
if token.startswith("--"):
|
||||
raise ValueError(f"unknown flag: {token}")
|
||||
if reasoning_model is not None:
|
||||
raise ValueError(f"unexpected extra argument: {token}")
|
||||
reasoning_model = token
|
||||
i += 1
|
||||
|
||||
return provider, reasoning_model, toolcall_model
|
||||
|
||||
|
||||
def _cmd_model(session: Session, console: Console, args: list[str]) -> bool:
|
||||
if not args and repl_tty_interactive():
|
||||
return _interactive_model_menu(session, console)
|
||||
|
||||
sub = (args[0].lower() if args else "show").strip()
|
||||
|
||||
if sub == "show":
|
||||
render_models_table(console, repl_data.load_llm_settings())
|
||||
return True
|
||||
|
||||
if sub == "toolcall":
|
||||
if len(args) >= 2 and args[1].lower() == "show":
|
||||
render_models_table(console, repl_data.load_llm_settings())
|
||||
return True
|
||||
if len(args) >= 2 and args[1].lower() in ("set", "use", "switch"):
|
||||
if len(args) < 3:
|
||||
console.print(f"[{DIM}]usage:[/] /model toolcall set <model>")
|
||||
return True
|
||||
switch_toolcall_model(args[2], console)
|
||||
return True
|
||||
console.print(
|
||||
f"[{DIM}]usage:[/] /model toolcall set <model> "
|
||||
f"[{DIM}](sets the toolcall model for the active provider)[/]"
|
||||
)
|
||||
return True
|
||||
|
||||
if sub in ("restore", "default", "reset"):
|
||||
if len(args) > 2:
|
||||
console.print(f"[{DIM}]usage:[/] /model restore [provider]")
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
provider_name = args[1] if len(args) == 2 else os.getenv("LLM_PROVIDER", "anthropic")
|
||||
restored = restore_default_model(provider_name, console)
|
||||
if not restored:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
if sub in ("set", "use", "switch"):
|
||||
try:
|
||||
provider_name, reasoning_model, tc_model = parse_model_set_args(args[1:])
|
||||
except ValueError as exc:
|
||||
console.print()
|
||||
console.print(f"[{ERROR}]{escape(str(exc))}[/]")
|
||||
console.print()
|
||||
console.print(
|
||||
f"[{DIM}]usage:[/] /model set <provider> [model] [--toolcall-model <model>]"
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
|
||||
if provider_name.strip().lower() not in PROVIDER_BY_VALUE:
|
||||
if tc_model is not None:
|
||||
console.print()
|
||||
console.print(f"[{ERROR}]--toolcall-model requires an explicit provider[/]")
|
||||
console.print()
|
||||
console.print(
|
||||
f"[{DIM}]usage:[/] /model set <provider> [model] [--toolcall-model <model>]"
|
||||
)
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
model_value = (
|
||||
provider_name if reasoning_model is None else f"{provider_name}-{reasoning_model}"
|
||||
)
|
||||
switched = switch_reasoning_model(model_value, console)
|
||||
if not switched:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
switched = switch_llm_provider(
|
||||
provider_name,
|
||||
console,
|
||||
model=reasoning_model,
|
||||
toolcall_model=tc_model,
|
||||
)
|
||||
if not switched:
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
return True
|
||||
|
||||
console.print(
|
||||
f"[{ERROR}]unknown subcommand:[/] {escape(sub)} "
|
||||
"(try [bold]/model show[/bold], "
|
||||
"[bold]/model set <provider> [model] [--toolcall-model <m>][/bold], "
|
||||
"[bold]/model restore [provider][/bold], "
|
||||
"or [bold]/model toolcall set <model>[/bold])"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
_MODEL_FIRST_ARGS: tuple[tuple[str, str], ...] = (
|
||||
("show", "show active provider and models"),
|
||||
("set", "switch provider · /model set <provider> [model]"),
|
||||
("restore", "restore the active provider's default reasoning model"),
|
||||
("toolcall", "manage toolcall model for the active provider"),
|
||||
)
|
||||
|
||||
COMMANDS: list[SlashCommand] = [
|
||||
SlashCommand(
|
||||
"/model",
|
||||
"Show or change active LLM settings.",
|
||||
_cmd_model,
|
||||
usage=(
|
||||
"/model",
|
||||
"/model show",
|
||||
"/model set <provider> [model] [--toolcall-model <model>]",
|
||||
"/model restore [provider]",
|
||||
"/model toolcall set <model>",
|
||||
),
|
||||
notes=(
|
||||
"In a TTY, bare /model opens an interactive menu.",
|
||||
"The menu stays open after show actions and closes after set, restore, or toolcall changes.",
|
||||
),
|
||||
first_arg_completions=_MODEL_FIRST_ARGS,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Provider/model switch helpers for the /model slash command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from rich.console import Console
|
||||
from rich.markup import escape
|
||||
|
||||
import surfaces.interactive_shell.command_registry.repl_data as repl_data
|
||||
from surfaces.interactive_shell.ui import DIM, ERROR, HIGHLIGHT, WARNING, render_models_table
|
||||
from surfaces.interactive_shell.ui.components.choice_menu import print_valid_choice_list
|
||||
|
||||
|
||||
def _format_supported_models(provider_models: tuple[object, ...]) -> str:
|
||||
values = [str(getattr(model, "value", "")) for model in provider_models]
|
||||
visible = [value for value in values if value]
|
||||
return ", ".join(visible) if visible else "provider default"
|
||||
|
||||
|
||||
def _normalize_model_id(model: str) -> str:
|
||||
"""Collapse internal whitespace in a model id to single hyphens.
|
||||
|
||||
A model id is a single token, so a value like ``"gpt 5.5"`` is a mis-parsed
|
||||
``"gpt-5.5"``. The CLI path (``/model set gpt 5.5``) already rebuilds the id as
|
||||
``gpt-5.5``; normalizing here keeps the planner/tool path
|
||||
(``llm_set_provider`` -> ``switch_reasoning_model``) consistent so a custom-model
|
||||
provider (e.g. openai) can't persist a whitespace-bearing slug that later fails
|
||||
availability checks and silently falls back.
|
||||
"""
|
||||
return "-".join(model.split())
|
||||
|
||||
|
||||
def _is_model_supported(
|
||||
_provider_value: str, model: str, provider_models: tuple[object, ...]
|
||||
) -> bool:
|
||||
supported_values = {str(getattr(option, "value", "")) for option in provider_models}
|
||||
return model in supported_values
|
||||
|
||||
|
||||
def _provider_allows_custom_models(provider: object) -> bool:
|
||||
return bool(getattr(provider, "allow_custom_models", False))
|
||||
|
||||
|
||||
def _is_model_allowed(provider: object, model: str) -> bool:
|
||||
provider_value = str(getattr(provider, "value", ""))
|
||||
provider_models = getattr(provider, "models", ())
|
||||
if _is_model_supported(provider_value, model, provider_models):
|
||||
return True
|
||||
return bool(model) and _provider_allows_custom_models(provider)
|
||||
|
||||
|
||||
def _reset_runtime_llm_caches() -> None:
|
||||
"""Force subsequent REPL assistant calls to use the updated model env."""
|
||||
from core.llm.factory import reset_llm_clients
|
||||
|
||||
reset_llm_clients()
|
||||
|
||||
|
||||
def switch_llm_provider(
|
||||
provider_name: str,
|
||||
console: Console,
|
||||
model: str | None = None,
|
||||
*,
|
||||
toolcall_model: str | None = None,
|
||||
) -> bool:
|
||||
from config.llm_auth.credentials import status as credential_status
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
from surfaces.cli.wizard.env_sync import sync_provider_env
|
||||
|
||||
provider_key = provider_name.strip().lower()
|
||||
provider = PROVIDER_BY_VALUE.get(provider_key)
|
||||
if provider is None:
|
||||
console.print(f"[{ERROR}]unknown LLM provider:[/] {escape(provider_name)}")
|
||||
print_valid_choice_list(
|
||||
console,
|
||||
title="valid providers:",
|
||||
choices=sorted(PROVIDER_BY_VALUE),
|
||||
)
|
||||
return False
|
||||
|
||||
# Refuse to half-update .env when prompt-safe status says the target
|
||||
# provider has no credential path. Stale metadata gets a warning, because
|
||||
# confirming it requires an intentional request-time credential read.
|
||||
auth_status = credential_status(provider.value)
|
||||
if provider.value == "azure-openai":
|
||||
from core.llm.providers.azure_openai import azure_openai_endpoint_configured
|
||||
|
||||
if not azure_openai_endpoint_configured():
|
||||
console.print(
|
||||
f"[{ERROR}]missing Azure OpenAI endpoint config:[/] "
|
||||
"set AZURE_OPENAI_BASE_URL, or run [bold]opensre onboard[/bold]."
|
||||
)
|
||||
return False
|
||||
if provider.credential_secret and provider.api_key_env and not auth_status.configured:
|
||||
console.print(
|
||||
f"[{ERROR}]missing credential for {provider.value}:[/] "
|
||||
f"{provider.api_key_env} is not set."
|
||||
)
|
||||
if not getattr(console, "is_terminal", False):
|
||||
# Non-interactive (script/headless): no stdin to prompt on.
|
||||
console.print(
|
||||
f"[{DIM}]set it with[/] [bold]export {provider.api_key_env}=<your-key>[/bold] "
|
||||
f"[{DIM}]or run[/] [bold]opensre auth login {provider.value}[/bold] "
|
||||
f"[{DIM}]to save it, then rerun this command.[/]"
|
||||
)
|
||||
return False
|
||||
api_key = console.input(
|
||||
f"[{HIGHLIGHT}]paste your {provider.api_key_env} (blank to cancel)> [/]",
|
||||
password=True,
|
||||
).strip()
|
||||
if not api_key:
|
||||
console.print(
|
||||
f"[{DIM}]cancelled — set it later with[/] "
|
||||
f"[bold]opensre auth login {provider.value}[/bold][{DIM}].[/]"
|
||||
)
|
||||
return False
|
||||
from surfaces.cli.llm_auth.providers import resolve_auth_profile
|
||||
from surfaces.cli.llm_auth.service import AuthSetupError, configure_api_key_provider
|
||||
|
||||
console.print(f"[{DIM}]validating {provider.value} key…[/]")
|
||||
try:
|
||||
configure_api_key_provider(
|
||||
profile=resolve_auth_profile(provider.value),
|
||||
api_key=api_key,
|
||||
set_provider=False,
|
||||
)
|
||||
except (AuthSetupError, KeyError) as exc:
|
||||
console.print(f"[{ERROR}]could not save {provider.api_key_env}:[/] {escape(str(exc))}")
|
||||
return False
|
||||
console.print(f"[{DIM}]saved {provider.api_key_env}.[/]")
|
||||
auth_status = credential_status(provider.value)
|
||||
if provider.credential_secret and provider.api_key_env and auth_status.stale:
|
||||
console.print(
|
||||
f"[{WARNING}]credential status for {provider.value} is stale:[/] "
|
||||
f"{escape(auth_status.detail)}"
|
||||
)
|
||||
console.print(
|
||||
f"[{DIM}]run[/] [bold]opensre auth verify {provider.value}[/bold] "
|
||||
f"[{DIM}]to refresh metadata if the next LLM request fails.[/]"
|
||||
)
|
||||
|
||||
selected_model = _normalize_model_id(model) if model else provider.default_model
|
||||
if selected_model and not _is_model_allowed(provider, selected_model):
|
||||
console.print(f"[{ERROR}]unknown model for {provider.value}:[/] {escape(selected_model)}")
|
||||
console.print(
|
||||
f"[{DIM}]known reasoning models:[/] {escape(_format_supported_models(provider.models))}"
|
||||
)
|
||||
return False
|
||||
|
||||
selected_toolcall: str | None = None
|
||||
if toolcall_model is not None:
|
||||
if not provider.toolcall_model_env:
|
||||
console.print(
|
||||
f"[{WARNING}]provider {provider.value} does not expose a separate "
|
||||
"toolcall model[/] — toolcall override ignored."
|
||||
)
|
||||
else:
|
||||
selected_toolcall = _normalize_model_id(toolcall_model)
|
||||
if selected_toolcall and not _is_model_allowed(provider, selected_toolcall):
|
||||
console.print(
|
||||
f"[{ERROR}]unknown model for {provider.value}:[/] {escape(selected_toolcall)}"
|
||||
)
|
||||
console.print(
|
||||
f"[{DIM}]known toolcall models:[/] "
|
||||
f"{escape(_format_supported_models(provider.models))}"
|
||||
)
|
||||
return False
|
||||
|
||||
env_path = sync_provider_env(
|
||||
provider=provider,
|
||||
model=selected_model,
|
||||
toolcall_model=selected_toolcall or None,
|
||||
)
|
||||
_reset_runtime_llm_caches()
|
||||
|
||||
# Be explicit about which slot each model lands in.
|
||||
console.print(f"[{HIGHLIGHT}]switched LLM provider:[/] {provider.value}")
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]reasoning model:[/] {selected_model or 'provider default'} "
|
||||
f"[{DIM}]({provider.model_env})[/]"
|
||||
)
|
||||
if selected_toolcall:
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]toolcall model:[/] {selected_toolcall} "
|
||||
f"[{DIM}]({provider.toolcall_model_env})[/]"
|
||||
)
|
||||
console.print(f"[{DIM}]updated {env_path}[/]")
|
||||
render_models_table(console, repl_data.load_llm_settings())
|
||||
return True
|
||||
|
||||
|
||||
def switch_toolcall_model(
|
||||
toolcall_model: str,
|
||||
console: Console,
|
||||
*,
|
||||
provider_name: str | None = None,
|
||||
) -> bool:
|
||||
"""Set the toolcall model for the active (or named) provider."""
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
from surfaces.cli.wizard.env_sync import sync_env_values
|
||||
|
||||
raw_name = provider_name if provider_name else os.getenv("LLM_PROVIDER", "anthropic")
|
||||
resolved_name = (raw_name or "anthropic").strip().lower()
|
||||
provider = PROVIDER_BY_VALUE.get(resolved_name)
|
||||
if provider is None:
|
||||
console.print(f"[{ERROR}]unknown LLM provider:[/] {escape(resolved_name)}")
|
||||
print_valid_choice_list(
|
||||
console,
|
||||
title="valid providers:",
|
||||
choices=sorted(PROVIDER_BY_VALUE),
|
||||
)
|
||||
return False
|
||||
if not provider.toolcall_model_env:
|
||||
console.print(
|
||||
f"[{WARNING}]provider {provider.value} does not expose a separate "
|
||||
"toolcall model[/] — nothing to set."
|
||||
)
|
||||
return False
|
||||
new_model = _normalize_model_id(toolcall_model)
|
||||
if not new_model:
|
||||
console.print(f"[{ERROR}]toolcall model cannot be empty[/]")
|
||||
return False
|
||||
|
||||
values = {provider.toolcall_model_env: new_model}
|
||||
env_path = sync_env_values(values)
|
||||
os.environ.update(values)
|
||||
_reset_runtime_llm_caches()
|
||||
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]toolcall model set to:[/] {new_model} "
|
||||
f"[{DIM}]({provider.value} · {provider.toolcall_model_env})[/]"
|
||||
)
|
||||
console.print(f"[{DIM}]updated {env_path}[/]")
|
||||
render_models_table(console, repl_data.load_llm_settings())
|
||||
return True
|
||||
|
||||
|
||||
def switch_reasoning_model(
|
||||
reasoning_model: str,
|
||||
console: Console,
|
||||
*,
|
||||
provider_name: str | None = None,
|
||||
) -> bool:
|
||||
"""Set the reasoning model for the active (or named) provider."""
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
from surfaces.cli.wizard.env_sync import sync_reasoning_model_env
|
||||
|
||||
raw_name = provider_name if provider_name else os.getenv("LLM_PROVIDER", "anthropic")
|
||||
resolved_name = (raw_name or "anthropic").strip().lower()
|
||||
provider = PROVIDER_BY_VALUE.get(resolved_name)
|
||||
if provider is None:
|
||||
console.print(f"[{ERROR}]unknown LLM provider:[/] {escape(resolved_name)}")
|
||||
print_valid_choice_list(
|
||||
console,
|
||||
title="valid providers:",
|
||||
choices=sorted(PROVIDER_BY_VALUE),
|
||||
)
|
||||
return False
|
||||
|
||||
new_model = _normalize_model_id(reasoning_model)
|
||||
if not new_model:
|
||||
console.print(f"[{ERROR}]reasoning model cannot be empty[/]")
|
||||
return False
|
||||
if not _is_model_allowed(provider, new_model):
|
||||
console.print(f"[{ERROR}]unknown model for {provider.value}:[/] {escape(new_model)}")
|
||||
console.print(
|
||||
f"[{DIM}]known reasoning models:[/] {escape(_format_supported_models(provider.models))}"
|
||||
)
|
||||
return False
|
||||
|
||||
env_path = sync_reasoning_model_env(provider=provider, model=new_model)
|
||||
_reset_runtime_llm_caches()
|
||||
|
||||
console.print(
|
||||
f"[{HIGHLIGHT}]reasoning model set to:[/] {new_model} "
|
||||
f"[{DIM}]({provider.value} · {provider.model_env})[/]"
|
||||
)
|
||||
console.print(f"[{DIM}]updated {env_path}[/]")
|
||||
render_models_table(console, repl_data.load_llm_settings())
|
||||
return True
|
||||
|
||||
|
||||
def restore_default_model(provider_name: str, console: Console) -> bool:
|
||||
"""Reset a provider to its configured default reasoning model."""
|
||||
from surfaces.cli.wizard.config import PROVIDER_BY_VALUE
|
||||
|
||||
provider_key = provider_name.strip().lower()
|
||||
provider = PROVIDER_BY_VALUE.get(provider_key)
|
||||
if provider is None:
|
||||
console.print(f"[{ERROR}]unknown LLM provider:[/] {escape(provider_name)}")
|
||||
print_valid_choice_list(
|
||||
console,
|
||||
title="valid providers:",
|
||||
choices=sorted(PROVIDER_BY_VALUE),
|
||||
)
|
||||
return False
|
||||
return switch_llm_provider(provider.value, console, model=provider.default_model)
|
||||
Reference in New Issue
Block a user