use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use validator::Validate;
use super::create::{BackgroundColor, EmbeddingId, KnowledgeIcon};
use super::types::KnowledgeOperationResponse;
use crate::client::ZaiClient;
#[derive(Clone, Default, Serialize, Deserialize, Validate)]
pub struct KnowledgeUpdateBody {
#[serde(skip_serializing_if = "Option::is_none")]
pub embedding_id: Option<EmbeddingId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub embedding_model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(range(max = 1))]
pub contextual: Option<u8>,
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(length(min = 1))]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<BackgroundColor>,
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<KnowledgeIcon>,
#[serde(skip_serializing_if = "Option::is_none")]
#[validate(url)]
pub callback_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub callback_header: Option<BTreeMap<String, String>>,
}
impl std::fmt::Debug for KnowledgeUpdateBody {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("KnowledgeUpdateBody")
.field("embedding_id", &self.embedding_id)
.field(
"embedding_model_configured",
&self.embedding_model.is_some(),
)
.field("contextual", &self.contextual)
.field("name_configured", &self.name.is_some())
.field("description_configured", &self.description.is_some())
.field("background", &self.background)
.field("icon", &self.icon)
.field("callback_url_configured", &self.callback_url.is_some())
.field(
"callback_header_entries",
&self.callback_header.as_ref().map(BTreeMap::len),
)
.finish()
}
}
impl KnowledgeUpdateBody {
fn validate_embedding_pair(&self) -> crate::ZaiResult<()> {
let Some(model) = self.embedding_model.as_deref() else {
return Ok(());
};
if !matches!(model, "Embedding-2" | "Embedding-3" | "Embedding-3-pro") {
return Err(crate::client::validation::invalid(
"embedding_model must be one of: Embedding-2, Embedding-3, Embedding-3-pro",
));
}
if let Some(id) = self.embedding_id
&& model != id.as_model_name()
{
return Err(crate::client::validation::invalid(format!(
"embedding_id {} requires embedding_model '{}'",
id.as_i64(),
id.as_model_name()
)));
}
Ok(())
}
}
pub struct KnowledgeUpdateRequest {
id: String,
body: KnowledgeUpdateBody,
}
impl KnowledgeUpdateRequest {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
body: KnowledgeUpdateBody::default(),
}
}
pub fn with_embedding_id(mut self, id: EmbeddingId) -> Self {
self.body.embedding_id = Some(id);
self
}
pub fn with_embedding_model(mut self, model: impl Into<String>) -> Self {
self.body.embedding_model = Some(model.into());
self
}
pub fn with_contextual(mut self, contextual: u8) -> Self {
self.body.contextual = Some(contextual);
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.body.name = Some(name.into());
self
}
pub fn with_description(mut self, desc: impl Into<String>) -> Self {
self.body.description = Some(desc.into());
self
}
pub fn with_background(mut self, bg: BackgroundColor) -> Self {
self.body.background = Some(bg);
self
}
pub fn with_icon(mut self, icon: KnowledgeIcon) -> Self {
self.body.icon = Some(icon);
self
}
pub fn with_callback_url(mut self, url: impl Into<String>) -> Self {
self.body.callback_url = Some(url.into());
self
}
pub fn with_callback_header(mut self, headers: BTreeMap<String, String>) -> Self {
self.body.callback_header = Some(headers);
self
}
pub fn validate(&self) -> crate::ZaiResult<()> {
crate::client::validation::require_non_blank(&self.id, "knowledge_id")?;
self.body.validate()?;
if self
.body
.name
.as_deref()
.is_some_and(|name| name.trim().is_empty())
{
return Err(crate::client::validation::invalid(
"knowledge name must not be blank",
));
}
self.body.validate_embedding_pair()
}
pub async fn send_via(&self, client: &ZaiClient) -> crate::ZaiResult<KnowledgeUpdateResponse> {
self.validate()?;
let route = crate::client::routes::KNOWLEDGE_UPDATE;
let url = client.endpoints().resolve_route(route, &[&self.id])?;
client
.send_json::<_, KnowledgeUpdateResponse>(route.method(), url, &self.body)
.await
}
}
pub type KnowledgeUpdateResponse = KnowledgeOperationResponse;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_blank_target_and_blank_name_without_inventing_required_fields() {
assert!(
KnowledgeUpdateRequest::new(" ")
.with_description("value")
.validate()
.is_err()
);
assert!(KnowledgeUpdateRequest::new("kb-1").validate().is_ok());
assert!(
KnowledgeUpdateRequest::new("kb-1")
.with_name(" \t")
.validate()
.is_err()
);
assert!(
KnowledgeUpdateRequest::new("kb-1")
.with_name("docs")
.validate()
.is_ok()
);
assert_eq!(
serde_json::to_value(&KnowledgeUpdateRequest::new("kb-1").body).unwrap(),
serde_json::json!({})
);
}
#[test]
fn request_body_debug_redacts_text_callbacks_and_headers() {
let body = KnowledgeUpdateBody {
embedding_model: Some("private-model".to_owned()),
name: Some("private-name".to_owned()),
description: Some("private-description".to_owned()),
callback_url: Some("https://private.example/callback".to_owned()),
callback_header: Some(BTreeMap::from([(
"Authorization".to_owned(),
"private-token".to_owned(),
)])),
..KnowledgeUpdateBody::default()
};
let debug = format!("{body:?}");
for secret in [
"private-model",
"private-name",
"private-description",
"private.example",
"Authorization",
"private-token",
] {
assert!(!debug.contains(secret));
}
assert!(debug.contains("callback_header_entries: Some(1)"));
}
}