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).
@@ -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");
+ }
}