1use crate::ai::adapter::AiError;
19use crate::ai::types::{
20 ChatMessage, ChatRequest, ChatResponse, ChatRole, ContentPart, FinishReason, ResponseFormat,
21 StreamEvent, ToolCall, ToolChoice, ToolSpec,
22};
23use serde_json::{json, Map, Value};
24use std::time::{SystemTime, UNIX_EPOCH};
25
26pub fn parse_chat_request(body: &Value) -> Result<ChatRequest, AiError> {
31 let obj = body
32 .as_object()
33 .ok_or_else(|| AiError::InvalidRequest("request body must be a JSON object".to_string()))?;
34 let model = obj
35 .get("model")
36 .and_then(Value::as_str)
37 .filter(|m| !m.is_empty())
38 .ok_or_else(|| AiError::InvalidRequest("'model' must be a non-empty string".to_string()))?
39 .to_string();
40 let messages_value = obj
41 .get("messages")
42 .and_then(Value::as_array)
43 .ok_or_else(|| AiError::InvalidRequest("'messages' must be an array".to_string()))?;
44 if messages_value.is_empty() {
45 return Err(AiError::InvalidRequest(
46 "'messages' must contain at least one message".to_string(),
47 ));
48 }
49 let mut messages = Vec::with_capacity(messages_value.len());
50 for (i, m) in messages_value.iter().enumerate() {
51 messages.push(
52 parse_message(m).map_err(|e| AiError::InvalidRequest(format!("messages[{i}]: {e}")))?,
53 );
54 }
55 let mut tools = Vec::new();
56 if let Some(arr) = obj.get("tools").and_then(Value::as_array) {
57 for t in arr {
58 tools.push(parse_tool(t)?);
59 }
60 }
61 let tool_choice = match obj.get("tool_choice") {
62 None | Some(Value::Null) => None,
63 Some(Value::String(s)) => match s.as_str() {
64 "auto" => Some(ToolChoice::Auto),
65 "none" => Some(ToolChoice::None),
66 "required" => Some(ToolChoice::Any),
67 other => {
68 return Err(AiError::InvalidRequest(format!(
69 "tool_choice '{other}' is not one of auto|none|required"
70 )))
71 }
72 },
73 Some(v @ Value::Object(_)) => {
74 let name = v
76 .get("function")
77 .and_then(|f| f.get("name"))
78 .and_then(Value::as_str);
79 match (v.get("type").and_then(Value::as_str), name) {
80 (Some("function"), Some(n)) => Some(ToolChoice::Tool(n.to_string())),
81 (Some("function"), None) => {
82 return Err(AiError::InvalidRequest(
83 "tool_choice object form requires function.name".to_string(),
84 ))
85 }
86 _ => None,
87 }
88 }
89 Some(_) => {
90 return Err(AiError::InvalidRequest(
91 "tool_choice must be a string or an object".to_string(),
92 ))
93 }
94 };
95 let stop = match obj.get("stop") {
96 None | Some(Value::Null) => None,
97 Some(Value::String(s)) => Some(vec![s.clone()]),
98 Some(Value::Array(a)) => Some(
99 a.iter()
100 .map(|v| {
101 v.as_str().map(|s| s.to_string()).ok_or_else(|| {
102 AiError::InvalidRequest("stop array entries must be strings".to_string())
103 })
104 })
105 .collect::<Result<Vec<_>, _>>()?,
106 ),
107 Some(_) => {
108 return Err(AiError::InvalidRequest(
109 "stop must be a string or an array of strings".to_string(),
110 ))
111 }
112 };
113 let stream = obj.get("stream").and_then(Value::as_bool).unwrap_or(false);
114 let stream_options_include_usage = obj
115 .get("stream_options")
116 .and_then(|o| o.get("include_usage"))
117 .and_then(Value::as_bool)
118 .unwrap_or(false);
119 let response_format = parse_response_format(obj.get("response_format"));
122 let prompt = obj
126 .get("prompt")
127 .and_then(Value::as_str)
128 .filter(|s| !s.is_empty())
129 .map(|s| s.to_string());
130 let prompt_variables = obj
131 .get("prompt_variables")
132 .and_then(Value::as_object)
133 .map(|m| {
134 m.iter()
135 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
136 .collect()
137 })
138 .unwrap_or_default();
139 const KNOWN: &[&str] = &[
142 "model",
143 "messages",
144 "tools",
145 "tool_choice",
146 "temperature",
147 "top_p",
148 "max_tokens",
149 "stop",
150 "stream",
151 "stream_options",
152 "response_format", "prompt", "prompt_variables", ];
156 let other = obj
157 .iter()
158 .filter(|(k, _)| !KNOWN.contains(&k.as_str()))
159 .map(|(k, v)| (k.clone(), v.clone()))
160 .collect();
161 Ok(ChatRequest {
162 model,
163 messages,
164 tools,
165 tool_choice,
166 temperature: obj.get("temperature").and_then(Value::as_f64),
167 top_p: obj.get("top_p").and_then(Value::as_f64),
168 max_tokens: obj
169 .get("max_tokens")
170 .and_then(Value::as_u64)
171 .or_else(|| obj.get("max_completion_tokens").and_then(Value::as_u64)),
172 stop,
173 stream,
174 stream_options_include_usage,
175 response_format,
176 prompt,
177 prompt_variables,
178 other,
179 })
180}
181
182fn parse_response_format(v: Option<&Value>) -> Option<ResponseFormat> {
186 let v = v?;
187 let obj = v.as_object()?;
188 let format_type = obj.get("type").and_then(Value::as_str)?;
189 match format_type {
190 "text" => Some(ResponseFormat::Text),
191 "json_object" => Some(ResponseFormat::JsonObject),
192 "json_schema" => {
193 let schema_obj = obj.get("json_schema")?.as_object()?;
194 let name = schema_obj.get("name")?.as_str()?.to_string();
195 let schema = schema_obj.get("schema")?.clone();
196 Some(ResponseFormat::JsonSchema { name, schema })
197 }
198 _ => None,
199 }
200}
201
202fn parse_message(v: &Value) -> Result<ChatMessage, String> {
204 let obj = v.as_object().ok_or("must be an object")?;
205 let role = match obj.get("role").and_then(Value::as_str) {
206 Some("system") => ChatRole::System,
207 Some("user") => ChatRole::User,
208 Some("assistant") => ChatRole::Assistant,
209 Some("tool") => ChatRole::Tool,
210 _ => return Err("role must be one of system|user|assistant|tool".into()),
211 };
212 let mut content = Vec::new();
213 match obj.get("content") {
214 None | Some(Value::Null) => {}
215 Some(Value::String(s)) => content.push(ContentPart::Text { text: s.clone() }),
216 Some(Value::Array(parts)) => {
217 for p in parts {
218 let pobj = p.as_object().ok_or("content parts must be objects")?;
219 match pobj.get("type").and_then(Value::as_str) {
220 Some("text") => content.push(ContentPart::Text {
221 text: pobj
222 .get("text")
223 .and_then(Value::as_str)
224 .unwrap_or_default()
225 .to_string(),
226 }),
227 Some("image_url") => {
228 let url = pobj
229 .get("image_url")
230 .and_then(|i| i.get("url"))
231 .and_then(Value::as_str)
232 .unwrap_or_default();
233 content.push(ContentPart::image_from_openai_url(url));
234 }
235 _ => return Err("content part type must be text or image_url".into()),
236 }
237 }
238 }
239 Some(_) => return Err("content must be a string or an array of parts".into()),
240 }
241 let mut tool_calls = Vec::new();
242 if let Some(arr) = obj.get("tool_calls").and_then(Value::as_array) {
243 for tc in arr {
244 let fn_obj = tc
245 .get("function")
246 .ok_or("tool_calls entries require function")?;
247 tool_calls.push(ToolCall {
248 id: tc
249 .get("id")
250 .and_then(Value::as_str)
251 .unwrap_or_default()
252 .to_string(),
253 name: fn_obj
254 .get("name")
255 .and_then(Value::as_str)
256 .unwrap_or_default()
257 .to_string(),
258 arguments: fn_obj
259 .get("arguments")
260 .and_then(Value::as_str)
261 .unwrap_or_default()
262 .to_string(),
263 });
264 }
265 }
266 Ok(ChatMessage {
267 role,
268 content,
269 name: obj.get("name").and_then(Value::as_str).map(String::from),
270 tool_calls,
271 tool_call_id: obj
272 .get("tool_call_id")
273 .and_then(Value::as_str)
274 .map(String::from),
275 })
276}
277
278fn parse_tool(v: &Value) -> Result<ToolSpec, AiError> {
280 let obj = v
281 .as_object()
282 .ok_or_else(|| AiError::InvalidRequest("tools entries must be objects".to_string()))?;
283 let func = obj.get("function").ok_or_else(|| {
284 AiError::InvalidRequest("tools entries require a function block".to_string())
285 })?;
286 Ok(ToolSpec {
287 name: func
288 .get("name")
289 .and_then(Value::as_str)
290 .unwrap_or_default()
291 .to_string(),
292 description: func
293 .get("description")
294 .and_then(Value::as_str)
295 .map(String::from),
296 parameters: func.get("parameters").cloned(),
297 })
298}
299
300pub fn response_to_openai(resp: &ChatResponse, model_alias: &str, request_id: &str) -> Value {
304 let choices: Vec<Value> = resp
305 .choices
306 .iter()
307 .map(|c| {
308 json!({
309 "index": c.index,
310 "message": message_to_openai(&c.message),
311 "finish_reason": finish_reason_to_openai(&c.finish_reason),
312 })
313 })
314 .collect();
315 let usage = resp.usage.map(|u| {
316 let mut obj = serde_json::Map::new();
317 obj.insert("prompt_tokens".into(), json!(u.prompt_tokens.unwrap_or(0)));
318 obj.insert(
319 "completion_tokens".into(),
320 json!(u.completion_tokens.unwrap_or(0)),
321 );
322 obj.insert("total_tokens".into(), json!(u.total_tokens.unwrap_or(0)));
323 if let Some(cached) = u.cached_tokens {
324 obj.insert(
325 "prompt_tokens_details".into(),
326 json!({"cached_tokens": cached}),
327 );
328 }
329 Value::Object(obj)
330 });
331 json!({
332 "id": resp.id.clone().unwrap_or_else(|| format!("chatcmpl-{request_id}")),
333 "object": "chat.completion",
334 "created": unix_now(),
335 "model": model_alias,
336 "choices": choices,
337 "usage": usage,
338 })
339}
340
341fn message_to_openai(m: &ChatMessage) -> Value {
344 let mut obj = Map::new();
345 obj.insert("role".into(), json!(m.role.as_str()));
346 if m.content
347 .iter()
348 .all(|p| matches!(p, ContentPart::Text { .. }))
349 && !m.content.is_empty()
350 {
351 obj.insert("content".into(), json!(m.text_content()));
352 } else if m.content.is_empty() {
353 obj.insert("content".into(), Value::Null);
354 } else {
355 let parts: Vec<Value> = m
356 .content
357 .iter()
358 .map(|p| match p {
359 ContentPart::Text { text } => json!({"type": "text", "text": text}),
360 ContentPart::Image { url, .. } => json!({
361 "type": "image_url",
362 "image_url": {"url": url.clone().unwrap_or_default()}
363 }),
364 })
365 .collect();
366 obj.insert("content".into(), Value::Array(parts));
367 }
368 if !m.tool_calls.is_empty() {
369 let calls: Vec<Value> = m
370 .tool_calls
371 .iter()
372 .map(|tc| {
373 json!({
374 "id": tc.id,
375 "type": "function",
376 "function": {"name": tc.name, "arguments": tc.arguments}
377 })
378 })
379 .collect();
380 obj.insert("tool_calls".into(), Value::Array(calls));
381 }
382 if let Some(id) = &m.tool_call_id {
383 obj.insert("tool_call_id".into(), json!(id));
384 }
385 Value::Object(obj)
386}
387
388fn finish_reason_to_openai(r: &FinishReason) -> Value {
390 match r {
391 FinishReason::Stop => json!("stop"),
392 FinishReason::Length => json!("length"),
393 FinishReason::ToolCalls => json!("tool_calls"),
394 FinishReason::ContentFilter => json!("content_filter"),
395 FinishReason::Other(_) => json!("stop"),
396 }
397}
398
399pub fn stream_event_to_openai_chunk(
403 event: &StreamEvent,
404 id: &str,
405 model_alias: &str,
406 created: u64,
407) -> Value {
408 match event {
409 StreamEvent::Delta(d) => {
410 let mut delta = Map::new();
411 if let Some(role) = &d.role {
412 delta.insert("role".into(), json!(role.as_str()));
413 }
414 if let Some(content) = &d.content {
415 delta.insert("content".into(), json!(content));
416 }
417 if !d.tool_calls.is_empty() {
418 let calls: Vec<Value> = d
419 .tool_calls
420 .iter()
421 .map(|tc| {
422 let mut o = Map::new();
423 o.insert("index".into(), json!(tc.index));
424 if let Some(id) = &tc.id {
425 o.insert("id".into(), json!(id));
426 }
427 let mut f = Map::new();
428 if let Some(name) = &tc.name {
429 f.insert("name".into(), json!(name));
430 }
431 f.insert("arguments".into(), json!(tc.arguments));
432 o.insert("function".into(), Value::Object(f));
433 Value::Object(o)
434 })
435 .collect();
436 delta.insert("tool_calls".into(), Value::Array(calls));
437 }
438 json!({
439 "id": id,
440 "object": "chat.completion.chunk",
441 "created": created,
442 "model": model_alias,
443 "choices": [{
444 "index": d.index,
445 "delta": Value::Object(delta),
446 "finish_reason": d.finish_reason.as_ref().map(finish_reason_to_openai),
447 }]
448 })
449 }
450 StreamEvent::Usage(u) => {
451 let mut usage_obj = serde_json::Map::new();
452 usage_obj.insert("prompt_tokens".into(), json!(u.prompt_tokens.unwrap_or(0)));
453 usage_obj.insert(
454 "completion_tokens".into(),
455 json!(u.completion_tokens.unwrap_or(0)),
456 );
457 usage_obj.insert("total_tokens".into(), json!(u.total_tokens.unwrap_or(0)));
458 if let Some(cached) = u.cached_tokens {
459 usage_obj.insert(
460 "prompt_tokens_details".into(),
461 json!({"cached_tokens": cached}),
462 );
463 }
464 json!({
465 "id": id,
466 "object": "chat.completion.chunk",
467 "created": created,
468 "model": model_alias,
469 "choices": [],
470 "usage": Value::Object(usage_obj),
471 })
472 }
473 StreamEvent::Done => json!({
474 "id": id,
475 "object": "chat.completion.chunk",
476 "created": created,
477 "model": model_alias,
478 "choices": [{
479 "index": 0,
480 "delta": {},
481 "finish_reason": "stop",
482 }]
483 }),
484 }
485}
486
487pub fn error_body(message: &str, error_type: &str, code: Option<&str>, request_id: &str) -> Value {
491 let mut err = Map::new();
492 err.insert("message".into(), json!(message));
493 err.insert("type".into(), json!(error_type));
494 if let Some(c) = code {
495 err.insert("code".into(), json!(c));
496 }
497 err.insert("request_id".into(), json!(request_id));
498 json!({ "error": Value::Object(err) })
499}
500
501pub fn error_type_of(err: &AiError) -> &'static str {
504 match err {
505 AiError::InvalidRequest(_) => "invalid_request_error",
506 AiError::Translation(_) => "api_error",
507 AiError::Provider { .. } => "api_error",
508 }
509}
510
511pub fn unix_now() -> u64 {
513 SystemTime::now()
514 .duration_since(UNIX_EPOCH)
515 .map(|d| d.as_secs())
516 .unwrap_or(0)
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use crate::ai::types::Choice;
523
524 #[test]
529 fn parse_minimal_request() {
530 let req = parse_chat_request(&json!({
531 "model": "gpt-x",
532 "messages": [{"role": "user", "content": "hi"}]
533 }))
534 .unwrap();
535 assert_eq!(req.model, "gpt-x");
536 assert_eq!(req.messages.len(), 1);
537 assert!(!req.stream);
538 assert!(req.other.is_empty());
539 }
540
541 #[test]
542 fn parse_preserves_unknown_params() {
543 let req = parse_chat_request(&json!({
544 "model": "m",
545 "messages": [{"role": "user", "content": "hi"}],
546 "seed": 7,
547 "response_format": {"type": "json_object"}
548 }))
549 .unwrap();
550 assert_eq!(req.other.get("seed"), Some(&json!(7)));
551 assert!(matches!(
554 req.response_format,
555 Some(ResponseFormat::JsonObject)
556 ));
557 assert!(!req.other.contains_key("response_format"));
558 }
559
560 #[test]
561 fn parse_rejects_missing_model_and_empty_messages() {
562 assert!(matches!(
563 parse_chat_request(&json!({"messages": []})),
564 Err(AiError::InvalidRequest(_))
565 ));
566 assert!(matches!(
567 parse_chat_request(&json!({"model": "m", "messages": []})),
568 Err(AiError::InvalidRequest(_))
569 ));
570 }
571
572 #[test]
573 fn parse_tool_flow_roundtrip() {
574 let req = parse_chat_request(&json!({
575 "model": "m",
576 "messages": [
577 {"role": "user", "content": "weather?"},
578 {"role": "assistant", "content": null, "tool_calls": [
579 {"id": "c1", "type": "function",
580 "function": {"name": "get_weather", "arguments": "{\"city\":\"SF\"}"}}
581 ]},
582 {"role": "tool", "tool_call_id": "c1", "content": "72F"}
583 ],
584 "tools": [{"type": "function", "function": {
585 "name": "get_weather", "parameters": {"type": "object"}
586 }}],
587 "tool_choice": {"type": "function", "function": {"name": "get_weather"}}
588 }))
589 .unwrap();
590 assert_eq!(req.messages[1].tool_calls.len(), 1);
591 assert_eq!(req.messages[2].tool_call_id.as_deref(), Some("c1"));
592 assert_eq!(req.tools.len(), 1);
593 assert_eq!(
594 req.tool_choice,
595 Some(ToolChoice::Tool("get_weather".into()))
596 );
597 let resp = ChatResponse {
598 id: Some("r1".into()),
599 model: Some("provider-model".into()),
600 choices: vec![Choice {
601 index: 0,
602 message: ChatMessage::text(ChatRole::Assistant, "72F and sunny"),
603 finish_reason: FinishReason::Stop,
604 }],
605 usage: Some(crate::ai::types::Usage {
606 prompt_tokens: Some(10),
607 completion_tokens: Some(5),
608 total_tokens: Some(15),
609 cached_tokens: None,
610 }),
611 };
612 let out = response_to_openai(&resp, "alias-x", "req-1");
613 assert_eq!(out["model"], "alias-x");
614 assert_eq!(out["choices"][0]["message"]["content"], "72F and sunny");
615 assert_eq!(out["usage"]["total_tokens"], 15);
616 }
617}