From 35d948b6e18525654cf8a12db8109f1322b7def8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 17 Mar 2026 02:40:34 -0700 Subject: [PATCH] feat: add Kilo Code (kilocode) as first-class inference provider (#1666) Add Kilo Gateway (kilo.ai) as an API-key provider with OpenAI-compatible endpoint at https://api.kilo.ai/api/gateway. Supports 500+ models from Anthropic, OpenAI, Google, xAI, Mistral, MiniMax via a single API key. - Register kilocode in PROVIDER_REGISTRY with aliases (kilo, kilo-code, kilo-gateway) and KILOCODE_API_KEY / KILOCODE_BASE_URL env vars - Add to model catalog, CLI provider menu, setup wizard, doctor checks - Add google/gemini-3-flash-preview as default aux model - 12 new tests covering registration, aliases, credential resolution, runtime config - Documentation updates (env vars, config, fallback providers) - Fix setup test index shift from provider insertion Inspired by PR #1473 by @amanning3390. Co-authored-by: amanning3390 --- agent/auxiliary_client.py | 1 + hermes_cli/auth.py | 13 ++++- hermes_cli/doctor.py | 2 + hermes_cli/main.py | 13 ++++- hermes_cli/models.py | 13 ++++- hermes_cli/setup.py | 49 ++++++++++++++++--- tests/hermes_cli/test_setup_model_provider.py | 2 +- tests/test_api_key_providers.py | 46 +++++++++++++++++ .../docs/reference/environment-variables.md | 4 +- website/docs/user-guide/configuration.md | 1 + .../user-guide/features/fallback-providers.md | 1 + 11 files changed, 131 insertions(+), 14 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 17fab37f..d008361b 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -60,6 +60,7 @@ _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = { "ai-gateway": "google/gemini-3-flash", "opencode-zen": "gemini-3-flash", "opencode-go": "glm-5", + "kilocode": "google/gemini-3-flash-preview", } # OpenRouter app attribution headers diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 9dc50e2e..d30dc5b3 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -174,11 +174,19 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = { "opencode-go": ProviderConfig( id="opencode-go", name="OpenCode Go", - auth_type="api_key", + auth_type="***", inference_base_url="https://opencode.ai/zen/go/v1", - api_key_env_vars=("OPENCODE_GO_API_KEY",), + api_key_env_vars=("OPEN...",), base_url_env_var="OPENCODE_GO_BASE_URL", ), + "kilocode": ProviderConfig( + id="kilocode", + name="Kilo Code", + auth_type="api_key", + inference_base_url="https://api.kilo.ai/api/gateway", + api_key_env_vars=("KILOCODE_API_KEY",), + base_url_env_var="KILOCODE_BASE_URL", + ), } @@ -559,6 +567,7 @@ def resolve_provider( "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway", "opencode": "opencode-zen", "zen": "opencode-zen", "go": "opencode-go", "opencode-go-sub": "opencode-go", + "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode", } normalized = _PROVIDER_ALIASES.get(normalized, normalized) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 33900b7c..d49e6317 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -46,6 +46,7 @@ _PROVIDER_ENV_HINTS = ( "KIMI_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", + "KILOCODE_API_KEY", ) @@ -571,6 +572,7 @@ def run_doctor(args): ("MiniMax", ("MINIMAX_API_KEY",), None, "MINIMAX_BASE_URL", False), ("MiniMax (China)", ("MINIMAX_CN_API_KEY",), None, "MINIMAX_CN_BASE_URL", False), ("AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True), + ("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True), ] for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers: _key = "" diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 982128d7..32d90ac6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -783,6 +783,7 @@ def cmd_model(args): "opencode-zen": "OpenCode Zen", "opencode-go": "OpenCode Go", "ai-gateway": "AI Gateway", + "kilocode": "Kilo Code", "custom": "Custom endpoint", } active_label = provider_labels.get(active, active) @@ -802,6 +803,7 @@ def cmd_model(args): ("kimi-coding", "Kimi / Moonshot (Moonshot AI direct API)"), ("minimax", "MiniMax (global direct API)"), ("minimax-cn", "MiniMax China (domestic direct API)"), + ("kilocode", "Kilo Code (Kilo Gateway API)"), ("opencode-zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"), ("opencode-go", "OpenCode Go (open models, $10/month subscription)"), ("ai-gateway", "AI Gateway (Vercel — 200+ models, pay-per-use)"), @@ -873,7 +875,7 @@ def cmd_model(args): _model_flow_anthropic(config, current_model) elif selected_provider == "kimi-coding": _model_flow_kimi(config, current_model) - elif selected_provider in ("zai", "minimax", "minimax-cn", "opencode-zen", "opencode-go", "ai-gateway"): + elif selected_provider in ("zai", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway"): _model_flow_api_key_provider(config, selected_provider, current_model) @@ -1433,6 +1435,13 @@ _PROVIDER_MODELS = { "MiniMax-M2.5-highspeed", "MiniMax-M2.1", ], + "kilocode": [ + "anthropic/claude-opus-4.6", + "anthropic/claude-sonnet-4.6", + "openai/gpt-5.4", + "google/gemini-3-pro-preview", + "google/gemini-3-flash-preview", + ], } @@ -2609,7 +2618,7 @@ For more help on a command: ) chat_parser.add_argument( "--provider", - choices=["auto", "openrouter", "nous", "openai-codex", "anthropic", "zai", "kimi-coding", "minimax", "minimax-cn"], + choices=["auto", "openrouter", "nous", "openai-codex", "anthropic", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode"], default=None, help="Inference provider (default: auto)" ) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 0d905205..5701641e 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -139,6 +139,13 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "google/gemini-2.5-flash", "deepseek/deepseek-v3.2", ], + "kilocode": [ + "anthropic/claude-opus-4.6", + "anthropic/claude-sonnet-4.6", + "openai/gpt-5.4", + "google/gemini-3-pro-preview", + "google/gemini-3-flash-preview", + ], } _PROVIDER_LABELS = { @@ -154,6 +161,7 @@ _PROVIDER_LABELS = { "opencode-zen": "OpenCode Zen", "opencode-go": "OpenCode Go", "ai-gateway": "AI Gateway", + "kilocode": "Kilo Code", "custom": "Custom endpoint", } @@ -176,6 +184,9 @@ _PROVIDER_ALIASES = { "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway", + "kilo": "kilocode", + "kilo-code": "kilocode", + "kilo-gateway": "kilocode", } @@ -209,7 +220,7 @@ def list_available_providers() -> list[dict[str, str]]: # Canonical providers in display order _PROVIDER_ORDER = [ "openrouter", "nous", "openai-codex", - "zai", "kimi-coding", "minimax", "minimax-cn", "anthropic", + "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic", "opencode-zen", "opencode-go", "ai-gateway", "deepseek", "custom", ] diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 271773fc..a79844de 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -60,6 +60,7 @@ _DEFAULT_PROVIDER_MODELS = { "minimax": ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"], "minimax-cn": ["MiniMax-M2.5", "MiniMax-M2.5-highspeed", "MiniMax-M2.1"], "ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"], + "kilocode": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"], } @@ -734,6 +735,7 @@ def setup_model_provider(config: dict): "Kimi / Moonshot (Kimi coding models)", "MiniMax (global endpoint)", "MiniMax China (mainland China endpoint)", + "Kilo Code (Kilo Gateway API)", "Anthropic (Claude models — API key or Claude Code subscription)", "AI Gateway (Vercel — 200+ models, pay-per-use)", "OpenCode Zen (35+ curated models, pay-as-you-go)", @@ -1142,7 +1144,40 @@ def setup_model_provider(config: dict): _set_model_provider(config, "minimax-cn", pconfig.inference_base_url) selected_base_url = pconfig.inference_base_url - elif provider_idx == 8: # Anthropic + elif provider_idx == 8: # Kilo Code + selected_provider = "kilocode" + print() + print_header("Kilo Code API Key") + pconfig = PROVIDER_REGISTRY["kilocode"] + print_info(f"Provider: {pconfig.name}") + print_info(f"Base URL: {pconfig.inference_base_url}") + print_info("Get your API key at: https://kilo.ai") + print() + + existing_key = get_env_value("KILOCODE_API_KEY") + if existing_key: + print_info(f"Current: {existing_key[:8]}... (configured)") + if prompt_yes_no("Update API key?", False): + api_key = prompt(" Kilo Code API key", password=True) + if api_key: + save_env_value("KILOCODE_API_KEY", api_key) + print_success("Kilo Code API key updated") + else: + api_key = prompt(" Kilo Code API key", password=True) + if api_key: + save_env_value("KILOCODE_API_KEY", api_key) + print_success("Kilo Code API key saved") + else: + print_warning("Skipped - agent won't work without an API key") + + # Clear custom endpoint vars if switching + if existing_custom: + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + _set_model_provider(config, "kilocode", pconfig.inference_base_url) + selected_base_url = pconfig.inference_base_url + + elif provider_idx == 9: # Anthropic selected_provider = "anthropic" print() print_header("Anthropic Authentication") @@ -1246,7 +1281,7 @@ def setup_model_provider(config: dict): _set_model_provider(config, "anthropic") selected_base_url = "" - elif provider_idx == 9: # AI Gateway + elif provider_idx == 10: # AI Gateway selected_provider = "ai-gateway" print() print_header("AI Gateway API Key") @@ -1278,7 +1313,7 @@ def setup_model_provider(config: dict): _update_config_for_provider("ai-gateway", pconfig.inference_base_url, default_model="anthropic/claude-opus-4.6") _set_model_provider(config, "ai-gateway", pconfig.inference_base_url) - elif provider_idx == 10: # OpenCode Zen + elif provider_idx == 11: # OpenCode Zen selected_provider = "opencode-zen" print() print_header("OpenCode Zen API Key") @@ -1311,7 +1346,7 @@ def setup_model_provider(config: dict): _set_model_provider(config, "opencode-zen", pconfig.inference_base_url) selected_base_url = pconfig.inference_base_url - elif provider_idx == 11: # OpenCode Go + elif provider_idx == 12: # OpenCode Go selected_provider = "opencode-go" print() print_header("OpenCode Go API Key") @@ -1344,7 +1379,7 @@ def setup_model_provider(config: dict): _set_model_provider(config, "opencode-go", pconfig.inference_base_url) selected_base_url = pconfig.inference_base_url - # else: provider_idx == 12 (Keep current) — only shown when a provider already exists + # else: provider_idx == 13 (Keep current) — only shown when a provider already exists # Normalize "keep current" to an explicit provider so downstream logic # doesn't fall back to the generic OpenRouter/static-model path. if selected_provider is None: @@ -1515,7 +1550,7 @@ def setup_model_provider(config: dict): _set_default_model(config, custom) _update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL) _set_model_provider(config, "openai-codex", DEFAULT_CODEX_BASE_URL) - elif selected_provider in ("zai", "kimi-coding", "minimax", "minimax-cn", "ai-gateway"): + elif selected_provider in ("zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "ai-gateway"): _setup_provider_model_selection( config, selected_provider, current_model, prompt_choice, prompt, @@ -1576,7 +1611,7 @@ def setup_model_provider(config: dict): # Write provider+base_url to config.yaml only after model selection is complete. # This prevents a race condition where the gateway picks up a new provider # before the model name has been updated to match. - if selected_provider in ("zai", "kimi-coding", "minimax", "minimax-cn", "anthropic") and selected_base_url is not None: + if selected_provider in ("zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic") and selected_base_url is not None: _update_config_for_provider(selected_provider, selected_base_url) save_config(config) diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py index daf0ce68..2ef01252 100644 --- a/tests/hermes_cli/test_setup_model_provider.py +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -187,7 +187,7 @@ def test_setup_keep_current_anthropic_can_configure_openai_vision_default(tmp_pa save_config(config) picks = iter([ - 9, # keep current provider + 10, # keep current provider (shifted +1 by kilocode insertion) 1, # configure vision with OpenAI 5, # use default gpt-4o-mini vision model 4, # keep current Anthropic model diff --git a/tests/test_api_key_providers.py b/tests/test_api_key_providers.py index 3ff377fb..deb55734 100644 --- a/tests/test_api_key_providers.py +++ b/tests/test_api_key_providers.py @@ -38,6 +38,7 @@ class TestProviderRegistry: ("minimax", "MiniMax", "api_key"), ("minimax-cn", "MiniMax (China)", "api_key"), ("ai-gateway", "AI Gateway", "api_key"), + ("kilocode", "Kilo Code", "api_key"), ]) def test_provider_registered(self, provider_id, name, auth_type): assert provider_id in PROVIDER_REGISTRY @@ -71,12 +72,18 @@ class TestProviderRegistry: assert pconfig.api_key_env_vars == ("AI_GATEWAY_API_KEY",) assert pconfig.base_url_env_var == "AI_GATEWAY_BASE_URL" + def test_kilocode_env_vars(self): + pconfig = PROVIDER_REGISTRY["kilocode"] + assert pconfig.api_key_env_vars == ("KILOCODE_API_KEY",) + assert pconfig.base_url_env_var == "KILOCODE_BASE_URL" + def test_base_urls(self): 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["minimax"].inference_base_url == "https://api.minimax.io/v1" assert PROVIDER_REGISTRY["minimax-cn"].inference_base_url == "https://api.minimaxi.com/v1" assert PROVIDER_REGISTRY["ai-gateway"].inference_base_url == "https://ai-gateway.vercel.sh/v1" + assert PROVIDER_REGISTRY["kilocode"].inference_base_url == "https://api.kilo.ai/api/gateway" def test_oauth_providers_unchanged(self): """Ensure we didn't break the existing OAuth providers.""" @@ -95,6 +102,7 @@ PROVIDER_ENV_VARS = ( "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", "KIMI_API_KEY", "KIMI_BASE_URL", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL", + "KILOCODE_API_KEY", "KILOCODE_BASE_URL", "OPENAI_BASE_URL", ) @@ -147,6 +155,18 @@ class TestResolveProvider: def test_alias_vercel(self): assert resolve_provider("vercel") == "ai-gateway" + def test_explicit_kilocode(self): + assert resolve_provider("kilocode") == "kilocode" + + def test_alias_kilo(self): + assert resolve_provider("kilo") == "kilocode" + + def test_alias_kilo_code(self): + assert resolve_provider("kilo-code") == "kilocode" + + def test_alias_kilo_gateway(self): + assert resolve_provider("kilo-gateway") == "kilocode" + def test_alias_case_insensitive(self): assert resolve_provider("GLM") == "zai" assert resolve_provider("Z-AI") == "zai" @@ -184,6 +204,10 @@ class TestResolveProvider: monkeypatch.setenv("AI_GATEWAY_API_KEY", "test-gw-key") assert resolve_provider("auto") == "ai-gateway" + def test_auto_detects_kilocode_key(self, monkeypatch): + monkeypatch.setenv("KILOCODE_API_KEY", "test-kilo-key") + assert resolve_provider("auto") == "kilocode" + def test_openrouter_takes_priority_over_glm(self, monkeypatch): """OpenRouter API key should win over GLM in auto-detection.""" monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") @@ -276,6 +300,19 @@ class TestResolveApiKeyProviderCredentials: assert creds["api_key"] == "gw-secret-key" assert creds["base_url"] == "https://ai-gateway.vercel.sh/v1" + def test_resolve_kilocode_with_key(self, monkeypatch): + monkeypatch.setenv("KILOCODE_API_KEY", "kilo-secret-key") + creds = resolve_api_key_provider_credentials("kilocode") + assert creds["provider"] == "kilocode" + assert creds["api_key"] == "kilo-secret-key" + assert creds["base_url"] == "https://api.kilo.ai/api/gateway" + + def test_resolve_kilocode_custom_base_url(self, monkeypatch): + monkeypatch.setenv("KILOCODE_API_KEY", "kilo-key") + monkeypatch.setenv("KILOCODE_BASE_URL", "https://custom.kilo.example/v1") + creds = resolve_api_key_provider_credentials("kilocode") + assert creds["base_url"] == "https://custom.kilo.example/v1" + def test_resolve_with_custom_base_url(self, monkeypatch): monkeypatch.setenv("GLM_API_KEY", "glm-key") monkeypatch.setenv("GLM_BASE_URL", "https://custom.glm.example/v4") @@ -346,6 +383,15 @@ class TestRuntimeProviderResolution: assert result["api_key"] == "gw-key" assert "ai-gateway.vercel.sh" in result["base_url"] + def test_runtime_kilocode(self, monkeypatch): + monkeypatch.setenv("KILOCODE_API_KEY", "kilo-key") + from hermes_cli.runtime_provider import resolve_runtime_provider + result = resolve_runtime_provider(requested="kilocode") + assert result["provider"] == "kilocode" + assert result["api_mode"] == "chat_completions" + assert result["api_key"] == "kilo-key" + assert "kilo.ai" in result["base_url"] + def test_runtime_auto_detects_api_key_provider(self, monkeypatch): monkeypatch.setenv("KIMI_API_KEY", "auto-kimi-key") from hermes_cli.runtime_provider import resolve_runtime_provider diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 07742c3d..0b5afa4b 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -28,6 +28,8 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | `MINIMAX_BASE_URL` | Override MiniMax base URL (default: `https://api.minimax.io/v1`) | | `MINIMAX_CN_API_KEY` | MiniMax API key — China endpoint ([minimaxi.com](https://www.minimaxi.com)) | | `MINIMAX_CN_BASE_URL` | Override MiniMax China base URL (default: `https://api.minimaxi.com/v1`) | +| `KILOCODE_API_KEY` | Kilo Code API key ([kilo.ai](https://kilo.ai)) | +| `KILOCODE_BASE_URL` | Override Kilo Code base URL (default: `https://api.kilo.ai/api/gateway`) | | `ANTHROPIC_API_KEY` | Anthropic Console API key ([console.anthropic.com](https://console.anthropic.com/)) | | `ANTHROPIC_TOKEN` | Manual or legacy Anthropic OAuth/setup-token override | | `CLAUDE_CODE_OAUTH_TOKEN` | Explicit Claude Code token override if you export one manually | @@ -44,7 +46,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | Variable | Description | |----------|-------------| -| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `openai-codex`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn` (default: `auto`) | +| `HERMES_INFERENCE_PROVIDER` | Override provider selection: `auto`, `openrouter`, `nous`, `openai-codex`, `anthropic`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`, `kilocode` (default: `auto`) | | `HERMES_PORTAL_BASE_URL` | Override Nous Portal URL (for development/testing) | | `NOUS_INFERENCE_BASE_URL` | Override Nous inference API URL | | `HERMES_NOUS_MIN_KEY_TTL_SECONDS` | Min agent key TTL before re-mint (default: 1800 = 30min) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index f9a2198f..f584b101 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -70,6 +70,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro | **Kimi / Moonshot** | `KIMI_API_KEY` in `~/.hermes/.env` (provider: `kimi-coding`) | | **MiniMax** | `MINIMAX_API_KEY` in `~/.hermes/.env` (provider: `minimax`) | | **MiniMax China** | `MINIMAX_CN_API_KEY` in `~/.hermes/.env` (provider: `minimax-cn`) | +| **Kilo Code** | `KILOCODE_API_KEY` in `~/.hermes/.env` (provider: `kilocode`) | | **Custom Endpoint** | `hermes model` (saved in `config.yaml`) or `OPENAI_BASE_URL` + `OPENAI_API_KEY` in `~/.hermes/.env` | :::info Codex Note diff --git a/website/docs/user-guide/features/fallback-providers.md b/website/docs/user-guide/features/fallback-providers.md index 5df658e8..e488c10d 100644 --- a/website/docs/user-guide/features/fallback-providers.md +++ b/website/docs/user-guide/features/fallback-providers.md @@ -43,6 +43,7 @@ Both `provider` and `model` are **required**. If either is missing, the fallback | Kimi / Moonshot | `kimi-coding` | `KIMI_API_KEY` | | MiniMax | `minimax` | `MINIMAX_API_KEY` | | MiniMax (China) | `minimax-cn` | `MINIMAX_CN_API_KEY` | +| Kilo Code | `kilocode` | `KILOCODE_API_KEY` | | Custom endpoint | `custom` | `base_url` + `api_key_env` (see below) | ### Custom Endpoint Fallback