Compare commits

...

8 Commits

Author SHA1 Message Date
RichardTang-Aden fd79dceb0f Merge pull request #6166 from aden-hive/fix/subagent-reply-stall
Release / Create Release (push) Waiting to run
micro-fix: update escalation tests for new ESCALATION_REQUESTED flow
2026-03-10 19:47:00 -07:00
Richard Tang ad50139d67 chore: lint 2026-03-10 19:46:35 -07:00
Richard Tang 12fb40c110 test: update escalation tests for ESCALATION_REQUESTED flow
Tests were asserting the old CLIENT_OUTPUT_DELTA + CLIENT_INPUT_REQUESTED
pattern; the fix in 89ccd66f routes escalations through the queen via
ESCALATION_REQUESTED instead.
2026-03-10 19:45:21 -07:00
RichardTang-Aden 738e469d96 Merge pull request #6165 from aden-hive/feature/provider-moonshotai-kimi
feat: support MoonShot AI Kimi subscription
2026-03-10 19:39:25 -07:00
Timothy 80ccbcc827 chore: lint 2026-03-10 19:37:18 -07:00
RichardTang-Aden 08fac31a9d Merge pull request #6159 from aden-hive/fix/subagent-reply-stall
fix: route subagent report_to_parent escalations to queen instead of user
2026-03-10 18:24:33 -07:00
Richard Tang 89ccd66fb9 fix: subagent _EscalationReceiver 2026-03-10 18:21:50 -07:00
Timothy 7c47e367de feat: support moonshotai kimi subscription 2026-03-10 18:03:44 -07:00
11 changed files with 362 additions and 102 deletions
+14
View File
@@ -90,6 +90,17 @@ def get_api_key() -> str | None:
except ImportError:
pass
# Kimi Code subscription: read API key from ~/.kimi/config.toml
if llm.get("use_kimi_code_subscription"):
try:
from framework.runner.runner import get_kimi_code_token
token = get_kimi_code_token()
if token:
return token
except ImportError:
pass
# Standard env-var path (covers ZAI Code and all API-key providers)
api_key_env_var = llm.get("api_key_env_var")
if api_key_env_var:
@@ -108,6 +119,9 @@ def get_api_base() -> str | None:
if llm.get("use_codex_subscription"):
# Codex subscription routes through the ChatGPT backend, not api.openai.com.
return "https://chatgpt.com/backend-api/codex"
if llm.get("use_kimi_code_subscription"):
# Kimi Code uses an Anthropic-compatible endpoint (no /v1 suffix).
return "https://api.kimi.com/coding"
return llm.get("api_base")
+10 -13
View File
@@ -73,6 +73,7 @@ class _EscalationReceiver:
def __init__(self) -> None:
self._event = asyncio.Event()
self._response: str | None = None
self._awaiting_input = True # So inject_worker_message() can prefer us
async def inject_event(self, content: str, *, is_client_input: bool = False) -> None:
"""Called by ExecutionStream.inject_input() when the user responds."""
@@ -4323,22 +4324,18 @@ class EventLoopNode(NodeProtocol):
registry[escalation_id] = receiver
try:
# Stream message to user (parent's node_id so TUI shows parent talking)
await self._event_bus.emit_client_output_delta(
stream_id=ctx.node_id,
node_id=ctx.node_id,
content=message,
snapshot=message,
execution_id=ctx.execution_id,
)
# Request input (escalation_id for routing response back)
await self._event_bus.emit_client_input_requested(
stream_id=ctx.node_id,
# Escalate to the queen instead of asking the user directly.
# The queen handles the request and injects the response via
# inject_worker_message(), which finds this receiver through
# its _awaiting_input flag.
await self._event_bus.emit_escalation_requested(
stream_id=ctx.stream_id or ctx.node_id,
node_id=escalation_id,
prompt=message,
reason=f"Subagent report (wait_for_response) from {agent_id}",
context=message,
execution_id=ctx.execution_id,
)
# Block until user responds
# Block until queen responds
return await receiver.wait()
finally:
registry.pop(escalation_id, None)
+19 -1
View File
@@ -118,6 +118,10 @@ RATE_LIMIT_MAX_RETRIES = 10
RATE_LIMIT_BACKOFF_BASE = 2 # seconds
RATE_LIMIT_MAX_DELAY = 120 # seconds - cap to prevent absurd waits
MINIMAX_API_BASE = "https://api.minimax.io/v1"
# Kimi For Coding uses an Anthropic-compatible endpoint (no /v1 suffix).
# Claude Code integration uses this format; the /v1 OpenAI-compatible endpoint
# enforces a coding-agent whitelist that blocks unknown User-Agents.
KIMI_API_BASE = "https://api.kimi.com/coding"
# Empty-stream retries use a short fixed delay, not the rate-limit backoff.
# Conversation-structure issues are deterministic — long waits don't help.
@@ -323,9 +327,21 @@ class LiteLLMProvider(LLMProvider):
api_base: Custom API base URL (for proxies or local deployments)
**kwargs: Additional arguments passed to litellm.completion()
"""
# Kimi For Coding exposes an Anthropic-compatible endpoint at
# https://api.kimi.com/coding (the same format Claude Code uses natively).
# Translate kimi/ prefix to anthropic/ so litellm uses the Anthropic
# Messages API handler and routes to that endpoint — no special headers needed.
_original_model = model
if model.lower().startswith("kimi/"):
model = "anthropic/" + model[len("kimi/") :]
# Normalise api_base: litellm's Anthropic handler appends /v1/messages,
# so the base must be https://api.kimi.com/coding (no /v1 suffix).
# Strip a trailing /v1 in case the user's saved config has the old value.
if api_base and api_base.rstrip("/").endswith("/v1"):
api_base = api_base.rstrip("/")[:-3]
self.model = model
self.api_key = api_key
self.api_base = api_base or self._default_api_base_for_model(model)
self.api_base = api_base or self._default_api_base_for_model(_original_model)
self.extra_kwargs = kwargs
# The Codex ChatGPT backend (chatgpt.com/backend-api/codex) rejects
# several standard OpenAI params: max_output_tokens, stream_options.
@@ -350,6 +366,8 @@ class LiteLLMProvider(LLMProvider):
model_lower = model.lower()
if model_lower.startswith("minimax/") or model_lower.startswith("minimax-"):
return MINIMAX_API_BASE
if model_lower.startswith("kimi/"):
return KIMI_API_BASE
return None
def _completion_with_rate_limit_retry(
+54
View File
@@ -517,6 +517,41 @@ def get_codex_account_id() -> str | None:
return None
# ---------------------------------------------------------------------------
# Kimi Code subscription token helpers
# ---------------------------------------------------------------------------
def get_kimi_code_token() -> str | None:
"""Get the API key from a Kimi Code CLI installation.
Reads the API key from ``~/.kimi/config.toml``, which is created when
the user runs ``kimi /login`` in the Kimi Code CLI.
Returns:
The API key if available, None otherwise.
"""
import tomllib
config_path = Path.home() / ".kimi" / "config.toml"
if not config_path.exists():
return None
try:
with open(config_path, "rb") as f:
config = tomllib.load(f)
providers = config.get("providers", {})
# kimi-cli stores credentials under providers.kimi-for-coding
for provider_cfg in providers.values():
if isinstance(provider_cfg, dict):
key = provider_cfg.get("api_key")
if key:
return key
except Exception:
pass
return None
@dataclass
class AgentInfo:
"""Information about an exported agent."""
@@ -1104,6 +1139,7 @@ class AgentRunner:
llm_config = config.get("llm", {})
use_claude_code = llm_config.get("use_claude_code_subscription", False)
use_codex = llm_config.get("use_codex_subscription", False)
use_kimi_code = llm_config.get("use_kimi_code_subscription", False)
api_base = llm_config.get("api_base")
api_key = None
@@ -1119,6 +1155,12 @@ class AgentRunner:
if not api_key:
print("Warning: Codex subscription configured but no token found.")
print("Run 'codex' to authenticate, then try again.")
elif use_kimi_code:
# Get API key from Kimi Code CLI config (~/.kimi/config.toml)
api_key = get_kimi_code_token()
if not api_key:
print("Warning: Kimi Code subscription configured but no key found.")
print("Run 'kimi /login' to authenticate, then try again.")
if api_key and use_claude_code:
# Use litellm's built-in Anthropic OAuth support.
@@ -1149,6 +1191,14 @@ class AgentRunner:
store=False,
allowed_openai_params=["store"],
)
elif api_key and use_kimi_code:
# Kimi Code subscription uses the Kimi coding API (OpenAI-compatible).
# The api_base is set automatically by LiteLLMProvider for kimi/ models.
self._llm = LiteLLMProvider(
model=self.model,
api_key=api_key,
api_base=api_base,
)
else:
# Local models (e.g. Ollama) don't need an API key
if self._is_local_model(self.model):
@@ -1314,6 +1364,8 @@ class AgentRunner:
return "TOGETHER_API_KEY"
elif model_lower.startswith("minimax/") or model_lower.startswith("minimax-"):
return "MINIMAX_API_KEY"
elif model_lower.startswith("kimi/"):
return "KIMI_API_KEY"
else:
# Default: assume OpenAI-compatible
return "OPENAI_API_KEY"
@@ -1334,6 +1386,8 @@ class AgentRunner:
cred_id = "anthropic"
elif model_lower.startswith("minimax/") or model_lower.startswith("minimax-"):
cred_id = "minimax"
elif model_lower.startswith("kimi/"):
cred_id = "kimi"
# Add more mappings as providers are added to LLM_CREDENTIALS
if cred_id is None:
+5
View File
@@ -1531,6 +1531,11 @@ class AgentRuntime:
for executor in stream._active_executors.values():
for node_id, node in executor.node_registry.items():
if getattr(node, "_awaiting_input", False):
# Skip escalation receivers — those are handled
# by the queen via inject_worker_message(), not
# by the user directly.
if ":escalation:" in node_id:
continue
return node_id, graph_id
return None, None
+17 -1
View File
@@ -1452,7 +1452,23 @@ def register_queen_lifecycle_tools(
if reg is None:
return json.dumps({"error": "Worker graph not found"})
# Find an active node that can accept injected input
# Prefer nodes that are actively waiting (e.g. escalation receivers
# blocked on queen guidance) over the main event-loop node.
for stream in reg.streams.values():
waiting = stream.get_waiting_nodes()
if waiting:
target_node_id = waiting[0]["node_id"]
ok = await stream.inject_input(target_node_id, content, is_client_input=True)
if ok:
return json.dumps(
{
"status": "delivered",
"node_id": target_node_id,
"content_preview": content[:100],
}
)
# Fallback: inject into any injectable node
for stream in reg.streams.values():
injectable = stream.get_injectable_nodes()
if injectable:
+8 -12
View File
@@ -970,13 +970,13 @@ class TestEscalationFlow:
)
@pytest.mark.asyncio
async def test_wait_for_response_emits_client_events(
async def test_wait_for_response_emits_escalation_event(
self,
runtime,
parent_node_spec,
subagent_node_spec,
):
"""Escalation should emit CLIENT_OUTPUT_DELTA and CLIENT_INPUT_REQUESTED events."""
"""Escalation should emit ESCALATION_REQUESTED to the queen."""
from framework.graph.event_loop_node import _EscalationReceiver
bus = EventBus()
@@ -986,7 +986,7 @@ class TestEscalationFlow:
bus_events.append(event)
bus.subscribe(
event_types=[EventType.CLIENT_OUTPUT_DELTA, EventType.CLIENT_INPUT_REQUESTED],
event_types=[EventType.ESCALATION_REQUESTED],
handler=handler,
)
@@ -1034,16 +1034,12 @@ class TestEscalationFlow:
await node._execute_subagent(ctx, "researcher", "Navigate page with CAPTCHA")
await injector
# Should have emitted both events
output_deltas = [e for e in bus_events if e.type == EventType.CLIENT_OUTPUT_DELTA]
input_requests = [e for e in bus_events if e.type == EventType.CLIENT_INPUT_REQUESTED]
# Should have emitted ESCALATION_REQUESTED
escalation_events = [e for e in bus_events if e.type == EventType.ESCALATION_REQUESTED]
assert len(output_deltas) >= 1, "Should emit CLIENT_OUTPUT_DELTA with the message"
assert output_deltas[0].data["content"] == "CAPTCHA detected on page"
assert output_deltas[0].node_id == "parent" # Shows as parent talking
assert len(input_requests) >= 1, "Should emit CLIENT_INPUT_REQUESTED for routing"
assert ":escalation:" in input_requests[0].node_id # Escalation ID for routing
assert len(escalation_events) >= 1, "Should emit ESCALATION_REQUESTED"
assert escalation_events[0].data["context"] == "CAPTCHA detected on page"
assert ":escalation:" in escalation_events[0].node_id
@pytest.mark.asyncio
async def test_non_blocking_report_still_works(
+30 -34
View File
@@ -3,9 +3,8 @@
Tests the FULL routing chain:
ExecutionStream GraphExecutor EventLoopNode _execute_subagent
_report_callback registers _EscalationReceiver in executor.node_registry
emit CLIENT_INPUT_REQUESTED with escalation_id
subscriber calls stream.inject_input(escalation_id, "done")
ExecutionStream finds _EscalationReceiver in executor.node_registry
emit ESCALATION_REQUESTED (queen handles the escalation)
queen inject_worker_message() finds _EscalationReceiver via get_waiting_nodes()
receiver.inject_event("done") unblocks the subagent
subagent continues and completes
"""
@@ -227,26 +226,30 @@ async def test_escalation_e2e_through_execution_stream(tmp_path):
stream_holder: list[ExecutionStream] = []
async def escalation_handler(event: AgentEvent):
"""Simulate a TUI/runner: when CLIENT_INPUT_REQUESTED arrives with
an escalation node_id, inject the user's response via the stream."""
"""Simulate the queen: when ESCALATION_REQUESTED arrives,
find the waiting receiver and inject the response via the stream."""
all_events.append(event)
if event.type == EventType.CLIENT_INPUT_REQUESTED:
node_id = event.node_id
if ":escalation:" in node_id:
escalation_events.append(event)
# Small delay to simulate user typing
await asyncio.sleep(0.05)
# Route through the REAL inject_input chain
stream = stream_holder[0]
success = await stream.inject_input(node_id, "done logging in")
assert success, (
f"inject_input({node_id!r}) returned False — "
"escalation receiver not found in executor.node_registry"
)
inject_called.set()
if event.type == EventType.ESCALATION_REQUESTED:
escalation_events.append(event)
# Small delay to simulate queen processing
await asyncio.sleep(0.05)
# Route through the REAL inject_input chain — find the waiting
# escalation receiver via get_waiting_nodes() (mirrors what
# inject_worker_message does in the queen lifecycle tools).
stream = stream_holder[0]
waiting = stream.get_waiting_nodes()
assert waiting, "Should have a waiting escalation receiver"
target_node_id = waiting[0]["node_id"]
assert ":escalation:" in target_node_id
success = await stream.inject_input(target_node_id, "done logging in")
assert success, (
f"inject_input({target_node_id!r}) returned False — "
"escalation receiver not found in executor.node_registry"
)
inject_called.set()
bus.subscribe(
event_types=[EventType.CLIENT_INPUT_REQUESTED, EventType.CLIENT_OUTPUT_DELTA],
event_types=[EventType.ESCALATION_REQUESTED],
handler=escalation_handler,
)
@@ -297,17 +300,7 @@ async def test_escalation_e2e_through_execution_stream(tmp_path):
# 3. Escalation event has correct structure
esc_event = escalation_events[0]
assert ":escalation:" in esc_event.node_id
assert esc_event.data["prompt"] == "Login required for LinkedIn. Please log in manually."
# 4. CLIENT_OUTPUT_DELTA was emitted for the escalation message
output_deltas = [
e
for e in all_events
if e.type == EventType.CLIENT_OUTPUT_DELTA and "Login required" in e.data.get("content", "")
]
assert len(output_deltas) >= 1, (
"Should have emitted CLIENT_OUTPUT_DELTA with escalation message"
)
assert esc_event.data["context"] == "Login required for LinkedIn. Please log in manually."
# 5. The parent node got the subagent's result
assert "result" in result.output
@@ -444,7 +437,7 @@ async def test_escalation_cleanup_after_completion(tmp_path):
stream_holder: list[ExecutionStream] = []
async def auto_respond(event: AgentEvent):
if event.type == EventType.CLIENT_INPUT_REQUESTED and ":escalation:" in event.node_id:
if event.type == EventType.ESCALATION_REQUESTED:
stream = stream_holder[0]
# Snapshot the active executor's node_registry BEFORE responding
@@ -462,10 +455,13 @@ async def test_escalation_cleanup_after_completion(tmp_path):
)
await asyncio.sleep(0.02)
await stream.inject_input(event.node_id, "ok")
# Find the waiting escalation receiver and inject response
waiting = stream.get_waiting_nodes()
if waiting:
await stream.inject_input(waiting[0]["node_id"], "ok")
bus.subscribe(
event_types=[EventType.CLIENT_INPUT_REQUESTED],
event_types=[EventType.ESCALATION_REQUESTED],
handler=auto_respond,
)
+112 -15
View File
@@ -911,6 +911,13 @@ $zaiKey = [System.Environment]::GetEnvironmentVariable("ZAI_API_KEY", "User")
if (-not $zaiKey) { $zaiKey = $env:ZAI_API_KEY }
if ($zaiKey) { $ZaiCredDetected = $true }
$KimiCredDetected = $false
$kimiConfigPath = Join-Path $env:USERPROFILE ".kimi\config.toml"
if (Test-Path $kimiConfigPath) { $KimiCredDetected = $true }
$kimiKey = [System.Environment]::GetEnvironmentVariable("KIMI_API_KEY", "User")
if (-not $kimiKey) { $kimiKey = $env:KIMI_API_KEY }
if ($kimiKey) { $KimiCredDetected = $true }
# Detect API key providers
$ProviderMenuEnvVars = @("ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY", "CEREBRAS_API_KEY")
$ProviderMenuNames = @("Anthropic (Claude) - Recommended", "OpenAI (GPT)", "Google Gemini - Free tier available", "Groq - Fast, free tier", "Cerebras - Fast, free tier")
@@ -938,7 +945,9 @@ if (Test-Path $HiveConfigFile) {
$PrevEnvVar = if ($prevLlm.api_key_env_var) { $prevLlm.api_key_env_var } else { "" }
if ($prevLlm.use_claude_code_subscription) { $PrevSubMode = "claude_code" }
elseif ($prevLlm.use_codex_subscription) { $PrevSubMode = "codex" }
elseif ($prevLlm.use_kimi_code_subscription) { $PrevSubMode = "kimi_code" }
elseif ($prevLlm.api_base -and $prevLlm.api_base -like "*api.z.ai*") { $PrevSubMode = "zai_code" }
elseif ($prevLlm.api_base -and $prevLlm.api_base -like "*api.kimi.com*") { $PrevSubMode = "kimi_code" }
}
} catch { }
}
@@ -951,6 +960,7 @@ if ($PrevSubMode -or $PrevProvider) {
"claude_code" { if ($ClaudeCredDetected) { $prevCredValid = $true } }
"zai_code" { if ($ZaiCredDetected) { $prevCredValid = $true } }
"codex" { if ($CodexCredDetected) { $prevCredValid = $true } }
"kimi_code" { if ($KimiCredDetected) { $prevCredValid = $true } }
default {
if ($PrevEnvVar) {
$envVal = [System.Environment]::GetEnvironmentVariable($PrevEnvVar, "Process")
@@ -964,14 +974,16 @@ if ($PrevSubMode -or $PrevProvider) {
"claude_code" { $DefaultChoice = "1" }
"zai_code" { $DefaultChoice = "2" }
"codex" { $DefaultChoice = "3" }
"kimi_code" { $DefaultChoice = "4" }
}
if (-not $DefaultChoice) {
switch ($PrevProvider) {
"anthropic" { $DefaultChoice = "4" }
"openai" { $DefaultChoice = "5" }
"gemini" { $DefaultChoice = "6" }
"groq" { $DefaultChoice = "7" }
"cerebras" { $DefaultChoice = "8" }
"anthropic" { $DefaultChoice = "5" }
"openai" { $DefaultChoice = "6" }
"gemini" { $DefaultChoice = "7" }
"groq" { $DefaultChoice = "8" }
"cerebras" { $DefaultChoice = "9" }
"kimi" { $DefaultChoice = "4" }
}
}
}
@@ -1003,12 +1015,19 @@ Write-Host ") OpenAI Codex Subscription " -NoNewline
Write-Color -Text "(use your Codex/ChatGPT Plus plan)" -Color DarkGray -NoNewline
if ($CodexCredDetected) { Write-Color -Text " (credential detected)" -Color Green } else { Write-Host "" }
# 4) Kimi Code
Write-Host " " -NoNewline
Write-Color -Text "4" -Color Cyan -NoNewline
Write-Host ") Kimi Code Subscription " -NoNewline
Write-Color -Text "(use your Kimi Code plan)" -Color DarkGray -NoNewline
if ($KimiCredDetected) { Write-Color -Text " (credential detected)" -Color Green } else { Write-Host "" }
Write-Host ""
Write-Color -Text " API key providers:" -Color Cyan
# 4-8) API key providers
# 5-9) API key providers
for ($idx = 0; $idx -lt $ProviderMenuEnvVars.Count; $idx++) {
$num = $idx + 4
$num = $idx + 5
$envVal = [System.Environment]::GetEnvironmentVariable($ProviderMenuEnvVars[$idx], "Process")
if (-not $envVal) { $envVal = [System.Environment]::GetEnvironmentVariable($ProviderMenuEnvVars[$idx], "User") }
Write-Host " " -NoNewline
@@ -1018,7 +1037,7 @@ for ($idx = 0; $idx -lt $ProviderMenuEnvVars.Count; $idx++) {
}
Write-Host " " -NoNewline
Write-Color -Text "9" -Color Cyan -NoNewline
Write-Color -Text "10" -Color Cyan -NoNewline
Write-Host ") Skip for now"
Write-Host ""
@@ -1029,16 +1048,16 @@ if ($DefaultChoice) {
while ($true) {
if ($DefaultChoice) {
$raw = Read-Host "Enter choice (1-9) [$DefaultChoice]"
$raw = Read-Host "Enter choice (1-10) [$DefaultChoice]"
if ([string]::IsNullOrWhiteSpace($raw)) { $raw = $DefaultChoice }
} else {
$raw = Read-Host "Enter choice (1-9)"
$raw = Read-Host "Enter choice (1-10)"
}
if ($raw -match '^\d+$') {
$num = [int]$raw
if ($num -ge 1 -and $num -le 9) { break }
if ($num -ge 1 -and $num -le 10) { break }
}
Write-Color -Text "Invalid choice. Please enter 1-9" -Color Red
Write-Color -Text "Invalid choice. Please enter 1-10" -Color Red
}
switch ($num) {
@@ -1102,9 +1121,20 @@ switch ($num) {
Write-Ok "Using OpenAI Codex subscription"
}
}
{ $_ -ge 4 -and $_ -le 8 } {
4 {
# Kimi Code Subscription
$SubscriptionMode = "kimi_code"
$SelectedProviderId = "kimi"
$SelectedEnvVar = "KIMI_API_KEY"
$SelectedModel = "kimi-k2.5"
$SelectedMaxTokens = 32768
Write-Host ""
Write-Ok "Using Kimi Code subscription"
Write-Color -Text " Model: kimi-k2.5 | API: api.kimi.com/coding" -Color DarkGray
}
{ $_ -ge 5 -and $_ -le 9 } {
# API key providers
$provIdx = $num - 4
$provIdx = $num - 5
$SelectedEnvVar = $ProviderMenuEnvVars[$provIdx]
$SelectedProviderId = $ProviderMenuIds[$provIdx]
$providerName = $ProviderMenuNames[$provIdx] -replace ' - .*', '' # strip description
@@ -1175,7 +1205,7 @@ switch ($num) {
}
}
}
9 {
10 {
Write-Host ""
Write-Warn "Skipped. An LLM API key is required to test and use worker agents."
Write-Host " Add your API key later by running:"
@@ -1252,6 +1282,70 @@ if ($SubscriptionMode -eq "zai_code") {
}
}
# For Kimi Code subscription: prompt for API key with verification + retry
if ($SubscriptionMode -eq "kimi_code") {
while ($true) {
$existingKimi = [System.Environment]::GetEnvironmentVariable("KIMI_API_KEY", "User")
if (-not $existingKimi) { $existingKimi = $env:KIMI_API_KEY }
if ($existingKimi) {
$masked = $existingKimi.Substring(0, [Math]::Min(4, $existingKimi.Length)) + "..." + $existingKimi.Substring([Math]::Max(0, $existingKimi.Length - 4))
Write-Host ""
Write-Color -Text " $([char]0x2B22) Current Kimi key: $masked" -Color Green
$apiKey = Read-Host " Press Enter to keep, or paste a new key to replace"
} else {
Write-Host ""
Write-Host "Get your API key from: " -NoNewline
Write-Color -Text "https://www.kimi.com/code" -Color Cyan
Write-Host ""
$apiKey = Read-Host "Paste your Kimi API key (or press Enter to skip)"
}
if ($apiKey) {
[System.Environment]::SetEnvironmentVariable("KIMI_API_KEY", $apiKey, "User")
$env:KIMI_API_KEY = $apiKey
Write-Host ""
Write-Ok "Kimi API key saved as User environment variable"
# Health check the new key
Write-Host " Verifying Kimi API key... " -NoNewline
try {
$hcResult = & uv run python (Join-Path $ScriptDir "scripts/check_llm_key.py") "kimi" $apiKey "https://api.kimi.com/coding" 2>$null
$hcJson = $hcResult | ConvertFrom-Json
if ($hcJson.valid -eq $true) {
Write-Color -Text "ok" -Color Green
break
} elseif ($hcJson.valid -eq $false) {
Write-Color -Text "failed" -Color Red
Write-Warn $hcJson.message
[System.Environment]::SetEnvironmentVariable("KIMI_API_KEY", $null, "User")
Remove-Item -Path "Env:\KIMI_API_KEY" -ErrorAction SilentlyContinue
Write-Host ""
Read-Host " Press Enter to try again"
} else {
Write-Color -Text "--" -Color Yellow
Write-Color -Text " Could not verify key (network issue). The key has been saved." -Color DarkGray
break
}
} catch {
Write-Color -Text "--" -Color Yellow
Write-Color -Text " Could not verify key (network issue). The key has been saved." -Color DarkGray
break
}
} elseif (-not $existingKimi) {
Write-Host ""
Write-Warn "Skipped. Add your Kimi API key later:"
Write-Color -Text " [System.Environment]::SetEnvironmentVariable('KIMI_API_KEY', 'your-key', 'User')" -Color Cyan
$SelectedEnvVar = ""
$SelectedProviderId = ""
$SubscriptionMode = ""
break
} else {
break
}
}
}
# Prompt for model if not already selected (manual provider path)
if ($SelectedProviderId -and -not $SelectedModel) {
$modelSel = Get-ModelSelection $SelectedProviderId
@@ -1287,6 +1381,9 @@ if ($SelectedProviderId) {
} elseif ($SubscriptionMode -eq "zai_code") {
$config.llm["api_base"] = "https://api.z.ai/api/coding/paas/v4"
$config.llm["api_key_env_var"] = $SelectedEnvVar
} elseif ($SubscriptionMode -eq "kimi_code") {
$config.llm["api_base"] = "https://api.kimi.com/coding"
$config.llm["api_key_env_var"] = $SelectedEnvVar
} else {
$config.llm["api_key_env_var"] = $SelectedEnvVar
}
+58 -24
View File
@@ -410,7 +410,7 @@ if [ "$USE_ASSOC_ARRAYS" = true ]; then
declare -A DEFAULT_MODELS=(
["anthropic"]="claude-haiku-4-5-20251001"
["openai"]="gpt-5-mini"
["minimax"]="MiniMax-M2.1"
["minimax"]="MiniMax-M2.5"
["gemini"]="gemini-3-flash-preview"
["groq"]="moonshotai/kimi-k2-instruct-0905"
["cerebras"]="zai-glm-4.7"
@@ -510,7 +510,7 @@ else
# Default models by provider id (parallel arrays)
MODEL_PROVIDER_IDS=(anthropic openai minimax gemini groq cerebras mistral together_ai deepseek)
MODEL_DEFAULTS=("claude-haiku-4-5-20251001" "gpt-5-mini" "MiniMax-M2.1" "gemini-3-flash-preview" "moonshotai/kimi-k2-instruct-0905" "zai-glm-4.7" "mistral-large-latest" "meta-llama/Llama-3.3-70B-Instruct-Turbo" "deepseek-chat")
MODEL_DEFAULTS=("claude-haiku-4-5-20251001" "gpt-5-mini" "MiniMax-M2.5" "gemini-3-flash-preview" "moonshotai/kimi-k2-instruct-0905" "zai-glm-4.7" "mistral-large-latest" "meta-llama/Llama-3.3-70B-Instruct-Turbo" "deepseek-chat")
# Helper: get provider display name for an env var
get_provider_name() {
@@ -824,6 +824,13 @@ if [ -n "${MINIMAX_API_KEY:-}" ]; then
MINIMAX_CRED_DETECTED=true
fi
KIMI_CRED_DETECTED=false
if [ -f "$HOME/.kimi/config.toml" ]; then
KIMI_CRED_DETECTED=true
elif [ -n "${KIMI_API_KEY:-}" ]; then
KIMI_CRED_DETECTED=true
fi
# Detect API key providers
if [ "$USE_ASSOC_ARRAYS" = true ]; then
for env_var in "${!PROVIDER_NAMES[@]}"; do
@@ -859,6 +866,7 @@ try:
sub = ''
if llm.get('use_claude_code_subscription'): sub = 'claude_code'
elif llm.get('use_codex_subscription'): sub = 'codex'
elif llm.get('use_kimi_code_subscription'): sub = 'kimi_code'
elif llm.get('provider', '') == 'minimax' or 'api.minimax.io' in llm.get('api_base', ''): sub = 'minimax_code'
elif 'api.z.ai' in llm.get('api_base', ''): sub = 'zai_code'
print(f'PREV_SUB_MODE={sub}')
@@ -875,6 +883,7 @@ if [ -n "$PREV_SUB_MODE" ] || [ -n "$PREV_PROVIDER" ]; then
claude_code) [ "$CLAUDE_CRED_DETECTED" = true ] && PREV_CRED_VALID=true ;;
zai_code) [ "$ZAI_CRED_DETECTED" = true ] && PREV_CRED_VALID=true ;;
codex) [ "$CODEX_CRED_DETECTED" = true ] && PREV_CRED_VALID=true ;;
kimi_code) [ "$KIMI_CRED_DETECTED" = true ] && PREV_CRED_VALID=true ;;
*)
# API key provider — check if the env var is set
if [ -n "$PREV_ENV_VAR" ] && [ -n "${!PREV_ENV_VAR}" ]; then
@@ -889,15 +898,17 @@ if [ -n "$PREV_SUB_MODE" ] || [ -n "$PREV_PROVIDER" ]; then
zai_code) DEFAULT_CHOICE=2 ;;
codex) DEFAULT_CHOICE=3 ;;
minimax_code) DEFAULT_CHOICE=4 ;;
kimi_code) DEFAULT_CHOICE=5 ;;
esac
if [ -z "$DEFAULT_CHOICE" ]; then
case "$PREV_PROVIDER" in
anthropic) DEFAULT_CHOICE=5 ;;
openai) DEFAULT_CHOICE=6 ;;
gemini) DEFAULT_CHOICE=7 ;;
groq) DEFAULT_CHOICE=8 ;;
cerebras) DEFAULT_CHOICE=9 ;;
anthropic) DEFAULT_CHOICE=6 ;;
openai) DEFAULT_CHOICE=7 ;;
gemini) DEFAULT_CHOICE=8 ;;
groq) DEFAULT_CHOICE=9 ;;
cerebras) DEFAULT_CHOICE=10 ;;
minimax) DEFAULT_CHOICE=4 ;;
kimi) DEFAULT_CHOICE=5 ;;
esac
fi
fi
@@ -936,14 +947,21 @@ else
echo -e " ${CYAN}4)${NC} MiniMax Coding Key ${DIM}(use your MiniMax coding key)${NC}"
fi
# 5) Kimi Code
if [ "$KIMI_CRED_DETECTED" = true ]; then
echo -e " ${CYAN}5)${NC} Kimi Code Subscription ${DIM}(use your Kimi Code plan)${NC} ${GREEN}(credential detected)${NC}"
else
echo -e " ${CYAN}5)${NC} Kimi Code Subscription ${DIM}(use your Kimi Code plan)${NC}"
fi
echo ""
echo -e " ${CYAN}${BOLD}API key providers:${NC}"
# 5-9) API key providers — show (credential detected) if key already set
# 6-10) API key providers — show (credential detected) if key already set
PROVIDER_MENU_ENVS=(ANTHROPIC_API_KEY OPENAI_API_KEY GEMINI_API_KEY GROQ_API_KEY CEREBRAS_API_KEY)
PROVIDER_MENU_NAMES=("Anthropic (Claude) - Recommended" "OpenAI (GPT)" "Google Gemini - Free tier available" "Groq - Fast, free tier" "Cerebras - Fast, free tier")
for idx in 0 1 2 3 4; do
num=$((idx + 5))
num=$((idx + 6))
env_var="${PROVIDER_MENU_ENVS[$idx]}"
if [ -n "${!env_var}" ]; then
echo -e " ${CYAN}$num)${NC} ${PROVIDER_MENU_NAMES[$idx]} ${GREEN}(credential detected)${NC}"
@@ -952,7 +970,7 @@ for idx in 0 1 2 3 4; do
fi
done
echo -e " ${CYAN}10)${NC} Skip for now"
echo -e " ${CYAN}11)${NC} Skip for now"
echo ""
if [ -n "$DEFAULT_CHOICE" ]; then
@@ -962,15 +980,15 @@ fi
while true; do
if [ -n "$DEFAULT_CHOICE" ]; then
read -r -p "Enter choice (1-10) [$DEFAULT_CHOICE]: " choice || true
read -r -p "Enter choice (1-11) [$DEFAULT_CHOICE]: " choice || true
choice="${choice:-$DEFAULT_CHOICE}"
else
read -r -p "Enter choice (1-10): " choice || true
read -r -p "Enter choice (1-11): " choice || true
fi
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le 10 ]; then
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le 11 ]; then
break
fi
echo -e "${RED}Invalid choice. Please enter 1-10${NC}"
echo -e "${RED}Invalid choice. Please enter 1-11${NC}"
done
case $choice in
@@ -1038,46 +1056,60 @@ case $choice in
SUBSCRIPTION_MODE="minimax_code"
SELECTED_ENV_VAR="MINIMAX_API_KEY"
SELECTED_PROVIDER_ID="minimax"
SELECTED_MODEL="MiniMax-M2.1"
SELECTED_MAX_TOKENS=8192
SELECTED_MODEL="MiniMax-M2.5"
SELECTED_MAX_TOKENS=32768
SELECTED_API_BASE="https://api.minimax.io/v1"
PROVIDER_NAME="MiniMax"
SIGNUP_URL="https://platform.minimax.io/user-center/basic-information/interface-key"
echo ""
echo -e "${GREEN}${NC} Using MiniMax coding key"
echo -e " ${DIM}Model: MiniMax-M2.1 | API: api.minimax.io${NC}"
echo -e " ${DIM}Model: MiniMax-M2.5 | API: api.minimax.io${NC}"
;;
5)
# Kimi Code Subscription
SUBSCRIPTION_MODE="kimi_code"
SELECTED_PROVIDER_ID="kimi"
SELECTED_ENV_VAR="KIMI_API_KEY"
SELECTED_MODEL="kimi-k2.5"
SELECTED_MAX_TOKENS=32768
SELECTED_API_BASE="https://api.kimi.com/coding"
PROVIDER_NAME="Kimi"
SIGNUP_URL="https://www.kimi.com/code"
echo ""
echo -e "${GREEN}${NC} Using Kimi Code subscription"
echo -e " ${DIM}Model: kimi-k2.5 | API: api.kimi.com/coding${NC}"
;;
6)
SELECTED_ENV_VAR="ANTHROPIC_API_KEY"
SELECTED_PROVIDER_ID="anthropic"
PROVIDER_NAME="Anthropic"
SIGNUP_URL="https://console.anthropic.com/settings/keys"
;;
6)
7)
SELECTED_ENV_VAR="OPENAI_API_KEY"
SELECTED_PROVIDER_ID="openai"
PROVIDER_NAME="OpenAI"
SIGNUP_URL="https://platform.openai.com/api-keys"
;;
7)
8)
SELECTED_ENV_VAR="GEMINI_API_KEY"
SELECTED_PROVIDER_ID="gemini"
PROVIDER_NAME="Google Gemini"
SIGNUP_URL="https://aistudio.google.com/apikey"
;;
8)
9)
SELECTED_ENV_VAR="GROQ_API_KEY"
SELECTED_PROVIDER_ID="groq"
PROVIDER_NAME="Groq"
SIGNUP_URL="https://console.groq.com/keys"
;;
9)
10)
SELECTED_ENV_VAR="CEREBRAS_API_KEY"
SELECTED_PROVIDER_ID="cerebras"
PROVIDER_NAME="Cerebras"
SIGNUP_URL="https://cloud.cerebras.ai/"
;;
10)
11)
echo ""
echo -e "${YELLOW}Skipped.${NC} An LLM API key is required to test and use worker agents."
echo -e "Add your API key later by running:"
@@ -1090,7 +1122,7 @@ case $choice in
esac
# For API-key providers: prompt for key (allow replacement if already set)
if { [ -z "$SUBSCRIPTION_MODE" ] || [ "$SUBSCRIPTION_MODE" = "minimax_code" ]; } && [ -n "$SELECTED_ENV_VAR" ]; then
if { [ -z "$SUBSCRIPTION_MODE" ] || [ "$SUBSCRIPTION_MODE" = "minimax_code" ] || [ "$SUBSCRIPTION_MODE" = "kimi_code" ]; } && [ -n "$SELECTED_ENV_VAR" ]; then
while true; do
CURRENT_KEY="${!SELECTED_ENV_VAR}"
if [ -n "$CURRENT_KEY" ]; then
@@ -1118,7 +1150,7 @@ if { [ -z "$SUBSCRIPTION_MODE" ] || [ "$SUBSCRIPTION_MODE" = "minimax_code" ]; }
echo -e "${GREEN}${NC} API key saved to $SHELL_RC_FILE"
# Health check the new key
echo -n " Verifying API key... "
if [ "$SUBSCRIPTION_MODE" = "minimax_code" ] && [ -n "${SELECTED_API_BASE:-}" ]; then
if { [ "$SUBSCRIPTION_MODE" = "minimax_code" ] || [ "$SUBSCRIPTION_MODE" = "kimi_code" ]; } && [ -n "${SELECTED_API_BASE:-}" ]; then
HC_RESULT=$(uv run python "$SCRIPT_DIR/scripts/check_llm_key.py" "$SELECTED_PROVIDER_ID" "$API_KEY" "$SELECTED_API_BASE" 2>/dev/null) || true
else
HC_RESULT=$(uv run python "$SCRIPT_DIR/scripts/check_llm_key.py" "$SELECTED_PROVIDER_ID" "$API_KEY" 2>/dev/null) || true
@@ -1238,6 +1270,8 @@ if [ -n "$SELECTED_PROVIDER_ID" ]; then
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "" "https://api.z.ai/api/coding/paas/v4" > /dev/null
elif [ "$SUBSCRIPTION_MODE" = "minimax_code" ]; then
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "" "$SELECTED_API_BASE" > /dev/null
elif [ "$SUBSCRIPTION_MODE" = "kimi_code" ]; then
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" "" "$SELECTED_API_BASE" > /dev/null
else
save_configuration "$SELECTED_PROVIDER_ID" "$SELECTED_ENV_VAR" "$SELECTED_MODEL" "$SELECTED_MAX_TOKENS" > /dev/null
fi
+35 -2
View File
@@ -56,7 +56,9 @@ def check_openai_compatible(api_key: str, endpoint: str, name: str) -> dict:
return {"valid": False, "message": f"{name} API returned status {r.status_code}"}
def check_minimax(api_key: str, api_base: str = "https://api.minimax.io/v1", **_: str) -> dict:
def check_minimax(
api_key: str, api_base: str = "https://api.minimax.io/v1", **_: str
) -> dict:
"""Validate via chatcompletion_v2 endpoint with empty messages.
MiniMax doesn't support GET /models; their native endpoint is
@@ -69,7 +71,7 @@ def check_minimax(api_key: str, api_base: str = "https://api.minimax.io/v1", **_
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={"model": "MiniMax-M2.1", "messages": []},
json={"model": "MiniMax-M2.5", "messages": []},
)
if r.status_code in (200, 400, 422, 429):
return {"valid": True, "message": "MiniMax API key valid"}
@@ -80,6 +82,27 @@ def check_minimax(api_key: str, api_base: str = "https://api.minimax.io/v1", **_
return {"valid": False, "message": f"MiniMax API returned status {r.status_code}"}
def check_anthropic_compatible(api_key: str, endpoint: str, name: str) -> dict:
"""POST empty messages to an Anthropic-compatible endpoint to validate key."""
with httpx.Client(timeout=TIMEOUT) as client:
r = client.post(
endpoint,
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
json={"model": "kimi-k2.5", "max_tokens": 1, "messages": []},
)
if r.status_code in (200, 400, 429):
return {"valid": True, "message": f"{name} API key valid"}
if r.status_code == 401:
return {"valid": False, "message": f"Invalid {name} API key"}
if r.status_code == 403:
return {"valid": False, "message": f"{name} API key lacks permissions"}
return {"valid": False, "message": f"{name} API returned status {r.status_code}"}
def check_gemini(api_key: str, **_: str) -> dict:
"""List models with query param auth."""
with httpx.Client(timeout=TIMEOUT) as client:
@@ -107,6 +130,11 @@ PROVIDERS = {
key, "https://api.cerebras.ai/v1/models", "Cerebras"
),
"minimax": lambda key, **kw: check_minimax(key),
# Kimi For Coding uses an Anthropic-compatible endpoint; check via /v1/messages
# with empty messages (same as check_anthropic, triggers 400 not 401).
"kimi": lambda key, **kw: check_anthropic_compatible(
key, "https://api.kimi.com/coding/v1/messages", "Kimi"
),
}
@@ -129,6 +157,11 @@ def main() -> None:
try:
if api_base and provider_id == "minimax":
result = check_minimax(api_key, api_base)
elif api_base and provider_id == "kimi":
# Kimi uses an Anthropic-compatible endpoint; check via /v1/messages
result = check_anthropic_compatible(
api_key, api_base.rstrip("/") + "/v1/messages", "Kimi"
)
elif api_base:
# Custom API base (ZAI or other OpenAI-compatible)
endpoint = api_base.rstrip("/") + "/models"