xz-provider 0.5.0

LLM 服务提供者抽象层 — 统一的 LLM 服务提供者接口
Documentation
//! OpenAI Chat Completions protocol adapter (`/v1/chat/completions`).
//!
//! Thin adapter: HTTP shape lives in [`super::openai_wire`].

use serde_json::Value;

use crate::error::ProviderError;
use crate::protocol::openai_wire::{
    apply_chat_completion_options, map_openai_finish_reason, openai_style_auth_headers,
    parse_chat_usage, to_openai_messages,
};
use crate::protocol::{AuthMethod, ProtocolAdapter};
use crate::types::{CompletionRequest, CompletionResponse, StreamEvent, ToolCall};

/// Adapter for the OpenAI Chat Completions API protocol.
#[derive(Debug)]
pub struct OpenAiChatAdapter;

impl OpenAiChatAdapter {
    /// Create a new OpenAI Chat Completions protocol adapter.
    pub fn new() -> Self {
        Self
    }
}

impl Default for OpenAiChatAdapter {
    fn default() -> Self {
        Self::new()
    }
}

impl ProtocolAdapter for OpenAiChatAdapter {
    fn endpoint_path(&self) -> &str {
        "/chat/completions"
    }

    fn build_request_body(
        &self,
        request: &CompletionRequest,
        stream: bool,
    ) -> Result<Value, ProviderError> {
        let model = request.model.as_deref().unwrap_or("gpt-4o");
        let messages = to_openai_messages(&request.messages)?;

        let mut body = serde_json::json!({
            "model": model,
            "messages": messages,
            "stream": stream,
        });
        apply_chat_completion_options(&mut body, request)?;
        Ok(body)
    }

    fn build_auth_headers(&self, auth: &AuthMethod) -> Vec<(String, String)> {
        openai_style_auth_headers(auth)
    }

    fn parse_response(&self, body: &Value) -> Result<CompletionResponse, ProviderError> {
        let model = body["model"].as_str().unwrap_or("unknown").to_owned();

        let choice = &body["choices"][0];
        if choice.is_null() {
            return Err(ProviderError::Format("missing choices[0] in response".to_owned()));
        }

        let message_obj = choice["message"]
            .as_object()
            .ok_or_else(|| ProviderError::Format("missing message in response".to_owned()))?;

        let content = message_obj["content"].as_str().map(|s| s.to_owned());

        let tool_calls: Vec<ToolCall> = message_obj
            .get("tool_calls")
            .and_then(|tc| tc.as_array())
            .map(|calls| {
                calls
                    .iter()
                    .filter_map(|call| {
                        Some(ToolCall {
                            id: call["id"].as_str()?.to_owned(),
                            function_name: call["function"]["name"].as_str()?.to_owned(),
                            arguments: serde_json::from_str(
                                call["function"]["arguments"].as_str()?,
                            )
                            .ok()?,
                        })
                    })
                    .collect()
            })
            .unwrap_or_default();

        let finish_reason = map_openai_finish_reason(choice["finish_reason"].as_str());
        let usage = parse_chat_usage(&body["usage"]);

        let thinking =
            message_obj.get("reasoning_content").and_then(|v| v.as_str()).map(|s| s.to_string());

        let refusal = message_obj.get("refusal").and_then(|v| v.as_str()).map(|s| s.to_string());

        Ok(CompletionResponse {
            content,
            thinking,
            tool_calls,
            usage,
            model,
            finish_reason,
            id: body["id"].as_str().map(|s| s.to_string()),
            created: body["created"].as_u64(),
            system_fingerprint: body["system_fingerprint"].as_str().map(|s| s.to_string()),
            refusal,
            ..Default::default()
        })
    }

    fn parse_sse_event(&self, data: &str) -> Result<Option<StreamEvent>, ProviderError> {
        if data == "[DONE]" {
            return Ok(None);
        }

        let parsed: Value = serde_json::from_str(data)?;

        let has_choices =
            parsed.get("choices").and_then(|c| c.as_array()).is_some_and(|arr| !arr.is_empty());

        if has_choices {
            let choice = &parsed["choices"][0];

            // tool_calls first: a chunk that also carries content/reasoning
            // must not drop tools via early-return on the other field.
            if let Some(tool_calls) = choice["delta"]["tool_calls"].as_array() {
                // Prefer the lowest index so parallel fan-out stays ordered.
                // (Multiple indices in one SSE event: subsequent indices are
                // usually repeated in following events; we still pick the
                // first complete-looking entry here.)
                let mut chosen: Option<&Value> = None;
                let mut chosen_index = usize::MAX;
                for tc in tool_calls {
                    if let Some(index) = tc["index"].as_u64() {
                        let idx = index as usize;
                        if idx < chosen_index {
                            chosen_index = idx;
                            chosen = Some(tc);
                        }
                    }
                }
                if let Some(tc) = chosen {
                    return Ok(Some(StreamEvent::ToolCallDelta {
                        index: chosen_index,
                        id: tc["id"].as_str().map(String::from),
                        function_name: tc["function"]["name"].as_str().map(String::from),
                        arguments_delta: tc["function"]["arguments"]
                            .as_str()
                            .unwrap_or("")
                            .to_owned(),
                    }));
                }
            }

            if let Some(reasoning) = choice["delta"]["reasoning_content"].as_str() {
                if !reasoning.is_empty() {
                    return Ok(Some(StreamEvent::ThinkingDelta {
                        delta: reasoning.to_owned(),
                    }));
                }
            }

            if let Some(content) = choice["delta"]["content"].as_str() {
                if !content.is_empty() {
                    return Ok(Some(StreamEvent::ContentDelta { delta: content.to_owned() }));
                }
            }

            if let Some(fr) = choice["finish_reason"].as_str() {
                if !fr.is_empty() {
                    let finish_reason = map_openai_finish_reason(Some(fr));
                    let usage = parsed.get("usage").filter(|u| u.is_object()).map(parse_chat_usage);
                    return Ok(Some(StreamEvent::Done { finish_reason, usage }));
                }
            }
        }

        if let Some(usage_val) = parsed.get("usage").filter(|u| u.is_object()) {
            return Ok(Some(StreamEvent::Usage { usage: parse_chat_usage(usage_val) }));
        }

        Ok(None)
    }

    fn protocol_name(&self) -> &str {
        "openai-chat"
    }
}

#[cfg(test)]
#[path = "openai_chat_tests.rs"]
mod tests;