Skip to content

Commit 3e02e68

Browse files
SimianAstronaut7joe2643claude
authored
feat(providers): add ZhipuJwt auth style for Z.AI and GLM providers (#4911)
Z.AI and GLM APIs require HMAC-SHA256 JWT authentication instead of plain Bearer tokens. Add ZhipuJwt AuthStyle variant that generates short-lived JWTs (3.5 min expiry) from id.secret credentials, and refactor auth application into a shared helper for spawned tasks. Co-authored-by: khhjoe <joe264326832008@hotmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent dd147dd commit 3e02e68

File tree

6 files changed

+222
-54
lines changed

6 files changed

+222
-54
lines changed

Cargo.lock

Lines changed: 16 additions & 26 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,6 @@ indicatif = "0.18"
9797
# Temp files (update pipeline rollback)
9898
tempfile = "3.26"
9999

100-
# Tar/gzip extraction (update pipeline)
101-
flate2 = "1.1"
102-
tar = "0.4"
103100

104101
# Zip extraction for ClawhHub / OpenClaw registry installers
105102
zip = { version = "8.1", default-features = false, features = ["deflate-flate2"] }

src/providers/compatible.rs

Lines changed: 133 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,59 @@ pub enum AuthStyle {
5959
XApiKey,
6060
/// Custom header name
6161
Custom(String),
62+
/// Zhipu/GLM JWT auth: the credential is `id.secret`, and a short-lived
63+
/// JWT (HMAC-SHA256, 3.5 min expiry) is generated per request.
64+
/// Used by Z.AI and GLM providers.
65+
ZhipuJwt,
66+
}
67+
68+
/// Generate a Zhipu JWT from an `id.secret` API key.
69+
/// Returns `Authorization: Bearer <jwt>` value. Token is valid for 3.5 minutes.
70+
fn zhipu_jwt_bearer(credential: &str) -> Result<String, String> {
71+
let (id, secret) = credential
72+
.split_once('.')
73+
.ok_or_else(|| "Zhipu API key must be in 'id.secret' format".to_string())?;
74+
75+
#[allow(clippy::cast_possible_truncation)] // millis won't exceed u64 until year 584 million
76+
let now_ms = std::time::SystemTime::now()
77+
.duration_since(std::time::UNIX_EPOCH)
78+
.map_err(|e| e.to_string())?
79+
.as_millis() as u64;
80+
let exp_ms = now_ms + 210_000; // 3.5 minutes
81+
82+
// Header: {"alg":"HS256","typ":"JWT","sign_type":"SIGN"}
83+
let header_b64 = base64url_no_pad(br#"{"alg":"HS256","typ":"JWT","sign_type":"SIGN"}"#);
84+
let payload = format!(r#"{{"api_key":"{id}","exp":{exp_ms},"timestamp":{now_ms}}}"#);
85+
let payload_b64 = base64url_no_pad(payload.as_bytes());
86+
87+
let signing_input = format!("{header_b64}.{payload_b64}");
88+
let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, secret.as_bytes());
89+
let sig = ring::hmac::sign(&key, signing_input.as_bytes());
90+
let sig_b64 = base64url_no_pad(sig.as_ref());
91+
92+
Ok(format!("Bearer {signing_input}.{sig_b64}"))
93+
}
94+
95+
fn base64url_no_pad(data: &[u8]) -> String {
96+
use base64::engine::{general_purpose::URL_SAFE_NO_PAD, Engine};
97+
URL_SAFE_NO_PAD.encode(data)
98+
}
99+
100+
/// Apply auth to a request builder (usable from spawned tasks without `&self`).
101+
fn apply_auth_to_request(
102+
req: reqwest::RequestBuilder,
103+
style: &AuthStyle,
104+
credential: &str,
105+
) -> reqwest::RequestBuilder {
106+
match style {
107+
AuthStyle::Bearer => req.header("Authorization", format!("Bearer {credential}")),
108+
AuthStyle::XApiKey => req.header("x-api-key", credential),
109+
AuthStyle::Custom(header) => req.header(header, credential),
110+
AuthStyle::ZhipuJwt => match zhipu_jwt_bearer(credential) {
111+
Ok(val) => req.header("Authorization", val),
112+
Err(_) => req.header("Authorization", format!("Bearer {credential}")),
113+
},
114+
}
62115
}
63116

64117
impl OpenAiCompatibleProvider {
@@ -1364,6 +1417,10 @@ impl OpenAiCompatibleProvider {
13641417
AuthStyle::Bearer => req.header("Authorization", format!("Bearer {credential}")),
13651418
AuthStyle::XApiKey => req.header("x-api-key", credential),
13661419
AuthStyle::Custom(header) => req.header(header, credential),
1420+
AuthStyle::ZhipuJwt => match zhipu_jwt_bearer(credential) {
1421+
Ok(val) => req.header("Authorization", val),
1422+
Err(_) => req.header("Authorization", format!("Bearer {credential}")),
1423+
},
13671424
}
13681425
}
13691426

@@ -2167,13 +2224,7 @@ impl Provider for OpenAiCompatibleProvider {
21672224
tokio::spawn(async move {
21682225
let mut req_builder = client.post(&url).json(&payload);
21692226

2170-
req_builder = match &auth_header {
2171-
AuthStyle::Bearer => {
2172-
req_builder.header("Authorization", format!("Bearer {}", credential))
2173-
}
2174-
AuthStyle::XApiKey => req_builder.header("x-api-key", &credential),
2175-
AuthStyle::Custom(header) => req_builder.header(header, &credential),
2176-
};
2227+
req_builder = apply_auth_to_request(req_builder, &auth_header, &credential);
21772228
req_builder = req_builder.header("Accept", "text/event-stream");
21782229

21792230
let response = match req_builder.send().await {
@@ -2268,13 +2319,7 @@ impl Provider for OpenAiCompatibleProvider {
22682319
let mut req_builder = client.post(&url).json(&request);
22692320

22702321
// Apply auth header
2271-
req_builder = match &auth_header {
2272-
AuthStyle::Bearer => {
2273-
req_builder.header("Authorization", format!("Bearer {}", credential))
2274-
}
2275-
AuthStyle::XApiKey => req_builder.header("x-api-key", &credential),
2276-
AuthStyle::Custom(header) => req_builder.header(header, &credential),
2277-
};
2322+
req_builder = apply_auth_to_request(req_builder, &auth_header, &credential);
22782323

22792324
// Set accept header for streaming
22802325
req_builder = req_builder.header("Accept", "text/event-stream");
@@ -2375,15 +2420,7 @@ impl Provider for OpenAiCompatibleProvider {
23752420

23762421
tokio::spawn(async move {
23772422
let mut req_builder = client.post(&url).json(&request);
2378-
2379-
req_builder = match &auth_header {
2380-
AuthStyle::Bearer => {
2381-
req_builder.header("Authorization", format!("Bearer {}", credential))
2382-
}
2383-
AuthStyle::XApiKey => req_builder.header("x-api-key", &credential),
2384-
AuthStyle::Custom(header) => req_builder.header(header, &credential),
2385-
};
2386-
2423+
req_builder = apply_auth_to_request(req_builder, &auth_header, &credential);
23872424
req_builder = req_builder.header("Accept", "text/event-stream");
23882425

23892426
let response = match req_builder.send().await {
@@ -2612,6 +2649,79 @@ mod tests {
26122649
assert!(matches!(p.auth_header, AuthStyle::Custom(_)));
26132650
}
26142651

2652+
#[test]
2653+
fn zhipu_jwt_produces_valid_three_part_token() {
2654+
let result = zhipu_jwt_bearer("testid.testsecret").unwrap();
2655+
assert!(result.starts_with("Bearer "));
2656+
let jwt = result.strip_prefix("Bearer ").unwrap();
2657+
let parts: Vec<&str> = jwt.split('.').collect();
2658+
assert_eq!(parts.len(), 3, "JWT must have 3 dot-separated parts: {jwt}");
2659+
}
2660+
2661+
#[test]
2662+
fn zhipu_jwt_header_is_correct() {
2663+
use base64::engine::{general_purpose::URL_SAFE_NO_PAD, Engine};
2664+
let result = zhipu_jwt_bearer("myid.mysecret").unwrap();
2665+
let jwt = result.strip_prefix("Bearer ").unwrap();
2666+
let header_b64 = jwt.split('.').next().unwrap();
2667+
let header_bytes = URL_SAFE_NO_PAD.decode(header_b64).unwrap();
2668+
let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap();
2669+
assert_eq!(header["alg"], "HS256");
2670+
assert_eq!(header["typ"], "JWT");
2671+
assert_eq!(header["sign_type"], "SIGN");
2672+
}
2673+
2674+
#[test]
2675+
fn zhipu_jwt_payload_contains_api_key_and_timestamps() {
2676+
use base64::engine::{general_purpose::URL_SAFE_NO_PAD, Engine};
2677+
let result = zhipu_jwt_bearer("myapiid.mysecretkey").unwrap();
2678+
let jwt = result.strip_prefix("Bearer ").unwrap();
2679+
let payload_b64 = jwt.split('.').nth(1).unwrap();
2680+
let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).unwrap();
2681+
let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
2682+
assert_eq!(payload["api_key"], "myapiid");
2683+
assert!(payload["exp"].is_number());
2684+
assert!(payload["timestamp"].is_number());
2685+
// exp should be ~210s after timestamp
2686+
let ts = payload["timestamp"].as_u64().unwrap();
2687+
let exp = payload["exp"].as_u64().unwrap();
2688+
assert_eq!(exp - ts, 210_000);
2689+
}
2690+
2691+
#[test]
2692+
fn zhipu_jwt_signature_is_verifiable() {
2693+
let secret = "testsecret123";
2694+
let credential = format!("testid.{secret}");
2695+
let result = zhipu_jwt_bearer(&credential).unwrap();
2696+
let jwt = result.strip_prefix("Bearer ").unwrap();
2697+
let parts: Vec<&str> = jwt.split('.').collect();
2698+
let signing_input = format!("{}.{}", parts[0], parts[1]);
2699+
2700+
// Verify HMAC-SHA256 signature
2701+
let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, secret.as_bytes());
2702+
use base64::engine::{general_purpose::URL_SAFE_NO_PAD, Engine};
2703+
let sig_bytes = URL_SAFE_NO_PAD.decode(parts[2]).unwrap();
2704+
ring::hmac::verify(&key, signing_input.as_bytes(), &sig_bytes)
2705+
.expect("signature must verify");
2706+
}
2707+
2708+
#[test]
2709+
fn zhipu_jwt_rejects_invalid_key_format() {
2710+
assert!(zhipu_jwt_bearer("no-dot-here").is_err());
2711+
assert!(zhipu_jwt_bearer("").is_err());
2712+
}
2713+
2714+
#[test]
2715+
fn zhipu_jwt_auth_style_applies_correctly() {
2716+
let p = OpenAiCompatibleProvider::new(
2717+
"Z.AI",
2718+
"https://api.z.ai/api/coding/paas/v4",
2719+
Some("testid.testsecret"),
2720+
AuthStyle::ZhipuJwt,
2721+
);
2722+
assert!(matches!(p.auth_header, AuthStyle::ZhipuJwt));
2723+
}
2724+
26152725
#[tokio::test]
26162726
async fn all_compatible_providers_fail_without_key() {
26172727
let providers = vec![

src/providers/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1286,14 +1286,14 @@ pub(crate) fn create_provider_with_url_and_options(
12861286
"Z.AI",
12871287
zai_base_url(name).expect("checked in guard"),
12881288
key,
1289-
AuthStyle::Bearer,
1289+
AuthStyle::ZhipuJwt,
12901290
))),
12911291
name if glm_base_url(name).is_some() => {
12921292
Ok(compat(OpenAiCompatibleProvider::new_no_responses_fallback(
12931293
"GLM",
12941294
glm_base_url(name).expect("checked in guard"),
12951295
key,
1296-
AuthStyle::Bearer,
1296+
AuthStyle::ZhipuJwt,
12971297
)))
12981298
}
12991299
name if minimax_base_url(name).is_some() => Ok(compat(

tests/live/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
mod gemini_fallback_oauth_refresh;
22
mod openai_codex_vision_e2e;
33
mod providers;
4+
mod zai_jwt_auth;

0 commit comments

Comments
 (0)