zai-rs 0.5.1

一个 Rust SDK, 用于调用 智谱AI API
Documentation
//! Response-decoding helpers.
//!
//! [`extract_error_envelope`] is used by the buffered transport before returning
//! bytes or deserializing JSON. [`validate_content_type`] is available to
//! streaming or endpoint-specific callers, but the buffered transport does not
//! currently call it automatically.

use crate::{ZaiError, ZaiResult, client::error::codes};

/// Prefixes accepted by the current JSON content-type validator.
///
/// In addition to JSON and `+json` types, the current implementation accepts any
/// `application/*` media type.
pub const JSON_CONTENT_TYPES: &[&str] = &["application/json", "application/"];
/// The SSE content type.
pub const SSE_CONTENT_TYPE: &str = "text/event-stream";

/// Validate a response `Content-Type` against the expected kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExpectedKind {
    /// A JSON-compatible media type.
    Json,
    /// The `text/event-stream` SSE media type.
    Sse,
    /// A specific binary MIME from the operation manifest.
    Binary(&'static str),
}

/// Validate a content type against the requested kind.
///
/// A mismatch returns [`ZaiError::ApiError`] with the SDK validation code.
pub fn validate_content_type(raw: &str, expected: ExpectedKind) -> ZaiResult<()> {
    let lower = raw.to_ascii_lowercase();
    let ok = match expected {
        ExpectedKind::Json => JSON_CONTENT_TYPES
            .iter()
            .any(|p| lower == *p || lower.starts_with(p) || lower.ends_with("+json")),
        ExpectedKind::Sse => lower.starts_with(SSE_CONTENT_TYPE),
        ExpectedKind::Binary(mime) => {
            let m = mime.to_ascii_lowercase();
            lower == m || lower.starts_with(&m)
        },
    };
    if ok {
        Ok(())
    } else {
        Err(ZaiError::ApiError {
            code: codes::SDK_VALIDATION,
            message: format!("unexpected content-type: {raw:?} (expected {expected:?})"),
        })
    }
}

/// Decide whether a body parses as a genuine business error envelope.
///
/// A flat `{code, message}` with `code != 200` is an error; a nested `{error}`
/// is always an error; `code == 200` (knowledge success) flows through.
#[derive(serde::Deserialize)]
#[serde(untagged)]
pub(crate) enum WireEnvelope {
    Nested {
        error: WireError,
    },
    Flat {
        code: serde_json::Value,
        message: String,
    },
}

#[derive(serde::Deserialize)]
pub(crate) struct WireError {
    code: serde_json::Value,
    #[allow(dead_code)]
    message: String,
}

impl WireEnvelope {
    pub(crate) fn is_error(&self) -> bool {
        match self {
            WireEnvelope::Nested { .. } => true,
            WireEnvelope::Flat { code, .. } => !is_success_code(code),
        }
    }
}

fn is_success_code(code: &serde_json::Value) -> bool {
    match code {
        serde_json::Value::Number(n) => n.as_u64() == Some(200),
        serde_json::Value::String(s) => s == "200",
        _ => false,
    }
}

/// Probe the body for a genuine business error envelope; return `true` if the
/// body parses as `{code, message}` with `code != 200` or as `{error}`.
///
/// The parsed envelope type is crate-private; the Transport uses
/// [`extract_error_envelope`] to pull the code/message for error construction.
pub fn probe_error_envelope(body: &str) -> bool {
    serde_json::from_str::<WireEnvelope>(body)
        .ok()
        .is_some_and(|env| env.is_error())
}

/// Error code and message extracted from a recognized business envelope.
#[derive(Debug, Clone)]
pub struct BusinessError {
    /// Numeric, textual, or otherwise open-format business code.
    pub code: Option<serde_json::Value>,
    /// Human-readable service error message.
    pub message: String,
}

/// Parse and extract a genuine business error envelope's code/message, or
/// `None` if the body is a success / non-envelope.
pub fn extract_error_envelope(body: &str) -> Option<BusinessError> {
    let env = serde_json::from_str::<WireEnvelope>(body).ok()?;
    if !env.is_error() {
        return None;
    }
    match env {
        WireEnvelope::Nested { error } => Some(BusinessError {
            code: Some(error.code),
            message: error.message,
        }),
        WireEnvelope::Flat { code, message } => Some(BusinessError {
            code: Some(code),
            message,
        }),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn json_content_type_accepted() {
        assert!(validate_content_type("application/json", ExpectedKind::Json).is_ok());
        assert!(
            validate_content_type("application/json; charset=utf-8", ExpectedKind::Json).is_ok()
        );
        assert!(validate_content_type("application/vnd.api+json", ExpectedKind::Json).is_ok());
        assert!(validate_content_type("text/plain", ExpectedKind::Json).is_err());
    }

    #[test]
    fn sse_content_type_accepted() {
        assert!(validate_content_type("text/event-stream", ExpectedKind::Sse).is_ok());
        assert!(
            validate_content_type("text/event-stream; charset=utf-8", ExpectedKind::Sse).is_ok()
        );
        assert!(validate_content_type("application/json", ExpectedKind::Sse).is_err());
    }

    #[test]
    fn binary_content_type_matches_manifest_mime() {
        assert!(validate_content_type("audio/pcm", ExpectedKind::Binary("audio/pcm")).is_ok());
        assert!(
            validate_content_type(
                "application/octet-stream",
                ExpectedKind::Binary("application/octet-stream")
            )
            .is_ok()
        );
        assert!(validate_content_type("text/html", ExpectedKind::Binary("audio/pcm")).is_err());
    }

    #[test]
    fn probe_finds_business_error_envelopes() {
        assert!(probe_error_envelope(r#"{"code":500,"message":"x"}"#));
        assert!(probe_error_envelope(
            r#"{"error":{"code":1302,"message":"x"}}"#
        ));
        // code == 200 is success, not an error envelope.
        assert!(!probe_error_envelope(r#"{"code":200,"message":"ok"}"#));
        // Non-envelope body (chat success) does not match.
        assert!(!probe_error_envelope(r#"{"id":"x","choices":[]}"#));
    }
}