diff --git a/neurosploit-rs/crates/harness/src/models.rs b/neurosploit-rs/crates/harness/src/models.rs index 8524309..c117edc 100644 --- a/neurosploit-rs/crates/harness/src/models.rs +++ b/neurosploit-rs/crates/harness/src/models.rs @@ -39,8 +39,13 @@ pub fn providers() -> Vec { Provider { key: "qwen", label: "Qwen (DashScope)", base_url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", env_key: "DASHSCOPE_API_KEY", kind: "api", models: vec!["qwen3.8-max", "qwen3.8-omni-flash", "qwen-max", "qwen2.5-coder-32b-instruct", "qwq-plus"] }, // Z.ai (Zhipu / GLM). OpenAI-compatible open platform endpoint. - Provider { key: "zai", label: "Z.ai (GLM)", base_url: "https://api.z.ai/api/paas/v4", env_key: "ZAI_API_KEY", kind: "api", - models: vec!["glm-5.3", "glm-5.3-flashx", "glm-4.6"] }, + // Z.ai GLM. `kind: "cli"` so a run can drive GLM agentically through the + // Claude Code CLI using Z.ai's Anthropic-compatible endpoint (the Z.ai + // devpack "cline"/"zcode" integration) — the base URL + auth token are + // injected for the `claude` binary in chat_claude_stream. The plain HTTP + // API path (api.z.ai/api/paas/v4, OpenAI-compatible) still works too. + Provider { key: "zai", label: "Z.ai (GLM)", base_url: "https://api.z.ai/api/paas/v4", env_key: "ZAI_API_KEY", kind: "cli", + models: vec!["glm-5.3", "glm-5.2", "glm-5.1", "glm-5.3-flashx", "glm-4.6"] }, Provider { key: "groq", label: "Groq", base_url: "https://api.groq.com/openai/v1", env_key: "GROQ_API_KEY", kind: "api", models: vec!["llama-3.3-70b-versatile", "qwen-2.5-coder-32b"] }, Provider { key: "together", label: "Together AI", base_url: "https://api.together.xyz/v1", env_key: "TOGETHER_API_KEY", kind: "api", @@ -298,7 +303,7 @@ impl ChatClient { // Claude Code can stream structured events (tools, commands, files) which // we surface live as a categorized activity feed, attributed to `label`. if bin == "claude" { - return self.chat_claude_stream(label, model, &prompt, mcp_config, progress).await; + return self.chat_claude_stream(label, provider, model, &prompt, mcp_config, progress).await; } // Codex exec streams JSONL events (`--json`): commands it runs, agent // messages, file changes, token usage. Surface them live so recon and @@ -441,6 +446,7 @@ impl ChatClient { async fn chat_claude_stream( &self, label: &str, + provider: &str, model: &str, prompt: &str, mcp_config: Option<&str>, @@ -450,15 +456,28 @@ impl ChatClient { cmd.arg("-p").arg("--model").arg(model) .arg("--output-format").arg("stream-json").arg("--verbose") .arg("--dangerously-skip-permissions") - .env("IS_SANDBOX", "1") + .env("IS_SANDBOX", "1"); + if provider == "zai" { + // Z.ai GLM via the Claude Code CLI (devpack / cline / zcode): point + // the CLI at Z.ai's Anthropic-compatible endpoint and authenticate + // with the Z.ai key, so `claude --model glm-5.3` drives GLM. + let token = std::env::var("ZAI_API_KEY").unwrap_or_default(); + if token.trim().is_empty() { + return Err(anyhow!("Z.ai GLM via CLI needs ZAI_API_KEY (get a key at https://z.ai, see https://docs.z.ai/devpack/tool/cline)")); + } + cmd.env("ANTHROPIC_BASE_URL", zai_anthropic_base_url()) + .env("ANTHROPIC_AUTH_TOKEN", token) + .env_remove("ANTHROPIC_API_KEY"); + } else { // Subscription mode means the `claude` CLI must authenticate with the // user's logged-in session. A stray ANTHROPIC_API_KEY in the inherited // environment (e.g. an invalid/stale key saved in the web console) // makes the CLI switch to API-key billing and fail with // "Invalid API key · Fix external API key". Strip it so the CLI // always uses the subscription login here. - .env_remove("ANTHROPIC_API_KEY") - .env_remove("ANTHROPIC_AUTH_TOKEN"); + cmd.env_remove("ANTHROPIC_API_KEY") + .env_remove("ANTHROPIC_AUTH_TOKEN"); + } if let Some(mcp) = mcp_config { cmd.arg("--mcp-config").arg(mcp); } @@ -720,6 +739,11 @@ fn mcp_servers_from(path: &str) -> Vec<(String, String, String)> { pub fn cli_binary_for(provider: &str) -> Option<&'static str> { match provider { "anthropic" => Some("claude"), + // Z.ai GLM runs through the Claude Code CLI pointed at Z.ai's + // Anthropic-compatible endpoint (the Z.ai devpack "cline"/"zcode" setup): + // chat_claude_stream injects ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN + // from ZAI_API_KEY for this provider. + "zai" => Some("claude"), "openai" => Some("codex"), "xai" => Some("grok"), "gemini" => Some("gemini"), @@ -729,6 +753,14 @@ pub fn cli_binary_for(provider: &str) -> Option<&'static str> { } } +/// Z.ai's Anthropic-compatible base URL for driving GLM through the Claude Code +/// CLI (Z.ai devpack / cline / zcode). Overridable for self-hosted gateways. +pub fn zai_anthropic_base_url() -> String { + std::env::var("ZAI_ANTHROPIC_BASE_URL") + .or_else(|_| std::env::var("ZAI_CODING_BASE_URL")) + .unwrap_or_else(|_| "https://api.z.ai/api/anthropic".to_string()) +} + /// Is `name` an executable found on PATH? pub fn binary_in_path(name: &str) -> bool { std::env::var_os("PATH") @@ -757,6 +789,14 @@ pub enum LoginStatus { pub async fn cli_login_status(provider: &str) -> LoginStatus { let Some(bin) = cli_binary_for(provider) else { return LoginStatus::NotInstalled }; if !binary_in_path(bin) { return LoginStatus::NotInstalled; } + // Z.ai GLM runs through the `claude` binary but authenticates with a Z.ai + // key, not the Claude login — so a trivial `claude` probe here would check + // the wrong credential. It's usable iff the binary exists and ZAI_API_KEY + // is set. + if provider == "zai" { + let has_key = std::env::var("ZAI_API_KEY").map(|v| !v.trim().is_empty()).unwrap_or(false); + return if has_key { LoginStatus::LoggedIn } else { LoginStatus::NotLoggedIn }; + } let mut cmd = Command::new(bin); // opencode/hermes take the probe prompt as an argv value, not stdin. let prompt_via_stdin = !matches!(bin, "opencode" | "hermes"); @@ -1037,6 +1077,19 @@ mod response_tests { use super::*; use serde_json::json; + #[test] + fn zai_glm_catalog_and_cli_backend() { + // GLM must be offered in all requested versions and be drivable through + // the Claude Code CLI (Z.ai devpack / cline / zcode). + let z = providers().into_iter().find(|p| p.key == "zai").expect("zai provider"); + for m in ["glm-5.1", "glm-5.2", "glm-5.3"] { + assert!(z.models.contains(&m), "GLM {m} missing from catalog: {:?}", z.models); + } + assert_eq!(cli_binary_for("zai"), Some("claude"), "GLM runs via the claude CLI"); + // GLM is reasoning-heavy → gets the larger token ceiling. + assert_eq!(max_tokens_for("glm-5.3"), 32768); + } + #[test] fn anthropic_catalog_lists_opus_4_6() { // Regression: the web console offers claude-opus-4-6, so the harness diff --git a/web/server.js b/web/server.js index f3d0c33..3f52758 100644 --- a/web/server.js +++ b/web/server.js @@ -121,8 +121,11 @@ const PROVIDERS = [ models: ['mistral-large-latest', 'codestral-latest'] }, { key: 'qwen', label: 'Qwen (DashScope)', kind: 'api', envKey: 'DASHSCOPE_API_KEY', models: ['qwen3.8-max', 'qwen3.8-omni-flash', 'qwen-max', 'qwen2.5-coder-32b-instruct', 'qwq-plus'] }, + // Z.ai GLM: always needs a ZAI_API_KEY (so kind:'api' keeps the key field), + // but with the subscription toggle on it runs agentically through the Claude + // Code CLI pointed at Z.ai's Anthropic-compatible endpoint (devpack/cline). { key: 'zai', label: 'Z.ai (GLM)', kind: 'api', envKey: 'ZAI_API_KEY', - models: ['glm-5.3', 'glm-5.3-flashx', 'glm-4.6'] }, + models: ['glm-5.3', 'glm-5.2', 'glm-5.1', 'glm-5.3-flashx', 'glm-4.6'] }, { key: 'groq', label: 'Groq', kind: 'api', envKey: 'GROQ_API_KEY', models: ['llama-3.3-70b-versatile', 'qwen-2.5-coder-32b'] }, { key: 'together', label: 'Together AI', kind: 'api', envKey: 'TOGETHER_API_KEY', @@ -163,7 +166,8 @@ function keyProbe(provider, key) { case 'moonshot': return { url: 'https://api.moonshot.ai/v1/models', headers: bearer }; case 'qwen': return { url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models', headers: bearer }; case 'nvidia_nim': return { url: 'https://integrate.api.nvidia.com/v1/models', headers: bearer }; - default: return null; // opencode, nous, zai, litellm, azure, ollama, llamacpp — not auto-verifiable here + case 'zai': return { url: 'https://api.z.ai/api/paas/v4/models', headers: bearer }; + default: return null; // opencode, nous, litellm, azure, ollama, llamacpp — not auto-verifiable here } }