diff --git a/README.md b/README.md index aa1a856..04b8efd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # claude-code-proxy -Claude Code, powered by **OpenAI**, **Kimi**, **Grok**, or **Cursor**. +Claude Code, powered by **OpenAI**, **Kimi**, **Grok**, **Cursor**, or **GLM** (z.ai). Claude Code running through claude-code-proxy @@ -85,6 +85,18 @@ Cursor authentication uses Cursor's browser login, but the proxy stores its own tokens. It does not read Cursor Agent's Keychain/auth.json. You can also set `CCP_CURSOR_AUTH_TOKEN` for the proxy process. +**GLM (z.ai):** + +```sh +claude-code-proxy glm auth login # paste your z.ai API key (read from stdin) +# or, non-interactively: +export CCP_GLM_API_KEY= +``` + +Get an API key from your [z.ai](https://z.ai) console. z.ai speaks the Anthropic +Messages API natively, so the proxy forwards requests verbatim and pipes the +Anthropic reply straight back (no format translation). + On macOS credentials go to Keychain. On Windows they are written under `%APPDATA%\claude-code-proxy\\auth.json`; on Linux they are written under `${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-proxy//auth.json` @@ -98,6 +110,7 @@ claude-code-proxy codex auth status claude-code-proxy kimi auth status claude-code-proxy grok auth status claude-code-proxy cursor auth status +claude-code-proxy glm auth status ``` ### 3. Start the proxy @@ -121,6 +134,7 @@ requests, and error events. Use `--no-monitor` for plain terminal output. - `kimi-for-coding`, `kimi-k2.6`, `k2.6` → **kimi** - `grok-composer-2.5-fast`, `grok-4.5` → **grok** - `cursor`, `cursor-plan`, `cursor-ask`, `composer-2.5`, `composer-2.5-fast`, `cursor:`, `cursor-plan:`, `cursor-ask:` → **cursor** +- `glm-4.7`, `glm-5.2` → **glm** An unknown model returns a 400 listing the supported ids. There is no implicit default provider. @@ -166,6 +180,15 @@ ANTHROPIC_AUTH_TOKEN=unused \ ANTHROPIC_MODEL=cursor \ ANTHROPIC_SMALL_FAST_MODEL=cursor \ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ +CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1 \ + claude + +# GLM (z.ai) +ANTHROPIC_BASE_URL=http://localhost:18765 \ +ANTHROPIC_AUTH_TOKEN=unused \ +ANTHROPIC_MODEL=glm-5.2 \ +ANTHROPIC_SMALL_FAST_MODEL=glm-4.7 \ +CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \ CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1 \ claude ``` @@ -375,6 +398,33 @@ refreshes them five minutes before expiry, and does not use `~/.grok/auth.json`. | `grok auth status` | Show token expiry and storage path | | `grok auth logout` | Delete proxy-owned credentials | +### GLM (z.ai) + +Upstream: `https://api.z.ai/api/anthropic` (Anthropic Messages API). z.ai is +Anthropic-native, so the proxy forwards the request verbatim (after stripping the +local `[1m]` compaction hint) and pipes the upstream Anthropic SSE/JSON reply +back to Claude Code unchanged — there is no format translation. + +Supported proxy model ids: + +- `glm-4.7` +- `glm-5.2` + +Unknown `glm-*` ids are not auto-routed; set `ANTHROPIC_MODEL` to one of the +registered ids above. Append `[1m]` (for example `glm-5.2[1m]`) if you want +Claude Code to use a larger local compaction threshold; the proxy strips it +before calling z.ai. + +Auth (z.ai uses static API keys, not OAuth): + +| Command | What it does | +| ----------------- | ------------------------------------------- | +| `glm auth login` | Read a z.ai API key from stdin and store it | +| `glm auth status` | Show whether the key is set (env or stored) | +| `glm auth logout` | Delete the stored key | + +`CCP_GLM_API_KEY` (alias `GLM_API_KEY`) takes precedence over stored credentials. + ### Cursor Agent Upstream: `https://api2.cursor.sh/agent.v1.AgentService/Run` (Cursor Agent's @@ -467,6 +517,7 @@ sequenceDiagram | `codex auth login` / `device` / `status` / `logout` | Codex OAuth management | | `kimi auth login` / `status` / `logout` | Kimi OAuth management | | `cursor auth login` / `status` / `logout` | Cursor OAuth management | +| `glm auth login` / `status` / `logout` | GLM API-key management | --- @@ -683,6 +734,9 @@ Windows, and at "clientVersion": "cli-2026.06.04-5fd875e", "agentBundle": "/path/to/cursor-agent/index.js" }, + "glm": { + "baseUrl": "https://api.z.ai/api/anthropic" + }, "log": { "stderr": false, "verbose": false @@ -701,6 +755,8 @@ Windows, and at | `CCP_ALIAS_PROVIDER` | `aliasProvider` | `codex` | Route Anthropic-style aliases (`haiku`, `sonnet`, `opus`, `claude-*`) through `codex` or `kimi` | | `CCP_KIMI_OAUTH_HOST` | `kimi.oauthHost` | `https://auth.kimi.com` | Override Kimi's OAuth host (debugging only) | | `CCP_KIMI_BASE_URL` | `kimi.baseUrl` | `https://api.kimi.com/coding/v1` | Override Kimi's API base URL | +| `CCP_GLM_BASE_URL` | `glm.baseUrl` | `https://api.z.ai/api/anthropic` | Override the GLM (z.ai) Anthropic endpoint | +| `CCP_GLM_API_KEY` | — | unset | z.ai API key (alias `GLM_API_KEY`); takes precedence over stored credentials | | `CCP_CODEX_MODEL` | `codex.model` | unset | Force all Codex requests to this model (`gpt-5.2`, `gpt-5.3-codex`, `gpt-5.3-codex-spark`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-iterra`) | | `CCP_CODEX_EFFORT` | `codex.effort` | unset | Force all Codex requests to this reasoning effort (`none`, `low`, `medium`, `high`, `xhigh`, `max`) | | `CCP_CODEX_REASONING_SUMMARY` | `codex.reasoningSummary` | unset | Request Codex reasoning summaries when reasoning effort is enabled; `off` and `none` suppress summaries | @@ -783,6 +839,12 @@ CCP_TRAFFIC_LOG=1`. `CCP_CONFIG_DIR` is set, Cursor tokens are written to `cursor/auth.json` under that directory, including on macOS. `CCP_CURSOR_AUTH_TOKEN` overrides local proxy-owned storage. +- GLM API key — macOS stores it under Keychain service `claude-code-proxy.glm` + (the store falls back to a mode-0600 file when non-interactive Keychain writes + are unavailable). Linux uses + `${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-proxy/glm/auth.json`; Windows + uses `%APPDATA%\claude-code-proxy\glm\auth.json`. `CCP_GLM_API_KEY` (alias + `GLM_API_KEY`) overrides local storage. ## Limitations @@ -818,6 +880,13 @@ CCP_TRAFFIC_LOG=1`. session continuation are implemented. Full Cursor workspace/tool callbacks are captured and documented under `history/`, but not yet implemented as Claude tool round-trips. +- **GLM — buffered streaming:** z.ai returns Anthropic SSE natively, but the + proxy buffers the upstream response before forwarding it to Claude Code + (matching the Codex/Kimi providers), so tokens arrive in one batch rather than + streaming incrementally. +- **GLM — pass-through only:** the provider forwards Anthropic requests verbatim; + it does not translate or drop Anthropic-specific fields, so anything z.ai does + not accept is surfaced as an upstream error. ## Development diff --git a/src/config.rs b/src/config.rs index 65023ab..1ec061b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,6 +39,7 @@ struct FileConfig { pub codex: Option, pub cursor: Option, pub grok: Option, + pub glm: Option, } #[derive(Deserialize, Clone)] @@ -90,6 +91,12 @@ struct GrokConfig { pub client_version: Option, } +#[derive(Deserialize, Clone)] +struct GlmConfig { + #[serde(rename = "baseUrl")] + pub base_url: Option, +} + #[derive(Deserialize)] struct FileLog { pub verbose: Option, @@ -206,6 +213,9 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec { if env.contains_key("CCP_KIMI_BASE_URL") { out.push("kimi.baseUrl (env)".to_string()); } + if env.contains_key("CCP_GLM_BASE_URL") { + out.push("glm.baseUrl (env)".to_string()); + } if env.contains_key("CCP_CURSOR_BASE_URL") { out.push("cursor.baseUrl (env)".to_string()); } @@ -312,6 +322,21 @@ pub fn kimi_base_url() -> String { "https://api.kimi.com/coding/v1".to_string() } +pub fn glm_base_url() -> String { + let env: HashMap<_, _> = std::env::vars().collect(); + if let Some(raw) = env.get("CCP_GLM_BASE_URL") { + return raw.clone(); + } + let config_dir = paths::config_dir(); + if let Some(file) = read_file_config(&config_dir) + && let Some(glm) = file.glm + && let Some(url) = glm.base_url + { + return url; + } + "https://api.z.ai/api/anthropic".to_string() +} + pub fn kimi_user_agent(default: &str) -> String { let env: HashMap<_, _> = std::env::vars().collect(); if let Some(raw) = env.get("CCP_KIMI_USER_AGENT") { diff --git a/src/main.rs b/src/main.rs index ef4b703..e2dd2bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -56,6 +56,10 @@ enum Commands { #[command(subcommand)] command: ProviderGroup, }, + Glm { + #[command(subcommand)] + command: ProviderGroup, + }, } #[derive(Debug, Subcommand)] @@ -135,6 +139,7 @@ fn main() -> Result<()> { Commands::Kimi { command } => run_provider_cli("kimi", command), Commands::Cursor { command } => run_provider_cli("cursor", command), Commands::Grok { command } => run_provider_cli("grok", command), + Commands::Glm { command } => run_provider_cli("glm", command), } } @@ -194,7 +199,7 @@ fn run_provider_cli(name: &str, command: ProviderGroup) -> Result<()> { fn print_models(registry: &Registry, full: bool) { let grouped = registry.grouped_models(); - for provider in ["codex", "kimi", "grok", "cursor"] { + for provider in ["codex", "kimi", "grok", "cursor", "glm"] { let Some(models) = grouped.get(provider) else { continue; }; diff --git a/src/providers/glm/auth.rs b/src/providers/glm/auth.rs new file mode 100644 index 0000000..6c094d0 --- /dev/null +++ b/src/providers/glm/auth.rs @@ -0,0 +1,122 @@ +use serde::{Deserialize, Serialize}; + +use crate::auth::{AuthStorage, KeychainFileAuthStore, SystemKeychain}; +use crate::paths; + +pub const KEYCHAIN_SERVICE: &str = "claude-code-proxy.glm"; +pub const KEYCHAIN_ACCOUNT: &str = "auth"; + +/// Stored GLM (z.ai) credential. z.ai uses a single static API key (no OAuth, +/// no refresh tokens), so this is intentionally minimal compared with the +/// OAuth-backed providers. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct StoredGlmAuth { + pub api_key: String, +} + +pub type DefaultGlmAuthStore = KeychainFileAuthStore; + +pub fn file_store() -> DefaultGlmAuthStore { + let primary = paths::provider_auth_file("glm"); + let legacy = paths::provider_legacy_auth_file("glm"); + KeychainFileAuthStore::new( + primary.to_string_lossy().to_string(), + legacy.to_string_lossy().to_string(), + KEYCHAIN_SERVICE, + KEYCHAIN_ACCOUNT, + use_macos_keychain(), + SystemKeychain, + ) +} + +pub(crate) fn env_glm_api_key() -> Option { + env_glm_api_key_from(|key| std::env::var(key).ok()) +} + +fn env_glm_api_key_from(get: impl Fn(&str) -> Option) -> Option { + get("CCP_GLM_API_KEY") + .filter(|k| !k.trim().is_empty()) + .or_else(|| get("GLM_API_KEY").filter(|k| !k.trim().is_empty())) +} + +/// Load the GLM API key, preferring the environment over stored credentials. +pub fn load_glm_api_key() -> Option { + if let Some(key) = env_glm_api_key() { + return Some(key); + } + let stored = file_store().load().ok().flatten()?; + if stored.api_key.trim().is_empty() { + return None; + } + Some(stored.api_key) +} + +pub fn save_glm_api_key(api_key: String) -> anyhow::Result<()> { + if api_key.trim().is_empty() { + anyhow::bail!("GLM API key must not be empty"); + } + file_store().save(StoredGlmAuth { api_key }) +} + +pub fn clear_glm_auth() -> anyhow::Result<()> { + file_store().clear() +} + +pub fn auth_location() -> String { + file_store().path() +} + +pub fn missing_auth_message() -> String { + [ + "GLM (z.ai) API key not found.", + "Run `claude-code-proxy glm auth login`, or set CCP_GLM_API_KEY / GLM_API_KEY.", + ] + .join(" ") +} + +fn use_macos_keychain() -> bool { + cfg!(target_os = "macos") && std::env::var_os("CCP_CONFIG_DIR").is_none() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn env_prefers_ccp_over_glm() { + let key = env_glm_api_key_from(|k| match k { + "CCP_GLM_API_KEY" => Some("ccp".into()), + "GLM_API_KEY" => Some("glm".into()), + _ => None, + }); + assert_eq!(key.as_deref(), Some("ccp")); + } + + #[test] + fn env_returns_none_when_unset() { + assert!(env_glm_api_key_from(|_| None).is_none()); + } + + #[test] + fn env_ignores_blank_values_and_falls_through() { + let key = env_glm_api_key_from(|k| match k { + "CCP_GLM_API_KEY" => Some(" ".into()), + "GLM_API_KEY" => Some("real".into()), + _ => None, + }); + assert_eq!(key.as_deref(), Some("real")); + } + + #[test] + fn stored_auth_round_trips_camel_case() { + let auth = StoredGlmAuth { + api_key: "sk-test".to_string(), + }; + let value = serde_json::to_value(&auth).unwrap(); + assert_eq!(value["apiKey"], "sk-test"); + assert!(value.get("api_key").is_none()); + let back: StoredGlmAuth = serde_json::from_value(value).unwrap(); + assert_eq!(back, auth); + } +} diff --git a/src/providers/glm/client.rs b/src/providers/glm/client.rs new file mode 100644 index 0000000..a249a36 --- /dev/null +++ b/src/providers/glm/client.rs @@ -0,0 +1,104 @@ +use std::time::Duration; + +use crate::config; + +#[derive(Debug)] +pub struct GlmError { + pub status: u16, + pub message: String, + pub detail: Option, + pub retry_after: Option, +} + +pub struct GlmResponse { + pub body: Vec, + pub status: u16, +} + +/// Async HTTP client for the z.ai Anthropic-compatible endpoint. +/// +/// z.ai speaks the Anthropic Messages API natively, so the request body is +/// forwarded verbatim (no translation) and the upstream Anthropic SSE/JSON +/// reply is piped straight back to Claude Code. +pub struct GlmHttpClient { + client: reqwest::Client, +} + +impl GlmHttpClient { + pub fn new() -> anyhow::Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(300)) + .build()?; + Ok(Self { client }) + } + + pub async fn post_messages(&self, api_key: &str, body: &[u8]) -> Result { + let base = config::glm_base_url(); + let url = format!("{}/v1/messages", base.trim_end_matches('/')); + + let resp = self + .client + .post(&url) + .header("authorization", format!("Bearer {api_key}")) + .header("anthropic-version", "2023-06-01") + .header("accept", "application/json") + .header("content-type", "application/json") + .body(body.to_vec()) + .send() + .await + .map_err(|e| GlmError { + status: 0, + message: "Network error".to_string(), + detail: Some(e.to_string()), + retry_after: None, + })?; + + let status = resp.status().as_u16(); + + if status == 429 { + let retry_after = resp + .headers() + .get("retry-after") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let text = resp.text().await.unwrap_or_default(); + return Err(GlmError { + status: 429, + message: "Rate limited".to_string(), + detail: if text.is_empty() { None } else { Some(text) }, + retry_after, + }); + } + + if status == 401 || status == 403 { + let text = resp.text().await.unwrap_or_default(); + return Err(GlmError { + status, + message: if status == 401 { + "Unauthorized" + } else { + "Forbidden" + } + .to_string(), + detail: if text.is_empty() { None } else { Some(text) }, + retry_after: None, + }); + } + + if !(200..300).contains(&status) { + let text = resp.text().await.unwrap_or_default(); + return Err(GlmError { + status, + message: "Upstream error".to_string(), + detail: if text.is_empty() { None } else { Some(text) }, + retry_after: None, + }); + } + + let body_bytes = resp.bytes().await.map(|b| b.to_vec()).unwrap_or_default(); + Ok(GlmResponse { + body: body_bytes, + status, + }) + } +} diff --git a/src/providers/glm/mod.rs b/src/providers/glm/mod.rs new file mode 100644 index 0000000..e7fa948 --- /dev/null +++ b/src/providers/glm/mod.rs @@ -0,0 +1,261 @@ +pub mod auth; +pub mod client; + +use async_trait::async_trait; +use axum::Json; +use axum::response::{IntoResponse, Response}; +use http::StatusCode; + +use crate::anthropic::error::json_error; +use crate::anthropic::schema::{CountTokensResponse, MessagesRequest}; +use crate::auth::AuthStorage; +use crate::monitor::usage_from_anthropic_sse; +use crate::provider::{CliHandlers, Provider, RequestContext}; +use crate::registry::{GLM_MODELS, normalize_incoming_model}; + +use self::auth::{ + auth_location, clear_glm_auth, env_glm_api_key, file_store, load_glm_api_key, + missing_auth_message, save_glm_api_key, +}; +use self::client::{GlmError, GlmHttpClient}; + +pub struct GlmProvider; + +impl GlmProvider { + pub fn new() -> Self { + Self + } +} + +impl Default for GlmProvider { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Provider for GlmProvider { + fn name(&self) -> &'static str { + "glm" + } + + fn supported_models(&self) -> Vec { + GLM_MODELS.iter().map(|s| s.to_string()).collect() + } + + fn cli(&self) -> &'static dyn CliHandlers { + &GLM_CLI + } + + async fn handle_messages(&self, mut body: MessagesRequest, ctx: RequestContext) -> Response { + let want_stream = body.stream; + + // z.ai is Anthropic-native: forward the request verbatim after stripping + // the local [1m] compaction hint, then pipe the upstream Anthropic + // SSE/JSON reply straight back to Claude Code (no format translation). + body.model = body.model.map(|m| normalize_incoming_model(&m)); + + let api_key = match load_glm_api_key() { + Some(k) => k, + None => { + return json_error( + StatusCode::UNAUTHORIZED, + "authentication_error", + missing_auth_message(), + ); + } + }; + + let body_bytes = match serde_json::to_vec(&body) { + Ok(b) => b, + Err(e) => { + return json_error( + StatusCode::BAD_REQUEST, + "invalid_request_error", + format!("Failed to serialize request: {e}"), + ); + } + }; + + if let Some(monitor) = ctx.monitor.as_ref() { + monitor.upstream_started(&ctx.req_id); + } + + let client = match GlmHttpClient::new() { + Ok(c) => c, + Err(e) => { + return json_error( + StatusCode::BAD_GATEWAY, + "api_error", + format!("Failed to create HTTP client: {e}"), + ); + } + }; + + let upstream = match client.post_messages(&api_key, &body_bytes).await { + Ok(r) => r, + Err(e) => return map_glm_error(&e), + }; + + if want_stream { + let sse_bytes = upstream.body; + if let Some(monitor) = ctx.monitor.as_ref() { + let (input_tokens, output_tokens) = usage_from_anthropic_sse(&sse_bytes); + monitor.stream_progress( + &ctx.req_id, + sse_bytes.len() as u64, + count_sse_events(&sse_bytes), + input_tokens, + output_tokens, + ); + } + let headers = [ + (http::header::CONTENT_TYPE, "text/event-stream"), + (http::header::CACHE_CONTROL, "no-cache"), + (http::header::CONNECTION, "keep-alive"), + ]; + (headers, sse_bytes).into_response() + } else { + let json: serde_json::Value = match serde_json::from_slice(&upstream.body) { + Ok(v) => v, + Err(e) => { + return json_error( + StatusCode::BAD_GATEWAY, + "api_error", + format!("Failed to parse upstream response: {e}"), + ); + } + }; + if let Some(monitor) = ctx.monitor.as_ref() { + monitor.usage_updated( + &ctx.req_id, + json.pointer("/usage/input_tokens").and_then(|v| v.as_u64()), + json.pointer("/usage/output_tokens") + .and_then(|v| v.as_u64()), + ); + } + (StatusCode::OK, Json(json)).into_response() + } + } + + async fn handle_count_tokens(&self, body: MessagesRequest, ctx: RequestContext) -> Response { + // z.ai exposes the Anthropic count_tokens endpoint, but calling upstream + // on every count adds latency. Use a rough local estimate (mirrors the + // cursor provider), which is sufficient for Claude Code's compaction. + let tokens = (serde_json::to_vec(&body).unwrap_or_default().len() / 4) as u64; + if let Some(monitor) = ctx.monitor.as_ref() { + monitor.usage_updated(&ctx.req_id, Some(tokens), None); + } + ( + StatusCode::OK, + Json(CountTokensResponse { + input_tokens: tokens, + }), + ) + .into_response() + } +} + +fn count_sse_events(bytes: &[u8]) -> u64 { + String::from_utf8_lossy(bytes).matches("event:").count() as u64 +} + +fn map_glm_error(err: &GlmError) -> Response { + match err.status { + 401 | 403 => json_error( + StatusCode::UNAUTHORIZED, + "authentication_error", + err.detail.as_deref().unwrap_or("Authentication failed"), + ), + 429 => { + let retry_after = err.retry_after.as_deref().unwrap_or("5"); + let resp = json_error( + StatusCode::TOO_MANY_REQUESTS, + "rate_limit_error", + &err.message, + ); + let headers = [(http::header::RETRY_AFTER, retry_after)]; + (headers, resp).into_response() + } + 0 => json_error( + StatusCode::BAD_GATEWAY, + "api_error", + err.detail.as_deref().unwrap_or(&err.message), + ), + _ => json_error( + StatusCode::BAD_GATEWAY, + "api_error", + err.detail.as_deref().unwrap_or("Upstream error"), + ), + } +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +pub(crate) struct GlmCli; + +impl CliHandlers for GlmCli { + fn login(&self) -> Result<(), anyhow::Error> { + use std::io::{self, BufRead}; + println!("Enter your z.ai API key (https://z.ai) and press Enter:"); + let mut buf = String::new(); + io::stdin().lock().read_line(&mut buf)?; + let key = buf.trim().to_string(); + if key.is_empty() { + anyhow::bail!("no API key provided"); + } + save_glm_api_key(key)?; + println!("GLM API key saved to {}", auth_location()); + println!("Tip: you can also export CCP_GLM_API_KEY (or GLM_API_KEY)."); + Ok(()) + } + + fn device(&self) -> Result<(), anyhow::Error> { + anyhow::bail!( + "glm: API-key provider — set CCP_GLM_API_KEY / GLM_API_KEY or run `claude-code-proxy glm auth login`" + ); + } + + fn status(&self) -> Result<(), anyhow::Error> { + let env_present = env_glm_api_key().is_some(); + let stored_present = file_store().load().ok().flatten().is_some(); + if !env_present && !stored_present { + anyhow::bail!("Not authenticated"); + } + println!("Authenticated: true"); + println!( + "Env (CCP_GLM_API_KEY/GLM_API_KEY): {}", + if env_present { "set" } else { "unset" } + ); + if stored_present { + println!("Stored: yes ({})", auth_location()); + } else { + println!("Stored: no"); + } + println!("API key has no expiry (static key)."); + Ok(()) + } + + fn logout(&self) -> Result<(), anyhow::Error> { + clear_glm_auth()?; + println!("GLM stored auth cleared. Unset CCP_GLM_API_KEY / GLM_API_KEY if using env auth."); + Ok(()) + } +} + +pub(crate) static GLM_CLI: GlmCli = GlmCli; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn supported_models_lists_known_glm_models() { + let provider = GlmProvider::new(); + let models = provider.supported_models(); + assert!(models.contains(&"glm-4.7".to_string())); + assert!(models.contains(&"glm-5.2".to_string())); + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 438af71..9e64f44 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -1,5 +1,6 @@ pub mod codex; pub mod cursor; +pub mod glm; pub mod grok; pub mod kimi; pub mod translate_shared; diff --git a/src/registry.rs b/src/registry.rs index 4b149a3..983da6b 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -51,6 +51,8 @@ pub(crate) const CODEX_MODELS: &[&str] = &[ pub(crate) const KIMI_MODELS: &[&str] = &["kimi-for-coding", "kimi-k2.6", "k2.6"]; pub(crate) const GROK_MODELS: &[&str] = &["grok-composer-2.5-fast", "grok-4.5"]; +pub(crate) const GLM_MODELS: &[&str] = &["glm-4.7", "glm-5.2"]; + pub struct Registry { alias_provider: AliasProvider, models: BTreeMap>, @@ -73,6 +75,10 @@ impl Registry { .map(|model| (*model).to_string()) .collect(), ); + models.insert( + "glm".into(), + GLM_MODELS.iter().map(|m| (*m).to_string()).collect(), + ); let mut handlers = BTreeMap::new(); for (name, entries) in &models { @@ -81,6 +87,7 @@ impl Registry { "kimi" => Arc::new(crate::providers::kimi::KimiProvider::new()), "cursor" => Arc::new(crate::providers::cursor::CursorProvider::new()), "grok" => Arc::new(crate::providers::grok::GrokProvider::new()), + "glm" => Arc::new(crate::providers::glm::GlmProvider::new()), _ => Arc::new(PlaceholderProvider::new(name, entries.clone())), }; handlers.insert(name.clone(), handler); @@ -374,4 +381,17 @@ mod tests { "cursor" ); } + + #[test] + fn glm_model_routes_to_glm_provider() { + let registry = Registry::new(AliasProvider::Codex); + for model in ["glm-4.7", "glm-5.2"] { + let p = registry.provider_for_model(model, None); + assert!(p.is_some(), "{model} should route to a provider"); + assert_eq!(p.expect("provider").name(), "glm"); + } + // The [1m] compaction hint is stripped before routing. + let p = registry.provider_for_model("glm-5.2[1m]", None); + assert_eq!(p.expect("provider").name(), "glm"); + } }