From 287ca1764fd0229c0c7c43db9804b93942f25691 Mon Sep 17 00:00:00 2001 From: jiunshinn Date: Mon, 16 Feb 2026 17:14:10 +0900 Subject: [PATCH 1/3] Add native Z.ai (GLM) provider support Allow users with a GLM Coding Plan subscription to use Z.ai models directly without an intermediary like OpenRouter. Z.ai's API is OpenAI-compatible so the implementation reuses existing message conversion and response parsing, only changing the endpoint URL and auth header. Changes: - Add zhipu_key to LlmConfig, TomlLlmConfig, and env/TOML resolution - Add "zhipu" match arm in LlmManager::get_api_key() - Add call_zhipu() in SpacebotModel targeting api.z.ai - Add "Z.ai (GLM)" to onboarding wizard provider list - Fix fallbacks resolution to replace (not extend) when explicitly set Usage: configure zhipu_key and route models as zhipu/glm-4.7, etc. Co-Authored-By: Claude Opus 4.6 --- src/config.rs | 23 +++++++++---- src/llm/manager.rs | 2 ++ src/llm/model.rs | 86 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/src/config.rs b/src/config.rs index d88ff5c37..b652bf6b0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -55,12 +55,13 @@ pub struct LlmConfig { pub anthropic_key: Option, pub openai_key: Option, pub openrouter_key: Option, + pub zhipu_key: Option, } impl LlmConfig { /// Check if any provider key is configured. pub fn has_any_key(&self) -> bool { - self.anthropic_key.is_some() || self.openai_key.is_some() || self.openrouter_key.is_some() + self.anthropic_key.is_some() || self.openai_key.is_some() || self.openrouter_key.is_some() || self.zhipu_key.is_some() } } @@ -851,6 +852,7 @@ struct TomlLlmConfig { anthropic_key: Option, openai_key: Option, openrouter_key: Option, + zhipu_key: Option, } #[derive(Deserialize, Default)] @@ -882,8 +884,7 @@ struct TomlRoutingConfig { rate_limit_cooldown_secs: Option, #[serde(default)] task_overrides: HashMap, - #[serde(default)] - fallbacks: HashMap>, + fallbacks: Option>>, } #[derive(Deserialize)] @@ -1079,8 +1080,10 @@ fn resolve_routing(toml: Option, base: &RoutingConfig) -> Rou let mut task_overrides = base.task_overrides.clone(); task_overrides.extend(t.task_overrides); - let mut fallbacks = base.fallbacks.clone(); - fallbacks.extend(t.fallbacks); + let fallbacks = match t.fallbacks { + Some(f) => f, + None => base.fallbacks.clone(), + }; RoutingConfig { channel: t.channel.unwrap_or_else(|| base.channel.clone()), @@ -1155,6 +1158,7 @@ impl Config { anthropic_key: std::env::var("ANTHROPIC_API_KEY").ok(), openai_key: std::env::var("OPENAI_API_KEY").ok(), openrouter_key: std::env::var("OPENROUTER_API_KEY").ok(), + zhipu_key: std::env::var("ZHIPU_API_KEY").ok(), }; // Note: We allow boot without provider keys now. System starts in setup mode. @@ -1220,6 +1224,12 @@ impl Config { .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("OPENROUTER_API_KEY").ok()), + zhipu_key: toml + .llm + .zhipu_key + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("ZHIPU_API_KEY").ok()), }; // Note: We allow boot without provider keys now. System starts in setup mode. @@ -2010,7 +2020,7 @@ pub fn run_onboarding() -> anyhow::Result { println!(); // 1. Pick a provider - let providers = &["Anthropic", "OpenRouter", "OpenAI"]; + let providers = &["Anthropic", "OpenRouter", "OpenAI", "Z.ai (GLM)"]; let provider_idx = Select::new() .with_prompt("Which LLM provider do you want to use?") .items(providers) @@ -2021,6 +2031,7 @@ pub fn run_onboarding() -> anyhow::Result { 0 => ("Anthropic API key", "anthropic_key"), 1 => ("OpenRouter API key", "openrouter_key"), 2 => ("OpenAI API key", "openai_key"), + 3 => ("Z.ai (GLM) API key", "zhipu_key"), _ => unreachable!(), }; diff --git a/src/llm/manager.rs b/src/llm/manager.rs index 6070bfe17..794f9e3ef 100644 --- a/src/llm/manager.rs +++ b/src/llm/manager.rs @@ -44,6 +44,8 @@ impl LlmManager { .ok_or_else(|| LlmError::MissingProviderKey("openai".into()).into()), "openrouter" => self.config.openrouter_key.clone() .ok_or_else(|| LlmError::MissingProviderKey("openrouter".into()).into()), + "zhipu" => self.config.zhipu_key.clone() + .ok_or_else(|| LlmError::MissingProviderKey("zhipu".into()).into()), _ => Err(LlmError::UnknownProvider(provider.into()).into()), } } diff --git a/src/llm/model.rs b/src/llm/model.rs index abbea5943..40b14643e 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -69,6 +69,7 @@ impl SpacebotModel { "anthropic" => self.call_anthropic(request).await, "openai" => self.call_openai(request).await, "openrouter" => self.call_openrouter(request).await, + "zhipu" => self.call_zhipu(request).await, other => Err(CompletionError::ProviderError(format!( "unknown provider: {other}" ))), @@ -510,6 +511,91 @@ impl SpacebotModel { // OpenRouter returns OpenAI-format responses parse_openai_response(response_body, "OpenRouter") } + + async fn call_zhipu( + &self, + request: CompletionRequest, + ) -> Result, CompletionError> { + let api_key = self + .llm_manager + .get_api_key("zhipu") + .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + + let mut messages = Vec::new(); + + if let Some(preamble) = &request.preamble { + messages.push(serde_json::json!({ + "role": "system", + "content": preamble, + })); + } + + messages.extend(convert_messages_to_openai(&request.chat_history)); + + let mut body = serde_json::json!({ + "model": self.model_name, + "messages": messages, + }); + + if let Some(max_tokens) = request.max_tokens { + body["max_tokens"] = serde_json::json!(max_tokens); + } + + if let Some(temperature) = request.temperature { + body["temperature"] = serde_json::json!(temperature); + } + + if !request.tools.is_empty() { + let tools: Vec = request + .tools + .iter() + .map(|t| { + serde_json::json!({ + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.parameters, + } + }) + }) + .collect(); + body["tools"] = serde_json::json!(tools); + } + + let response = self + .llm_manager + .http_client() + .post("https://api.z.ai/api/paas/v4/chat/completions") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| CompletionError::ProviderError(e.to_string()))?; + + let status = response.status(); + let response_text = response + .text() + .await + .map_err(|e| CompletionError::ProviderError(format!("failed to read response body: {e}")))?; + + let response_body: serde_json::Value = serde_json::from_str(&response_text) + .map_err(|e| CompletionError::ProviderError(format!( + "Z.ai response ({status}) is not valid JSON: {e}\nBody: {}", truncate_body(&response_text) + )))?; + + if !status.is_success() { + let message = response_body["error"]["message"] + .as_str() + .unwrap_or("unknown error"); + return Err(CompletionError::ProviderError(format!( + "Z.ai API error ({status}): {message}" + ))); + } + + parse_openai_response(response_body, "Z.ai") + } } // --- Helpers --- From 877b0431739805667cf95136f01b47c1685a2738 Mon Sep 17 00:00:00 2001 From: jiunshinn Date: Mon, 16 Feb 2026 17:19:38 +0900 Subject: [PATCH 2/3] Add Z.ai to supported providers list in README Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dc38e8a68..caf0303d9 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ Read the full vision in [docs/spacedrive.md](docs/spacedrive.md). ### Prerequisites - **Rust** 1.85+ ([rustup](https://rustup.rs/)) -- An LLM API key (OpenRouter, Anthropic, OpenAI, etc.) +- An LLM API key (OpenRouter, Anthropic, OpenAI, Z.ai, etc.) ### Build and Run From e2a51af8d8f16e383d48a77536d01bcdfe2319b8 Mon Sep 17 00:00:00 2001 From: jiunshinn Date: Mon, 16 Feb 2026 17:21:37 +0900 Subject: [PATCH 3/3] Add Z.ai (GLM) routing example to README Co-Authored-By: Claude Opus 4.6 --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index caf0303d9..a0f71ee99 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,20 @@ process_types = ["channel", "branch"] "anthropic/claude-sonnet-4" = ["anthropic/claude-haiku-4.5"] ``` +**Z.ai (GLM) example** — use GLM models directly with a [GLM Coding Plan](https://z.ai) subscription: + +```toml +[llm] +zhipu_key = "env:ZHIPU_API_KEY" + +[defaults.routing] +channel = "zhipu/glm-4.7" +worker = "zhipu/glm-4.7" + +[defaults.routing.task_overrides] +coding = "zhipu/glm-4.7" +``` + ### Skills Extensible skill system for domain-specific behavior: