Skip to main content

llm_dialect/
canonical.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize)]
4pub struct ChatMessage {
5    pub role: String,
6    // Some upstreams deserialize messages into a
7    // struct where these are required, non-Option fields — an explicit `null`
8    // 400s with "missing field". Omit them instead of serializing as null.
9    #[serde(default, skip_serializing_if = "Option::is_none")]
10    pub content: Option<serde_json::Value>,
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub name: Option<String>,
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub tool_calls: Option<serde_json::Value>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub tool_call_id: Option<String>,
17    /// Anthropic extended-thinking blocks belonging to this assistant turn,
18    /// kept as their JSON form (`{"type":"thinking","thinking":...,"signature":...}`).
19    /// Dropped when the outbound dialect can't carry them.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub thinking_blocks: Option<Vec<serde_json::Value>>,
22    /// vLLM/DeepSeek-style unsigned reasoning, carried on assistant turns.
23    /// OpenAI-compatible upstreams read it; Anthropic outbound
24    /// never sees it.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub reasoning_content: Option<String>,
27}
28
29impl ChatMessage {
30    pub fn text(&self) -> String {
31        match &self.content {
32            Some(serde_json::Value::String(s)) => s.clone(),
33            Some(serde_json::Value::Array(parts)) => join_text_parts(parts),
34            _ => String::new(),
35        }
36    }
37}
38
39/// Concatenate the "text" entries of an OpenAI content-parts array.
40pub fn join_text_parts(parts: &[serde_json::Value]) -> String {
41    parts
42        .iter()
43        .filter_map(|p| p.get("text").and_then(|t| t.as_str()))
44        .collect::<Vec<_>>()
45        .join("")
46}
47
48/// The JSON encoding of a string leaf — quotes and escapes exactly as
49/// serde_json emits them — so hand-assembled frames stay byte-identical to
50/// the `Value`-tree output they replaced (keys ride in serde's BTreeMap
51/// order; dynamic leaves still escape through serde itself).
52pub(crate) fn json_str(s: &str) -> String {
53    serde_json::to_string(s).expect("string JSON encoding is infallible")
54}
55
56/// Append the JSON encoding of `s` (quotes + escapes, serde-identical) onto
57/// `buf` without an intermediate allocation — the writer variant of
58/// [`json_str`], for hot paths that assemble frames into one buffer.
59///
60/// Fast path first: streamed text rarely contains a byte that needs JSON
61/// escaping, so scan for one (`"` `\` and the C0 controls) and append the
62/// string verbatim between quotes; only fall back to serde's escaper when
63/// the scan finds a byte it must handle. The two paths emit identical bytes.
64pub(crate) fn write_json_str(buf: &mut String, s: &str) {
65    // C0 controls, quote, backslash — every byte JSON must escape.
66    fn needs_escape(c: char) -> bool {
67        matches!(c, '"' | '\\' | '\u{0000}'..='\u{001f}')
68    }
69    if !s.contains(needs_escape) {
70        buf.push('"');
71        buf.push_str(s);
72        buf.push('"');
73        return;
74    }
75    serde_json::to_writer(StrWrite(buf), s).expect("string JSON encoding is infallible");
76}
77
78/// `io::Write` adapter over a `String`. serde_json's escaper emits each
79/// escape sequence and each passthrough slice as one write; both are valid
80/// UTF-8 on their own, so the validation below always succeeds — it exists
81/// to keep the borrow sound, not to police serde.
82struct StrWrite<'a>(&'a mut String);
83
84impl std::io::Write for StrWrite<'_> {
85    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
86        match std::str::from_utf8(buf) {
87            Ok(s) => {
88                self.0.push_str(s);
89                Ok(buf.len())
90            }
91            Err(_) => Err(std::io::Error::new(
92                std::io::ErrorKind::InvalidData,
93                "non-UTF-8 byte in JSON writer output",
94            )),
95        }
96    }
97    fn flush(&mut self) -> std::io::Result<()> {
98        Ok(())
99    }
100}
101
102/// `ChatRequest::extra` key meaning "the trailing assistant turn is a prefill
103/// the model must continue, not a finished turn". Set by the items deflater,
104/// consumed and removed by the provider senders — it names an intent the
105/// OpenAI wire has no field for, and must never reach an upstream.
106pub const PREFILL_MARKER: &str = "_prefill";
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ChatRequest {
110    pub model: String,
111    pub messages: Vec<ChatMessage>,
112    #[serde(default)]
113    pub stream: bool,
114    #[serde(default)]
115    pub temperature: Option<f64>,
116    #[serde(default)]
117    pub top_p: Option<f64>,
118    #[serde(default)]
119    pub max_tokens: Option<u32>,
120    #[serde(default)]
121    pub stop: Option<serde_json::Value>,
122    #[serde(default)]
123    pub tools: Option<serde_json::Value>,
124    #[serde(default)]
125    pub stream_options: Option<serde_json::Value>,
126    #[serde(flatten)]
127    pub extra: serde_json::Map<String, serde_json::Value>,
128}
129
130#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131pub struct Usage {
132    /// Cache-INCLUSIVE prompt count (OpenAI convention): fresh + cached_read +
133    /// cache_write. Translators normalize to this at the boundary so
134    /// `fresh = prompt_tokens - cached_read - cache_write` holds uniformly
135    /// (billing and rate-limit math rely on it). Anthropic's wire reports
136    /// input_tokens cache-exclusive — both Anthropic translators add the
137    /// cache classes in, and the Anthropic client surface subtracts them back.
138    pub prompt_tokens: u64,
139    pub completion_tokens: u64,
140    /// Provider-side prompt-cache reads (tokens reused from KV cache)
141    #[serde(default)]
142    pub cached_read_tokens: u64,
143    /// Tokens written to the prompt cache this turn (Anthropic cache_creation,
144    /// which bills above the plain input rate)
145    #[serde(default)]
146    pub cache_write_tokens: u64,
147    /// Reasoning/CoT tokens inside `completion_tokens`, when the provider
148    /// reports them (OpenAI completion_tokens_details.reasoning_tokens,
149    /// Responses output_tokens_details, Gemini thoughtsTokenCount).
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub reasoning_tokens: Option<u64>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct Choice {
156    pub index: u32,
157    pub message: ChatMessage,
158    pub finish_reason: Option<String>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct ChatResponse {
163    pub id: String,
164    pub object: String,
165    pub created: u64,
166    pub model: String,
167    pub choices: Vec<Choice>,
168    pub usage: UsageJson,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct UsageJson {
173    pub prompt_tokens: u64,
174    pub completion_tokens: u64,
175    pub total_tokens: u64,
176    #[serde(default, skip_serializing_if = "is_zero")]
177    pub cached_read_tokens: u64,
178    #[serde(default, skip_serializing_if = "is_zero")]
179    pub cache_write_tokens: u64,
180    /// Reasoning tokens, carried for the Responses surface. Skipped on the
181    /// wire: the chat surface must keep its stock OpenAI shape.
182    #[serde(skip)]
183    pub reasoning_tokens: Option<u64>,
184}
185
186fn is_zero(v: &u64) -> bool {
187    *v == 0
188}
189
190impl ChatResponse {
191    pub fn new(model: &str, content: String, finish_reason: Option<String>, usage: Usage) -> Self {
192        Self::full(model, content, None, finish_reason, usage)
193    }
194
195    pub fn full(
196        model: &str,
197        content: String,
198        tool_calls: Option<serde_json::Value>,
199        finish_reason: Option<String>,
200        usage: Usage,
201    ) -> Self {
202        let now = std::time::SystemTime::now()
203            .duration_since(std::time::UNIX_EPOCH)
204            .unwrap_or_default()
205            .as_secs();
206        ChatResponse {
207            id: format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()),
208            object: "chat.completion".into(),
209            created: now,
210            model: model.to_string(),
211            choices: vec![Choice {
212                index: 0,
213                message: ChatMessage {
214                    role: "assistant".into(),
215                    content: Some(serde_json::Value::String(content)),
216                    name: None,
217                    tool_calls,
218                    tool_call_id: None,
219                    thinking_blocks: None,
220                    reasoning_content: None,
221                },
222                finish_reason,
223            }],
224            usage: UsageJson {
225                prompt_tokens: usage.prompt_tokens,
226                completion_tokens: usage.completion_tokens,
227                total_tokens: usage.prompt_tokens + usage.completion_tokens,
228                cached_read_tokens: usage.cached_read_tokens,
229                cache_write_tokens: usage.cache_write_tokens,
230                reasoning_tokens: usage.reasoning_tokens,
231            },
232        }
233    }
234}
235
236/// One streaming chunk in canonical (OpenAI delta) form.
237#[derive(Debug, Clone, Default)]
238pub struct CanonChunk {
239    pub delta_text: String,
240    /// OpenAI-shaped streamed tool_call deltas ({index, id, type, function}).
241    pub tool_calls: Option<serde_json::Value>,
242    pub finish_reason: Option<String>,
243    pub usage: Option<Usage>,
244    /// Anthropic extended-thinking blocks are streamed as their own content
245    /// blocks; carry them through so the Anthropic inbound surface can re-emit
246    /// `thinking_delta` / `signature_delta`. `block_index` is the upstream
247    /// content-block index; `kind` is "thinking" | "signature".
248    pub thinking: Option<ThinkingDelta>,
249    /// Known upstream input-token count, when the provider reports it at
250    /// stream start (Anthropic's own `message_start`). Used to fill the
251    /// passthrough stream's `message_start` with something more useful than 0.
252    pub input_tokens: Option<u64>,
253}
254
255#[derive(Debug, Clone)]
256pub struct ThinkingDelta {
257    pub block_index: u64,
258    pub kind: &'static str,
259    pub text: String,
260}
261
262impl CanonChunk {
263    pub fn to_sse_json(
264        &self,
265        id: &str,
266        model: &str,
267        created: u64,
268        include_usage: bool,
269    ) -> Option<String> {
270        // The early-out gate stays in this thin prologue (one `is_empty` and
271        // three `Option::is_none` checks) so the canary-guarded empty-chunk
272        // skip keeps its inline fast path; the assembly body below is a
273        // separate cold function the prologue tail-calls only when there is
274        // work — codegen then sizes each path for its own job.
275        if include_usage {
276            return self
277                .usage
278                .as_ref()
279                .map(|u| self.usage_frame(id, model, created, u));
280        }
281        if self.delta_text.is_empty()
282            && self.tool_calls.is_none()
283            && self.finish_reason.is_none()
284            && self.thinking.is_none()
285        {
286            return None;
287        }
288        Some(self.delta_frame(id, model, created))
289    }
290
291    /// The usage-trailer chunk frame (one-buffer assembly; alphabetical key
292    /// order as serde emits it).
293    fn usage_frame(&self, id: &str, model: &str, created: u64, u: &Usage) -> String {
294        let mut out = String::with_capacity(160 + id.len() + model.len());
295        out.push_str("{\"choices\":[],\"created\":");
296        out.push_str(&created.to_string());
297        out.push_str(",\"id\":");
298        write_json_str(&mut out, id);
299        out.push_str(",\"model\":");
300        write_json_str(&mut out, model);
301        out.push_str(",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_write_tokens\":");
302        out.push_str(&u.cache_write_tokens.to_string());
303        out.push_str(",\"cached_read_tokens\":");
304        out.push_str(&u.cached_read_tokens.to_string());
305        out.push_str(",\"completion_tokens\":");
306        out.push_str(&u.completion_tokens.to_string());
307        out.push_str(",\"prompt_tokens\":");
308        out.push_str(&u.prompt_tokens.to_string());
309        out.push_str(",\"total_tokens\":");
310        out.push_str(&(u.prompt_tokens + u.completion_tokens).to_string());
311        out.push_str("}}");
312        out
313    }
314
315    // omit `content` entirely on tool-call-only deltas — a literal "" confuses
316    // strict merge-by-index clients
317    //
318    /// The delta-chunk frame. The whole frame assembles into ONE buffer — no
319    /// intermediate delta/finish_reason/id/model strings; dynamic leaves
320    /// escape straight into `out` via the writer, keeping the wire bytes
321    /// identical.
322    fn delta_frame(&self, id: &str, model: &str, created: u64) -> String {
323        let mut out = String::with_capacity(112 + id.len() + model.len() + self.delta_text.len());
324        out.push_str("{\"choices\":[{\"delta\":{");
325        let mut wrote_key = false;
326        if !self.delta_text.is_empty() {
327            out.push_str("\"content\":");
328            write_json_str(&mut out, &self.delta_text);
329            wrote_key = true;
330        }
331        if let Some(th) = &self.thinking {
332            if wrote_key {
333                out.push(',');
334            }
335            wrote_key = true;
336            out.push_str("\"thinking\":{\"block_index\":");
337            out.push_str(&th.block_index.to_string());
338            out.push_str(",\"kind\":");
339            write_json_str(&mut out, th.kind);
340            out.push_str(",\"text\":");
341            write_json_str(&mut out, &th.text);
342            out.push('}');
343        }
344        if let Some(tcs) = &self.tool_calls {
345            if wrote_key {
346                out.push(',');
347            }
348            out.push_str("\"tool_calls\":");
349            // a Value serializes to the identical bytes it contributed inside
350            // the parent tree — embed it without the deep clone
351            serde_json::to_writer(StrWrite(&mut out), tcs)
352                .expect("Value serialization is infallible");
353        }
354        out.push_str("},\"finish_reason\":");
355        match &self.finish_reason {
356            Some(fr) => write_json_str(&mut out, fr),
357            None => out.push_str("null"),
358        }
359        out.push_str(",\"index\":0}],\"created\":");
360        out.push_str(&created.to_string());
361        out.push_str(",\"id\":");
362        write_json_str(&mut out, id);
363        out.push_str(",\"model\":");
364        write_json_str(&mut out, model);
365        out.push_str(",\"object\":\"chat.completion.chunk\"}");
366        out
367    }
368}