use std::env;
use std::sync::Arc;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tracing::{info_span, instrument, Instrument};
use super::types::*;
use crate::providers::error::{CompletionError, ProviderError};
use crate::providers::http::build_http_client;
const DEFAULT_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
const DEFAULT_MAX_TOKENS: u64 = 8192;
#[derive(Clone)]
pub struct ZaiClient {
inner: Arc<ZaiClientInner>,
}
struct ZaiClientInner {
http_client: Client,
api_key: String,
base_url: String,
enable_thinking: bool,
#[allow(dead_code)] thinking_budget_tokens: Option<u64>,
temperature: Option<f64>,
top_p: Option<f64>,
top_k: Option<u64>,
max_tokens: Option<u64>,
parallel_tool_calls: Option<bool>,
}
impl ZaiClient {
pub fn from_env() -> Result<Self, ProviderError> {
let api_key = env::var("ZAI_API_KEY")
.map_err(|_| ProviderError::EnvVarNotSet("ZAI_API_KEY".to_string()))?;
Ok(ZaiClientBuilder::new(&api_key).build())
}
pub fn completion_model(&self, model_id: &str) -> ZaiCompletionModel {
ZaiCompletionModel {
client: self.clone(),
model_id: model_id.to_string(),
}
}
}
pub struct ZaiClientBuilder {
api_key: String,
base_url: String,
enable_thinking: bool,
thinking_budget_tokens: Option<u64>,
temperature: Option<f64>,
top_p: Option<f64>,
top_k: Option<u64>,
max_tokens: Option<u64>,
parallel_tool_calls: Option<bool>,
}
impl ZaiClientBuilder {
pub fn new(api_key: &str) -> Self {
Self {
api_key: api_key.to_string(),
base_url: DEFAULT_BASE_URL.to_string(),
enable_thinking: true,
thinking_budget_tokens: None,
temperature: None,
top_p: None,
top_k: None,
max_tokens: None,
parallel_tool_calls: Some(true),
}
}
pub fn base_url(mut self, url: &str) -> Self {
self.base_url = url.to_string();
self
}
pub fn enable_thinking(mut self, enabled: bool) -> Self {
self.enable_thinking = enabled;
self
}
pub fn thinking_budget_tokens(mut self, tokens: u64) -> Self {
self.thinking_budget_tokens = Some(tokens);
self
}
pub fn temperature(mut self, temp: f64) -> Self {
self.temperature = Some(temp.clamp(0.0, 1.0));
self
}
pub fn top_p(mut self, p: f64) -> Self {
self.top_p = Some(p.clamp(0.0, 1.0));
self
}
pub fn top_k(mut self, k: u64) -> Self {
self.top_k = Some(k);
self
}
pub fn max_tokens(mut self, tokens: u64) -> Self {
self.max_tokens = Some(tokens);
self
}
pub fn parallel_tool_calls(mut self, enabled: bool) -> Self {
self.parallel_tool_calls = Some(enabled);
self
}
pub fn build(self) -> ZaiClient {
ZaiClient {
inner: Arc::new(ZaiClientInner {
http_client: build_http_client(),
api_key: self.api_key,
base_url: self.base_url,
enable_thinking: self.enable_thinking,
thinking_budget_tokens: self.thinking_budget_tokens,
temperature: self.temperature,
top_p: self.top_p,
top_k: self.top_k,
max_tokens: self.max_tokens,
parallel_tool_calls: self.parallel_tool_calls,
}),
}
}
}
#[derive(Clone)]
pub struct ZaiCompletionModel {
client: ZaiClient,
model_id: String,
}
#[derive(Debug, Clone, Default)]
pub struct CompletionRequest {
pub preamble: Option<String>,
pub messages: Vec<Message>,
pub tools: Vec<ToolDefinition>,
pub temperature: Option<f64>,
pub max_tokens: Option<u64>,
pub additional_params: Option<serde_json::Value>,
}
#[derive(Debug, Clone)]
pub struct Message {
pub role: String,
pub content: String,
pub tool_calls: Option<Vec<ToolCall>>,
pub tool_call_id: Option<String>,
pub reasoning: Option<String>,
}
impl Message {
pub fn user(content: &str) -> Self {
Self {
role: "user".to_string(),
content: content.to_string(),
tool_calls: None,
tool_call_id: None,
reasoning: None,
}
}
pub fn assistant(content: &str) -> Self {
Self {
role: "assistant".to_string(),
content: content.to_string(),
tool_calls: None,
tool_call_id: None,
reasoning: None,
}
}
pub fn system(content: &str) -> Self {
Self {
role: "system".to_string(),
content: content.to_string(),
tool_calls: None,
tool_call_id: None,
reasoning: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
#[derive(Debug, Clone)]
pub struct CompletionResponse<R> {
pub message: Message,
pub usage: Usage,
pub raw: R,
pub reasoning_content: Option<String>,
pub finish_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Usage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
#[serde(default)]
pub cache_read_tokens: u64,
#[serde(default)]
pub cache_creation_tokens: u64,
}
impl ZaiCompletionModel {
pub fn model_id(&self) -> &str {
&self.model_id
}
pub fn provider(&self) -> &str {
"zai"
}
#[instrument(skip(self, request), fields(model = %self.model_id, provider = "zai"))]
pub async fn completion(
&self,
request: CompletionRequest,
) -> Result<CompletionResponse<ZaiResponse>, CompletionError> {
let inner = &self.client.inner;
let mut messages = Vec::new();
if let Some(preamble) = &request.preamble {
messages.push(ZaiMessage {
role: "system".to_string(),
content: Some(preamble.clone()),
tool_calls: None,
tool_call_id: None,
reasoning_content: None,
});
}
for msg in &request.messages {
if msg.role == "user" && msg.content.is_empty() {
continue;
}
messages.push(ZaiMessage {
role: msg.role.clone(),
content: Some(msg.content.clone()),
tool_calls: msg.tool_calls.as_ref().map(|calls| {
calls
.iter()
.map(|tc| ZaiToolCall {
id: tc.id.clone(),
call_type: "function".to_string(),
function: ZaiFunctionCall {
name: tc.name.clone(),
arguments: tc.arguments.clone(),
},
})
.collect()
}),
tool_call_id: msg.tool_call_id.clone(),
reasoning_content: None,
});
}
let tools = if request.tools.is_empty() {
None
} else {
Some(
request
.tools
.iter()
.map(|t| ZaiTool {
tool_type: "function".to_string(),
function: ZaiFunction {
name: t.name.clone(),
description: Some(t.description.clone()),
parameters: Some(t.parameters.clone()),
},
})
.collect(),
)
};
let thinking = if inner.enable_thinking {
Some(ZaiThinkingConfig::enabled())
} else {
None
};
let zai_request = ZaiRequest {
model: self.model_id.clone(),
messages,
temperature: request.temperature.or(inner.temperature),
max_tokens: request
.max_tokens
.or(inner.max_tokens)
.or(Some(DEFAULT_MAX_TOKENS)),
top_p: inner.top_p,
top_k: inner.top_k,
tools,
tool_choice: None,
thinking,
parallel_tool_calls: inner.parallel_tool_calls,
};
let url = format!("{}/chat/completions", inner.base_url);
let response = inner
.http_client
.post(&url)
.header("Authorization", format!("Bearer {}", inner.api_key))
.header("Content-Type", "application/json")
.json(&zai_request)
.send()
.instrument(info_span!("zai_http_request"))
.await
.map_err(ProviderError::Request)?;
let status = response.status();
if !status.is_success() {
let error_text = response.text().await.unwrap_or_default();
return Err(CompletionError::Provider(ProviderError::Http {
status: status.as_u16(),
message: error_text,
}));
}
let zai_response: ZaiResponse = response.json().await.map_err(ProviderError::Request)?;
let choice = zai_response.choices.first().ok_or_else(|| {
CompletionError::Provider(ProviderError::InvalidResponse(
"No choices in response".to_string(),
))
})?;
let tool_calls = choice.message.tool_calls.as_ref().map(|calls| {
calls
.iter()
.map(|tc| ToolCall {
id: tc.id.clone(),
name: tc.function.name.clone(),
arguments: tc.function.arguments.clone(),
})
.collect()
});
let message = Message {
role: choice.message.role.clone(),
content: choice.message.content.clone().unwrap_or_default(),
tool_calls,
tool_call_id: None,
reasoning: None,
};
let cache_read_tokens = zai_response
.usage
.cached_tokens
.or_else(|| {
zai_response
.usage
.prompt_tokens_details
.as_ref()
.and_then(|d| d.cached_tokens)
})
.unwrap_or(0);
Ok(CompletionResponse {
message,
usage: Usage {
prompt_tokens: zai_response.usage.prompt_tokens,
completion_tokens: zai_response.usage.completion_tokens,
total_tokens: zai_response.usage.total_tokens,
cache_read_tokens,
cache_creation_tokens: 0, },
raw: zai_response.clone(),
reasoning_content: choice.message.reasoning_content.clone(),
finish_reason: choice.finish_reason.clone(),
})
}
}