llm_link/adapters.rs
1use crate::settings::Settings;
2use axum::http::HeaderMap;
3use llm_connector::StreamFormat;
4use serde_json::Value;
5
6/// Client adapter type
7///
8/// Used to identify different clients and apply corresponding response transformations.
9///
10/// # Workflow
11/// 1. Detect client type (through HTTP headers, User-Agent, configuration, etc.)
12/// 2. Determine preferred streaming format (SSE/NDJSON/JSON)
13/// 3. Apply client-specific response adaptations (field addition, format adjustment, etc.)
14///
15/// # Usage Locations
16/// - `src/api/ollama.rs::detect_ollama_client()` - Ollama API client detection
17/// - `src/api/openai.rs::detect_openai_client()` - OpenAI API client detection
18///
19/// # Example
20/// ```rust,ignore
21/// let adapter = detect_client(&headers, &config);
22/// let format = adapter.preferred_format();
23/// adapter.apply_response_adaptations(&config, &mut response_data);
24/// ```
25#[derive(Debug, Clone, PartialEq)]
26#[allow(dead_code)]
27pub enum ClientAdapter {
28 /// Standard Ollama client
29 /// - Preferred format: NDJSON
30 /// - Special handling: None
31 Standard,
32
33 /// Zed editor adapter
34 /// - Preferred format: NDJSON
35 /// - Special handling: Add `images` field
36 Zed,
37
38 /// OpenAI API client adapter (including Codex CLI)
39 /// - Preferred format: SSE
40 /// - Special handling: finish_reason correction (handled in llm/stream.rs)
41 OpenAI,
42}
43
44impl ClientAdapter {
45 /// 获取该客户端的首选流式格式
46 ///
47 /// 当客户端没有明确指定 Accept 头(或使用 `*/*`)时,
48 /// 使用此方法返回的格式。
49 ///
50 /// # 返回值
51 /// - `StreamFormat::SSE` - Server-Sent Events (OpenAI/Codex 偏好)
52 /// - `StreamFormat::NDJSON` - Newline Delimited JSON (Ollama/Zed 偏好)
53 ///
54 /// # 使用场景
55 /// ```rust,ignore
56 /// let format = if headers.get("accept").contains("*/*") {
57 /// adapter.preferred_format() // 使用偏好格式
58 /// } else {
59 /// detected_format // 使用客户端指定的格式
60 /// };
61 /// ```
62 pub fn preferred_format(&self) -> StreamFormat {
63 match self {
64 ClientAdapter::Standard => StreamFormat::NDJSON, // Ollama 标准
65 ClientAdapter::Zed => StreamFormat::NDJSON, // Zed 偏好 NDJSON
66 ClientAdapter::OpenAI => StreamFormat::SSE, // OpenAI/Codex 偏好 SSE
67 }
68 }
69
70 /// 应用客户端特定的响应处理
71 ///
72 /// 根据客户端类型,对 LLM 返回的响应数据进行适配转换。
73 ///
74 /// # 参数
75 /// - `config`: 全局配置
76 /// - `data`: 响应数据(可变引用),会被就地修改
77 ///
78 /// # 适配内容
79 ///
80 /// ## Standard
81 /// - 无特殊处理
82 ///
83 /// ## Zed
84 /// - 添加 `images: null` 字段(Zed 要求)
85 ///
86 /// ## OpenAI
87 /// - finish_reason 修正(在 client.rs 中处理)
88 ///
89 /// # 调用位置
90 /// - `src/handlers/ollama.rs` - 在流式响应的每个 chunk 中调用
91 /// - `src/handlers/openai.rs` - 在流式响应的每个 chunk 中调用
92 ///
93 /// # 示例
94 /// ```rust,ignore
95 /// let mut response_data = serde_json::from_str(&chunk)?;
96 /// adapter.apply_response_adaptations(&config, &mut response_data);
97 /// // response_data 已被适配
98 /// ```
99 pub fn apply_response_adaptations(&self, config: &Settings, data: &mut Value) {
100 match self {
101 ClientAdapter::Standard => {
102 // 标准模式:无特殊处理
103 }
104 ClientAdapter::Zed => {
105 // Zed 特定适配:添加 images 字段
106 let should_add_images = if let Some(ref adapters) = config.client_adapters {
107 if let Some(ref zed_config) = adapters.zed {
108 zed_config.force_images_field.unwrap_or(true)
109 } else {
110 true
111 }
112 } else {
113 true
114 };
115
116 if should_add_images {
117 if let Some(message) = data.get_mut("message") {
118 if message.get("images").is_none() {
119 if let Some(msg_obj) = message.as_object_mut() {
120 msg_obj.insert("images".to_string(), Value::Null);
121 }
122 }
123 }
124 }
125 }
126 ClientAdapter::OpenAI => {
127 // OpenAI 特定适配:无特殊处理
128 // finish_reason 修正在 client.rs 中处理
129 }
130 }
131 }
132}
133
134/// 格式检测器
135#[allow(dead_code)]
136pub struct FormatDetector;
137
138impl FormatDetector {
139 /// 根据 HTTP 标准确定响应格式
140 pub fn determine_format(headers: &HeaderMap) -> (StreamFormat, &'static str) {
141 if let Some(accept) = headers.get("accept") {
142 if let Ok(accept_str) = accept.to_str() {
143 if accept_str.contains("text/event-stream") {
144 return (StreamFormat::SSE, "text/event-stream");
145 }
146 if accept_str.contains("application/x-ndjson")
147 || accept_str.contains("application/jsonlines")
148 {
149 return (StreamFormat::NDJSON, "application/x-ndjson");
150 }
151 }
152 }
153 // 默认:NDJSON
154 (StreamFormat::NDJSON, "application/x-ndjson")
155 }
156
157 /// 获取格式对应的 Content-Type
158 pub fn get_content_type(format: StreamFormat) -> &'static str {
159 match format {
160 StreamFormat::SSE => "text/event-stream",
161 StreamFormat::NDJSON => "application/x-ndjson",
162 StreamFormat::Json => "application/json",
163 }
164 }
165}