1use serde_json::{json, Value};
10use std::fmt;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Protocol {
15 OpenAI,
16 Anthropic,
17}
18
19impl Protocol {
20 pub fn parse(s: &str) -> Option<Self> {
21 match s.trim().to_ascii_lowercase().as_str() {
22 "openai" | "oai" | "chat" | "chat-completions" => Some(Self::OpenAI),
23 "anthropic" | "claude" | "messages" => Some(Self::Anthropic),
24 _ => None,
25 }
26 }
27
28 pub fn chat_path(&self) -> &'static str {
29 match self {
30 Self::OpenAI => "/chat/completions",
31 Self::Anthropic => "/messages",
32 }
33 }
34
35 pub fn models_path(&self) -> &'static str {
36 "/models"
37 }
38
39 pub fn count_tokens_path(&self) -> Option<&'static str> {
42 match self {
43 Self::Anthropic => Some("/messages/count_tokens"),
44 Self::OpenAI => None,
45 }
46 }
47
48 pub fn as_str(&self) -> &'static str {
49 match self {
50 Self::OpenAI => "openai",
51 Self::Anthropic => "anthropic",
52 }
53 }
54}
55
56impl fmt::Display for Protocol {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 f.write_str(self.as_str())
59 }
60}
61
62#[derive(Debug, Clone)]
65pub struct ChatRequest {
66 pub model: String,
67 pub system: Option<String>,
68 pub messages: Vec<(String, String)>,
70 pub max_tokens: u32,
71 pub temperature: Option<f64>,
72 pub stop_sequences: Vec<String>,
73 pub stream: bool,
74 pub tools: Option<Value>,
76 pub extra: Vec<(String, Value)>,
79}
80
81impl ChatRequest {
82 pub fn new(model: &str, user: &str) -> Self {
83 Self {
84 model: model.to_string(),
85 system: None,
86 messages: vec![("user".into(), user.into())],
87 max_tokens: 256,
88 temperature: None,
89 stop_sequences: Vec::new(),
90 stream: false,
91 tools: None,
92 extra: Vec::new(),
93 }
94 }
95
96 pub fn max_tokens(mut self, n: u32) -> Self {
97 self.max_tokens = n;
98 self
99 }
100
101 pub fn temperature(mut self, t: f64) -> Self {
102 self.temperature = Some(t);
103 self
104 }
105
106 pub fn system(mut self, s: &str) -> Self {
107 self.system = Some(s.to_string());
108 self
109 }
110
111 pub fn stop(mut self, seqs: &[&str]) -> Self {
112 self.stop_sequences = seqs.iter().map(|s| s.to_string()).collect();
113 self
114 }
115
116 pub fn stream(mut self, on: bool) -> Self {
117 self.stream = on;
118 self
119 }
120
121 pub fn model_id(mut self, m: &str) -> Self {
122 self.model = m.to_string();
123 self
124 }
125
126 pub fn prompt_text(&self) -> String {
128 let mut s = self.system.clone().unwrap_or_default();
129 for (_, c) in &self.messages {
130 s.push('\n');
131 s.push_str(c);
132 }
133 s
134 }
135
136 pub fn to_body(&self, proto: Protocol) -> Value {
137 let mut body = match proto {
138 Protocol::OpenAI => {
139 let mut msgs: Vec<Value> = Vec::with_capacity(self.messages.len() + 1);
140 if let Some(sys) = &self.system {
141 msgs.push(json!({"role": "system", "content": sys}));
142 }
143 for (role, content) in &self.messages {
144 msgs.push(json!({"role": role, "content": content}));
145 }
146 let mut b = json!({
147 "model": self.model,
148 "messages": msgs,
149 "max_tokens": self.max_tokens,
150 });
151 if let Some(t) = self.temperature {
152 b["temperature"] = json!(t);
153 }
154 if !self.stop_sequences.is_empty() {
155 b["stop"] = json!(self.stop_sequences);
156 }
157 if self.stream {
158 b["stream"] = json!(true);
159 b["stream_options"] = json!({"include_usage": true});
163 }
164 if let Some(tools) = &self.tools {
165 b["tools"] = tools.clone();
166 }
167 b
168 }
169 Protocol::Anthropic => {
170 let msgs: Vec<Value> = self
171 .messages
172 .iter()
173 .map(|(role, content)| json!({"role": role, "content": content}))
174 .collect();
175 let mut b = json!({
176 "model": self.model,
177 "messages": msgs,
178 "max_tokens": self.max_tokens,
179 });
180 if let Some(sys) = &self.system {
181 b["system"] = json!(sys);
182 }
183 if let Some(t) = self.temperature {
184 b["temperature"] = json!(t);
185 }
186 if !self.stop_sequences.is_empty() {
187 b["stop_sequences"] = json!(self.stop_sequences);
188 }
189 if self.stream {
190 b["stream"] = json!(true);
191 }
192 if let Some(tools) = &self.tools {
193 b["tools"] = tools.clone();
194 }
195 b
196 }
197 };
198 for (k, v) in &self.extra {
199 body[k] = v.clone();
200 }
201 body
202 }
203}
204
205#[derive(Debug, Clone, Default, serde::Serialize)]
208pub struct Usage {
209 pub input_tokens: u32,
210 pub output_tokens: u32,
211 pub cache_create_tokens: u32,
212 pub cache_read_tokens: u32,
213 pub present: bool,
216}
217
218#[derive(Debug, Clone, Default)]
219pub struct ChatResponse {
220 pub id: String,
221 pub model: String,
222 pub role: String,
223 pub object_type: String,
224 pub text: String,
225 pub stop_reason: String,
226 pub usage: Usage,
227 pub tool_calls: Vec<String>,
228}
229
230impl ChatResponse {
231 pub fn parse(proto: Protocol, v: &Value) -> Self {
232 match proto {
233 Protocol::Anthropic => Self::parse_anthropic(v),
234 Protocol::OpenAI => Self::parse_openai(v),
235 }
236 }
237
238 fn parse_anthropic(v: &Value) -> Self {
239 let mut text = String::new();
240 let mut tool_calls = Vec::new();
241 if let Some(blocks) = v.get("content").and_then(|c| c.as_array()) {
242 for b in blocks {
243 match b.get("type").and_then(|t| t.as_str()) {
244 Some("text") => {
245 if let Some(t) = b.get("text").and_then(|t| t.as_str()) {
246 text.push_str(t);
247 }
248 }
249 Some("tool_use") => {
250 if let Some(n) = b.get("name").and_then(|n| n.as_str()) {
251 tool_calls.push(n.to_string());
252 }
253 }
254 _ => {}
255 }
256 }
257 }
258 let u = v.get("usage");
259 Self {
260 id: str_at(v, "id"),
261 model: str_at(v, "model"),
262 role: str_at(v, "role"),
263 object_type: str_at(v, "type"),
264 text,
265 stop_reason: str_at(v, "stop_reason"),
266 usage: Usage {
267 input_tokens: u32_at(u, "input_tokens"),
268 output_tokens: u32_at(u, "output_tokens"),
269 cache_create_tokens: u32_at(u, "cache_creation_input_tokens"),
270 cache_read_tokens: u32_at(u, "cache_read_input_tokens"),
271 present: u.is_some(),
272 },
273 tool_calls,
274 }
275 }
276
277 fn parse_openai(v: &Value) -> Self {
278 let choice = v
279 .get("choices")
280 .and_then(|c| c.as_array())
281 .and_then(|a| a.first());
282 let msg = choice.and_then(|c| c.get("message"));
283 let text = msg
284 .and_then(|m| m.get("content"))
285 .and_then(|c| c.as_str())
286 .unwrap_or_default()
287 .to_string();
288 let tool_calls = msg
289 .and_then(|m| m.get("tool_calls"))
290 .and_then(|t| t.as_array())
291 .map(|a| {
292 a.iter()
293 .filter_map(|t| {
294 t.get("function")
295 .and_then(|f| f.get("name"))
296 .and_then(|n| n.as_str())
297 .map(String::from)
298 })
299 .collect()
300 })
301 .unwrap_or_default();
302 let u = v.get("usage");
303 let cached = u
305 .and_then(|u| u.get("prompt_tokens_details"))
306 .and_then(|d| d.get("cached_tokens"))
307 .and_then(|c| c.as_u64())
308 .unwrap_or(0) as u32;
309 Self {
310 id: str_at(v, "id"),
311 model: str_at(v, "model"),
312 role: msg
313 .map(|m| str_at(m, "role"))
314 .filter(|r| !r.is_empty())
315 .unwrap_or_else(|| "assistant".into()),
316 object_type: str_at(v, "object"),
317 text,
318 stop_reason: choice
319 .map(|c| str_at(c, "finish_reason"))
320 .unwrap_or_default(),
321 usage: Usage {
322 input_tokens: u32_at(u, "prompt_tokens"),
323 output_tokens: u32_at(u, "completion_tokens"),
324 cache_create_tokens: 0,
325 cache_read_tokens: cached,
326 present: u.is_some(),
327 },
328 tool_calls,
329 }
330 }
331
332 pub fn stop_reason_is_known(&self, proto: Protocol) -> bool {
334 let r = self.stop_reason.trim();
335 if r.is_empty() {
336 return false;
337 }
338 match proto {
339 Protocol::Anthropic => matches!(
340 r,
341 "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | "pause_turn" | "refusal"
342 ),
343 Protocol::OpenAI => matches!(
344 r,
345 "stop" | "length" | "tool_calls" | "function_call" | "content_filter"
346 ),
347 }
348 }
349
350 pub fn stopped_at_limit(&self, proto: Protocol) -> bool {
352 match proto {
353 Protocol::Anthropic => self.stop_reason == "max_tokens",
354 Protocol::OpenAI => self.stop_reason == "length",
355 }
356 }
357
358 pub fn stopped_at_sequence(&self, proto: Protocol) -> bool {
360 match proto {
361 Protocol::Anthropic => self.stop_reason == "stop_sequence",
362 Protocol::OpenAI => self.stop_reason == "stop",
364 }
365 }
366
367 pub fn id_prefix_ok(&self, proto: Protocol) -> bool {
368 let id = self.id.to_ascii_lowercase();
369 match proto {
370 Protocol::Anthropic => id.starts_with("msg_"),
371 Protocol::OpenAI => !id.is_empty(),
373 }
374 }
375}
376
377fn str_at(v: &Value, k: &str) -> String {
378 v.get(k)
379 .and_then(|x| x.as_str())
380 .unwrap_or_default()
381 .to_string()
382}
383
384fn u32_at(v: Option<&Value>, k: &str) -> u32 {
385 v.and_then(|v| v.get(k))
386 .and_then(|x| x.as_u64())
387 .unwrap_or(0) as u32
388}
389
390pub fn error_envelope_ok(proto: Protocol, v: &Value) -> bool {
395 match proto {
396 Protocol::Anthropic => {
397 v.get("type").and_then(|t| t.as_str()) == Some("error")
398 && v.get("error")
399 .map(|e| !str_at(e, "type").is_empty() && !str_at(e, "message").is_empty())
400 .unwrap_or(false)
401 }
402 Protocol::OpenAI => v
403 .get("error")
404 .map(|e| !str_at(e, "message").is_empty())
405 .unwrap_or(false),
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412
413 #[test]
414 fn protocol_parses_aliases_and_rejects_junk() {
415 assert_eq!(Protocol::parse("Claude"), Some(Protocol::Anthropic));
416 assert_eq!(Protocol::parse(" openai "), Some(Protocol::OpenAI));
417 assert_eq!(Protocol::parse("gemini"), None);
418 }
419
420 #[test]
421 fn only_anthropic_offers_authoritative_token_counting() {
422 assert!(Protocol::Anthropic.count_tokens_path().is_some());
423 assert!(Protocol::OpenAI.count_tokens_path().is_none());
424 }
425
426 #[test]
427 fn openai_body_lifts_system_into_messages() {
428 let b = ChatRequest::new("gpt-4o", "hi")
429 .system("be terse")
430 .to_body(Protocol::OpenAI);
431 let msgs = b["messages"].as_array().unwrap();
432 assert_eq!(msgs[0]["role"], "system");
433 assert_eq!(msgs[1]["role"], "user");
434 assert!(b.get("system").is_none());
435 }
436
437 #[test]
438 fn anthropic_body_keeps_system_top_level() {
439 let b = ChatRequest::new("claude", "hi")
440 .system("be terse")
441 .stop(&["END"])
442 .to_body(Protocol::Anthropic);
443 assert_eq!(b["system"], "be terse");
444 assert_eq!(b["messages"].as_array().unwrap().len(), 1);
445 assert_eq!(b["stop_sequences"][0], "END");
446 }
447
448 #[test]
449 fn openai_stream_requests_usage_explicitly() {
450 let b = ChatRequest::new("m", "hi")
451 .stream(true)
452 .to_body(Protocol::OpenAI);
453 assert_eq!(b["stream_options"]["include_usage"], true);
454 }
455
456 #[test]
457 fn parses_anthropic_response_with_tool_use() {
458 let v = json!({
459 "id": "msg_01ABC", "type": "message", "role": "assistant",
460 "model": "claude-x", "stop_reason": "tool_use",
461 "content": [
462 {"type": "text", "text": "let me check"},
463 {"type": "tool_use", "name": "get_weather", "input": {}}
464 ],
465 "usage": {"input_tokens": 12, "output_tokens": 5,
466 "cache_read_input_tokens": 3}
467 });
468 let r = ChatResponse::parse(Protocol::Anthropic, &v);
469 assert_eq!(r.text, "let me check");
470 assert_eq!(r.tool_calls, vec!["get_weather"]);
471 assert_eq!(r.usage.input_tokens, 12);
472 assert_eq!(r.usage.cache_read_tokens, 3);
473 assert!(r.usage.present);
474 assert!(r.id_prefix_ok(Protocol::Anthropic));
475 assert!(r.stop_reason_is_known(Protocol::Anthropic));
476 }
477
478 #[test]
479 fn parses_openai_response_and_nested_cache_tokens() {
480 let v = json!({
481 "id": "chatcmpl-9", "object": "chat.completion", "model": "gpt-4o",
482 "choices": [{"finish_reason": "length",
483 "message": {"role": "assistant", "content": "hello"}}],
484 "usage": {"prompt_tokens": 8, "completion_tokens": 2,
485 "prompt_tokens_details": {"cached_tokens": 4}}
486 });
487 let r = ChatResponse::parse(Protocol::OpenAI, &v);
488 assert_eq!(r.text, "hello");
489 assert_eq!(r.usage.cache_read_tokens, 4);
490 assert!(r.stopped_at_limit(Protocol::OpenAI));
491 assert!(!r.stopped_at_limit(Protocol::Anthropic));
492 }
493
494 #[test]
495 fn missing_usage_block_is_distinguishable_from_zero() {
496 let v = json!({"id": "x", "content": [], "type": "message"});
497 let r = ChatResponse::parse(Protocol::Anthropic, &v);
498 assert!(!r.usage.present);
499 assert_eq!(r.usage.input_tokens, 0);
500 }
501
502 #[test]
503 fn unknown_stop_reasons_are_rejected_per_protocol() {
504 let r = ChatResponse {
505 stop_reason: "length".into(),
506 ..Default::default()
507 };
508 assert!(r.stop_reason_is_known(Protocol::OpenAI));
509 assert!(!r.stop_reason_is_known(Protocol::Anthropic));
510 assert!(!ChatResponse::default().stop_reason_is_known(Protocol::OpenAI));
511 }
512
513 #[test]
514 fn error_envelope_requires_documented_shape() {
515 let good =
516 json!({"type": "error", "error": {"type": "invalid_request_error", "message": "bad"}});
517 assert!(error_envelope_ok(Protocol::Anthropic, &good));
518 let missing_type = json!({"type": "error", "error": {"message": "bad"}});
519 assert!(!error_envelope_ok(Protocol::Anthropic, &missing_type));
520 assert!(error_envelope_ok(
521 Protocol::OpenAI,
522 &json!({"error": {"message": "bad"}})
523 ));
524 assert!(!error_envelope_ok(
525 Protocol::OpenAI,
526 &json!({"detail": "bad"})
527 ));
528 }
529}