Skip to main content

llm_kernel/llm/
client.rs

1use std::time::Duration;
2
3use async_trait::async_trait;
4
5use crate::error::{KernelError, Result};
6use crate::llm::tool::{ToolCall, ToolDefinition};
7use crate::llm::types::{
8    LLMRequest, LLMResponse, LLMStream, ModelConfig, ReasoningConfig, ReasoningEffort,
9    ResponseFormat, StreamEvent, TokenUsage, Verbosity,
10};
11
12/// Best-effort redaction of an HTTP error response body before it lands in a
13/// [`KernelError::Http`]. Some API gateways/proxies echo the request
14/// `Authorization` header inside error bodies; without this, a caller that logs
15/// the error leaks the API key. Full pattern masking when the `safety` feature
16/// is enabled; otherwise the body is passed through unchanged (the masking
17/// regex is an opt-in dependency).
18fn redact_http_body(body: &str) -> String {
19    #[cfg(feature = "safety")]
20    {
21        crate::safety::sanitize::mask_secrets(body)
22    }
23    #[cfg(not(feature = "safety"))]
24    {
25        body.to_string()
26    }
27}
28
29/// Convert kernel [`ToolDefinition`]s into OpenAI `tools` (`type: "function"`).
30fn openai_tools(tools: &[ToolDefinition]) -> Vec<serde_json::Value> {
31    tools
32        .iter()
33        .map(|t| {
34            serde_json::json!({
35                "type": "function",
36                "function": {
37                    "name": t.name,
38                    "description": t.description,
39                    "parameters": t.input_schema,
40                }
41            })
42        })
43        .collect()
44}
45
46/// Map a [`ResponseFormat`] to OpenAI's `response_format` object, or `None` for
47/// the provider default (plain text).
48fn openai_response_format(rf: &ResponseFormat) -> Option<serde_json::Value> {
49    match rf {
50        ResponseFormat::Text => None,
51        ResponseFormat::Json => Some(serde_json::json!({ "type": "json_object" })),
52        ResponseFormat::JsonSchema { schema } => Some(serde_json::json!({
53            "type": "json_schema",
54            "json_schema": { "name": "response", "schema": schema, "strict": true }
55        })),
56    }
57}
58
59/// Convert kernel [`ToolDefinition`]s into Anthropic `tools` (with `input_schema`).
60fn anthropic_tools(tools: &[ToolDefinition]) -> Vec<serde_json::Value> {
61    tools
62        .iter()
63        .map(|t| {
64            serde_json::json!({
65                "name": t.name,
66                "description": t.description,
67                "input_schema": t.input_schema,
68            })
69        })
70        .collect()
71}
72
73/// Map a [`ResponseFormat`] to Anthropic's `output_config`. Only
74/// [`ResponseFormat::JsonSchema`] has a native equivalent; `Json` (schemaless)
75/// and `Text` return `None`.
76fn anthropic_output_config(rf: &ResponseFormat) -> Option<serde_json::Value> {
77    match rf {
78        ResponseFormat::JsonSchema { schema } => Some(serde_json::json!({
79            "format": { "type": "json_schema", "schema": schema }
80        })),
81        ResponseFormat::Json | ResponseFormat::Text => None,
82    }
83}
84
85/// Map kernel [`ReasoningConfig`] to the wire keys the OpenAI-compatible path
86/// forwards: `effort` becomes the official top-level `reasoning_effort`
87/// parameter; `enabled` (OpenRouter extension) and `summary` (official
88/// Responses-API `reasoning.summary`) share one `reasoning` object. Each key
89/// is emitted only when set, so a `None` config (or an unset knob) adds
90/// nothing to the request body.
91fn openai_reasoning(
92    cfg: Option<&ReasoningConfig>,
93) -> (Option<ReasoningEffort>, Option<serde_json::Value>) {
94    match cfg {
95        None => (None, None),
96        Some(c) => {
97            let mut obj = serde_json::Map::new();
98            if let Some(enabled) = c.enabled {
99                obj.insert("enabled".into(), enabled.into());
100            }
101            if let Some(summary) = c.summary {
102                obj.insert(
103                    "summary".into(),
104                    serde_json::to_value(summary).expect("ReasoningSummary is a plain enum"),
105                );
106            }
107            (
108                c.effort,
109                if obj.is_empty() {
110                    None
111                } else {
112                    Some(serde_json::Value::Object(obj))
113                },
114            )
115        }
116    }
117}
118
119/// Serialize the outgoing body and merge [`LLMRequest::extra_body`] keys into
120/// it (last-write-wins), for official spec parameters or provider extensions
121/// the kernel does not model natively.
122fn openai_body_with_extra(
123    body: &OpenAIChatRequest,
124    extra: Option<&serde_json::Map<String, serde_json::Value>>,
125) -> serde_json::Value {
126    let mut v = serde_json::to_value(body).expect("OpenAIChatRequest serializes");
127    if let Some(extra) = extra
128        && let serde_json::Value::Object(map) = &mut v
129    {
130        for (k, val) in extra {
131            map.insert(k.clone(), val.clone());
132        }
133    }
134    v
135}
136
137/// Build a `reqwest::Client` with connect and total timeouts.
138fn http_client() -> Result<reqwest::Client> {
139    crate::tls::ensure_tls_provider();
140    reqwest::Client::builder()
141        .connect_timeout(Duration::from_secs(10))
142        .timeout(Duration::from_secs(120))
143        .build()
144        .map_err(|e| KernelError::Config(format!("Failed to build HTTP client: {}", e)))
145}
146
147/// Check for HTTP 429 rate-limit response and extract `retry-after` header.
148fn check_rate_limit(resp: &reqwest::Response) -> Result<()> {
149    if resp.status().as_u16() == 429 {
150        let retry = resp
151            .headers()
152            .get("retry-after")
153            .and_then(|v| v.to_str().ok())
154            .and_then(|v| v.parse().ok())
155            .unwrap_or(60);
156        return Err(KernelError::RateLimited(retry));
157    }
158    Ok(())
159}
160
161/// Unified async interface for LLM chat completion and streaming.
162#[async_trait]
163pub trait LLMClient: Send + Sync {
164    /// Send a chat completion request and return the full response.
165    ///
166    /// **Reasoning-model answer promotion (non-streaming only):** when a provider
167    /// returns the final answer in `reasoning_content` and leaves `content` empty
168    /// (notably GLM-4.7), `complete` promotes the reasoning into `content` so
169    /// downstream consumers transparently receive the answer. The original
170    /// reasoning is preserved in [`LLMResponse::reasoning`].
171    ///
172    /// This promotion is **not** applied by [`stream_complete`](Self::stream_complete),
173    /// which surfaces reasoning as separate [`StreamEvent::ReasoningDelta`] events —
174    /// see that variant's docs for the streaming accumulation contract.
175    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse>;
176    /// Return the model name this client is configured to use.
177    fn model_name(&self) -> &str;
178
179    /// Stream a chat completion, yielding events as they arrive.
180    ///
181    /// Does **not** promote reasoning into a final `content` — see
182    /// [`StreamEvent::ReasoningDelta`] for why streaming consumers of
183    /// reasoning-only models (GLM-4.7) must accumulate both `ReasoningDelta` and
184    /// `Delta` to reconstruct the answer.
185    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream>;
186}
187
188/// Async LLM client for the OpenAI chat completions API.
189pub struct OpenAIClient {
190    api_key: String,
191    model: String,
192    base_url: String,
193    client: reqwest::Client,
194}
195
196impl OpenAIClient {
197    /// Create a new client using credentials from the environment variable in `config`.
198    pub fn new(config: &ModelConfig) -> Result<Self> {
199        let api_key = std::env::var(&config.api_key_env).map_err(|_| {
200            KernelError::Config(format!(
201                "Environment variable {} not set",
202                config.api_key_env
203            ))
204        })?;
205        Ok(Self {
206            api_key,
207            model: config.model.clone(),
208            base_url: config
209                .base_url
210                .clone()
211                .unwrap_or_else(|| "https://api.openai.com/v1".into()),
212            client: http_client()?,
213        })
214    }
215
216    /// Create a new client with an explicit API key, using the default OpenAI base URL.
217    ///
218    /// Returns a [`KernelError::Config`] if the HTTP client (with its connect /
219    /// total timeouts) cannot be built, rather than silently falling back to a
220    /// timeout-less `reqwest::Client::default()`.
221    ///
222    /// # Example
223    ///
224    /// ```no_run
225    /// use llm_kernel::llm::OpenAIClient;
226    /// let client = OpenAIClient::from_key("gpt-4o-mini", "sk-...")?;
227    /// # Ok::<(), llm_kernel::error::KernelError>(())
228    /// ```
229    pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
230        Ok(Self {
231            api_key: api_key.into(),
232            model: model.into(),
233            base_url: "https://api.openai.com/v1".into(),
234            client: http_client()?,
235        })
236    }
237
238    /// Create from an explicit key and a shared `reqwest::Client`.
239    ///
240    /// Prefer this over [`from_key`](Self::from_key) when constructing multiple
241    /// clients in a hot path — the shared client reuses the underlying TCP
242    /// connection pool.
243    pub fn from_key_with_client(
244        model: impl Into<String>,
245        api_key: impl Into<String>,
246        client: reqwest::Client,
247    ) -> Self {
248        Self {
249            api_key: api_key.into(),
250            model: model.into(),
251            base_url: "https://api.openai.com/v1".into(),
252            client,
253        }
254    }
255
256    /// Create from an explicit key, a custom base URL, and a shared `reqwest::Client`.
257    ///
258    /// Use this for OpenAI-compatible providers that are not the default OpenAI
259    /// endpoint (DeepSeek, Groq, Ollama, LM Studio, custom gateways, …) when you
260    /// already hold the key in memory and want to reuse a shared connection pool.
261    pub fn from_key_with_base_url(
262        model: impl Into<String>,
263        api_key: impl Into<String>,
264        base_url: impl Into<String>,
265        client: reqwest::Client,
266    ) -> Self {
267        Self {
268            api_key: api_key.into(),
269            model: model.into(),
270            base_url: base_url.into(),
271            client,
272        }
273    }
274}
275
276#[derive(serde::Serialize)]
277struct OpenAIChatRequest {
278    model: String,
279    messages: Vec<OpenAIChatMessage>,
280    temperature: f32,
281    #[serde(skip_serializing_if = "Option::is_none")]
282    max_tokens: Option<u32>,
283    #[serde(skip_serializing_if = "std::ops::Not::not")]
284    stream: bool,
285    #[serde(skip_serializing_if = "Option::is_none")]
286    tools: Option<Vec<serde_json::Value>>,
287    #[serde(skip_serializing_if = "Option::is_none")]
288    response_format: Option<serde_json::Value>,
289    /// Official OpenAI `reasoning_effort` parameter.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    reasoning_effort: Option<ReasoningEffort>,
292    /// OpenRouter extension / Responses-style `reasoning` object.
293    #[serde(skip_serializing_if = "Option::is_none")]
294    reasoning: Option<serde_json::Value>,
295    /// Official OpenAI `verbosity` parameter.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    verbosity: Option<Verbosity>,
298}
299
300#[derive(serde::Serialize)]
301struct OpenAIChatMessage {
302    role: String,
303    content: String,
304}
305
306#[derive(serde::Deserialize)]
307struct OpenAIChatResponse {
308    #[serde(default)]
309    id: Option<String>,
310    #[serde(default)]
311    created: Option<u64>,
312    choices: Vec<OpenAIChoice>,
313    model: String,
314    usage: Option<OpenAIUsage>,
315}
316
317#[derive(serde::Deserialize)]
318struct OpenAIChoice {
319    message: OpenAIRespMessage,
320    #[serde(default)]
321    finish_reason: Option<String>,
322}
323
324/// Response-side assistant message. `content` is `null` on tool-call turns, so
325/// it is optional and defaults to empty.
326///
327/// Reasoning models (GLM-4.5+/z.ai, OpenAI o1) emit their chain-of-thought in
328/// `reasoning_content` and leave `content` null — see ADR below. DeepSeek-R1 uses
329/// the field name `reasoning`, covered via serde alias.
330#[derive(serde::Deserialize)]
331struct OpenAIRespMessage {
332    #[serde(default)]
333    content: Option<String>,
334    /// Reasoning model's chain-of-thought. Aliased from `reasoning` (DeepSeek-R1).
335    #[serde(default, alias = "reasoning")]
336    reasoning_content: Option<String>,
337    #[serde(default)]
338    tool_calls: Vec<OpenAIToolCall>,
339}
340
341#[derive(serde::Deserialize)]
342struct OpenAIToolCall {
343    id: String,
344    function: OpenAIFunctionCall,
345}
346
347#[derive(serde::Deserialize)]
348struct OpenAIFunctionCall {
349    name: String,
350    #[serde(default)]
351    arguments: String,
352}
353
354/// Token usage reported by OpenAI-compatible providers.
355///
356/// All fields are `#[serde(default)]`: some providers (and partial/error
357/// responses) omit `usage` fields, and a missing field should not cause the
358/// whole response to fail parsing — `total_tokens` can be recomputed downstream.
359#[derive(serde::Deserialize)]
360struct OpenAIUsage {
361    #[serde(default)]
362    prompt_tokens: u32,
363    #[serde(default)]
364    completion_tokens: u32,
365    #[serde(default)]
366    total_tokens: u32,
367    /// OpenAI o1 / GLM-4.7 expose reasoning token counts here.
368    #[serde(default)]
369    completion_tokens_details: Option<OpenAICompletionTokensDetails>,
370}
371
372/// Nested under `usage.completion_tokens_details` for reasoning models.
373#[derive(serde::Deserialize)]
374struct OpenAICompletionTokensDetails {
375    #[serde(default)]
376    reasoning_tokens: Option<u32>,
377}
378
379#[async_trait]
380impl LLMClient for OpenAIClient {
381    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
382        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
383        let temperature = request.temperature;
384        let max_tokens = request.max_tokens;
385        let tools = request
386            .tools
387            .as_deref()
388            .map(openai_tools)
389            .filter(|t| !t.is_empty());
390        let response_format = request
391            .response_format
392            .as_ref()
393            .and_then(openai_response_format);
394        let (reasoning_effort, reasoning) = openai_reasoning(request.reasoning.as_ref());
395        let verbosity = request.verbosity;
396        let extra_body = request.extra_body.clone();
397        let messages: Vec<_> = request
398            .into_openai_messages()
399            .into_iter()
400            .map(|(role, content)| OpenAIChatMessage { role, content })
401            .collect();
402
403        let body = OpenAIChatRequest {
404            model,
405            messages,
406            temperature,
407            max_tokens,
408            stream: false,
409            tools,
410            response_format,
411            reasoning_effort,
412            reasoning,
413            verbosity,
414        };
415
416        let resp = self
417            .client
418            .post(format!("{}/chat/completions", self.base_url))
419            .header("Authorization", format!("Bearer {}", self.api_key))
420            .json(&openai_body_with_extra(&body, extra_body.as_ref()))
421            .send()
422            .await
423            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
424
425        check_rate_limit(&resp)?;
426
427        let status = resp.status();
428
429        if !status.is_success() {
430            let text = resp.text().await.unwrap_or_default();
431            return Err(KernelError::Http {
432                status: status.as_u16(),
433                message: redact_http_body(&text),
434            });
435        }
436
437        let text = resp
438            .text()
439            .await
440            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
441        let chat_resp: OpenAIChatResponse = from_json_lenient(&text)
442            .map_err(|e| KernelError::LlmApi(format!("error decoding response body: {e}")))?;
443
444        let id = chat_resp.id;
445        let created = chat_resp.created;
446        let first = chat_resp.choices.into_iter().next();
447        let finish_reason = first.as_ref().and_then(|c| c.finish_reason.clone());
448        let (content, reasoning, tool_calls) = match first {
449            Some(c) => {
450                let raw_content = c.message.content.unwrap_or_default();
451                let reasoning = c.message.reasoning_content;
452                let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
453                let calls = c
454                    .message
455                    .tool_calls
456                    .into_iter()
457                    .map(|tc| ToolCall {
458                        id: tc.id,
459                        name: tc.function.name,
460                        arguments: tc.function.arguments,
461                    })
462                    .collect();
463                (content, reasoning, calls)
464            }
465            None => (String::new(), None, Vec::new()),
466        };
467
468        let usage = chat_resp.usage.map(|u| TokenUsage {
469            prompt_tokens: u.prompt_tokens,
470            completion_tokens: u.completion_tokens,
471            total_tokens: u.total_tokens,
472            reasoning_tokens: u.completion_tokens_details.and_then(|d| d.reasoning_tokens),
473        });
474
475        Ok(LLMResponse {
476            content,
477            reasoning,
478            model: chat_resp.model,
479            usage: usage.unwrap_or_default(),
480            tool_calls,
481            finish_reason,
482            id,
483            created,
484        })
485    }
486
487    fn model_name(&self) -> &str {
488        &self.model
489    }
490
491    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
492        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
493        let temperature = request.temperature;
494        let max_tokens = request.max_tokens;
495        let (reasoning_effort, reasoning) = openai_reasoning(request.reasoning.as_ref());
496        let verbosity = request.verbosity;
497        let extra_body = request.extra_body.clone();
498        let messages: Vec<_> = request
499            .into_openai_messages()
500            .into_iter()
501            .map(|(role, content)| OpenAIChatMessage { role, content })
502            .collect();
503
504        let body = OpenAIChatRequest {
505            model,
506            messages,
507            temperature,
508            max_tokens,
509            stream: true,
510            // Streaming is text-only here: the SSE parser emits text deltas and
511            // does not reassemble streamed tool-call fragments.
512            tools: None,
513            response_format: None,
514            reasoning_effort,
515            reasoning,
516            verbosity,
517        };
518
519        let resp = self
520            .client
521            .post(format!("{}/chat/completions", self.base_url))
522            .header("Authorization", format!("Bearer {}", self.api_key))
523            .json(&openai_body_with_extra(&body, extra_body.as_ref()))
524            .send()
525            .await
526            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
527
528        check_rate_limit(&resp)?;
529
530        let status = resp.status();
531        if !status.is_success() {
532            let text = resp.text().await.unwrap_or_default();
533            return Err(KernelError::Http {
534                status: status.as_u16(),
535                message: redact_http_body(&text),
536            });
537        }
538
539        let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
540
541        tokio::spawn(async move {
542            let mut stream = std::pin::pin!(resp.bytes_stream());
543            let mut buffer: Vec<u8> = Vec::new();
544
545            use tokio_stream::StreamExt;
546
547            while let Some(chunk) = stream.next().await {
548                let chunk = match chunk {
549                    Ok(c) => c,
550                    Err(e) => {
551                        let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
552                        return;
553                    }
554                };
555
556                for line in drain_sse_lines(&mut buffer, &chunk) {
557                    if let Some(data) = parse_sse_line(&line)
558                        && let Some(event) = parse_openai_sse(data)
559                    {
560                        let is_done = matches!(event, StreamEvent::Done);
561                        if tx.send(Ok(event)).await.is_err() || is_done {
562                            return;
563                        }
564                    }
565                }
566            }
567            let _ = tx.send(Ok(StreamEvent::Done)).await;
568        });
569
570        Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
571    }
572}
573
574/// Extract the data payload from an SSE `data: ...` line.
575/// Returns `None` for non-data lines and for `data: [DONE]`.
576fn parse_sse_line(line: &str) -> Option<&str> {
577    line.strip_prefix("data: ").filter(|d| *d != "[DONE]")
578}
579
580/// Append a raw network chunk to `buffer` and drain every complete,
581/// newline-terminated line, decoded as UTF-8.
582///
583/// Decoding is deferred until a line's bytes are fully buffered. A single
584/// codepoint can straddle two network chunks, and decoding each chunk eagerly
585/// with [`String::from_utf8_lossy`] would replace the split bytes with `U+FFFD`
586/// — corrupting e.g. CJK or emoji deltas. Because `\n` (`0x0A`) is never a UTF-8
587/// lead or continuation byte, splitting on it can't cut a codepoint, so every
588/// drained line is a whole number of codepoints and decodes losslessly.
589fn drain_sse_lines(buffer: &mut Vec<u8>, chunk: &[u8]) -> Vec<String> {
590    buffer.extend_from_slice(chunk);
591    let mut lines = Vec::new();
592    while let Some(pos) = buffer.iter().position(|&b| b == b'\n') {
593        let line: Vec<u8> = buffer.drain(..=pos).collect();
594        lines.push(String::from_utf8_lossy(&line).trim_end().to_string());
595    }
596    lines
597}
598
599/// Decide the `content` exposed to consumers for a reasoning-model response.
600///
601/// # Two provider behaviors, one rule
602///
603/// - **GLM-4.7 (non-standard):** leaves `content` empty/null and returns the
604///   final answer inside `reasoning_content`. Promoting reasoning into `content`
605///   is required so downstream `json_extract` finds the JSON.
606/// - **Standard reasoning models (OpenAI o1, DeepSeek-R1):** put the final
607///   answer in `content` and the chain-of-thought in `reasoning_content`. When
608///   `content` is genuinely present it is preserved unchanged.
609///
610/// # Caveat — empty `content` on a standard model
611///
612/// The promotion triggers on *any* empty `content`. If a standard model ever
613/// returns an empty `content` together with reasoning (e.g. the model produced
614/// only chain-of-thought and no final answer, or a transport/parse glitch), this
615/// rule would surface the chain-of-thought as the answer. That is an acceptable
616/// trade-off: the alternative is an empty answer, which fails every downstream
617/// consumer the same way. The original reasoning is always preserved verbatim in
618/// [`LLMResponse::reasoning`], so callers can detect this case and reject it.
619fn promote_reasoning_into_content(raw_content: String, reasoning: Option<&str>) -> String {
620    if raw_content.is_empty() {
621        reasoning.unwrap_or_default().to_string()
622    } else {
623        raw_content
624    }
625}
626
627/// Escape raw C0 control characters (`0x00`–`0x1F`) inside JSON string
628/// literals as `\u00XX`.
629///
630/// Some OpenAI-compatible gateways (observed on OpenRouter) return HTTP 200
631/// bodies whose `reasoning` strings contain unescaped control characters
632/// (raw `\n`, `\t`, …), which strict JSON parsers reject wholesale. This
633/// walk-only pass rewrites each such character to its escape and leaves
634/// everything outside string literals untouched. Best effort: structurally
635/// broken JSON stays broken and the original parse error is surfaced.
636fn escape_unescaped_control_chars(raw: &str) -> String {
637    let mut out = String::with_capacity(raw.len());
638    let mut in_string = false;
639    let mut escaped = false;
640    for ch in raw.chars() {
641        if escaped {
642            escaped = false;
643            out.push(ch);
644            continue;
645        }
646        match ch {
647            '"' => {
648                in_string = !in_string;
649                out.push(ch);
650            }
651            '\\' if in_string => {
652                escaped = true;
653                out.push(ch);
654            }
655            c if in_string && (c as u32) < 0x20 => {
656                out.push_str(&format!("\\u{:04x}", c as u32));
657            }
658            c => out.push(c),
659        }
660    }
661    out
662}
663
664/// Parse `raw` as JSON, retrying once with control-character escaping when
665/// strict parsing fails.
666///
667/// Valid bodies take the strict path unchanged (zero overhead beyond the
668/// string read); only bodies that strict-JSON rejects get the sanitize pass.
669/// If the sanitized retry also fails, the *original* error is returned so
670/// callers see the real reason and not a secondary artifact.
671fn from_json_lenient<T: serde::de::DeserializeOwned>(raw: &str) -> serde_json::Result<T> {
672    match serde_json::from_str(raw) {
673        Ok(v) => Ok(v),
674        Err(err) => serde_json::from_str(&escape_unescaped_control_chars(raw)).map_err(|_| err),
675    }
676}
677
678/// Parse an OpenAI streaming JSON chunk into a StreamEvent.
679fn parse_openai_sse(data: &str) -> Option<StreamEvent> {
680    let v: serde_json::Value = from_json_lenient(data).ok()?;
681
682    // GLM-4.5+/o1 send reasoning and answer as separate delta chunks; check
683    // reasoning_content first so it is surfaced as ReasoningDelta, not dropped.
684    if let Some(rc) = v
685        .get("choices")?
686        .get(0)?
687        .get("delta")?
688        .get("reasoning_content")
689        .and_then(|c| c.as_str())
690        && !rc.is_empty()
691    {
692        return Some(StreamEvent::ReasoningDelta {
693            content: rc.to_string(),
694        });
695    }
696
697    // Extract delta content
698    if let Some(content) = v
699        .get("choices")?
700        .get(0)?
701        .get("delta")?
702        .get("content")
703        .and_then(|c| c.as_str())
704        && !content.is_empty()
705    {
706        return Some(StreamEvent::Delta {
707            content: content.to_string(),
708        });
709    }
710
711    // Extract usage from the final chunk
712    if let Some(usage) = v.get("usage").and_then(|u| {
713        Some(TokenUsage {
714            prompt_tokens: u.get("prompt_tokens")?.as_u64()? as u32,
715            completion_tokens: u.get("completion_tokens")?.as_u64()? as u32,
716            total_tokens: u.get("total_tokens")?.as_u64()? as u32,
717            reasoning_tokens: u
718                .get("completion_tokens_details")
719                .and_then(|d| d.get("reasoning_tokens"))
720                .and_then(|r| r.as_u64())
721                .map(|n| n as u32),
722        })
723    }) {
724        return Some(StreamEvent::Usage(usage));
725    }
726
727    // finish_reason = "stop" means done (no more content in this chunk)
728    if v.get("choices")?
729        .get(0)?
730        .get("finish_reason")
731        .and_then(|r| r.as_str())
732        .is_some()
733    {
734        return Some(StreamEvent::Done);
735    }
736
737    None
738}
739
740/// Parse an Anthropic streaming JSON chunk into a StreamEvent.
741fn parse_anthropic_sse(event_type: &str, data: &str) -> Option<StreamEvent> {
742    let v: serde_json::Value = serde_json::from_str(data).ok()?;
743
744    match event_type {
745        "content_block_delta" => {
746            let delta = v.get("delta")?;
747            // Extended thinking deltas arrive as {"type":"thinking_delta","thinking":"..."}.
748            match delta.get("type").and_then(|t| t.as_str()) {
749                Some("thinking_delta") => {
750                    let text = delta.get("thinking")?.as_str()?;
751                    if !text.is_empty() {
752                        return Some(StreamEvent::ReasoningDelta {
753                            content: text.to_string(),
754                        });
755                    }
756                    None
757                }
758                _ => {
759                    // text_delta (default) carries {"text":"..."}.
760                    let text = delta.get("text")?.as_str()?;
761                    if !text.is_empty() {
762                        return Some(StreamEvent::Delta {
763                            content: text.to_string(),
764                        });
765                    }
766                    None
767                }
768            }
769        }
770        "message_delta" => {
771            let usage = v.get("usage").and_then(|u| {
772                Some(TokenUsage {
773                    prompt_tokens: 0,
774                    completion_tokens: u.get("output_tokens")?.as_u64()? as u32,
775                    total_tokens: 0,
776                    reasoning_tokens: None,
777                })
778            });
779            if let Some(usage) = usage {
780                return Some(StreamEvent::Usage(usage));
781            }
782            Some(StreamEvent::Done)
783        }
784        "message_stop" => Some(StreamEvent::Done),
785        _ => None,
786    }
787}
788
789/// Async LLM client for the Anthropic Messages API.
790pub struct AnthropicClient {
791    api_key: String,
792    model: String,
793    base_url: String,
794    client: reqwest::Client,
795}
796
797impl AnthropicClient {
798    /// Create a new client using credentials from the environment variable in `config`.
799    pub fn new(config: &ModelConfig) -> Result<Self> {
800        let api_key = std::env::var(&config.api_key_env).map_err(|_| {
801            KernelError::Config(format!(
802                "Environment variable {} not set",
803                config.api_key_env
804            ))
805        })?;
806        Ok(Self {
807            api_key,
808            model: config.model.clone(),
809            base_url: config
810                .base_url
811                .clone()
812                .unwrap_or_else(|| "https://api.anthropic.com/v1".into()),
813            client: http_client()?,
814        })
815    }
816
817    /// Create a new client with an explicit API key, using the default Anthropic base URL.
818    ///
819    /// Returns a [`KernelError::Config`] if the HTTP client (with its connect /
820    /// total timeouts) cannot be built, rather than silently falling back to a
821    /// timeout-less `reqwest::Client::default()`.
822    pub fn from_key(model: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
823        Ok(Self {
824            api_key: api_key.into(),
825            model: model.into(),
826            base_url: "https://api.anthropic.com/v1".into(),
827            client: http_client()?,
828        })
829    }
830
831    /// Create from an explicit key and a shared `reqwest::Client`.
832    pub fn from_key_with_client(
833        model: impl Into<String>,
834        api_key: impl Into<String>,
835        client: reqwest::Client,
836    ) -> Self {
837        Self {
838            api_key: api_key.into(),
839            model: model.into(),
840            base_url: "https://api.anthropic.com/v1".into(),
841            client,
842        }
843    }
844
845    /// Create from an explicit key, a custom base URL, and a shared `reqwest::Client`.
846    ///
847    /// Use this for Anthropic-compatible endpoints that are not the default
848    /// `api.anthropic.com` (self-hosted proxies, regional gateways, …).
849    pub fn from_key_with_base_url(
850        model: impl Into<String>,
851        api_key: impl Into<String>,
852        base_url: impl Into<String>,
853        client: reqwest::Client,
854    ) -> Self {
855        Self {
856            api_key: api_key.into(),
857            model: model.into(),
858            base_url: base_url.into(),
859            client,
860        }
861    }
862}
863
864#[derive(serde::Serialize)]
865struct AnthropicRequest {
866    model: String,
867    max_tokens: u32,
868    temperature: f32,
869    #[serde(skip_serializing_if = "Option::is_none")]
870    system: Option<String>,
871    messages: Vec<AnthropicMessage>,
872    #[serde(skip_serializing_if = "std::ops::Not::not")]
873    stream: bool,
874    #[serde(skip_serializing_if = "Option::is_none")]
875    tools: Option<Vec<serde_json::Value>>,
876    #[serde(skip_serializing_if = "Option::is_none")]
877    output_config: Option<serde_json::Value>,
878}
879
880#[derive(serde::Serialize)]
881struct AnthropicMessage {
882    role: String,
883    content: String,
884}
885
886#[derive(serde::Deserialize)]
887struct AnthropicResponse {
888    #[serde(default)]
889    id: Option<String>,
890    content: Vec<AnthropicContentBlock>,
891    model: String,
892    #[serde(default)]
893    stop_reason: Option<String>,
894    usage: AnthropicUsage,
895}
896
897/// A response content block. `text` blocks carry `text`; `tool_use` blocks
898/// carry `id`/`name`/`input`; `thinking` blocks (extended thinking) carry `thinking`.
899#[derive(serde::Deserialize)]
900struct AnthropicContentBlock {
901    #[serde(rename = "type")]
902    block_type: String,
903    #[serde(default)]
904    text: Option<String>,
905    /// Extended thinking content (`{"type":"thinking","thinking":"..."}`).
906    #[serde(default)]
907    thinking: Option<String>,
908    #[serde(default)]
909    id: Option<String>,
910    #[serde(default)]
911    name: Option<String>,
912    #[serde(default)]
913    input: Option<serde_json::Value>,
914}
915
916#[derive(serde::Deserialize)]
917struct AnthropicUsage {
918    input_tokens: u32,
919    output_tokens: u32,
920}
921
922#[async_trait]
923impl LLMClient for AnthropicClient {
924    async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
925        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
926        let max_tokens = request.max_tokens.unwrap_or(4096);
927        let temperature = request.temperature;
928        let system = request.system.clone();
929        let tools = request
930            .tools
931            .as_deref()
932            .map(anthropic_tools)
933            .filter(|t| !t.is_empty());
934        let output_config = request
935            .response_format
936            .as_ref()
937            .and_then(anthropic_output_config);
938        let messages: Vec<AnthropicMessage> = request
939            .into_anthropic_messages()
940            .into_iter()
941            .map(|(role, content)| AnthropicMessage { role, content })
942            .collect();
943
944        let body = AnthropicRequest {
945            model,
946            max_tokens,
947            temperature,
948            system,
949            messages,
950            stream: false,
951            tools,
952            output_config,
953        };
954
955        let resp = self
956            .client
957            .post(format!("{}/messages", self.base_url))
958            .header("x-api-key", &self.api_key)
959            .header("anthropic-version", "2023-06-01")
960            .header("content-type", "application/json")
961            .json(&body)
962            .send()
963            .await
964            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
965
966        check_rate_limit(&resp)?;
967
968        let status = resp.status();
969
970        if !status.is_success() {
971            let text = resp.text().await.unwrap_or_default();
972            return Err(KernelError::Http {
973                status: status.as_u16(),
974                message: redact_http_body(&text),
975            });
976        }
977
978        let chat_resp: AnthropicResponse = resp
979            .json()
980            .await
981            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
982
983        let mut content = String::new();
984        let mut reasoning = String::new();
985        let mut tool_calls = Vec::new();
986        for block in chat_resp.content {
987            match block.block_type.as_str() {
988                "text" => {
989                    if let Some(t) = block.text {
990                        content.push_str(&t);
991                    }
992                }
993                "thinking" => {
994                    if let Some(t) = block.thinking {
995                        reasoning.push_str(&t);
996                    }
997                }
998                "tool_use" => {
999                    if let (Some(id), Some(name)) = (block.id, block.name) {
1000                        let arguments = block
1001                            .input
1002                            .map(|v| v.to_string())
1003                            .unwrap_or_else(|| "{}".to_string());
1004                        tool_calls.push(ToolCall {
1005                            id,
1006                            name,
1007                            arguments,
1008                        });
1009                    }
1010                }
1011                _ => {}
1012            }
1013        }
1014
1015        Ok(LLMResponse {
1016            content,
1017            reasoning: if reasoning.is_empty() {
1018                None
1019            } else {
1020                Some(reasoning)
1021            },
1022            model: chat_resp.model,
1023            usage: TokenUsage {
1024                prompt_tokens: chat_resp.usage.input_tokens,
1025                completion_tokens: chat_resp.usage.output_tokens,
1026                total_tokens: chat_resp.usage.input_tokens + chat_resp.usage.output_tokens,
1027                reasoning_tokens: None,
1028            },
1029            tool_calls,
1030            finish_reason: chat_resp.stop_reason,
1031            id: chat_resp.id,
1032            created: None,
1033        })
1034    }
1035
1036    fn model_name(&self) -> &str {
1037        &self.model
1038    }
1039
1040    async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
1041        let model = request.model.clone().unwrap_or_else(|| self.model.clone());
1042        let max_tokens = request.max_tokens.unwrap_or(4096);
1043        let temperature = request.temperature;
1044        let system = request.system.clone();
1045        let messages: Vec<AnthropicMessage> = request
1046            .into_anthropic_messages()
1047            .into_iter()
1048            .map(|(role, content)| AnthropicMessage { role, content })
1049            .collect();
1050
1051        let body = AnthropicRequest {
1052            model,
1053            max_tokens,
1054            temperature,
1055            system,
1056            messages,
1057            stream: true,
1058            // Streaming is text-only here: the SSE parser emits text deltas and
1059            // does not reassemble streamed tool-use blocks.
1060            tools: None,
1061            output_config: None,
1062        };
1063
1064        let resp = self
1065            .client
1066            .post(format!("{}/messages", self.base_url))
1067            .header("x-api-key", &self.api_key)
1068            .header("anthropic-version", "2023-06-01")
1069            .header("content-type", "application/json")
1070            .json(&body)
1071            .send()
1072            .await
1073            .map_err(|e| KernelError::LlmApi(e.to_string()))?;
1074
1075        check_rate_limit(&resp)?;
1076
1077        let status = resp.status();
1078        if !status.is_success() {
1079            let text = resp.text().await.unwrap_or_default();
1080            return Err(KernelError::Http {
1081                status: status.as_u16(),
1082                message: redact_http_body(&text),
1083            });
1084        }
1085
1086        let (tx, rx) = tokio::sync::mpsc::channel::<Result<StreamEvent>>(16);
1087
1088        tokio::spawn(async move {
1089            let mut stream = std::pin::pin!(resp.bytes_stream());
1090            let mut buffer: Vec<u8> = Vec::new();
1091            let mut current_event = String::new();
1092
1093            use tokio_stream::StreamExt;
1094
1095            while let Some(chunk) = stream.next().await {
1096                let chunk = match chunk {
1097                    Ok(c) => c,
1098                    Err(e) => {
1099                        let _ = tx.send(Err(KernelError::LlmApi(e.to_string()))).await;
1100                        return;
1101                    }
1102                };
1103
1104                for line in drain_sse_lines(&mut buffer, &chunk) {
1105                    if let Some(evt) = line.strip_prefix("event: ") {
1106                        current_event = evt.to_string();
1107                    } else if let Some(data) = line.strip_prefix("data: ") {
1108                        if data == "[DONE]" {
1109                            let _ = tx.send(Ok(StreamEvent::Done)).await;
1110                            return;
1111                        }
1112                        if let Some(event) = parse_anthropic_sse(&current_event, data) {
1113                            let is_done = matches!(event, StreamEvent::Done);
1114                            if tx.send(Ok(event)).await.is_err() || is_done {
1115                                return;
1116                            }
1117                        }
1118                        current_event.clear();
1119                    }
1120                }
1121            }
1122            let _ = tx.send(Ok(StreamEvent::Done)).await;
1123        });
1124
1125        Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
1126    }
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131    use super::*;
1132    use crate::llm::types::ReasoningSummary;
1133
1134    #[test]
1135    fn parse_sse_line_extracts_data() {
1136        assert_eq!(
1137            parse_sse_line("data: {\"id\":\"1\"}"),
1138            Some("{\"id\":\"1\"}")
1139        );
1140    }
1141
1142    #[test]
1143    fn parse_sse_line_skips_done() {
1144        assert_eq!(parse_sse_line("data: [DONE]"), None);
1145    }
1146
1147    #[test]
1148    fn parse_sse_line_skips_non_data() {
1149        assert_eq!(parse_sse_line("event: ping"), None);
1150        assert_eq!(parse_sse_line(""), None);
1151    }
1152
1153    #[test]
1154    fn drain_sse_lines_reassembles_multibyte_split_across_chunks() {
1155        // "data: 안녕\n" — "data: " is 6 bytes, 안/녕 are 3 bytes each.
1156        let full = "data: 안녕\n".as_bytes().to_vec();
1157        // Split at byte 7, mid-way through "안"'s 3-byte sequence.
1158        let (first, rest) = full.split_at(7);
1159
1160        let mut buffer = Vec::new();
1161        // No newline yet, and the trailing bytes are a partial codepoint:
1162        // nothing should be emitted, and nothing should be corrupted.
1163        assert!(drain_sse_lines(&mut buffer, first).is_empty());
1164
1165        let lines = drain_sse_lines(&mut buffer, rest);
1166        assert_eq!(lines, vec!["data: 안녕".to_string()]);
1167        // A per-chunk from_utf8_lossy would instead have produced U+FFFD here.
1168        assert!(!lines[0].contains('\u{FFFD}'));
1169    }
1170
1171    #[test]
1172    fn drain_sse_lines_handles_multiple_lines_and_keeps_partial_tail() {
1173        let mut buffer = Vec::new();
1174        let lines = drain_sse_lines(&mut buffer, b"event: ping\r\ndata: {}\npartial");
1175        assert_eq!(
1176            lines,
1177            vec!["event: ping".to_string(), "data: {}".to_string()]
1178        );
1179        // The unterminated "partial" tail stays buffered for the next chunk.
1180        let lines = drain_sse_lines(&mut buffer, b" tail\n");
1181        assert_eq!(lines, vec!["partial tail".to_string()]);
1182    }
1183
1184    #[test]
1185    fn openai_delta_extraction() {
1186        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
1187        let event = parse_openai_sse(data).unwrap();
1188        match event {
1189            StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
1190            _ => panic!("expected Delta, got {:?}", event),
1191        }
1192    }
1193
1194    #[test]
1195    fn openai_usage_extraction() {
1196        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#;
1197        let event = parse_openai_sse(data).unwrap();
1198        match event {
1199            StreamEvent::Usage(usage) => {
1200                assert_eq!(usage.prompt_tokens, 10);
1201                assert_eq!(usage.completion_tokens, 5);
1202                assert_eq!(usage.total_tokens, 15);
1203            }
1204            _ => panic!("expected Usage, got {:?}", event),
1205        }
1206    }
1207
1208    #[test]
1209    fn openai_finish_reason_is_done() {
1210        let data =
1211            r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#;
1212        let event = parse_openai_sse(data).unwrap();
1213        assert!(matches!(event, StreamEvent::Done));
1214    }
1215
1216    #[test]
1217    fn openai_empty_delta_skipped() {
1218        let data = r#"{"id":"chatcmpl-1","choices":[{"index":0,"delta":{"content":""},"finish_reason":null}]}"#;
1219        assert!(parse_openai_sse(data).is_none());
1220    }
1221
1222    #[test]
1223    fn anthropic_content_block_delta() {
1224        let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
1225        let event = parse_anthropic_sse("content_block_delta", data).unwrap();
1226        match event {
1227            StreamEvent::Delta { content } => assert_eq!(content, "Hello"),
1228            _ => panic!("expected Delta, got {:?}", event),
1229        }
1230    }
1231
1232    #[test]
1233    fn anthropic_message_delta_usage() {
1234        let data = r#"{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}"#;
1235        let event = parse_anthropic_sse("message_delta", data).unwrap();
1236        match event {
1237            StreamEvent::Usage(usage) => assert_eq!(usage.completion_tokens, 5),
1238            _ => panic!("expected Usage, got {:?}", event),
1239        }
1240    }
1241
1242    #[test]
1243    fn anthropic_message_stop() {
1244        let event = parse_anthropic_sse("message_stop", r#"{"type":"message_stop"}"#).unwrap();
1245        assert!(matches!(event, StreamEvent::Done));
1246    }
1247
1248    #[test]
1249    fn anthropic_unknown_event_ignored() {
1250        assert!(parse_anthropic_sse("ping", "{}").is_none());
1251    }
1252
1253    fn sample_tool() -> ToolDefinition {
1254        ToolDefinition {
1255            name: "get_weather".into(),
1256            description: "Get weather".into(),
1257            input_schema: serde_json::json!({
1258                "type": "object",
1259                "properties": { "location": { "type": "string" } },
1260                "required": ["location"]
1261            }),
1262        }
1263    }
1264
1265    #[test]
1266    fn openai_tools_use_function_wrapper() {
1267        let out = openai_tools(&[sample_tool()]);
1268        assert_eq!(out.len(), 1);
1269        assert_eq!(out[0]["type"], "function");
1270        assert_eq!(out[0]["function"]["name"], "get_weather");
1271        // input_schema is forwarded verbatim as `parameters`.
1272        assert_eq!(out[0]["function"]["parameters"]["required"][0], "location");
1273    }
1274
1275    #[test]
1276    fn openai_response_format_maps_each_variant() {
1277        assert!(openai_response_format(&ResponseFormat::Text).is_none());
1278        assert_eq!(
1279            openai_response_format(&ResponseFormat::Json).unwrap()["type"],
1280            "json_object"
1281        );
1282        let schema = serde_json::json!({"type": "object"});
1283        let js = openai_response_format(&ResponseFormat::JsonSchema { schema }).unwrap();
1284        assert_eq!(js["type"], "json_schema");
1285        assert_eq!(js["json_schema"]["strict"], true);
1286    }
1287
1288    #[test]
1289    fn anthropic_tools_use_input_schema_key() {
1290        let out = anthropic_tools(&[sample_tool()]);
1291        assert_eq!(out[0]["name"], "get_weather");
1292        assert_eq!(out[0]["input_schema"]["type"], "object");
1293        assert!(out[0].get("function").is_none());
1294    }
1295
1296    #[test]
1297    fn anthropic_output_config_only_for_json_schema() {
1298        assert!(anthropic_output_config(&ResponseFormat::Text).is_none());
1299        assert!(anthropic_output_config(&ResponseFormat::Json).is_none());
1300        let schema = serde_json::json!({"type": "object"});
1301        let cfg = anthropic_output_config(&ResponseFormat::JsonSchema { schema }).unwrap();
1302        assert_eq!(cfg["format"]["type"], "json_schema");
1303    }
1304
1305    #[test]
1306    fn openai_request_serializes_tools_and_format() {
1307        let body = OpenAIChatRequest {
1308            model: "gpt-4o".into(),
1309            messages: vec![OpenAIChatMessage {
1310                role: "user".into(),
1311                content: "hi".into(),
1312            }],
1313            temperature: 0.7,
1314            max_tokens: None,
1315            stream: false,
1316            tools: Some(openai_tools(&[sample_tool()])),
1317            response_format: Some(serde_json::json!({ "type": "json_object" })),
1318            reasoning_effort: None,
1319            reasoning: None,
1320            verbosity: None,
1321        };
1322        let json = serde_json::to_value(&body).unwrap();
1323        assert_eq!(json["tools"][0]["function"]["name"], "get_weather");
1324        assert_eq!(json["response_format"]["type"], "json_object");
1325        // Omitted when None (backward-compatible request shape).
1326        assert!(json.get("max_tokens").is_none());
1327        assert!(json.get("reasoning_effort").is_none());
1328        assert!(json.get("reasoning").is_none());
1329    }
1330
1331    #[test]
1332    fn openai_reasoning_effort_maps_to_official_param() {
1333        let (effort, reasoning) =
1334            openai_reasoning(Some(&ReasoningConfig::effort(ReasoningEffort::High)));
1335        assert_eq!(effort, Some(ReasoningEffort::High));
1336        assert!(reasoning.is_none());
1337        let body = OpenAIChatRequest {
1338            model: "gpt-5.5".into(),
1339            messages: vec![],
1340            temperature: 0.7,
1341            max_tokens: None,
1342            stream: false,
1343            tools: None,
1344            response_format: None,
1345            reasoning_effort: effort,
1346            reasoning,
1347            verbosity: None,
1348        };
1349        let json = serde_json::to_value(&body).unwrap();
1350        assert_eq!(json["reasoning_effort"], "high");
1351        assert!(json.get("reasoning").is_none());
1352    }
1353
1354    #[test]
1355    fn openai_reasoning_disabled_maps_to_openrouter_object() {
1356        // immune's exact need: reasoning: {"enabled": false} on OpenRouter,
1357        // with no reasoning_effort key and no config -> no keys at all.
1358        let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig::disabled()));
1359        assert_eq!(effort, None);
1360        assert_eq!(reasoning.unwrap()["enabled"], false);
1361
1362        let (effort, reasoning) = openai_reasoning(None);
1363        assert!(effort.is_none() && reasoning.is_none());
1364    }
1365
1366    #[test]
1367    fn openai_reasoning_both_knobs_serialize_independently() {
1368        let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig {
1369            enabled: Some(true),
1370            effort: Some(ReasoningEffort::Low),
1371            summary: Some(ReasoningSummary::Concise),
1372        }));
1373        let body = OpenAIChatRequest {
1374            model: "m".into(),
1375            messages: vec![],
1376            temperature: 0.7,
1377            max_tokens: None,
1378            stream: false,
1379            tools: None,
1380            response_format: None,
1381            reasoning_effort: effort,
1382            reasoning,
1383            verbosity: Some(Verbosity::High),
1384        };
1385        let json = serde_json::to_value(&body).unwrap();
1386        assert_eq!(json["reasoning_effort"], "low");
1387        assert_eq!(json["reasoning"]["enabled"], true);
1388        assert_eq!(json["reasoning"]["summary"], "concise");
1389        assert_eq!(json["verbosity"], "high");
1390    }
1391
1392    #[test]
1393    fn openai_reasoning_summary_only_serializes_object() {
1394        let (effort, reasoning) = openai_reasoning(Some(&ReasoningConfig {
1395            enabled: None,
1396            effort: None,
1397            summary: Some(ReasoningSummary::Detailed),
1398        }));
1399        assert!(effort.is_none());
1400        assert_eq!(reasoning.unwrap()["summary"], "detailed");
1401    }
1402
1403    #[test]
1404    fn openai_body_merges_extra_body_last_write_wins() {
1405        let body = OpenAIChatRequest {
1406            model: "m".into(),
1407            messages: vec![],
1408            temperature: 0.7,
1409            max_tokens: None,
1410            stream: false,
1411            tools: None,
1412            response_format: None,
1413            reasoning_effort: None,
1414            reasoning: None,
1415            verbosity: None,
1416        };
1417        let mut extra = serde_json::Map::new();
1418        extra.insert("seed".into(), 7.into());
1419        extra.insert("temperature".into(), 0.1.into());
1420        let merged = openai_body_with_extra(&body, Some(&extra));
1421        assert_eq!(merged["seed"], 7);
1422        // Extra key overrides the natively forwarded one (last-write-wins).
1423        assert_eq!(merged["temperature"], 0.1);
1424        // Without extras the body is untouched. (Compare via json! so the f32
1425        // → f64 widening in the Number matches on both sides.)
1426        let plain = openai_body_with_extra(&body, None);
1427        assert_eq!(plain["temperature"], serde_json::json!(0.7f32));
1428        assert!(plain.get("seed").is_none());
1429    }
1430
1431    #[test]
1432    fn verbosity_wire_values_match_openai_spec() {
1433        // Official enum: low, medium, high.
1434        for (variant, wire) in [
1435            (Verbosity::Low, "low"),
1436            (Verbosity::Medium, "medium"),
1437            (Verbosity::High, "high"),
1438        ] {
1439            assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1440        }
1441    }
1442
1443    #[test]
1444    fn reasoning_summary_wire_values_match_openai_spec() {
1445        // Official enum: auto, concise, detailed.
1446        for (variant, wire) in [
1447            (ReasoningSummary::Auto, "auto"),
1448            (ReasoningSummary::Concise, "concise"),
1449            (ReasoningSummary::Detailed, "detailed"),
1450        ] {
1451            assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1452        }
1453    }
1454
1455    #[test]
1456    fn reasoning_effort_wire_values_match_openai_spec() {
1457        // Official enum: none, minimal, low, medium, high, xhigh, max.
1458        for (variant, wire) in [
1459            (ReasoningEffort::None, "none"),
1460            (ReasoningEffort::Minimal, "minimal"),
1461            (ReasoningEffort::Low, "low"),
1462            (ReasoningEffort::Medium, "medium"),
1463            (ReasoningEffort::High, "high"),
1464            (ReasoningEffort::XHigh, "xhigh"),
1465            (ReasoningEffort::Max, "max"),
1466        ] {
1467            assert_eq!(serde_json::to_value(variant).unwrap(), wire);
1468            let back: ReasoningEffort = serde_json::from_value(wire.into()).unwrap();
1469            assert_eq!(back, variant);
1470        }
1471    }
1472
1473    #[test]
1474    fn lenient_decode_rescues_openrouter_control_chars() {
1475        // OpenRouter was observed returning 200 with raw \n / \t inside the
1476        // reasoning string; strict serde_json fails the whole body.
1477        let raw = "{\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"답변\",\"reasoning_content\":\"step 1\nstep 2\ttab\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}";
1478        assert!(serde_json::from_str::<OpenAIChatResponse>(raw).is_err());
1479        let resp: OpenAIChatResponse = from_json_lenient(raw).unwrap();
1480        let first = resp.choices.into_iter().next().unwrap();
1481        assert_eq!(first.message.content.as_deref(), Some("답변"));
1482        assert_eq!(
1483            first.message.reasoning_content.as_deref(),
1484            Some("step 1\nstep 2\ttab")
1485        );
1486    }
1487
1488    #[test]
1489    fn lenient_decode_keeps_original_error_for_structural_garbage() {
1490        // Broken structure (not just control chars) still errors, surfacing
1491        // the *original* strict-parse error rather than a sanitize artifact.
1492        let raw = "{\"choices\": tr";
1493        let strict = serde_json::from_str::<OpenAIChatResponse>(raw)
1494            .map(|_: OpenAIChatResponse| ())
1495            .unwrap_err();
1496        let lenient = from_json_lenient::<OpenAIChatResponse>(raw)
1497            .map(|_: OpenAIChatResponse| ())
1498            .unwrap_err();
1499        assert_eq!(lenient.to_string(), strict.to_string());
1500    }
1501
1502    #[test]
1503    fn escape_unescaped_control_chars_only_touches_string_literals() {
1504        // Raw newline outside strings (pretty-printed JSON) stays as-is;
1505        // inside strings it becomes \uXXXX; existing escapes are untouched.
1506        let raw = "{\n  \"a\": \"x\ny\\n\\tz\",\n  \"b\": 1\n}";
1507        let out = escape_unescaped_control_chars(raw);
1508        assert_eq!(out, "{\n  \"a\": \"x\\u000ay\\n\\tz\",\n  \"b\": 1\n}");
1509        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
1510        assert_eq!(v["a"], "x\ny\n\tz");
1511    }
1512
1513    #[test]
1514    fn escape_unescaped_control_chars_handles_escaped_quotes() {
1515        // \" inside a string must not toggle the in-string state.
1516        let raw = "{\"a\":\"say \\\"hi\\\" ok\"}";
1517        assert_eq!(escape_unescaped_control_chars(raw), raw);
1518    }
1519
1520    #[test]
1521    fn openai_sse_lenient_reasoning_delta_with_control_chars() {
1522        // A streamed reasoning delta carrying a raw tab must not be dropped.
1523        let data = "{\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"think\ttab\"},\"finish_reason\":null}]}";
1524        let event = parse_openai_sse(data).unwrap();
1525        match event {
1526            StreamEvent::ReasoningDelta { content } => assert_eq!(content, "think\ttab"),
1527            other => panic!("expected ReasoningDelta, got {other:?}"),
1528        }
1529    }
1530
1531    #[test]
1532    fn openai_response_parses_tool_calls() {
1533        let raw = r#"{
1534            "id": "chatcmpl-1",
1535            "created": 1700000000,
1536            "model": "gpt-4o",
1537            "choices": [{
1538                "index": 0,
1539                "message": {
1540                    "role": "assistant",
1541                    "content": null,
1542                    "tool_calls": [{
1543                        "id": "call_abc",
1544                        "type": "function",
1545                        "function": { "name": "get_weather", "arguments": "{\"location\":\"Paris\"}" }
1546                    }]
1547                },
1548                "finish_reason": "tool_calls"
1549            }],
1550            "usage": { "prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15 }
1551        }"#;
1552        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1553        assert_eq!(resp.id.as_deref(), Some("chatcmpl-1"));
1554        let choice = resp.choices.into_iter().next().unwrap();
1555        assert_eq!(choice.finish_reason.as_deref(), Some("tool_calls"));
1556        assert!(choice.message.content.is_none());
1557        assert_eq!(choice.message.tool_calls.len(), 1);
1558        assert_eq!(choice.message.tool_calls[0].function.name, "get_weather");
1559    }
1560
1561    #[test]
1562    fn openai_response_parses_glm47_reasoning_content() {
1563        // GLM-4.7: content=null, reasoning_content carries the chain-of-thought + final JSON.
1564        let raw = r#"{
1565            "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1566            "choices":[{"index":0,"message":{"role":"assistant","content":null,
1567                "reasoning_content":"thinking... {\"rating\":\"Buy\"}"},"finish_reason":"stop"}],
1568            "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,
1569                "completion_tokens_details":{"reasoning_tokens":3}}
1570        }"#;
1571        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1572        let choice = resp.choices.into_iter().next().unwrap();
1573        assert!(choice.message.content.is_none());
1574        assert!(choice.message.reasoning_content.is_some());
1575        assert_eq!(
1576            resp.usage
1577                .unwrap()
1578                .completion_tokens_details
1579                .unwrap()
1580                .reasoning_tokens,
1581            Some(3)
1582        );
1583    }
1584
1585    #[test]
1586    fn openai_complete_promotes_reasoning_when_content_empty() {
1587        // complete() must promote reasoning_content into content when content is empty,
1588        // so downstream json_extract finds the JSON. Drives the *production* promotion
1589        // helper (not a re-implementation) so the regression guard tracks real code.
1590        let raw = r#"{
1591            "id":"chatcmpl-1","created":1700000000,"model":"glm-4.7",
1592            "choices":[{"index":0,"message":{"role":"assistant","content":"",
1593                "reasoning_content":"사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}"},"finish_reason":"stop"}]
1594        }"#;
1595        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1596        let first = resp.choices.into_iter().next().unwrap();
1597        let raw_content = first.message.content.unwrap_or_default();
1598        let reasoning = first.message.reasoning_content.clone();
1599        let content = promote_reasoning_into_content(raw_content, reasoning.as_deref());
1600        assert!(
1601            content.contains("\"rating\":\"Sell\""),
1602            "promoted content must contain JSON: {content}"
1603        );
1604        assert_eq!(
1605            reasoning.as_deref(),
1606            Some("사고 {\"rating\":\"Sell\",\"key_thesis\":\"약세\"}")
1607        );
1608    }
1609
1610    #[test]
1611    fn openai_complete_keeps_content_when_present() {
1612        // Non-empty content must NOT be overwritten by reasoning.
1613        let raw = r#"{
1614            "id":"chatcmpl-1","created":1700000000,"model":"gpt-4o",
1615            "choices":[{"index":0,"message":{"role":"assistant","content":"answer",
1616                "reasoning_content":"thought"},"finish_reason":"stop"}]
1617        }"#;
1618        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1619        let first = resp.choices.into_iter().next().unwrap();
1620        let raw_content = first.message.content.clone().unwrap_or_default();
1621        let reasoning = first.message.reasoning_content.as_deref();
1622        let content = promote_reasoning_into_content(raw_content.clone(), reasoning);
1623        assert_eq!(content, "answer");
1624        assert_eq!(reasoning, Some("thought"));
1625    }
1626
1627    #[test]
1628    fn promote_reasoning_into_content_edge_cases() {
1629        // #3 caveat: empty content + reasoning surfaces the reasoning as the answer
1630        // (documented trade-off; original reasoning is the source of truth).
1631        assert_eq!(
1632            promote_reasoning_into_content(String::new(), Some("chain-of-thought")),
1633            "chain-of-thought"
1634        );
1635        // content present wins regardless of reasoning.
1636        assert_eq!(
1637            promote_reasoning_into_content("ans".into(), Some("cot")),
1638            "ans"
1639        );
1640        // both empty -> empty (no panic, no spurious content).
1641        assert_eq!(promote_reasoning_into_content(String::new(), None), "");
1642        assert_eq!(promote_reasoning_into_content("x".into(), None), "x");
1643    }
1644
1645    #[test]
1646    fn openai_response_parses_deepseek_reasoning_alias() {
1647        // DeepSeek-R1 uses field name `reasoning` instead of `reasoning_content`.
1648        let raw = r#"{"model":"deepseek-r1","choices":[{"message":{"content":"ans","reasoning":"thought"}}]}"#;
1649        let resp: OpenAIChatResponse = serde_json::from_str(raw).unwrap();
1650        assert_eq!(
1651            resp.choices[0].message.reasoning_content.as_deref(),
1652            Some("thought")
1653        );
1654    }
1655
1656    #[test]
1657    fn openai_sse_reasoning_delta_extracted() {
1658        let data = r#"{"choices":[{"index":0,"delta":{"reasoning_content":"thinking"},"finish_reason":null}]}"#;
1659        let event = parse_openai_sse(data).unwrap();
1660        match event {
1661            StreamEvent::ReasoningDelta { content } => assert_eq!(content, "thinking"),
1662            other => panic!("expected ReasoningDelta, got {other:?}"),
1663        }
1664    }
1665
1666    #[test]
1667    fn openai_sse_content_delta_still_works_alongside_reasoning() {
1668        // Separate content chunk must still produce Delta, not be swallowed.
1669        let data = r#"{"choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":null}]}"#;
1670        let event = parse_openai_sse(data).unwrap();
1671        assert!(matches!(event, StreamEvent::Delta { .. }));
1672    }
1673
1674    #[test]
1675    fn openai_sse_usage_carries_reasoning_tokens() {
1676        // Final streaming chunk carries choices (empty delta) + usage with reasoning_tokens.
1677        let data = r#"{"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
1678            "usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3,
1679            "completion_tokens_details":{"reasoning_tokens":7}}}"#;
1680        let event = parse_openai_sse(data).unwrap();
1681        match event {
1682            StreamEvent::Usage(u) => assert_eq!(u.reasoning_tokens, Some(7)),
1683            other => panic!("expected Usage, got {other:?}"),
1684        }
1685    }
1686
1687    #[test]
1688    fn anthropic_response_parses_tool_use_block() {
1689        let raw = r#"{
1690            "id": "msg_1",
1691            "model": "claude-sonnet-4-6",
1692            "stop_reason": "tool_use",
1693            "content": [
1694                { "type": "text", "text": "Let me check." },
1695                { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "location": "Paris" } }
1696            ],
1697            "usage": { "input_tokens": 12, "output_tokens": 8 }
1698        }"#;
1699        let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1700        assert_eq!(resp.stop_reason.as_deref(), Some("tool_use"));
1701        assert_eq!(resp.content.len(), 2);
1702        assert_eq!(resp.content[0].block_type, "text");
1703        assert_eq!(resp.content[1].block_type, "tool_use");
1704        assert_eq!(resp.content[1].name.as_deref(), Some("get_weather"));
1705        assert_eq!(resp.content[1].input.as_ref().unwrap()["location"], "Paris");
1706    }
1707
1708    #[test]
1709    fn anthropic_response_parses_thinking_block() {
1710        // Extended thinking block must surface in LLMResponse.reasoning via the parse loop.
1711        let raw = r#"{
1712            "id": "msg_2",
1713            "model": "claude-sonnet-4-6",
1714            "stop_reason": "end_turn",
1715            "content": [
1716                { "type": "thinking", "thinking": "step by step..." },
1717                { "type": "text", "text": "Final answer." }
1718            ],
1719            "usage": { "input_tokens": 5, "output_tokens": 9 }
1720        }"#;
1721        let resp: AnthropicResponse = serde_json::from_str(raw).unwrap();
1722        let mut reasoning = String::new();
1723        let mut content = String::new();
1724        for block in resp.content {
1725            match block.block_type.as_str() {
1726                "text" => {
1727                    if let Some(t) = block.text {
1728                        content.push_str(&t);
1729                    }
1730                }
1731                "thinking" => {
1732                    if let Some(t) = block.thinking {
1733                        reasoning.push_str(&t);
1734                    }
1735                }
1736                _ => {}
1737            }
1738        }
1739        assert_eq!(content, "Final answer.");
1740        assert_eq!(reasoning, "step by step...");
1741    }
1742
1743    #[test]
1744    fn anthropic_sse_thinking_delta_extracted() {
1745        let data = r#"{"delta":{"type":"thinking_delta","thinking":"a thought"}}"#;
1746        let event = parse_anthropic_sse("content_block_delta", data).unwrap();
1747        match event {
1748            StreamEvent::ReasoningDelta { content } => assert_eq!(content, "a thought"),
1749            other => panic!("expected ReasoningDelta, got {other:?}"),
1750        }
1751    }
1752}