Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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).

<img src="meta/claude-code-screenshot-2026-07.webp" alt="Claude Code running through claude-code-proxy" />

Expand Down Expand Up @@ -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=<your z.ai 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\<provider>\auth.json`; on Linux they are written
under `${XDG_CONFIG_HOME:-$HOME/.config}/claude-code-proxy/<provider>/auth.json`
Expand All @@ -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
Expand All @@ -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:<model-id>`, `cursor-plan:<model-id>`, `cursor-ask:<model-id>` → **cursor**
- `glm-4.7`, `glm-5.2` → **glm**

An unknown model returns a 400 listing the supported ids. There is no
implicit default provider.
Expand Down Expand Up @@ -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
```
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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
Expand All @@ -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 |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
25 changes: 25 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ struct FileConfig {
pub codex: Option<CodexConfig>,
pub cursor: Option<CursorConfig>,
pub grok: Option<GrokConfig>,
pub glm: Option<GlmConfig>,
}

#[derive(Deserialize, Clone)]
Expand Down Expand Up @@ -90,6 +91,12 @@ struct GrokConfig {
pub client_version: Option<String>,
}

#[derive(Deserialize, Clone)]
struct GlmConfig {
#[serde(rename = "baseUrl")]
pub base_url: Option<String>,
}

#[derive(Deserialize)]
struct FileLog {
pub verbose: Option<bool>,
Expand Down Expand Up @@ -206,6 +213,9 @@ pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec<String> {
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());
}
Expand Down Expand Up @@ -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") {
Expand Down
7 changes: 6 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ enum Commands {
#[command(subcommand)]
command: ProviderGroup,
},
Glm {
#[command(subcommand)]
command: ProviderGroup,
},
}

#[derive(Debug, Subcommand)]
Expand Down Expand Up @@ -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),
}
}

Expand Down Expand Up @@ -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;
};
Expand Down
122 changes: 122 additions & 0 deletions src/providers/glm/auth.rs
Original file line number Diff line number Diff line change
@@ -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<StoredGlmAuth, SystemKeychain>;

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<String> {
env_glm_api_key_from(|key| std::env::var(key).ok())
}

fn env_glm_api_key_from(get: impl Fn(&str) -> Option<String>) -> Option<String> {
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<String> {
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);
}
}
Loading