diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index e3957dab5..4f8c9a0a4 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -134,6 +134,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = { "gemini": "gemini-3-flash-preview", "zai": "glm-4.5-flash", "kimi-coding": "kimi-k2-turbo-preview", + "stepfun": "step-3.5-flash", "kimi-coding-cn": "kimi-k2-turbo-preview", "minimax": "MiniMax-M2.7", "minimax-cn": "MiniMax-M2.7", diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 6506bffe6..152e536fd 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -25,7 +25,7 @@ logger = logging.getLogger(__name__) # are preserved so the full model name reaches cache lookups and server queries. _PROVIDER_PREFIXES: frozenset[str] = frozenset({ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", - "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "anthropic", "deepseek", + "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-cn", "anthropic", "deepseek", "opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba", "qwen-oauth", "xiaomi", @@ -36,7 +36,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({ "glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot", "github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", "ollama", - "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen", + "stepfun", "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen", "mimo", "xiaomi-mimo", "arcee-ai", "arceeai", "xai", "x-ai", "x.ai", "grok", @@ -237,6 +237,8 @@ _URL_TO_PROVIDER: Dict[str, str] = { "api.moonshot.ai": "kimi-coding", "api.moonshot.cn": "kimi-coding-cn", "api.kimi.com": "kimi-coding", + "api.stepfun.ai": "stepfun", + "api.stepfun.com": "stepfun", "api.arcee.ai": "arcee", "api.minimax": "minimax", "dashscope.aliyuncs.com": "alibaba", diff --git a/agent/models_dev.py b/agent/models_dev.py index 3e5c911e7..2f06a75d8 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -146,6 +146,7 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = { "openai-codex": "openai", "zai": "zai", "kimi-coding": "kimi-for-coding", + "stepfun": "stepfun", "kimi-coding-cn": "kimi-for-coding", "minimax": "minimax", "minimax-cn": "minimax-cn", diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 98dfa6059..3fab36a2c 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -72,6 +72,8 @@ DEFAULT_QWEN_BASE_URL = "https://portal.qwen.ai/v1" DEFAULT_GITHUB_MODELS_BASE_URL = "https://api.githubcopilot.com" DEFAULT_COPILOT_ACP_BASE_URL = "acp://copilot" DEFAULT_OLLAMA_CLOUD_BASE_URL = "https://ollama.com/v1" +STEPFUN_STEP_PLAN_INTL_BASE_URL = "https://api.stepfun.ai/step_plan/v1" +STEPFUN_STEP_PLAN_CN_BASE_URL = "https://api.stepfun.com/step_plan/v1" CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token" CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 @@ -182,6 +184,14 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { inference_base_url="https://api.moonshot.cn/v1", api_key_env_vars=("KIMI_CN_API_KEY",), ), + "stepfun": ProviderConfig( + id="stepfun", + name="StepFun Step Plan", + auth_type="api_key", + inference_base_url=STEPFUN_STEP_PLAN_INTL_BASE_URL, + api_key_env_vars=("STEPFUN_API_KEY",), + base_url_env_var="STEPFUN_BASE_URL", + ), "arcee": ProviderConfig( id="arcee", name="Arcee AI", @@ -992,6 +1002,7 @@ def resolve_provider( "x-ai": "xai", "x.ai": "xai", "grok": "xai", "kimi": "kimi-coding", "kimi-for-coding": "kimi-coding", "moonshot": "kimi-coding", "kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn", + "step": "stepfun", "stepfun-coding-plan": "stepfun", "arcee-ai": "arcee", "arceeai": "arcee", "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", "claude": "anthropic", "claude-code": "anthropic", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c87b9f5a9..ebeace304 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1050,6 +1050,22 @@ OPTIONAL_ENV_VARS = { "category": "provider", "advanced": True, }, + "STEPFUN_API_KEY": { + "description": "StepFun Step Plan API key", + "prompt": "StepFun Step Plan API key", + "url": "https://platform.stepfun.com/", + "password": True, + "category": "provider", + "advanced": True, + }, + "STEPFUN_BASE_URL": { + "description": "StepFun Step Plan base URL override", + "prompt": "StepFun Step Plan base URL (leave empty for default)", + "url": None, + "password": False, + "category": "provider", + "advanced": True, + }, "ARCEEAI_API_KEY": { "description": "Arcee AI API key", "prompt": "Arcee AI API key", diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 2fc50321f..064b1d68d 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -912,6 +912,7 @@ def run_doctor(args): _apikey_providers = [ ("Z.AI / GLM", ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True), ("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True), + ("StepFun Step Plan", ("STEPFUN_API_KEY",), "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True), ("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True), ("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True), ("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True), diff --git a/hermes_cli/main.py b/hermes_cli/main.py index fe2fdd378..404e59089 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1566,6 +1566,8 @@ def select_provider_and_model(args=None): _model_flow_anthropic(config, current_model) elif selected_provider == "kimi-coding": _model_flow_kimi(config, current_model) + elif selected_provider == "stepfun": + _model_flow_stepfun(config, current_model) elif selected_provider == "bedrock": _model_flow_bedrock(config, current_model) elif selected_provider in ( @@ -3462,6 +3464,140 @@ def _model_flow_kimi(config, current_model=""): print("No change.") +def _infer_stepfun_region(base_url: str) -> str: + """Infer the current StepFun region from the configured endpoint.""" + normalized = (base_url or "").strip().lower() + if "api.stepfun.com" in normalized: + return "china" + return "international" + + +def _stepfun_base_url_for_region(region: str) -> str: + from hermes_cli.auth import ( + STEPFUN_STEP_PLAN_CN_BASE_URL, + STEPFUN_STEP_PLAN_INTL_BASE_URL, + ) + + return ( + STEPFUN_STEP_PLAN_CN_BASE_URL + if region == "china" + else STEPFUN_STEP_PLAN_INTL_BASE_URL + ) + + +def _model_flow_stepfun(config, current_model=""): + """StepFun Step Plan flow with region-specific endpoints.""" + from hermes_cli.auth import ( + PROVIDER_REGISTRY, + _prompt_model_selection, + _save_model_choice, + deactivate_provider, + ) + from hermes_cli.config import get_env_value, save_env_value, load_config, save_config + from hermes_cli.models import fetch_api_models + + provider_id = "stepfun" + pconfig = PROVIDER_REGISTRY[provider_id] + key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" + base_url_env = pconfig.base_url_env_var or "" + + existing_key = "" + for ev in pconfig.api_key_env_vars: + existing_key = get_env_value(ev) or os.getenv(ev, "") + if existing_key: + break + + if not existing_key: + print(f"No {pconfig.name} API key configured.") + if key_env: + try: + import getpass + new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip() + except (KeyboardInterrupt, EOFError): + print() + return + if not new_key: + print("Cancelled.") + return + save_env_value(key_env, new_key) + existing_key = new_key + print("API key saved.") + print() + else: + print(f" {pconfig.name} API key: {existing_key[:8]}... ✓") + print() + + current_base = "" + if base_url_env: + current_base = get_env_value(base_url_env) or os.getenv(base_url_env, "") + if not current_base: + model_cfg = config.get("model") + if isinstance(model_cfg, dict): + current_base = str(model_cfg.get("base_url") or "").strip() + current_region = _infer_stepfun_region(current_base or pconfig.inference_base_url) + + region_choices = [ + ("international", f"International ({_stepfun_base_url_for_region('international')})"), + ("china", f"China ({_stepfun_base_url_for_region('china')})"), + ] + ordered_regions = [] + for region_key, label in region_choices: + if region_key == current_region: + ordered_regions.insert(0, (region_key, f"{label} ← currently active")) + else: + ordered_regions.append((region_key, label)) + ordered_regions.append(("cancel", "Cancel")) + + region_idx = _prompt_provider_choice([label for _, label in ordered_regions]) + if region_idx is None or ordered_regions[region_idx][0] == "cancel": + print("No change.") + return + + selected_region = ordered_regions[region_idx][0] + effective_base = _stepfun_base_url_for_region(selected_region) + if base_url_env: + save_env_value(base_url_env, effective_base) + + live_models = fetch_api_models(existing_key, effective_base) + if live_models: + model_list = live_models + print(f" Found {len(model_list)} model(s) from {pconfig.name} API") + else: + model_list = _PROVIDER_MODELS.get(provider_id, []) + if model_list: + print( + f" Could not auto-detect models from {pconfig.name} API — " + "showing Step Plan fallback catalog." + ) + + if model_list: + selected = _prompt_model_selection(model_list, current_model=current_model) + else: + try: + selected = input("Model name: ").strip() + except (KeyboardInterrupt, EOFError): + selected = None + + if selected: + _save_model_choice(selected) + + cfg = load_config() + model = cfg.get("model") + if not isinstance(model, dict): + model = {"default": model} if model else {} + cfg["model"] = model + model["provider"] = provider_id + model["base_url"] = effective_base + model.pop("api_mode", None) + save_config(cfg) + deactivate_provider() + + config["model"] = dict(model) + print(f"Default model set to: {selected} (via {pconfig.name})") + else: + print("No change.") + + def _model_flow_bedrock_api_key(config, region, current_model=""): """Bedrock API Key mode — uses the OpenAI-compatible bedrock-mantle endpoint. @@ -6530,6 +6666,7 @@ For more help on a command: "zai", "kimi-coding", "kimi-coding-cn", + "stepfun", "minimax", "minimax-cn", "kilocode", diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 5b26f5b8b..e5feaa865 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -143,7 +143,7 @@ MODEL_ALIASES: dict[str, ModelIdentity] = { # Z.AI / GLM "glm": ModelIdentity("z-ai", "glm"), - # StepFun + # Step Plan (StepFun) "step": ModelIdentity("stepfun", "step"), # Xiaomi diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 186119b24..4b3493506 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -210,6 +210,10 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "kimi-k2-turbo-preview", "kimi-k2-0905-preview", ], + "stepfun": [ + "step-3.5-flash", + "step-3.5-flash-2603", + ], "moonshot": [ "kimi-k2.6", "kimi-k2.5", @@ -699,6 +703,7 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [ ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"), ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "Kimi Coding Plan (api.kimi.com) & Moonshot API"), ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Moonshot CN direct API)"), + ProviderEntry("stepfun", "StepFun Step Plan", "StepFun Step Plan (agent/coding models via Step Plan API)"), ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"), ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"), ProviderEntry("alibaba", "Alibaba Cloud (DashScope)","Alibaba Cloud / DashScope Coding (Qwen + multi-provider)"), @@ -733,6 +738,8 @@ _PROVIDER_ALIASES = { "moonshot": "kimi-coding", "kimi-cn": "kimi-coding-cn", "moonshot-cn": "kimi-coding-cn", + "step": "stepfun", + "stepfun-coding-plan": "stepfun", "arcee-ai": "arcee", "arceeai": "arcee", "minimax-china": "minimax-cn", @@ -1613,6 +1620,19 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) return live except Exception: pass + if normalized == "stepfun": + try: + from hermes_cli.auth import resolve_api_key_provider_credentials + + creds = resolve_api_key_provider_credentials("stepfun") + api_key = str(creds.get("api_key") or "").strip() + base_url = str(creds.get("base_url") or "").strip() + if api_key and base_url: + live = fetch_api_models(api_key, base_url) + if live: + return live + except Exception: + pass if normalized == "anthropic": live = _fetch_anthropic_models() if live: diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 00c3f64bc..e842086a4 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -94,6 +94,12 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = { transport="openai_chat", base_url_env_var="KIMI_BASE_URL", ), + "stepfun": HermesOverlay( + transport="openai_chat", + extra_env_vars=("STEPFUN_API_KEY",), + base_url_override="https://api.stepfun.ai/step_plan/v1", + base_url_env_var="STEPFUN_BASE_URL", + ), "minimax": HermesOverlay( transport="anthropic_messages", base_url_env_var="MINIMAX_BASE_URL", @@ -210,6 +216,10 @@ ALIASES: Dict[str, str] = { "kimi-coding-cn": "kimi-for-coding", "moonshot": "kimi-for-coding", + # stepfun + "step": "stepfun", + "stepfun-coding-plan": "stepfun", + # minimax-cn "minimax-china": "minimax-cn", "minimax_cn": "minimax-cn", @@ -294,6 +304,7 @@ _LABEL_OVERRIDES: Dict[str, str] = { "nous": "Nous Portal", "openai-codex": "OpenAI Codex", "copilot-acp": "GitHub Copilot ACP", + "stepfun": "StepFun Step Plan", "xiaomi": "Xiaomi MiMo", "local": "Local endpoint", "bedrock": "AWS Bedrock", diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 1a620d62b..1fe5ae058 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -96,6 +96,7 @@ _DEFAULT_PROVIDER_MODELS = { "zai": ["glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"], "kimi-coding": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], "kimi-coding-cn": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"], + "stepfun": ["step-3.5-flash", "step-3.5-flash-2603"], "arcee": ["trinity-large-thinking", "trinity-large-preview", "trinity-mini"], "minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"], "minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"], @@ -804,6 +805,7 @@ def setup_model_provider(config: dict, *, quick: bool = False): "zai": "Z.AI / GLM", "kimi-coding": "Kimi / Moonshot", "kimi-coding-cn": "Kimi / Moonshot (China)", + "stepfun": "StepFun Step Plan", "minimax": "MiniMax", "minimax-cn": "MiniMax CN", "anthropic": "Anthropic", diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 540afc303..8541f0a05 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -122,6 +122,7 @@ def show_status(args): "OpenAI": "OPENAI_API_KEY", "Z.AI/GLM": "GLM_API_KEY", "Kimi": "KIMI_API_KEY", + "StepFun Step Plan": "STEPFUN_API_KEY", "MiniMax": "MINIMAX_API_KEY", "MiniMax-CN": "MINIMAX_CN_API_KEY", "Firecrawl": "FIRECRAWL_API_KEY", @@ -252,6 +253,7 @@ def show_status(args): apikey_providers = { "Z.AI / GLM": ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "Kimi / Moonshot": ("KIMI_API_KEY",), + "StepFun Step Plan": ("STEPFUN_API_KEY",), "MiniMax": ("MINIMAX_API_KEY",), "MiniMax (China)": ("MINIMAX_CN_API_KEY",), } diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 6a0eab151..45e716022 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -385,6 +385,7 @@ class TestStripProviderPrefix: assert _strip_provider_prefix("local:my-model") == "my-model" assert _strip_provider_prefix("openrouter:anthropic/claude-sonnet-4") == "anthropic/claude-sonnet-4" assert _strip_provider_prefix("anthropic:claude-sonnet-4") == "claude-sonnet-4" + assert _strip_provider_prefix("stepfun:step-3.5-flash") == "step-3.5-flash" def test_ollama_model_tag_preserved(self): """Ollama model:tag format must NOT be stripped.""" diff --git a/tests/agent/test_models_dev.py b/tests/agent/test_models_dev.py index be4b3b139..c2a214018 100644 --- a/tests/agent/test_models_dev.py +++ b/tests/agent/test_models_dev.py @@ -82,6 +82,7 @@ class TestProviderMapping: def test_known_providers_mapped(self): assert PROVIDER_TO_MODELS_DEV["anthropic"] == "anthropic" assert PROVIDER_TO_MODELS_DEV["copilot"] == "github-copilot" + assert PROVIDER_TO_MODELS_DEV["stepfun"] == "stepfun" assert PROVIDER_TO_MODELS_DEV["kilocode"] == "kilo" assert PROVIDER_TO_MODELS_DEV["ai-gateway"] == "vercel" diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 7d0674b03..e8f181fa4 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -15,6 +15,8 @@ from hermes_cli.auth import ( get_auth_status, AuthError, KIMI_CODE_BASE_URL, + STEPFUN_STEP_PLAN_INTL_BASE_URL, + STEPFUN_STEP_PLAN_CN_BASE_URL, _resolve_kimi_base_url, ) from hermes_cli.copilot_auth import _try_gh_cli_token @@ -35,6 +37,7 @@ class TestProviderRegistry: ("xai", "xAI", "api_key"), ("nvidia", "NVIDIA NIM", "api_key"), ("kimi-coding", "Kimi / Moonshot", "api_key"), + ("stepfun", "StepFun Step Plan", "api_key"), ("minimax", "MiniMax", "api_key"), ("minimax-cn", "MiniMax (China)", "api_key"), ("ai-gateway", "Vercel AI Gateway", "api_key"), @@ -83,6 +86,11 @@ class TestProviderRegistry: assert pconfig.api_key_env_vars == ("MINIMAX_API_KEY",) assert pconfig.base_url_env_var == "MINIMAX_BASE_URL" + def test_stepfun_env_vars(self): + pconfig = PROVIDER_REGISTRY["stepfun"] + assert pconfig.api_key_env_vars == ("STEPFUN_API_KEY",) + assert pconfig.base_url_env_var == "STEPFUN_BASE_URL" + def test_minimax_cn_env_vars(self): pconfig = PROVIDER_REGISTRY["minimax-cn"] assert pconfig.api_key_env_vars == ("MINIMAX_CN_API_KEY",) @@ -108,6 +116,7 @@ class TestProviderRegistry: assert PROVIDER_REGISTRY["copilot-acp"].inference_base_url == "acp://copilot" assert PROVIDER_REGISTRY["zai"].inference_base_url == "https://api.z.ai/api/paas/v4" assert PROVIDER_REGISTRY["kimi-coding"].inference_base_url == "https://api.moonshot.ai/v1" + assert PROVIDER_REGISTRY["stepfun"].inference_base_url == STEPFUN_STEP_PLAN_INTL_BASE_URL assert PROVIDER_REGISTRY["minimax"].inference_base_url == "https://api.minimax.io/anthropic" assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/anthropic" assert PROVIDER_REGISTRY["ai-gateway"].inference_base_url == "https://ai-gateway.vercel.sh/v1" @@ -130,7 +139,8 @@ PROVIDER_ENV_VARS = ( "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", - "KIMI_API_KEY", "KIMI_BASE_URL", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", + "KIMI_API_KEY", "KIMI_BASE_URL", "STEPFUN_API_KEY", "STEPFUN_BASE_URL", + "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL", "KILOCODE_API_KEY", "KILOCODE_BASE_URL", "DASHSCOPE_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY", @@ -156,6 +166,9 @@ class TestResolveProvider: def test_explicit_kimi_coding(self): assert resolve_provider("kimi-coding") == "kimi-coding" + def test_explicit_stepfun(self): + assert resolve_provider("stepfun") == "stepfun" + def test_explicit_minimax(self): assert resolve_provider("minimax") == "minimax" @@ -180,6 +193,9 @@ class TestResolveProvider: def test_alias_moonshot(self): assert resolve_provider("moonshot") == "kimi-coding" + def test_alias_step(self): + assert resolve_provider("step") == "stepfun" + def test_alias_minimax_underscore(self): assert resolve_provider("minimax_cn") == "minimax-cn" @@ -248,6 +264,10 @@ class TestResolveProvider: monkeypatch.setenv("KIMI_API_KEY", "test-kimi-key") assert resolve_provider("auto") == "kimi-coding" + def test_auto_detects_stepfun_key(self, monkeypatch): + monkeypatch.setenv("STEPFUN_API_KEY", "test-stepfun-key") + assert resolve_provider("auto") == "stepfun" + def test_auto_detects_minimax_key(self, monkeypatch): monkeypatch.setenv("MINIMAX_API_KEY", "test-mm-key") assert resolve_provider("auto") == "minimax" @@ -312,6 +332,13 @@ class TestApiKeyProviderStatus: status = get_api_key_provider_status("kimi-coding") assert status["base_url"] == "https://custom.kimi.example/v1" + def test_stepfun_status_uses_configured_base_url(self, monkeypatch): + monkeypatch.setenv("STEPFUN_API_KEY", "stepfun-key") + monkeypatch.setenv("STEPFUN_BASE_URL", STEPFUN_STEP_PLAN_CN_BASE_URL) + status = get_api_key_provider_status("stepfun") + assert status["configured"] is True + assert status["base_url"] == STEPFUN_STEP_PLAN_CN_BASE_URL + def test_copilot_status_uses_gh_cli_token(self, monkeypatch): monkeypatch.setattr("hermes_cli.copilot_auth._try_gh_cli_token", lambda: "gho_gh_cli_token") status = get_api_key_provider_status("copilot") @@ -429,6 +456,19 @@ class TestResolveApiKeyProviderCredentials: assert creds["api_key"] == "kimi-secret-key" assert creds["base_url"] == "https://api.moonshot.ai/v1" + def test_resolve_stepfun_with_key(self, monkeypatch): + monkeypatch.setenv("STEPFUN_API_KEY", "stepfun-secret-key") + creds = resolve_api_key_provider_credentials("stepfun") + assert creds["provider"] == "stepfun" + assert creds["api_key"] == "stepfun-secret-key" + assert creds["base_url"] == STEPFUN_STEP_PLAN_INTL_BASE_URL + + def test_resolve_stepfun_custom_base_url(self, monkeypatch): + monkeypatch.setenv("STEPFUN_API_KEY", "stepfun-secret-key") + monkeypatch.setenv("STEPFUN_BASE_URL", STEPFUN_STEP_PLAN_CN_BASE_URL) + creds = resolve_api_key_provider_credentials("stepfun") + assert creds["base_url"] == STEPFUN_STEP_PLAN_CN_BASE_URL + def test_resolve_minimax_with_key(self, monkeypatch): monkeypatch.setenv("MINIMAX_API_KEY", "mm-secret-key") creds = resolve_api_key_provider_credentials("minimax") @@ -519,6 +559,16 @@ class TestRuntimeProviderResolution: assert result["api_mode"] == "chat_completions" assert result["api_key"] == "kimi-key" + def test_runtime_stepfun(self, monkeypatch): + monkeypatch.setenv("STEPFUN_API_KEY", "stepfun-key") + monkeypatch.setenv("STEPFUN_BASE_URL", STEPFUN_STEP_PLAN_CN_BASE_URL) + from hermes_cli.runtime_provider import resolve_runtime_provider + result = resolve_runtime_provider(requested="stepfun") + assert result["provider"] == "stepfun" + assert result["api_mode"] == "chat_completions" + assert result["api_key"] == "stepfun-key" + assert result["base_url"] == STEPFUN_STEP_PLAN_CN_BASE_URL + def test_runtime_minimax(self, monkeypatch): monkeypatch.setenv("MINIMAX_API_KEY", "mm-key") from hermes_cli.runtime_provider import resolve_runtime_provider diff --git a/tests/hermes_cli/test_model_provider_persistence.py b/tests/hermes_cli/test_model_provider_persistence.py index a06facd30..067483680 100644 --- a/tests/hermes_cli/test_model_provider_persistence.py +++ b/tests/hermes_cli/test_model_provider_persistence.py @@ -32,6 +32,8 @@ def config_home(tmp_path, monkeypatch): monkeypatch.delenv("OPENAI_BASE_URL", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("STEPFUN_API_KEY", raising=False) + monkeypatch.delenv("STEPFUN_BASE_URL", raising=False) return home @@ -330,3 +332,33 @@ class TestBaseUrlValidation: saved = get_env_value("GLM_BASE_URL") or "" assert saved == "", "Empty input should not save a base URL" + + def test_stepfun_provider_saved_with_selected_region(self, config_home, monkeypatch): + from hermes_cli.main import _model_flow_stepfun + from hermes_cli.config import load_config, get_env_value + + monkeypatch.setenv("STEPFUN_API_KEY", "stepfun-test-key") + + with patch( + "hermes_cli.main._prompt_provider_choice", + return_value=1, + ), patch( + "hermes_cli.models.fetch_api_models", + return_value=["step-3.5-flash", "step-3-agent-lite"], + ), patch( + "hermes_cli.auth._prompt_model_selection", + return_value="step-3-agent-lite", + ), patch( + "hermes_cli.auth.deactivate_provider", + ): + _model_flow_stepfun(load_config(), "old-model") + + import yaml + + config = yaml.safe_load((config_home / "config.yaml").read_text()) or {} + model = config.get("model") + assert isinstance(model, dict) + assert model.get("provider") == "stepfun" + assert model.get("default") == "step-3-agent-lite" + assert model.get("base_url") == "https://api.stepfun.com/step_plan/v1" + assert get_env_value("STEPFUN_BASE_URL") == "https://api.stepfun.com/step_plan/v1" diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 72ffc5216..6a1a230c4 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -63,6 +63,11 @@ class TestParseModelInput: assert provider == "zai" assert model == "glm-5" + def test_stepfun_alias_resolved(self): + provider, model = parse_model_input("step:step-3.5-flash", "openrouter") + assert provider == "stepfun" + assert model == "step-3.5-flash" + def test_no_slash_no_colon_keeps_provider(self): provider, model = parse_model_input("gpt-5.4", "openrouter") assert provider == "openrouter" @@ -154,6 +159,7 @@ class TestNormalizeProvider: assert normalize_provider("glm") == "zai" assert normalize_provider("kimi") == "kimi-coding" assert normalize_provider("moonshot") == "kimi-coding" + assert normalize_provider("step") == "stepfun" assert normalize_provider("github-copilot") == "copilot" def test_case_insensitive(self): @@ -164,6 +170,7 @@ class TestProviderLabel: def test_known_labels_and_auto(self): assert provider_label("anthropic") == "Anthropic" assert provider_label("kimi") == "Kimi / Kimi Coding Plan" + assert provider_label("stepfun") == "StepFun Step Plan" assert provider_label("copilot") == "GitHub Copilot" assert provider_label("copilot-acp") == "GitHub Copilot ACP" assert provider_label("auto") == "Auto" @@ -193,6 +200,16 @@ class TestProviderModelIds: def test_zai_returns_glm_models(self): assert "glm-5" in provider_model_ids("zai") + def test_stepfun_prefers_live_catalog(self): + with patch( + "hermes_cli.auth.resolve_api_key_provider_credentials", + return_value={"api_key": "***", "base_url": "https://api.stepfun.com/step_plan/v1"}, + ), patch( + "hermes_cli.models.fetch_api_models", + return_value=["step-3.5-flash", "step-3-agent-lite"], + ): + assert provider_model_ids("stepfun") == ["step-3.5-flash", "step-3-agent-lite"] + def test_copilot_prefers_live_catalog(self): with patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={"api_key": "gh-token"}), \ patch("hermes_cli.models._fetch_github_models", return_value=["gpt-5.4", "claude-sonnet-4.6"]):