xz-provider 0.5.0

LLM 服务提供者抽象层 — 统一的 LLM 服务提供者接口
Documentation
//! Shared OpenAI-compatible wire helpers.
//!
//! Pure encode/decode functions used by [`super::openai_chat`] and
//! [`super::openai_responses`]. Keeps protocol adapters thin and avoids
//! duplicated usage / finish_reason / tool wrapping logic.

use serde_json::Value;

use crate::error::ProviderError;
use crate::protocol::AuthMethod;
use crate::types::{
    ContentPart, FinishReason, Message, MessageContent, TokenUsage, ToolDefinition,
};

/// Wrap tool definitions in OpenAI `{"type":"function","function":...}` form.
pub(crate) fn to_openai_function_tools(tools: &[ToolDefinition]) -> Vec<Value> {
    tools
        .iter()
        .map(|t| {
            serde_json::json!({
                "type": "function",
                "function": t,
            })
        })
        .collect()
}

/// Map OpenAI `finish_reason` strings to [`FinishReason`].
///
/// Unknown values degrade to [`FinishReason::Stop`] with a warning.
pub(crate) fn map_openai_finish_reason(raw: Option<&str>) -> FinishReason {
    match raw {
        Some("stop") | None => FinishReason::Stop,
        Some("tool_calls") => FinishReason::ToolCall,
        Some("length") => FinishReason::MaxTokens,
        Some("content_filter") => FinishReason::ContentFilter,
        Some("pause_turn") => FinishReason::PauseTurn,
        Some("refusal") => FinishReason::Refusal,
        Some(other) => {
            tracing::warn!(
                finish_reason = %other,
                "Unknown OpenAI finish_reason, degrading to Stop"
            );
            FinishReason::Stop
        }
    }
}

/// Parse Chat Completions style usage objects.
///
/// Supports standard OpenAI fields and common vendor extensions
/// (DeepSeek cache hit/miss, reasoning_tokens).
pub(crate) fn parse_chat_usage(usage: &Value) -> TokenUsage {
    if !usage.is_object() {
        return TokenUsage::new(0, 0);
    }
    TokenUsage {
        prompt_tokens: usage.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
        completion_tokens: usage.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or(0)
            as u32,
        total_tokens: usage.get("total_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
        cached_tokens: usage
            .get("prompt_tokens_details")
            .and_then(|d| d.get("cached_tokens"))
            .and_then(|v| v.as_u64())
            .map(|v| v as u32),
        reasoning_tokens: usage
            .get("completion_tokens_details")
            .and_then(|d| d.get("reasoning_tokens"))
            .and_then(|v| v.as_u64())
            .map(|v| v as u32),
        // DeepSeek top-level fields, with optional nested fallbacks.
        prompt_cache_hit_tokens: usage
            .get("prompt_cache_hit_tokens")
            .and_then(|v| v.as_u64())
            .or_else(|| {
                usage
                    .get("prompt_tokens_details")
                    .and_then(|d| d.get("cached_tokens"))
                    .and_then(|v| v.as_u64())
            })
            .map(|v| v as u32),
        prompt_cache_miss_tokens: usage
            .get("prompt_cache_miss_tokens")
            .and_then(|v| v.as_u64())
            .map(|v| v as u32),
        ..Default::default()
    }
}

/// Standard OpenAI-style auth headers (Bearer / ApiKey / None).
pub(crate) fn openai_style_auth_headers(auth: &AuthMethod) -> Vec<(String, String)> {
    match auth {
        AuthMethod::None => vec![],
        AuthMethod::Bearer { token } => {
            vec![("Authorization".to_owned(), format!("Bearer {token}"))]
        }
        AuthMethod::ApiKey { header_name, key } => {
            vec![(header_name.clone(), key.clone())]
        }
    }
}

/// Convert unified messages to OpenAI chat `{ role, content, ... }` objects.
pub(crate) fn to_openai_messages(messages: &[Message]) -> Result<Vec<Value>, ProviderError> {
    messages
        .iter()
        .map(|msg| {
            let content = match &msg {
                Message::System { content, .. }
                | Message::Developer { content, .. }
                | Message::User { content, .. }
                | Message::Assistant { content, .. }
                | Message::Tool { content, .. } => content,
            };

            let content_value = match content {
                MessageContent::Text(text) => Value::String(text.clone()),
                MessageContent::MultiPart(parts) => {
                    let result: Vec<Value> = parts
                        .iter()
                        .map(|part| -> Result<Value, ProviderError> {
                            match part {
                                ContentPart::Text { text } => {
                                    Ok(serde_json::json!({"type": "text", "text": text}))
                                }
                                ContentPart::ImageUrl { url, detail } => {
                                    let mut obj = serde_json::json!({
                                        "type": "image_url",
                                        "image_url": { "url": url }
                                    });
                                    if let Some(d) = detail {
                                        obj["image_url"]["detail"] = serde_json::to_value(d)?;
                                    }
                                    Ok(obj)
                                }
                                ContentPart::ImageBase64 { media_type, data } => {
                                    Ok(serde_json::json!({
                                        "type": "image_url",
                                        "image_url": {
                                            "url": format!("data:{media_type};base64,{data}")
                                        }
                                    }))
                                }
                                ContentPart::AudioBase64 { .. }
                                | ContentPart::File { .. }
                                | ContentPart::Document { .. }
                                | ContentPart::RedactedThinking { .. }
                                | ContentPart::ToolReference { .. } => {
                                    Ok(serde_json::json!({"type": "text", "text": ""}))
                                }
                            }
                        })
                        .collect::<Result<Vec<_>, _>>()?;
                    Value::Array(result)
                }
                MessageContent::None => Value::Null,
            };

            let mut m = serde_json::json!({
                "role": msg.role_str(),
                "content": content_value,
            });

            match msg {
                Message::Tool { tool_call_id, .. } => {
                    m["tool_call_id"] = Value::String(tool_call_id.clone());
                }
                Message::System { cache_control: Some(cc), .. }
                | Message::Developer { cache_control: Some(cc), .. }
                | Message::Assistant { cache_control: Some(cc), .. } => {
                    m["cache_control"] = serde_json::to_value(cc)?;
                }
                _ => {}
            }

            if let Message::Assistant { tool_calls: Some(calls), .. } = msg {
                if !calls.is_empty() {
                    m["tool_calls"] = serde_json::to_value(calls)?;
                    m["content"] = Value::Null;
                }
            }

            // DeepSeek thinking mode: for assistant messages that issued
            // tool_calls, reasoning_content MUST be replayed on later turns.
            // Always emit the field when present (including empty string);
            // for tool-calling assistants without it, emit "" so the API
            // still receives the key rather than treating the turn as broken.
            match msg {
                Message::Assistant {
                    tool_calls: Some(calls),
                    reasoning_content,
                    ..
                } if !calls.is_empty() => {
                    m["reasoning_content"] = Value::String(
                        reasoning_content.clone().unwrap_or_default(),
                    );
                }
                Message::Assistant {
                    reasoning_content: Some(rc),
                    ..
                } if !rc.is_empty() => {
                    m["reasoning_content"] = Value::String(rc.clone());
                }
                _ => {}
            }

            Ok(m)
        })
        .collect()
}

/// Apply common Chat Completions sampling / tool fields onto a JSON body.
pub(crate) fn apply_chat_completion_options(
    body: &mut Value,
    request: &crate::types::CompletionRequest,
) -> Result<(), ProviderError> {
    if let Some(temp) = request.temperature {
        body["temperature"] = serde_json::to_value(temp)?;
    }
    if let Some(max_tokens) = request.max_tokens {
        body["max_tokens"] = serde_json::to_value(max_tokens)?;
    }
    if let Some(max_ct) = request.max_completion_tokens {
        body["max_completion_tokens"] = serde_json::to_value(max_ct)?;
    }
    if let Some(stop) = &request.stop {
        body["stop"] = serde_json::to_value(stop)?;
    }
    if let Some(top_p) = request.top_p {
        body["top_p"] = serde_json::to_value(top_p)?;
    }
    if let Some(top_k) = request.top_k {
        body["top_k"] = serde_json::to_value(top_k)?;
    }
    if let Some(freq_penalty) = request.frequency_penalty {
        body["frequency_penalty"] = serde_json::to_value(freq_penalty)?;
    }
    if let Some(pres_penalty) = request.presence_penalty {
        body["presence_penalty"] = serde_json::to_value(pres_penalty)?;
    }
    if let Some(seed) = request.seed {
        body["seed"] = serde_json::to_value(seed)?;
    }
    if let Some(ref re) = request.reasoning_effort {
        body["reasoning_effort"] = serde_json::to_value(re)?;
    }
    if let Some(ref logprobs) = request.logprobs {
        body["logprobs"] = serde_json::to_value(logprobs)?;
    }
    if let Some(ref logit_bias) = request.logit_bias {
        body["logit_bias"] = serde_json::to_value(logit_bias)?;
    }
    if let Some(tools) = &request.tools {
        body["tools"] = Value::Array(to_openai_function_tools(tools));
    }
    if let Some(tool_choice) = &request.tool_choice {
        body["tool_choice"] = serde_json::to_value(tool_choice)?;
    }
    if let Some(ref response_format) = request.response_format {
        body["response_format"] = serde_json::to_value(response_format)?;
    }
    // OpenAI / DeepSeek: `stream_options` is only valid when `stream: true`.
    // DeepSeek returns 400 if stream_options is present on a non-stream request:
    // "stream_options should be set along with stream = true".
    if let Some(include_usage) = request.stream_include_usage {
        if body.get("stream").and_then(|v| v.as_bool()) == Some(true) {
            body["stream_options"] = serde_json::json!({ "include_usage": include_usage });
        }
    }
    if let Some(parallel_tool_calls) = request.parallel_tool_calls {
        body["parallel_tool_calls"] = serde_json::to_value(parallel_tool_calls)?;
    }
    if let Some(ref user) = request.user {
        body["user"] = serde_json::to_value(user)?;
    }
    if let Some(ref store) = request.store {
        body["store"] = serde_json::to_value(store)?;
    }
    if let Some(ref metadata) = request.metadata {
        body["metadata"] = serde_json::to_value(metadata)?;
    }
    if let Some(ref service_tier) = request.service_tier {
        body["service_tier"] = serde_json::to_value(service_tier)?;
    }
    if let Some(ref thinking) = request.thinking {
        body["thinking"] = serde_json::to_value(thinking)?;
    }
    Ok(())
}