@@ -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
64117impl 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 ! [
0 commit comments