fix(provider): 测试与入口修复
This commit is contained in:
@@ -25,7 +25,6 @@ console = Console()
|
|||||||
|
|
||||||
|
|
||||||
def _secure_prompt_key(provider: Provider) -> str:
|
def _secure_prompt_key(provider: Provider) -> str:
|
||||||
"""Prompt securely for an API key, falling back to /dev/tty when needed."""
|
|
||||||
prompt_text = f"Enter {provider.display_name} API key"
|
prompt_text = f"Enter {provider.display_name} API key"
|
||||||
try:
|
try:
|
||||||
return click.prompt(prompt_text, hide_input=True, err=True)
|
return click.prompt(prompt_text, hide_input=True, err=True)
|
||||||
@@ -40,7 +39,7 @@ def _secure_prompt_key(provider: Provider) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def provider_list_table() -> None:
|
def provider_list_table() -> None:
|
||||||
"""Print the provider list table (reusable from info command)."""
|
"""Print the provider list (reusable from info command)."""
|
||||||
active = get_active_provider()
|
active = get_active_provider()
|
||||||
console.print("[bold]Available providers:[/bold]")
|
console.print("[bold]Available providers:[/bold]")
|
||||||
for provider in list_providers():
|
for provider in list_providers():
|
||||||
@@ -58,51 +57,19 @@ def provider_list_table() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@click.group("provider")
|
def _do_switch(
|
||||||
def provider_cmd() -> None:
|
provider: Provider,
|
||||||
"""Switch Claude Code's LLM provider (DeepSeek, Kimi, Kimi Code, Claude)."""
|
key: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
@provider_cmd.command("list")
|
yes: bool = False,
|
||||||
def provider_list() -> None:
|
|
||||||
"""List available providers and the active one."""
|
|
||||||
provider_list_table()
|
|
||||||
|
|
||||||
|
|
||||||
@provider_cmd.command("current")
|
|
||||||
def provider_current() -> None:
|
|
||||||
"""Show the active provider."""
|
|
||||||
console.print(f"[bold]Active provider:[/bold] {describe_active_backend()}")
|
|
||||||
|
|
||||||
|
|
||||||
@provider_cmd.command("use")
|
|
||||||
@click.argument("provider_id")
|
|
||||||
@click.option("--key", help="API key (scripting only; appears in shell history)")
|
|
||||||
@click.option("--model", help="Override the default model")
|
|
||||||
@click.option("--base-url", help="Override the provider base URL")
|
|
||||||
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
|
||||||
def provider_use(
|
|
||||||
provider_id: str,
|
|
||||||
key: str | None,
|
|
||||||
model: str | None,
|
|
||||||
base_url: str | None,
|
|
||||||
yes: bool,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Switch to the given provider."""
|
|
||||||
try:
|
|
||||||
provider = get_provider(provider_id)
|
|
||||||
except KeyError:
|
|
||||||
known = ", ".join(p.id for p in list_providers())
|
|
||||||
raise click.ClickException(
|
|
||||||
f"Unknown provider '{provider_id}'. Choose from: {known}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_third_party(provider):
|
if is_third_party(provider):
|
||||||
resolved_key = key or get_key(provider.key_name)
|
resolved_key = key or get_key(provider.key_name)
|
||||||
if not resolved_key:
|
if not resolved_key:
|
||||||
resolved_key = _secure_prompt_key(provider)
|
resolved_key = _secure_prompt_key(provider)
|
||||||
if not resolved_key:
|
if not resolved_key:
|
||||||
raise click.ClickException("API key is required for this provider.")
|
raise click.ClickException("API key is required.")
|
||||||
else:
|
else:
|
||||||
resolved_key = None
|
resolved_key = None
|
||||||
|
|
||||||
@@ -121,10 +88,7 @@ def provider_use(
|
|||||||
return
|
return
|
||||||
|
|
||||||
settings = apply_provider(
|
settings = apply_provider(
|
||||||
provider,
|
provider, key=resolved_key, model=model, base_url=base_url
|
||||||
key=resolved_key,
|
|
||||||
model=model,
|
|
||||||
base_url=base_url,
|
|
||||||
)
|
)
|
||||||
save_settings(settings)
|
save_settings(settings)
|
||||||
set_setting("backend_provider", provider.id)
|
set_setting("backend_provider", provider.id)
|
||||||
@@ -136,12 +100,56 @@ def provider_use(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@provider_cmd.command("reset")
|
@click.group("provider", invoke_without_command=True)
|
||||||
|
@click.argument("provider_id", required=False)
|
||||||
|
@click.option("--key", help="API key (scripting only; appears in shell history)")
|
||||||
|
@click.option("--model", help="Override the default model")
|
||||||
|
@click.option("--base-url", help="Override the provider base URL")
|
||||||
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
|
||||||
def provider_reset(yes: bool) -> None:
|
@click.pass_context
|
||||||
"""Reset to the official Claude provider."""
|
def provider_cmd(
|
||||||
ctx = click.get_current_context()
|
ctx: click.Context,
|
||||||
ctx.invoke(provider_use, provider_id="claude", yes=yes)
|
provider_id: str | None,
|
||||||
|
key: str | None,
|
||||||
|
model: str | None,
|
||||||
|
base_url: str | None,
|
||||||
|
yes: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Switch Claude Code's LLM provider.
|
||||||
|
|
||||||
|
\b
|
||||||
|
xiaohe switch provider # list available providers
|
||||||
|
xiaohe switch provider deepseek # switch to DeepSeek
|
||||||
|
xiaohe switch provider claude # reset to official Claude
|
||||||
|
"""
|
||||||
|
if ctx.invoked_subcommand is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not provider_id:
|
||||||
|
provider_list_table()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = get_provider(provider_id)
|
||||||
|
except KeyError:
|
||||||
|
known = ", ".join(p.id for p in list_providers())
|
||||||
|
raise click.ClickException(
|
||||||
|
f"Unknown provider '{provider_id}'. Choose from: {known}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_do_switch(provider, key=key, model=model, base_url=base_url, yes=yes)
|
||||||
|
|
||||||
|
|
||||||
|
@provider_cmd.command("list")
|
||||||
|
def provider_list() -> None:
|
||||||
|
"""List available providers."""
|
||||||
|
provider_list_table()
|
||||||
|
|
||||||
|
|
||||||
|
@provider_cmd.command("current")
|
||||||
|
def provider_current() -> None:
|
||||||
|
"""Show the active provider."""
|
||||||
|
console.print(f"[bold]Active provider:[/bold] {describe_active_backend()}")
|
||||||
|
|
||||||
|
|
||||||
@provider_cmd.group("key")
|
@provider_cmd.group("key")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Tests for myagents.commands.backend."""
|
"""Tests for myagents.commands.provider (xiaohe switch provider)."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -8,7 +8,7 @@ from click.testing import CliRunner
|
|||||||
|
|
||||||
from myagents import claude_settings as claude_settings_mod
|
from myagents import claude_settings as claude_settings_mod
|
||||||
from myagents import secrets as secrets_mod
|
from myagents import secrets as secrets_mod
|
||||||
from myagents.commands import backend as backend_mod
|
from myagents.commands.provider import provider_cmd
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
@@ -23,9 +23,9 @@ def fake_config_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[st
|
|||||||
return {"xiaohe": xiaohe_dir, "claude": claude_dir}
|
return {"xiaohe": xiaohe_dir, "claude": claude_dir}
|
||||||
|
|
||||||
|
|
||||||
class TestBackendList:
|
class TestProviderList:
|
||||||
def test_list_shows_providers(self) -> None:
|
def test_list_shows_providers(self) -> None:
|
||||||
result = CliRunner().invoke(backend_mod.backend_cmd, ["list"])
|
result = CliRunner().invoke(provider_cmd, ["list"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "deepseek" in result.output
|
assert "deepseek" in result.output
|
||||||
assert "kimi" in result.output
|
assert "kimi" in result.output
|
||||||
@@ -33,20 +33,25 @@ class TestBackendList:
|
|||||||
assert "claude" in result.output
|
assert "claude" in result.output
|
||||||
|
|
||||||
|
|
||||||
class TestBackendCurrent:
|
class TestProviderCurrent:
|
||||||
def test_current_shows_default_when_unset(
|
def test_current_shows_default_when_unset(
|
||||||
self, fake_config_dirs: dict[str, Path]
|
self, fake_config_dirs: dict[str, Path]
|
||||||
) -> None:
|
) -> None:
|
||||||
result = CliRunner().invoke(backend_mod.backend_cmd, ["current"])
|
result = CliRunner().invoke(provider_cmd, ["current"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Claude (default / not configured)" in result.output
|
assert "Claude (default" in result.output or "Claude (official" in result.output
|
||||||
|
|
||||||
|
|
||||||
class TestBackendUse:
|
class TestProviderSwitch:
|
||||||
def test_use_deepseek_stores_env(self, fake_config_dirs: dict[str, Path]) -> None:
|
def test_bare_invocation_lists(self) -> None:
|
||||||
|
result = CliRunner().invoke(provider_cmd)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Available providers" in result.output
|
||||||
|
assert "deepseek" in result.output
|
||||||
|
|
||||||
|
def test_switch_to_deepseek(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
result = CliRunner().invoke(
|
result = CliRunner().invoke(
|
||||||
backend_mod.backend_cmd,
|
provider_cmd, ["deepseek", "--key", "sk-test", "--yes"]
|
||||||
["use", "deepseek", "--key", "sk-test", "--yes"],
|
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
settings_path = fake_config_dirs["claude"] / "settings.json"
|
settings_path = fake_config_dirs["claude"] / "settings.json"
|
||||||
@@ -54,10 +59,9 @@ class TestBackendUse:
|
|||||||
assert settings["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-test"
|
assert settings["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-test"
|
||||||
assert settings["env"]["ANTHROPIC_BASE_URL"] == "https://api.deepseek.com/anthropic"
|
assert settings["env"]["ANTHROPIC_BASE_URL"] == "https://api.deepseek.com/anthropic"
|
||||||
|
|
||||||
def test_use_kimi_code(self, fake_config_dirs: dict[str, Path]) -> None:
|
def test_switch_to_kimi_code(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
result = CliRunner().invoke(
|
result = CliRunner().invoke(
|
||||||
backend_mod.backend_cmd,
|
provider_cmd, ["kimi-code", "--key", "sk-test", "--yes"]
|
||||||
["use", "kimi-code", "--key", "sk-test", "--yes"],
|
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
settings_path = fake_config_dirs["claude"] / "settings.json"
|
settings_path = fake_config_dirs["claude"] / "settings.json"
|
||||||
@@ -65,46 +69,39 @@ class TestBackendUse:
|
|||||||
assert settings["env"]["ANTHROPIC_BASE_URL"] == "https://api.kimi.com/coding"
|
assert settings["env"]["ANTHROPIC_BASE_URL"] == "https://api.kimi.com/coding"
|
||||||
assert settings["env"]["ANTHROPIC_MODEL"] == "kimi-for-coding"
|
assert settings["env"]["ANTHROPIC_MODEL"] == "kimi-for-coding"
|
||||||
|
|
||||||
def test_use_unknown_provider_errors(self) -> None:
|
def test_unknown_provider_errors(self) -> None:
|
||||||
result = CliRunner().invoke(
|
result = CliRunner().invoke(provider_cmd, ["openai", "--yes"])
|
||||||
backend_mod.backend_cmd, ["use", "openai", "--yes"]
|
|
||||||
)
|
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "Unknown backend" in result.output
|
assert "Unknown provider" in result.output
|
||||||
|
|
||||||
def test_use_without_key_prompts(self, fake_config_dirs: dict[str, Path]) -> None:
|
def test_prompts_for_missing_key(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
result = CliRunner().invoke(
|
result = CliRunner().invoke(
|
||||||
backend_mod.backend_cmd,
|
provider_cmd, ["deepseek", "--yes"], input="sk-from-prompt\n"
|
||||||
["use", "deepseek", "--yes"],
|
|
||||||
input="sk-from-prompt\n",
|
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
settings_path = fake_config_dirs["claude"] / "settings.json"
|
settings_path = fake_config_dirs["claude"] / "settings.json"
|
||||||
settings = json.loads(settings_path.read_text())
|
settings = json.loads(settings_path.read_text())
|
||||||
assert settings["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-from-prompt"
|
assert settings["env"]["ANTHROPIC_AUTH_TOKEN"] == "sk-from-prompt"
|
||||||
|
|
||||||
def test_use_cancelled_does_not_write(self, fake_config_dirs: dict[str, Path]) -> None:
|
def test_cancelled_does_not_write(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
result = CliRunner().invoke(
|
result = CliRunner().invoke(
|
||||||
backend_mod.backend_cmd,
|
provider_cmd, ["deepseek", "--key", "sk-test"], input="n\n"
|
||||||
["use", "deepseek", "--key", "sk-test"],
|
|
||||||
input="n\n",
|
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Cancelled" in result.output
|
assert "Cancelled" in result.output
|
||||||
settings_path = fake_config_dirs["claude"] / "settings.json"
|
settings_path = fake_config_dirs["claude"] / "settings.json"
|
||||||
assert not settings_path.exists()
|
assert not settings_path.exists()
|
||||||
|
|
||||||
def test_use_model_override(self, fake_config_dirs: dict[str, Path]) -> None:
|
def test_model_override(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
result = CliRunner().invoke(
|
result = CliRunner().invoke(
|
||||||
backend_mod.backend_cmd,
|
provider_cmd, ["deepseek", "--key", "sk-test", "--model", "custom", "--yes"]
|
||||||
["use", "deepseek", "--key", "sk-test", "--model", "custom-model", "--yes"],
|
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
settings_path = fake_config_dirs["claude"] / "settings.json"
|
settings_path = fake_config_dirs["claude"] / "settings.json"
|
||||||
settings = json.loads(settings_path.read_text())
|
settings = json.loads(settings_path.read_text())
|
||||||
assert settings["env"]["ANTHROPIC_MODEL"] == "custom-model"
|
assert settings["env"]["ANTHROPIC_MODEL"] == "custom"
|
||||||
|
|
||||||
def test_use_claude_clears_third_party(self, fake_config_dirs: dict[str, Path]) -> None:
|
def test_claude_clears_third_party(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
settings_path = fake_config_dirs["claude"] / "settings.json"
|
settings_path = fake_config_dirs["claude"] / "settings.json"
|
||||||
fake_config_dirs["claude"].mkdir(parents=True)
|
fake_config_dirs["claude"].mkdir(parents=True)
|
||||||
settings_path.write_text(
|
settings_path.write_text(
|
||||||
@@ -117,33 +114,16 @@ class TestBackendUse:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
result = CliRunner().invoke(
|
result = CliRunner().invoke(provider_cmd, ["claude", "--yes"])
|
||||||
backend_mod.backend_cmd, ["use", "claude", "--yes"]
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
settings = json.loads(settings_path.read_text())
|
settings = json.loads(settings_path.read_text())
|
||||||
assert "env" not in settings
|
|
||||||
|
|
||||||
|
|
||||||
class TestBackendReset:
|
|
||||||
def test_reset_alias(self, fake_config_dirs: dict[str, Path]) -> None:
|
|
||||||
result = CliRunner().invoke(
|
|
||||||
backend_mod.backend_cmd,
|
|
||||||
["use", "deepseek", "--key", "sk-test", "--yes"],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
result = CliRunner().invoke(backend_mod.backend_cmd, ["reset", "--yes"])
|
|
||||||
assert result.exit_code == 0, result.output
|
|
||||||
settings_path = fake_config_dirs["claude"] / "settings.json"
|
|
||||||
settings = json.loads(settings_path.read_text())
|
|
||||||
assert "ANTHROPIC_AUTH_TOKEN" not in settings.get("env", {})
|
assert "ANTHROPIC_AUTH_TOKEN" not in settings.get("env", {})
|
||||||
|
|
||||||
|
|
||||||
class TestBackendKeySet:
|
class TestProviderKeySet:
|
||||||
def test_key_set_stores_in_xiaohe_config(self, fake_config_dirs: dict[str, Path]) -> None:
|
def test_key_set_stores_in_xiaohe_config(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
result = CliRunner().invoke(
|
result = CliRunner().invoke(
|
||||||
backend_mod.backend_key,
|
provider_cmd, ["key", "set", "deepseek", "--key", "sk-test"]
|
||||||
["set", "deepseek", "--key", "sk-test"],
|
|
||||||
)
|
)
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
config_path = fake_config_dirs["xiaohe"] / "config.json"
|
config_path = fake_config_dirs["xiaohe"] / "config.json"
|
||||||
@@ -152,22 +132,19 @@ class TestBackendKeySet:
|
|||||||
|
|
||||||
def test_key_set_for_claude_errors(self) -> None:
|
def test_key_set_for_claude_errors(self) -> None:
|
||||||
result = CliRunner().invoke(
|
result = CliRunner().invoke(
|
||||||
backend_mod.backend_key,
|
provider_cmd, ["key", "set", "claude", "--key", "sk-test"]
|
||||||
["set", "claude", "--key", "sk-test"],
|
|
||||||
)
|
)
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "ANTHROPIC_API_KEY" in result.output
|
assert "ANTHROPIC_API_KEY" in result.output
|
||||||
|
|
||||||
|
|
||||||
class TestBackendKeyRm:
|
class TestProviderKeyRm:
|
||||||
def test_key_rm_removes_stored_key(self, fake_config_dirs: dict[str, Path]) -> None:
|
def test_key_rm_removes_stored_key(self, fake_config_dirs: dict[str, Path]) -> None:
|
||||||
config_path = fake_config_dirs["xiaohe"] / "config.json"
|
config_path = fake_config_dirs["xiaohe"] / "config.json"
|
||||||
fake_config_dirs["xiaohe"].mkdir(parents=True)
|
fake_config_dirs["xiaohe"].mkdir(parents=True)
|
||||||
config_path.write_text(json.dumps({"keys": {"deepseek": "sk-test"}}))
|
config_path.write_text(json.dumps({"keys": {"deepseek": "sk-test"}}))
|
||||||
|
|
||||||
result = CliRunner().invoke(
|
result = CliRunner().invoke(provider_cmd, ["key", "rm", "deepseek", "--yes"])
|
||||||
backend_mod.backend_key, ["rm", "deepseek", "--yes"]
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
data = json.loads(config_path.read_text())
|
data = json.loads(config_path.read_text())
|
||||||
assert "deepseek" not in data.get("keys", {})
|
assert "deepseek" not in data.get("keys", {})
|
||||||
@@ -177,9 +154,7 @@ class TestBackendKeyRm:
|
|||||||
fake_config_dirs["xiaohe"].mkdir(parents=True)
|
fake_config_dirs["xiaohe"].mkdir(parents=True)
|
||||||
config_path.write_text(json.dumps({"keys": {"deepseek": "sk-test"}}))
|
config_path.write_text(json.dumps({"keys": {"deepseek": "sk-test"}}))
|
||||||
|
|
||||||
result = CliRunner().invoke(
|
result = CliRunner().invoke(provider_cmd, ["key", "rm", "deepseek"], input="y\n")
|
||||||
backend_mod.backend_key, ["rm", "deepseek"], input="y\n"
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0, result.output
|
assert result.exit_code == 0, result.output
|
||||||
data = json.loads(config_path.read_text())
|
data = json.loads(config_path.read_text())
|
||||||
assert "deepseek" not in data.get("keys", {})
|
assert "deepseek" not in data.get("keys", {})
|
||||||
|
|||||||
@@ -8,14 +8,20 @@ from click.testing import CliRunner
|
|||||||
from myagents.entrypoints import build_xiaohe_cli, claude_cli, codex_cli, hermes_cli, kimi_cli
|
from myagents.entrypoints import build_xiaohe_cli, claude_cli, codex_cli, hermes_cli, kimi_cli
|
||||||
|
|
||||||
|
|
||||||
class TestXiaoheBackend:
|
class TestXiaoheProvider:
|
||||||
"""``xiaohe backend`` subcommand registration."""
|
"""``xiaohe switch provider`` subcommand registration."""
|
||||||
|
|
||||||
def test_help_lists_backend(self) -> None:
|
def test_help_lists_switch(self) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(build_xiaohe_cli(), ["--help"])
|
result = runner.invoke(build_xiaohe_cli(), ["--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "backend" in result.output
|
assert "switch" in result.output
|
||||||
|
|
||||||
|
def test_help_lists_info(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(build_xiaohe_cli(), ["--help"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "info" in result.output
|
||||||
|
|
||||||
|
|
||||||
class TestMyclaudeEntrypoint:
|
class TestMyclaudeEntrypoint:
|
||||||
|
|||||||
Reference in New Issue
Block a user