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/// `ChatRequest::extra` key meaning "the trailing assistant turn is a prefill
49/// the model must continue, not a finished turn". Set by the items deflater,
50/// consumed and removed by the provider senders — it names an intent the
51/// OpenAI wire has no field for, and must never reach an upstream.
52pub const PREFILL_MARKER: &str = "_prefill";
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ChatRequest {
56    pub model: String,
57    pub messages: Vec<ChatMessage>,
58    #[serde(default)]
59    pub stream: bool,
60    #[serde(default)]
61    pub temperature: Option<f64>,
62    #[serde(default)]
63    pub top_p: Option<f64>,
64    #[serde(default)]
65    pub max_tokens: Option<u32>,
66    #[serde(default)]
67    pub stop: Option<serde_json::Value>,
68    #[serde(default)]
69    pub tools: Option<serde_json::Value>,
70    #[serde(default)]
71    pub stream_options: Option<serde_json::Value>,
72    #[serde(flatten)]
73    pub extra: serde_json::Map<String, serde_json::Value>,
74}
75
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct Usage {
78    /// Cache-INCLUSIVE prompt count (OpenAI convention): fresh + cached_read +
79    /// cache_write. Translators normalize to this at the boundary so
80    /// `fresh = prompt_tokens - cached_read - cache_write` holds uniformly
81    /// (billing and rate-limit math rely on it). Anthropic's wire reports
82    /// input_tokens cache-exclusive — both Anthropic translators add the
83    /// cache classes in, and the Anthropic client surface subtracts them back.
84    pub prompt_tokens: u64,
85    pub completion_tokens: u64,
86    /// Provider-side prompt-cache reads (tokens reused from KV cache)
87    #[serde(default)]
88    pub cached_read_tokens: u64,
89    /// Tokens written to the prompt cache this turn (Anthropic cache_creation,
90    /// which bills above the plain input rate)
91    #[serde(default)]
92    pub cache_write_tokens: u64,
93    /// Reasoning/CoT tokens inside `completion_tokens`, when the provider
94    /// reports them (OpenAI completion_tokens_details.reasoning_tokens,
95    /// Responses output_tokens_details, Gemini thoughtsTokenCount).
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub reasoning_tokens: Option<u64>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct Choice {
102    pub index: u32,
103    pub message: ChatMessage,
104    pub finish_reason: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ChatResponse {
109    pub id: String,
110    pub object: String,
111    pub created: u64,
112    pub model: String,
113    pub choices: Vec<Choice>,
114    pub usage: UsageJson,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct UsageJson {
119    pub prompt_tokens: u64,
120    pub completion_tokens: u64,
121    pub total_tokens: u64,
122    #[serde(default, skip_serializing_if = "is_zero")]
123    pub cached_read_tokens: u64,
124    #[serde(default, skip_serializing_if = "is_zero")]
125    pub cache_write_tokens: u64,
126    /// Reasoning tokens, carried for the Responses surface. Skipped on the
127    /// wire: the chat surface must keep its stock OpenAI shape.
128    #[serde(skip)]
129    pub reasoning_tokens: Option<u64>,
130}
131
132fn is_zero(v: &u64) -> bool {
133    *v == 0
134}
135
136impl ChatResponse {
137    pub fn new(model: &str, content: String, finish_reason: Option<String>, usage: Usage) -> Self {
138        Self::full(model, content, None, finish_reason, usage)
139    }
140
141    pub fn full(
142        model: &str,
143        content: String,
144        tool_calls: Option<serde_json::Value>,
145        finish_reason: Option<String>,
146        usage: Usage,
147    ) -> Self {
148        let now = std::time::SystemTime::now()
149            .duration_since(std::time::UNIX_EPOCH)
150            .unwrap_or_default()
151            .as_secs();
152        ChatResponse {
153            id: format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()),
154            object: "chat.completion".into(),
155            created: now,
156            model: model.to_string(),
157            choices: vec![Choice {
158                index: 0,
159                message: ChatMessage {
160                    role: "assistant".into(),
161                    content: Some(serde_json::Value::String(content)),
162                    name: None,
163                    tool_calls,
164                    tool_call_id: None,
165                    thinking_blocks: None,
166                    reasoning_content: None,
167                },
168                finish_reason,
169            }],
170            usage: UsageJson {
171                prompt_tokens: usage.prompt_tokens,
172                completion_tokens: usage.completion_tokens,
173                total_tokens: usage.prompt_tokens + usage.completion_tokens,
174                cached_read_tokens: usage.cached_read_tokens,
175                cache_write_tokens: usage.cache_write_tokens,
176                reasoning_tokens: usage.reasoning_tokens,
177            },
178        }
179    }
180}
181
182/// One streaming chunk in canonical (OpenAI delta) form.
183#[derive(Debug, Clone, Default)]
184pub struct CanonChunk {
185    pub delta_text: String,
186    /// OpenAI-shaped streamed tool_call deltas ({index, id, type, function}).
187    pub tool_calls: Option<serde_json::Value>,
188    pub finish_reason: Option<String>,
189    pub usage: Option<Usage>,
190    /// Anthropic extended-thinking blocks are streamed as their own content
191    /// blocks; carry them through so the Anthropic inbound surface can re-emit
192    /// `thinking_delta` / `signature_delta`. `block_index` is the upstream
193    /// content-block index; `kind` is "thinking" | "signature".
194    pub thinking: Option<ThinkingDelta>,
195    /// Known upstream input-token count, when the provider reports it at
196    /// stream start (Anthropic's own `message_start`). Used to fill the
197    /// passthrough stream's `message_start` with something more useful than 0.
198    pub input_tokens: Option<u64>,
199}
200
201#[derive(Debug, Clone)]
202pub struct ThinkingDelta {
203    pub block_index: u64,
204    pub kind: &'static str,
205    pub text: String,
206}
207
208impl CanonChunk {
209    pub fn to_sse_json(
210        &self,
211        id: &str,
212        model: &str,
213        created: u64,
214        include_usage: bool,
215    ) -> Option<String> {
216        if include_usage {
217            let u = self.usage.as_ref()?;
218            return Some(
219                serde_json::json!({
220                    "id": id, "object": "chat.completion.chunk", "created": created,
221                    "model": model, "choices": [],
222                    "usage": {"prompt_tokens": u.prompt_tokens, "completion_tokens": u.completion_tokens,
223                              "total_tokens": u.prompt_tokens + u.completion_tokens,
224                              "cached_read_tokens": u.cached_read_tokens,
225                              "cache_write_tokens": u.cache_write_tokens}
226                })
227                .to_string(),
228            );
229        }
230        if self.delta_text.is_empty()
231            && self.tool_calls.is_none()
232            && self.finish_reason.is_none()
233            && self.thinking.is_none()
234        {
235            return None;
236        }
237        // omit `content` entirely on tool-call-only deltas — a literal "" confuses
238        // strict merge-by-index clients
239        let mut delta = serde_json::json!({});
240        if !self.delta_text.is_empty() {
241            delta["content"] = serde_json::json!(self.delta_text);
242        }
243        if let Some(tcs) = &self.tool_calls {
244            delta["tool_calls"] = tcs.clone();
245        }
246        if let Some(th) = &self.thinking {
247            delta["thinking"] = serde_json::json!({
248                "block_index": th.block_index,
249                "kind": th.kind,
250                "text": th.text,
251            });
252        }
253        Some(
254            serde_json::json!({
255                "id": id, "object": "chat.completion.chunk", "created": created,
256                "model": model,
257                "choices": [{"index": 0, "delta": delta, "finish_reason": self.finish_reason}]
258            })
259            .to_string(),
260        )
261    }
262}