1use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value, json};
11use std::borrow::Cow;
12use std::sync::Arc;
13
14use crate::catalog::ModelFacts;
15use crate::completion::options::{BaseInput, RawAt, Rewrite, request_params};
16use crate::completion::{CompletionRequest, FinishReason, ProviderCapabilities, Replay};
17use crate::error::{EncodeError, ProviderError};
18use crate::json_utils::Lenient;
19use crate::json_utils::remove_preserving_order;
20use crate::message::{
21 AssistantContent, AssistantMessage, DocumentData, DocumentMediaType,
22 DocumentSourceKind as Source, Message, MimeType, ToolCall, ToolResult, ToolResultContent,
23 UserContent,
24};
25use crate::observe::ObservedError;
26use crate::operation::{Block, CallFragment, Completion, Finish};
27use crate::providers::internal::object;
28use crate::providers::internal::wire::classify_chat_completions_frame;
29use crate::providers::internal::wire_ids::WireIds;
30use crate::wire::{
31 AdapterVerdict, Capabilities, Decoder, Descriptor, Encoded, Flow, Framing, Mode,
32 ObservationSink, Out, Wire, WireEvent, WireFrame,
33};
34use finish::{finish_reason, native_finish_reason, provider_error_envelope};
35
36use super::dto::merge_fields;
37use super::{BodyRewrite, OpenAIConfig, OutputCap, Quirks};
38
39#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
42pub struct Chat {
43 pub provider: OpenAIConfig,
45 pub model: String,
47 pub strict_tools: bool,
51 pub tool_result_array_content: bool,
53 #[serde(skip)]
55 pub facts: ModelFacts,
56}
57
58fn unsendable(what: &str) -> EncodeError {
62 EncodeError::request(format!("Chat Completions cannot carry {what} in this form"))
63}
64
65fn media_url(data: &Source, mime: Option<&str>) -> Option<String> {
68 match (data, mime) {
69 (Source::Url(url), _) => Some(url.clone()),
70 (Source::Base64(data), Some(mime)) => Some(format!("data:{mime};base64,{data}")),
71 _ => None,
72 }
73}
74
75fn image_url(image: &crate::message::Image, user: bool) -> Result<Value, EncodeError> {
78 let mime = image.media_type.as_ref().map(MimeType::to_mime_type);
79 let url = media_url(&image.data, mime).ok_or_else(|| unsendable("an image"))?;
80 let mut part = Map::from_iter([("url".to_owned(), Value::String(url))]);
81 let detail = match &image.detail {
82 Some(detail) => Some(detail.clone()),
83 None => user.then(Default::default),
84 };
85 if let Some(detail) = detail {
86 part.insert("detail".to_owned(), serde_json::to_value(detail)?);
87 }
88 Ok(object! {"type": "image_url", "image_url": part})
89}
90
91fn user_part(part: &UserContent) -> Result<Value, EncodeError> {
93 let file = |file: Value| object! {"type": "file", "file": file};
94 Ok(match part {
95 UserContent::Text(text) => json!({"type": "text", "text": text.text}),
96 UserContent::Image(image) => image_url(image, true)?,
97 UserContent::Document(document) => {
98 let pdf = document.media_type == Some(DocumentMediaType::PDF);
99 match &document.data {
100 DocumentData::Text(text) => json!({"type": "text", "text": text}),
101 DocumentData::File(Source::FileId(id)) => file(json!({ "file_id": id })),
102 DocumentData::File(Source::Base64(data)) if pdf => file(object! {
103 "file_data": format!("data:application/pdf;base64,{data}"),
104 "filename": "document.pdf",
105 }),
106 DocumentData::File(Source::Url(url)) if pdf => {
108 file(json!({"file_data": url, "filename": "document.pdf"}))
109 }
110 DocumentData::File(_) => return Err(unsendable("a document")),
111 }
112 }
113 UserContent::Audio(audio) => match &audio.data {
114 Source::Base64(data) => json!({"type": "input_audio", "input_audio": {
115 "data": data,
116 "format": audio.media_type.clone().unwrap_or(crate::message::AudioMediaType::MP3),
117 }}),
118 _ => return Err(unsendable("audio")),
119 },
120 UserContent::Video(video) => {
121 let mime = video.media_type.as_ref().map(MimeType::to_mime_type);
122 let url = media_url(&video.data, mime).ok_or_else(|| unsendable("a video"))?;
123 object! {"type": "video_url", "video_url": object! {"url": url}}
124 }
125 UserContent::ToolResult(_) => return Err(unsendable("a tool result as a content part")),
126 })
127}
128
129fn tool_message(result: &ToolResult, ids: &WireIds, array: bool) -> Result<Value, EncodeError> {
133 fn text(part: &ToolResultContent) -> Option<Cow<'_, str>> {
134 match part {
135 ToolResultContent::Text(text) => Some(Cow::Borrowed(&text.text)),
136 ToolResultContent::Json { value } => Some(Cow::Owned(value.to_string())),
137 ToolResultContent::Image(_) => None,
138 }
139 }
140 let image = |part: &ToolResultContent| matches!(part, ToolResultContent::Image(_));
141 let content = if array || result.content.iter().any(image) {
142 let parts = result.content.iter().map(|part| match part {
143 ToolResultContent::Image(image) => image_url(image, false),
144 _ => Ok(object! {"type": "text", "text": text(part).unwrap_or_default()}),
145 });
146 Value::Array(parts.collect::<Result<_, EncodeError>>()?)
147 } else {
148 let texts: Vec<Cow<'_, str>> = result.content.iter().filter_map(text).collect();
149 Value::String(texts.join("\n"))
150 };
151 let id = ids.spell(&result.call);
152 Ok(object! {"role": "tool", "tool_call_id": id, "content": content})
153}
154
155fn user_message(messages: &mut Vec<Value>, parts: &mut Vec<Value>) {
158 let content = match parts.as_mut_slice() {
159 [] => return,
160 [part] if part.str("type") == Some("text") => {
161 part.get_mut("text").map(Value::take).unwrap_or_default()
162 }
163 _ => Value::Array(std::mem::take(parts)),
164 };
165 parts.clear();
166 messages.push(object! {"role": "user", "content": content});
167}
168
169impl Chat {
170 fn reasoning_field<'a>(&'a self, model: &str) -> Option<&'a str> {
179 use crate::providers::openai::wire::{MOONSHOT, OPENROUTER};
180 let deepseek = self
181 .provider
182 .base_url
183 .to_ascii_lowercase()
184 .contains("deepseek.com");
185 let name = model.rsplit('/').next().unwrap_or(model);
186 let field = |vendor: &str, id: &str| {
187 self.facts
188 .for_model(vendor, id)
189 .and_then(|spec| spec.compat.reasoning_field.as_deref())
190 };
191 let listed = field(self.provider.dialect.name, model)
192 .or_else(|| field(MOONSHOT.name, name))
193 .or_else(|| field(OPENROUTER.name, model));
194 self.provider
195 .dialect
196 .quirks
197 .reasoning_field
198 .or(deepseek.then_some("reasoning_content"))
199 .or(listed)
200 }
201
202 pub fn new(provider: OpenAIConfig, model: impl Into<String>) -> Self {
204 Self {
205 provider,
206 model: model.into(),
207 strict_tools: false,
208 tool_result_array_content: false,
209 facts: ModelFacts::default(),
210 }
211 }
212
213 pub fn with_facts(mut self, facts: ModelFacts) -> Self {
216 self.facts = facts;
217 self
218 }
219
220 pub fn with_strict_tools(mut self) -> Self {
226 self.strict_tools = true;
227 self
228 }
229
230 pub fn with_tool_result_array_content(mut self) -> Self {
232 self.tool_result_array_content = true;
233 self
234 }
235
236 fn base(
239 &self,
240 request: &CompletionRequest,
241 input: &mut BaseInput<'_>,
242 ) -> Result<Map<String, Value>, EncodeError> {
243 let quirks = &self.provider.dialect.quirks;
244 let mut model = request.model.clone().unwrap_or_else(|| self.model.clone());
245 if quirks.rewrite == BodyRewrite::HuggingFaceRouter {
246 model = self.provider.route().model_identifier(&model);
248 }
249 let passthrough = input.raw_tools()?;
250 let custom: Vec<String> = passthrough
252 .iter()
253 .filter(|tool| tool.str("type") == Some("custom"))
254 .filter_map(|tool| tool.at("/custom/name").and_then(Value::as_str))
255 .map(str::to_owned)
256 .collect();
257 let messages = self.messages(&request.chat_history, &model, &custom)?;
258
259 let mut tools: Vec<Value> = Vec::new();
260 let mut tool_choice = None;
261 if quirks.supports_tools {
262 for tool in &request.tools {
263 let mut parameters = tool.parameters.clone();
264 let mut function = json!({"name": tool.name, "description": tool.description});
265 let mut strict = self.strict_tools;
266 if strict {
267 let mut sanitized = parameters.clone();
268 match crate::providers::openai::sanitize_tool_schema(&mut sanitized, quirks) {
269 Ok(()) => parameters = sanitized,
270 Err(reason) => {
271 tracing::warn!(
272 tool = %tool.name,
273 "sending the tool without strict mode: its schema holds {reason}, \
274 which this provider's strict mode cannot express"
275 );
276 strict = false;
277 }
278 }
279 }
280 if let Some(function) = function.as_object_mut() {
281 function.insert("parameters".to_owned(), parameters);
282 if strict {
283 function.insert("strict".to_owned(), Value::Bool(true));
284 }
285 }
286 tools.push(object! {"type": "function", "function": function});
287 }
288 tools.extend(passthrough);
291 tool_choice = request
292 .tool_choice
293 .clone()
294 .map(tool_choice_value)
295 .transpose()?
296 .filter(|_| !tools.is_empty());
297 } else {
298 if !request.tools.is_empty() {
299 tracing::warn!("Tool use is not supported by this provider; tools will be ignored");
300 }
301 if request.tool_choice.is_some() {
302 tracing::warn!("Tool choice is not supported by this provider and will be ignored");
303 }
304 tools.extend(passthrough);
307 }
308
309 if request.output_schema.is_some() && !quirks.supports_response_format {
310 tracing::warn!(
311 "Structured outputs are not supported by this provider; ignoring output_schema"
312 );
313 }
314 let answered = messages
316 .iter()
317 .any(|message| message.str("role") == Some("tool"));
318 let response_format = match request.output_schema.clone() {
319 Some(schema)
320 if quirks.supports_response_format
321 && (quirks.response_format_with_tools || tools.is_empty() || answered) =>
322 {
323 let (name, schema) = crate::providers::openai::structured_output_schema(schema);
324 Some(json!({"type": "json_schema",
325 "json_schema": {"name": name, "strict": true, "schema": schema}}))
326 }
327 _ => None,
328 };
329
330 Ok(crate::providers::internal::set_fields([
331 ("model", Some(Value::String(model))),
332 ("messages", Some(Value::Array(messages))),
333 ("tools", (!tools.is_empty()).then_some(Value::Array(tools))),
334 ("tool_choice", tool_choice),
335 ("temperature", request.temperature.map(Value::from)),
336 ("max_tokens", request.max_tokens.map(Value::from)),
337 ("response_format", response_format),
338 ]))
339 }
340
341 fn messages(
344 &self,
345 history: &[Arc<Message>],
346 model: &str,
347 custom: &[String],
348 ) -> Result<Vec<Value>, EncodeError> {
349 let ids = WireIds::for_target(history, self, model);
350 let reasoning_field = self.reasoning_field(model);
351 let mut messages = Vec::new();
352 for message in history {
353 match &**message {
354 Message::System { content } => messages.push(json!({"role": "system",
355 "content": [{"type": "text", "text": content}]})),
356 Message::User { content } => {
357 let mut parts = Vec::new();
358 for part in content {
359 if let UserContent::ToolResult(result) = part {
360 user_message(&mut messages, &mut parts);
361 let array = self.tool_result_array_content;
362 messages.push(tool_message(result, &ids, array)?);
363 } else {
364 parts.push(user_part(part)?);
365 }
366 }
367 user_message(&mut messages, &mut parts);
368 }
369 Message::Assistant(turn) => {
370 messages.extend(self.assistant(turn, &ids, custom, reasoning_field));
371 }
372 }
373 }
374 if messages.is_empty() {
375 return Err(EncodeError::request(
376 "OpenAI Chat Completions request has no provider-compatible messages after \
377 conversion",
378 ));
379 }
380 Ok(messages)
381 }
382
383 fn assistant(
390 &self,
391 turn: &AssistantMessage,
392 ids: &WireIds,
393 custom: &[String],
394 reasoning_field: Option<&str>,
395 ) -> Option<Value> {
396 let (mut text, mut parts, mut has_parts) = (String::new(), Vec::new(), false);
397 let mut reasoning: Vec<(String, String)> = Vec::new();
398 let (mut fields, mut calls) = (Map::new(), Vec::new());
399 for block in &turn.content {
400 let replay = block.replay(self, ids);
401 match block {
402 AssistantContent::Text(block) => {
403 text.push_str(&block.text);
404 parts.push(json!({"type": "text", "text": block.text}));
405 if let Replay::Item(item) = replay
407 && let Value::Object(item) = item.into_owned()
408 {
409 fields.extend(item);
410 }
411 }
412 AssistantContent::Reasoning(block) => {
413 let field = match replay {
414 Replay::Item(item) if item.get("type").is_some() => {
415 has_parts = true;
416 parts.push(item.into_owned());
417 None
418 }
419 Replay::Item(item) => {
420 let mut field = None;
421 for (key, value) in item.as_object().into_iter().flatten() {
422 if value.is_string() {
423 field.get_or_insert_with(|| key.clone());
424 } else {
425 let detail = Map::from_iter([(key.clone(), value.clone())]);
426 merge_fields(&mut fields, &detail);
427 }
428 }
429 field
430 }
431 Replay::Identity(identity)
432 if identity.get("type").and_then(Value::as_str) == Some("thinking") =>
433 {
434 has_parts = true;
435 parts.push(json!({"type": "thinking",
436 "thinking": [{"type": "text", "text": block.text}]}));
437 None
438 }
439 Replay::Identity(identity) => identity.keys().next().cloned(),
440 Replay::Rebuild => reasoning_field.map(str::to_owned),
441 };
442 if let Some(field) = field {
443 match reasoning.iter_mut().find(|(name, _)| *name == field) {
444 Some((_, joined)) => {
445 joined.push('\n');
446 joined.push_str(&block.text);
447 }
448 None => reasoning.push((field, block.text.clone())),
449 }
450 }
451 }
452 AssistantContent::ToolCall(call) => {
453 calls.push(call_item(call, replay, ids, custom));
454 }
455 AssistantContent::Opaque(opaque) if opaque.replay => {
458 if opaque.item.get("type").is_some() {
459 has_parts = true;
460 parts.push(opaque.item.clone());
461 }
462 }
463 AssistantContent::Opaque(_) | AssistantContent::Image(_) => {}
464 }
465 }
466 let mut message = Map::from_iter([("role".to_owned(), Value::from("assistant"))]);
467 if has_parts {
468 message.insert("content".to_owned(), Value::Array(parts));
469 } else if !text.is_empty() {
470 message.insert("content".to_owned(), Value::String(text));
471 }
472 for (field, text) in reasoning {
473 message.insert(field, Value::String(text));
474 }
475 message.extend(fields);
476 if !calls.is_empty() {
477 message.insert("tool_calls".to_owned(), Value::Array(calls));
478 }
479 let has_content = message.contains_key("audio")
480 || message.contains_key("tool_calls")
481 || match message.get("content") {
482 Some(Value::String(text)) => !text.is_empty(),
483 Some(Value::Array(parts)) => !parts.is_empty(),
484 _ => false,
485 };
486 if let Some(field) = reasoning_field {
487 message
488 .entry(field)
489 .or_insert_with(|| Value::String(String::new()));
490 }
491 has_content.then_some(Value::Object(message))
492 }
493}
494
495pub(crate) fn rewrite_body(
500 kind: BodyRewrite,
501 map: &mut Map<String, Value>,
502) -> Result<(), EncodeError> {
503 let forced = map
504 .get("tool_choice")
505 .and_then(|choice| choice.at("/function/name"))
506 .and_then(Value::as_str)
507 .map(str::to_owned);
508 match kind {
509 BodyRewrite::LlamaCpp => {
510 if let Some(name) = forced {
511 return Err(EncodeError::request(format!(
512 "llama.cpp cannot force a specific tool: `llama-server` accepts only \
513 `auto`, `none` or `required` for tool_choice and silently treats \
514 anything else as `auto`, so requesting `{name}` would return whichever \
515 tool the model picked. Use `ToolChoice::Required` to force a call, or \
516 advertise only `{name}` in `tools`."
517 )));
518 }
519 }
520 BodyRewrite::Moonshot => {
521 if forced.is_some() {
522 return Err(EncodeError::request(
523 "Moonshot does not support forcing a specific tool",
524 ));
525 }
526 if map.get("tool_choice").and_then(Value::as_str) == Some("required") {
527 tracing::warn!(
528 "Moonshot does not support tool_choice=required; coercing to auto with an \
529 additional steering message"
530 );
531 map.insert("tool_choice".to_owned(), Value::from("auto"));
532 if let Some(Value::Array(messages)) = map.get_mut("messages") {
533 messages.push(json!({"role": "user",
534 "content": "Please select a tool to handle the current issue."}));
535 }
536 }
537 }
538 BodyRewrite::Perplexity | BodyRewrite::Mira => {
544 let all = kind == BodyRewrite::Mira;
545 for content in messages_mut(map).filter_map(|message| message.get_mut("content")) {
546 if let Value::Array(parts) = content
547 && (all || parts.iter().all(|part| part.str("type") == Some("text")))
548 {
549 let texts: Vec<&str> =
550 parts.iter().filter_map(|part| part.str("text")).collect();
551 *content = Value::String(texts.join("\n"));
552 }
553 }
554 }
555 BodyRewrite::DeepSeek => finalize_deepseek(map),
556 BodyRewrite::Mistral => finalize_mistral(map),
557 BodyRewrite::Ollama => finalize_ollama(map)?,
558 BodyRewrite::None | BodyRewrite::OpenRouter | BodyRewrite::HuggingFaceRouter => {}
559 }
560 Ok(())
561}
562
563fn call_item(call: &ToolCall, replay: Replay<'_>, ids: &WireIds, custom: &[String]) -> Value {
569 let mut item = match replay {
570 Replay::Item(item) => match item.into_owned() {
571 Value::Object(item) => item,
572 _ => Map::new(),
573 },
574 Replay::Identity(identity) => identity,
575 Replay::Rebuild => Map::new(),
576 };
577 let name = call.function.name.as_str();
578 let custom = match item.get("type").and_then(Value::as_str) {
579 Some(kind) => kind == "custom",
580 None => {
581 let declared = custom.iter().any(|tool| tool == name);
582 let kind = if declared { "custom" } else { "function" };
583 item.insert("type".to_owned(), Value::from(kind));
584 declared
585 }
586 };
587 let id = ids.spell(&call.id);
588 item.insert("id".to_owned(), Value::String(id));
589 let (slot, key, value) = if custom {
590 let input = match call.function.arguments.get("input") {
591 Some(Value::String(input)) => input.clone(),
592 Some(input) => input.to_string(),
593 None => String::new(),
594 };
595 ("custom", "input", input)
596 } else {
597 let arguments = serde_json::to_string(&call.function.arguments).unwrap_or_default();
598 ("function", "arguments", arguments)
599 };
600 let slot = item
601 .entry(slot)
602 .or_insert_with(|| Value::Object(Map::new()));
603 if !slot.is_object() {
604 *slot = Value::Object(Map::new());
605 }
606 if let Value::Object(fields) = slot {
607 fields.insert("name".to_owned(), Value::from(name));
608 fields.insert(key.to_owned(), Value::String(value));
609 }
610 Value::Object(item)
611}
612
613fn tool_choice_value(choice: crate::message::ToolChoice) -> Result<Value, EncodeError> {
615 use crate::message::ToolChoice;
616 Ok(match choice {
617 ToolChoice::Auto => Value::from("auto"),
618 ToolChoice::None => Value::from("none"),
619 ToolChoice::Required => Value::from("required"),
620 ToolChoice::Specific { function_names } => {
621 let [name] = function_names.as_slice() else {
622 return Err(EncodeError::request(
623 "Provider only supports forcing exactly one specific tool",
624 ));
625 };
626 json!({"type": "function", "function": {"name": name}})
627 }
628 })
629}
630
631fn finalize_ollama(map: &mut Map<String, Value>) -> Result<(), EncodeError> {
634 if let Some(key) = ["num_ctx", "options"]
635 .into_iter()
636 .find(|key| map.contains_key(*key))
637 {
638 return Err(EncodeError::request(format!(
639 "Ollama's OpenAI-compatible API ignores `{key}`; send it through the native \
640 route (`Ollama::native_completion`)"
641 )));
642 }
643 Ok(())
644}
645
646fn messages_mut(map: &mut Map<String, Value>) -> impl Iterator<Item = &mut Map<String, Value>> {
648 let messages = map.get_mut("messages").and_then(Value::as_array_mut);
649 messages
650 .into_iter()
651 .flatten()
652 .filter_map(Value::as_object_mut)
653}
654
655fn finalize_deepseek(map: &mut Map<String, Value>) {
659 let thinking = match map
660 .get("thinking")
661 .and_then(|thinking| thinking.str("type"))
662 {
663 Some(mode) => !mode.eq_ignore_ascii_case("disabled"),
664 None => map.get("model").and_then(Value::as_str) != Some("deepseek-chat"),
665 };
666 let forced = map
667 .get("tool_choice")
668 .is_some_and(|choice| choice.is_object() || choice.as_str() == Some("required"));
669 if thinking && forced {
670 tracing::debug!(
671 "dropping tool_choice: DeepSeek rejects a forced tool choice while thinking"
672 );
673 remove_preserving_order(map, "tool_choice");
674 }
675}
676
677fn finalize_mistral(map: &mut Map<String, Value>) {
680 let forces_a_tool_call = map
683 .get("tool_choice")
684 .is_some_and(|choice| !matches!(choice.as_str(), Some("auto" | "none")));
685 let has_tools = map
686 .get("tools")
687 .and_then(Value::as_array)
688 .is_some_and(|tools| !tools.is_empty());
689 let structured = map
690 .get("response_format")
691 .and_then(|format| format.str("type"))
692 .is_some_and(|kind| matches!(kind, "json_schema" | "json_object"));
693 if forces_a_tool_call && has_tools && structured {
694 tracing::debug!(
695 "relaxing tool_choice to `auto`: Mistral rejects a forced tool choice \
696 alongside a response format"
697 );
698 map.insert("tool_choice".to_owned(), Value::from("auto"));
699 }
700 for content in messages_mut(map).filter_map(|message| message.get_mut("content")) {
701 for part in content.as_array_mut().into_iter().flatten() {
702 *part = mistral_chunk(part);
703 }
704 }
705}
706
707fn mistral_chunk(part: &Value) -> Value {
713 let field = |pointer: &str| part.at(pointer).and_then(Value::as_str);
714 match part.str("type") {
715 Some("image_url") => {
716 json!({"type": "image_url", "image_url": part.get("image_url")})
717 }
718 Some("input_audio") => json!({"type": "input_audio",
719 "input_audio": field("/input_audio/data")}),
720 Some("file") => match field("/file/file_data") {
721 Some(data) => json!({"type": "document_url", "document_url": data,
722 "document_name": field("/file/filename")}),
723 None => json!({"type": "file", "file_id": field("/file/file_id")}),
724 },
725 _ => part.clone(),
726 }
727}
728
729impl Wire for Chat {
730 type Op = crate::operation::Completion;
731 type Payload = crate::wire::Encoded;
732 type Frame = crate::wire::WireFrame;
733 type Event = ChatEvent;
734 type Decoder<'id> = ChatDecoder;
735 type Reassembler = document::ChatCompletion;
736
737 fn describe(&self) -> Descriptor<'_> {
740 Descriptor::new(self.provider.dialect.name)
741 .model(self.model.as_str())
742 .facts(&self.facts)
743 .capabilities(Capabilities::completion(
744 ProviderCapabilities::default().with_native_output_tool_composition(
745 self.provider.dialect.quirks.supports_response_format,
746 ),
747 ))
748 .replay(self)
749 }
750
751 fn encode(&self, request: CompletionRequest, mode: Mode) -> Result<Encoded, EncodeError> {
752 let quirks = &self.provider.dialect.quirks;
753 let base = self.provider.base_url.trim_end_matches('/');
754 let prefix = quirks
755 .strict_tools_prefix
756 .filter(|prefix| self.strict_tools && !base.ends_with(prefix))
757 .unwrap_or_default();
758 let path = format!("{prefix}{}", quirks.completion_path);
759 let uri = self
761 .provider
762 .uri(&path, self.provider.deployment(&self.model));
763 let builder = self.provider.completion_headers(
764 &request,
765 http::Request::post(uri).header("Content-Type", "application/json"),
766 );
767 let mut rewrites = Vec::new();
768 if quirks.output_cap == OutputCap::OpenAiReasoningFamilies {
769 rewrites.push(Rewrite::OutputCapRename);
770 }
771 if mode == Mode::Streaming {
772 if quirks.stream_include_usage {
774 rewrites.push(Rewrite::StreamUsage);
775 }
776 rewrites.push(Rewrite::Stream(true));
777 }
778 rewrites.push(Rewrite::ChatDialect(quirks.rewrite));
779 let raw_at = match quirks.rewrite {
780 BodyRewrite::Mira => RawAt::Ignored(
781 "Additional parameters are not supported by Mira and will be ignored",
782 ),
783 _ => RawAt::Top,
784 };
785 let body = request_params(
786 self,
787 &request,
788 |input| self.base(&request, input),
789 raw_at,
790 &rewrites,
791 )?;
792 let body = crate::providers::openai::options::check_body(
793 self,
794 &self.facts,
795 &request,
796 body,
797 crate::providers::openai::options::Endpoint::ChatCompletions,
798 )?;
799 crate::providers::internal::trace_json(
800 crate::providers::internal::LogTarget::Completions,
801 "OpenAI Chat Completions request",
802 &body,
803 );
804 let request = builder.body(body.into_body())?;
805 let framing = match mode {
806 Mode::Streaming => Framing::Sse,
807 Mode::Unary => Framing::Whole,
808 };
809 Ok(Encoded::new(request, framing)
810 .with_request_id_header(self.provider.dialect.request_id_header)
811 .with_projection(ChatDecoder::project)
812 .with_route(Some(quirks.completion_path)))
813 }
814
815 fn decoder<'id>(&self) -> Self::Decoder<'id> {
816 ChatDecoder::new(self.provider.dialect.quirks)
817 }
818}
819
820impl crate::completion::ReplayTarget for Chat {
821 fn catalog_priced(&self) -> bool {
824 self.provider.dialect.quirks.catalog_priced
825 }
826
827 fn map_options(
829 &self,
830 request: &CompletionRequest,
831 fields: crate::completion::options::OptionFields<'_>,
832 ) -> crate::completion::options::OptionMap {
833 crate::providers::openai::options::chat_options(self, request, fields)
834 }
835
836 fn facts(&self) -> Option<&ModelFacts> {
837 Some(&self.facts)
838 }
839
840 fn api(&self) -> crate::message::Api {
841 crate::message::Api::from_static("openai.chat")
842 }
843
844 fn provider(&self) -> &str {
845 self.provider.dialect.name
846 }
847
848 fn model(&self) -> &str {
849 &self.model
850 }
851
852 fn accepts(&self, model: &str) -> crate::completion::Accepts {
858 let quirks = &self.provider.dialect.quirks;
859 let user_images = reads_images(&self.facts, &self.provider.dialect, model);
860 crate::completion::Accepts {
861 user_images,
862 assistant_images: false,
863 tool_result_images: user_images && quirks.supports_image_tool_results,
864 tools: quirks.supports_tools,
865 foreign_reasoning: self.reasoning_field(model).is_some(),
866 }
867 }
868
869 fn encodes(&self, _model: &str, media: crate::completion::Media<'_>) -> bool {
876 use crate::completion::Media;
877 let dialect = &self.provider.dialect;
878 let rewrite = dialect.quirks.rewrite;
879 let parts = !matches!(rewrite, BodyRewrite::DeepSeek | BodyRewrite::Mira);
880 let files = parts
882 && rewrite != BodyRewrite::Perplexity
883 && ![super::dialects::COHERE.name, super::dialects::OLLAMA.name]
884 .contains(&dialect.name);
885 let linked = |source: &Source, typed: bool| match source {
886 Source::Url(_) => true,
887 Source::Base64(_) => typed,
888 Source::Raw(_) | Source::FileId(_) | Source::Unknown => false,
889 };
890 match media {
891 Media::Image(image, place) => {
892 parts
893 && place != crate::completion::Place::Assistant
894 && linked(&image.data, image.media_type.is_some())
895 && !(dialect.name == super::dialects::OLLAMA.name
897 && matches!(image.data, Source::Url(_)))
898 }
899 Media::Audio(audio) => files && matches!(audio.data, Source::Base64(_)),
900 Media::Video(video) => {
901 files
902 && rewrite != BodyRewrite::Mistral
903 && ![super::dialects::OPENAI.name, super::dialects::AZURE.name]
904 .contains(&dialect.name)
905 && linked(&video.data, video.media_type.is_some())
906 }
907 Media::Document(document) => {
908 let pdf = document.media_type == Some(DocumentMediaType::PDF);
909 match &document.data {
910 DocumentData::Text(_) => true,
911 DocumentData::File(Source::FileId(_)) => {
912 files && dialect.quirks.accepts_file_ids
913 }
914 DocumentData::File(Source::Base64(_)) => files && pdf,
915 DocumentData::File(Source::Url(_)) => {
916 pdf && matches!(rewrite, BodyRewrite::OpenRouter | BodyRewrite::Mistral)
917 }
918 DocumentData::File(Source::Raw(_) | Source::Unknown) => false,
919 }
920 }
921 }
922 }
923
924 fn normalize_tool_call_id(
929 &self,
930 id: &str,
931 _model: &str,
932 _: Option<&crate::message::Origin>,
933 ) -> String {
934 let sanitized =
935 |part: &str| crate::providers::internal::wire_ids::legal_call_id(part, usize::MAX);
936 if let Some((call, item)) = id.split_once('|') {
937 let call = sanitized(call);
938 let item = sanitized(item);
939 let combined = if item.is_empty() {
940 call.clone()
941 } else {
942 format!("{call}_{item}")
943 };
944 if combined.len() <= 40 {
945 return combined;
946 }
947 let hash: String = crate::providers::internal::wire_ids::short_hash(id)
948 .chars()
949 .take(8)
950 .collect();
951 let prefix: String = call.chars().take((40 - hash.len() - 1).max(1)).collect();
952 return format!("{prefix}_{hash}");
953 }
954 if self.provider.dialect.name == super::dialects::OPENAI.name {
955 return id.chars().take(40).collect();
956 }
957 id.to_owned()
958 }
959
960 fn identity(&self, item: &Value) -> Map<String, Value> {
964 if let Some(kind @ ("custom" | "thinking")) = item.str("type") {
965 return Map::from_iter([("type".to_owned(), Value::from(kind))]);
966 }
967 REASONING_TEXT_KEYS
968 .iter()
969 .find(|key| item.get(**key).is_some_and(Value::is_string))
970 .map(|key| Map::from_iter([((*key).to_owned(), Value::String(String::new()))]))
971 .unwrap_or_default()
972 }
973
974 fn call_id_slot(&self) -> Option<&'static str> {
975 Some("/id")
976 }
977
978 fn later_system(&self, _model: &str) -> crate::completion::LaterSystem {
979 self.provider.dialect.quirks.later_system
980 }
981
982 fn result_parts(&self, _model: &str) -> bool {
984 self.tool_result_array_content
985 }
986
987 fn sends_alone(&self, block: &AssistantContent) -> bool {
992 let replay = || block.replay(self, &WireIds::default());
993 match block {
994 AssistantContent::ToolCall(_) => true,
995 AssistantContent::Text(text) => {
996 !text.text.is_empty()
997 || matches!(replay(), Replay::Item(item) if item.get("audio").is_some())
998 }
999 AssistantContent::Reasoning(_) => match replay() {
1000 Replay::Item(item) => item.get("type").is_some(),
1001 Replay::Identity(identity) => {
1002 identity.get("type").and_then(Value::as_str) == Some("thinking")
1003 }
1004 Replay::Rebuild => false,
1005 },
1006 AssistantContent::Opaque(opaque) => opaque.item.get("type").is_some(),
1007 AssistantContent::Image(_) => false,
1008 }
1009 }
1010}
1011
1012fn image_rule(vendor: &str) -> Option<(&str, fn(&str) -> bool)> {
1022 use crate::providers::{minimax, moonshot, xiaomimimo, zai};
1023 Some(match vendor {
1024 "deepseek" | "mira" => return None,
1025 "groq" => ("groq", |model| model.contains("llama-4")),
1026 "mistral" | "mistralai" => ("mistral", |model| {
1027 !(model.starts_with("codestral") || model.starts_with("devstral"))
1028 }),
1029 "openai" | "azure.openai" => ("openai", crate::providers::openai::reads_images),
1030 "xai" | "x-ai" => ("xai", crate::providers::xai::reads_images),
1031 "cohere" => ("cohere", crate::providers::cohere::reads_images),
1032 "zai" | "z-ai" => ("zai", zai::reads_images),
1033 "moonshot" | "moonshotai" => ("moonshot", moonshot::reads_images),
1034 "minimax" => ("minimax", minimax::reads_images),
1035 "xiaomimimo" | "xiaomi" => ("xiaomimimo", xiaomimimo::reads_images),
1036 vendor => (vendor, |_| true),
1037 })
1038}
1039
1040fn reads_images(facts: &ModelFacts, dialect: &super::Dialect, model: &str) -> bool {
1045 match model.split_once('/') {
1046 Some((vendor, upstream)) if dialect.quirks.rewrite == BodyRewrite::OpenRouter => facts
1047 .reads_images_or(dialect.name, model, |_| {
1048 image_rule(vendor).is_some_and(|(_, rule)| rule(upstream))
1049 }),
1050 _ => image_rule(dialect.name)
1051 .is_some_and(|(vendor, rule)| facts.reads_images_or(vendor, model, rule)),
1052 }
1053}
1054
1055pub enum ChatEvent {
1057 Chunk(Value),
1059 Whole(Value),
1061 Done,
1063 Failure(ProviderError),
1065 BareText(String),
1068}
1069
1070const REASONING_TEXT_KEYS: [&str; 3] = ["reasoning_content", "reasoning", "reasoning_text"];
1074
1075const REASONING_DETAILS: &str = "reasoning_details";
1077
1078#[derive(Debug, PartialEq)]
1080pub(crate) enum Part {
1081 Text(String),
1083 Thinking(String),
1085 Image,
1087 Unknown,
1089}
1090
1091impl Part {
1092 pub(crate) fn of(part: &Value) -> Self {
1095 let text = |key: &str| part.str(key).unwrap_or_default().to_owned();
1096 match part.str("type") {
1097 Some("text") => Self::Text(text("text")),
1098 None if part.str("text").is_some() => Self::Text(text("text")),
1099 Some("refusal") => Self::Text(text("refusal")),
1100 Some("thinking") => Self::Thinking(match part.get("thinking") {
1101 Some(Value::Array(chunks)) => chunks
1102 .iter()
1103 .filter_map(|chunk| chunk.str("text"))
1104 .collect(),
1105 _ => text("thinking"),
1106 }),
1107 Some("image_url") => Self::Image,
1108 _ => Self::Unknown,
1109 }
1110 }
1111}
1112
1113#[derive(Debug, PartialEq)]
1115pub(crate) enum CallKind {
1116 Function,
1118 Custom,
1120 Unknown,
1122}
1123
1124impl CallKind {
1125 pub(crate) fn of(call: &Value) -> Self {
1128 match call.str("type") {
1129 None | Some("function") => Self::Function,
1130 Some("custom") => Self::Custom,
1131 Some(_) => Self::Unknown,
1132 }
1133 }
1134}
1135
1136#[derive(Clone, Copy, PartialEq)]
1139enum Writing {
1140 Text,
1141 Thinking,
1142}
1143
1144struct OpenCall {
1148 at: usize,
1149 id: Option<String>,
1150 opaque: bool,
1151 arguments: String,
1152}
1153
1154#[derive(Default)]
1165pub struct ChatDecoder {
1166 quirks: Quirks,
1167 writing: Option<(Writing, usize)>,
1169 thinking_text: String,
1170 reasoning: Option<usize>,
1174 reasoning_field: Option<&'static str>,
1175 reasoning_text: String,
1176 reasoning_details: Map<String, Value>,
1177 audio_id: Option<Value>,
1179 calls: Vec<OpenCall>,
1181 usage: Option<Value>,
1182 reply_fields: Map<String, Value>,
1184 first_text: Option<usize>,
1187 last_text: Option<usize>,
1190 turn_citations: Vec<crate::wire::WireCitation>,
1193 finish: Option<FinishReason>,
1194 response_id: Option<String>,
1195 response_model: Option<String>,
1196 ended: bool,
1198 chunked: bool,
1200}
1201
1202impl ChatDecoder {
1203 fn new(quirks: Quirks) -> Self {
1204 Self {
1205 quirks,
1206 ..Self::default()
1207 }
1208 }
1209
1210 fn absorb<'f>(&mut self, frame: &'f Value) -> Option<&'f Value> {
1213 keep_text(&mut self.response_id, frame.str("id"));
1215 keep_text(&mut self.response_model, frame.str("model"));
1216 let choice = frame.arr("choices").iter().find(|choice| {
1219 let index = choice.get("index").and_then(Value::as_u64);
1220 index.is_none_or(|index| index == 0)
1221 });
1222 if let Some(usage) = frame
1223 .at("/usage")
1224 .or_else(|| choice.and_then(|choice| choice.at("/usage")))
1225 && self.usage.as_ref() != Some(usage)
1226 {
1227 self.usage = Some(usage.clone());
1228 }
1229 for key in reported::REPLY_FIELDS {
1230 if let Some(value) = frame.get(key).filter(|value| !value.is_null())
1231 && self.reply_fields.get(key) != Some(value)
1232 {
1233 self.reply_fields.insert(key.to_owned(), value.clone());
1234 }
1235 }
1236 let choice = choice?;
1237 let reason = match choice
1240 .str("finish_reason")
1241 .filter(|reason| !reason.is_empty())
1242 {
1243 Some(reason) => Some(finish_reason(reason, &self.quirks)),
1244 None => choice
1245 .str("native_finish_reason")
1246 .filter(|reason| self.quirks.native_finish_reason && !reason.is_empty())
1247 .map(native_finish_reason),
1248 };
1249 if let Some(reason) = reason {
1250 self.finish = Some(reason);
1251 self.ended = true;
1252 }
1253 Some(choice)
1254 }
1255
1256 fn delta(
1260 &mut self,
1261 delta: &Map<String, Value>,
1262 out: &mut Out<'_, Completion>,
1263 ) -> Result<(), ProviderError> {
1264 let reasoning = REASONING_TEXT_KEYS.iter().find_map(|key| {
1265 delta
1266 .get(*key)
1267 .and_then(Value::as_str)
1268 .filter(|text| !text.is_empty())
1269 .map(|text| (*key, text))
1270 });
1271 if let Some((key, text)) = reasoning {
1272 self.reason(text, out)?;
1273 self.reasoning_field.get_or_insert(key);
1274 }
1275 if let Some(Value::Array(details)) = delta.get(REASONING_DETAILS)
1276 && !details.is_empty()
1277 {
1278 self.reason("", out)?;
1279 merge_details(&mut self.reasoning_details, details.clone());
1280 }
1281 let audio = delta.get("audio");
1282 if let Some(id) = audio.and_then(|audio| audio.at("/id")) {
1283 self.audio_id = Some(id.clone());
1284 }
1285 match delta.get("content") {
1286 Some(Value::Array(parts)) => {
1287 for part in parts {
1288 self.part(part, out)?;
1289 }
1290 }
1291 Some(part @ Value::Object(_)) => self.part(part, out)?,
1292 _ => {
1293 let text = ["content", "refusal"]
1294 .iter()
1295 .find_map(|key| {
1296 delta
1297 .get(*key)
1298 .and_then(Value::as_str)
1299 .filter(|text| !text.is_empty())
1300 })
1301 .or_else(|| audio.and_then(|audio| audio.str("transcript")))
1302 .filter(|text| !text.is_empty());
1303 if let Some(text) = text {
1304 self.write(Writing::Text, text, out)?;
1305 }
1306 }
1307 }
1308 for image in delta
1309 .get("images")
1310 .and_then(Value::as_array)
1311 .into_iter()
1312 .flatten()
1313 {
1314 self.image(image, out)?;
1315 }
1316 for call in delta
1317 .get("tool_calls")
1318 .and_then(Value::as_array)
1319 .into_iter()
1320 .flatten()
1321 {
1322 self.call(call, out)?;
1323 }
1324 self.turn_citations.extend(
1325 delta
1326 .get("annotations")
1327 .and_then(Value::as_array)
1328 .into_iter()
1329 .flatten()
1330 .filter_map(reported::annotation),
1331 );
1332 self.cite_turn(out);
1333 Ok(())
1334 }
1335
1336 fn cite_turn(&mut self, out: &mut Out<'_, Completion>) {
1339 if let Some(text) = self.first_text {
1340 for citation in self.turn_citations.drain(..) {
1341 out.cite(text, citation);
1342 }
1343 }
1344 }
1345
1346 fn reason(&mut self, text: &str, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1348 let at = match self.reasoning {
1349 Some(at) => at,
1350 None => {
1351 let at = out.fresh_index();
1352 out.open(at, Block::Reasoning { redacted: false }, Value::Null)?;
1353 out.lead(at)?;
1354 self.reasoning = Some(at);
1355 at
1356 }
1357 };
1358 self.reasoning_text.push_str(text);
1359 out.push(at, text)
1360 }
1361
1362 fn close_reasoning(&mut self, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1365 let Some(at) = self.reasoning.take() else {
1366 return Ok(());
1367 };
1368 let text = std::mem::take(&mut self.reasoning_text);
1369 let mut item = Map::new();
1370 if let Some(field) = self.reasoning_field.take() {
1371 item.insert(field.to_owned(), text.into());
1372 }
1373 item.append(&mut self.reasoning_details);
1374 out.edit(at, |slot| *slot = Value::Object(item))?;
1375 out.finish(at)
1376 }
1377
1378 fn write(
1381 &mut self,
1382 writing: Writing,
1383 text: &str,
1384 out: &mut Out<'_, Completion>,
1385 ) -> Result<(), ProviderError> {
1386 let index = match self.writing {
1387 Some((current, index)) if current == writing => index,
1388 _ => {
1389 self.close_writing(out)?;
1390 let index = out.fresh_index();
1391 let block = match writing {
1392 Writing::Thinking => Block::Reasoning { redacted: false },
1393 Writing::Text => {
1394 self.first_text.get_or_insert(index);
1395 self.last_text = Some(index);
1396 Block::Text
1397 }
1398 };
1399 out.open(index, block, Value::Null)?;
1400 self.writing = Some((writing, index));
1401 index
1402 }
1403 };
1404 if writing == Writing::Thinking {
1405 self.thinking_text.push_str(text);
1406 }
1407 out.push(index, text)
1408 }
1409
1410 fn close_writing(&mut self, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1413 let (index, item) = match self.writing.take() {
1414 None => return Ok(()),
1415 Some((Writing::Text, index)) => (
1416 index,
1417 self.audio_id
1418 .take()
1419 .map(|id| json!({ "audio": { "id": id } })),
1420 ),
1421 Some((Writing::Thinking, index)) => {
1422 let text = std::mem::take(&mut self.thinking_text);
1423 (
1424 index,
1425 Some(json!({"type": "thinking", "thinking": [{"type": "text", "text": text}]})),
1426 )
1427 }
1428 };
1429 if let Some(item) = item {
1430 out.edit(index, |slot| *slot = item)?;
1431 }
1432 out.finish(index)
1433 }
1434
1435 #[deny(clippy::wildcard_enum_match_arm)]
1437 fn part(&mut self, part: &Value, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1438 match Part::of(part) {
1439 Part::Text(text) => self.write(Writing::Text, &text, out),
1440 Part::Thinking(text) => self.write(Writing::Thinking, &text, out),
1441 Part::Image => self.image(part, out),
1442 Part::Unknown => {
1443 self.close_writing(out)?;
1444 if let Some(citation) = reported::reference(part) {
1445 match self.last_text {
1446 Some(text) => out.cite(text, citation),
1447 None => self.turn_citations.push(citation),
1448 }
1449 }
1450 let index = out.fresh_index();
1451 out.whole(index, Block::Opaque { replay: true }, part.clone(), "")
1452 }
1453 }
1454 }
1455
1456 fn image(&mut self, part: &Value, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1459 use crate::message::{Image, ImageMediaType};
1460 let url = part
1461 .at("/image_url/url")
1462 .or_else(|| part.get("image_url"))
1463 .and_then(Value::as_str)
1464 .unwrap_or_default();
1465 let inline = url
1466 .strip_prefix("data:")
1467 .and_then(|rest| rest.split_once(";base64,"));
1468 let mut image = Image::default();
1469 let data = match inline {
1470 Some((mime, data)) => {
1471 image.data = Source::Base64(String::new());
1472 image.media_type = ImageMediaType::from_mime_type(mime);
1473 data
1474 }
1475 None => {
1476 image.data = Source::Url(url.to_owned());
1477 ""
1478 }
1479 };
1480 self.close_writing(out)?;
1481 let index = out.fresh_index();
1482 out.whole(index, Block::Image(image), part.clone(), data)
1483 }
1484
1485 fn call(&mut self, call: &Value, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1493 self.close_writing(out)?;
1494 let id = match call.get("id") {
1495 Some(Value::String(id)) if !id.is_empty() && id != "null" => Some(id.clone()),
1496 Some(id @ Value::Number(_)) => Some(id.to_string()),
1497 _ => None,
1498 };
1499 let index = call
1500 .get("index")
1501 .and_then(Value::as_u64)
1502 .and_then(|index| usize::try_from(index).ok());
1503 let arguments = call
1504 .at("/function/arguments")
1505 .map(crate::json_utils::value_to_json_string);
1506 let name = call
1507 .at("/function/name")
1508 .or_else(|| call.at("/custom/name"))
1509 .and_then(Value::as_str);
1510 let starts = name.is_some_and(|name| !name.is_empty())
1511 || arguments.as_deref().is_some_and(|text| !text.is_empty());
1512 let at = match index {
1513 Some(index) => index,
1514 None => match id.as_deref() {
1515 Some(id) => self
1516 .calls
1517 .iter()
1518 .find(|open| open.id.as_deref() == Some(id)),
1519 None => self.calls.last().filter(|open| {
1520 open.opaque || !crate::json_utils::is_whole_object(&open.arguments) || !starts
1521 }),
1522 }
1523 .map_or_else(|| out.fresh_index(), |open| open.at),
1524 };
1525 let opaque = match self.calls.iter_mut().find(|open| open.at == at) {
1526 Some(open) => {
1527 if id.is_some() {
1528 open.id.clone_from(&id);
1529 }
1530 open.arguments
1531 .push_str(arguments.as_deref().unwrap_or_default());
1532 open.opaque
1533 }
1534 None => {
1535 let opaque = CallKind::of(call) == CallKind::Unknown;
1536 if opaque {
1537 out.open(at, Block::Opaque { replay: false }, Value::Null)?;
1538 }
1539 self.calls.push(OpenCall {
1540 at,
1541 id: id.clone(),
1542 opaque,
1543 arguments: arguments.clone().unwrap_or_default(),
1544 });
1545 opaque
1546 }
1547 };
1548 if !opaque {
1549 out.fragment(
1550 Some(at),
1551 CallFragment {
1552 id: id.as_deref(),
1553 name,
1554 arguments: arguments.as_deref(),
1555 },
1556 )?;
1557 }
1558 out.edit(at, |item| {
1559 if !item.is_object() {
1560 *item = Value::Object(Map::new());
1561 }
1562 if let (Value::Object(item), Some(fields)) = (item, call.as_object()) {
1563 merge_fields(item, fields);
1564 remove_preserving_order(item, "index");
1566 }
1567 })
1568 }
1569
1570 fn close_calls(
1577 &mut self,
1578 out: &mut Out<'_, Completion>,
1579 all: bool,
1580 ) -> Result<(), ProviderError> {
1581 let cut = self.finish == Some(FinishReason::Length);
1582 let mut waiting = Vec::new();
1583 for call in std::mem::take(&mut self.calls) {
1584 if call.opaque {
1585 out.finish(call.at)?;
1586 continue;
1587 }
1588 let (mut input, mut complete) = (None, false);
1589 out.edit(call.at, |item| {
1590 if CallKind::of(item) == CallKind::Custom {
1591 let custom = item.at("/custom/input").cloned();
1592 input = Some(custom.unwrap_or_else(|| Value::from("")));
1593 }
1594 complete = match item.at("/function/arguments") {
1595 Some(Value::String(text)) => matches!(
1596 crate::json_utils::parse_tool_arguments(text),
1597 Ok(Value::Object(_))
1598 ),
1599 Some(arguments) => arguments.is_object(),
1600 None => input.is_some(),
1601 };
1602 })?;
1603 if !all && !complete {
1604 waiting.push(call);
1605 continue;
1606 }
1607 if let Some(input) = input {
1608 out.announce(call.at, json!({ "input": input }))?;
1609 }
1610 if cut && !complete {
1611 out.close(call.at)?;
1612 } else {
1613 out.finish(call.at)?;
1614 }
1615 }
1616 self.calls = waiting;
1617 Ok(())
1618 }
1619
1620 fn chunk(&mut self, frame: &Value, out: &mut Out<'_, Completion>) -> Result<(), ProviderError> {
1622 let Some(choice) = self.absorb(frame) else {
1623 return Ok(());
1624 };
1625 if let Some(delta) = choice.obj("delta") {
1626 self.delta(delta, out)?;
1627 }
1628 if self.finish == Some(FinishReason::ToolCalls) {
1632 self.close_calls(out, false)?;
1633 }
1634 Ok(())
1635 }
1636
1637 fn whole(
1640 &mut self,
1641 frame: &Value,
1642 mut out: Out<'_, Completion>,
1643 ) -> Result<Flow, ProviderError> {
1644 let Some(choice) = self.absorb(frame) else {
1645 return Err(ProviderError::Response(
1646 "Response contained no choices".to_owned(),
1647 ));
1648 };
1649 let Some(mut message) = choice.obj("message").cloned() else {
1650 return Err(ProviderError::Response(
1651 "Response did not contain a valid message or tool call".to_owned(),
1652 ));
1653 };
1654 self.ended = true;
1655 if let Some(Value::Array(calls)) = message.get_mut("tool_calls") {
1656 for (index, call) in calls.iter_mut().enumerate() {
1657 if let Some(call) = call.as_object_mut() {
1658 call.insert("index".to_owned(), index.into());
1659 }
1660 }
1661 }
1662 self.delta(&message, &mut out)?;
1663 self.close_calls(&mut out, true)?;
1664 self.end(out)
1665 }
1666
1667 fn end(&mut self, mut out: Out<'_, Completion>) -> Result<Flow, ProviderError> {
1670 self.close_writing(&mut out)?;
1671 self.close_reasoning(&mut out)?;
1672 self.cite_turn(&mut out);
1673 if let Some(text) = self.first_text {
1674 for citation in reported::listed(&self.reply_fields) {
1675 out.cite(text, citation);
1676 }
1677 }
1678 let cost = reported::cost(self.usage.as_ref(), &self.reply_fields);
1679 let usage = self
1680 .usage
1681 .as_ref()
1682 .map(|usage| normalized_usage(usage, &self.quirks))
1683 .unwrap_or_default()
1684 .cost(cost);
1685 Ok(out.end(Finish {
1686 usage,
1687 reason: self.finish.take(),
1688 response_id: self.response_id.take(),
1689 model: self.response_model.take(),
1690 ..Finish::default()
1691 }))
1692 }
1693
1694 fn finish(&mut self, mut out: Out<'_, Completion>) -> Result<Flow, ProviderError> {
1697 if !self.ended {
1698 return Err(ProviderError::Truncated);
1699 }
1700 self.close_calls(&mut out, true)?;
1701 self.end(out)
1702 }
1703
1704 fn done(&mut self, out: Out<'_, Completion>) -> Result<Flow, ProviderError> {
1708 if !self.ended && self.chunked && self.quirks.done_without_finish_reason {
1709 self.finish = Some(if self.calls.is_empty() {
1710 FinishReason::Stop
1711 } else {
1712 FinishReason::ToolCalls
1713 });
1714 self.ended = true;
1715 }
1716 self.finish(out)
1717 }
1718}
1719
1720fn keep_text(slot: &mut Option<String>, text: Option<&str>) {
1726 if let Some(text) = text
1727 && slot.as_deref() != Some(text)
1728 {
1729 *slot = Some(text.to_owned());
1730 }
1731}
1732
1733fn merge_details(block: &mut Map<String, Value>, details: Vec<Value>) {
1734 let Value::Array(merged) = block
1735 .entry(REASONING_DETAILS)
1736 .or_insert_with(|| Value::Array(Vec::new()))
1737 else {
1738 return;
1739 };
1740 for detail in details {
1741 let kind = detail.str("type");
1742 let continues = matches!(kind, Some("reasoning.text" | "reasoning.summary"))
1743 && merged.last().is_some_and(|last| {
1744 last.str("type") == kind && last.get("index") == detail.get("index")
1745 });
1746 match (continues, merged.last_mut(), detail) {
1747 (true, Some(Value::Object(last)), Value::Object(fields)) => {
1748 for (key, value) in fields {
1749 let missing = last
1750 .get(&key)
1751 .is_none_or(|existing| existing.is_null() || existing.as_str() == Some(""));
1752 match (last.get_mut(&key), value) {
1753 (Some(Value::String(text)), Value::String(more))
1754 if matches!(key.as_str(), "text" | "summary") =>
1755 {
1756 text.push_str(&more);
1757 }
1758 (_, value) if missing => {
1759 last.insert(key, value);
1760 }
1761 _ => {}
1762 }
1763 }
1764 }
1765 (_, _, detail) => merged.push(detail),
1766 }
1767 }
1768}
1769
1770#[deny(clippy::wildcard_enum_match_arm)]
1771impl<'id> Decoder<'id, Completion> for ChatDecoder {
1772 type Event = ChatEvent;
1773
1774 fn classify(&self, frame: WireFrame) -> WireEvent<ChatEvent> {
1775 let data = frame.as_str();
1776 if data == "[DONE]" {
1778 return WireEvent::Known(ChatEvent::Done);
1779 }
1780 let envelope = |value: &Value| provider_error_envelope(value, &data);
1783 match classify_chat_completions_frame::<Value>(&data) {
1784 WireEvent::Known(frame) => WireEvent::Known(match envelope(&frame) {
1785 Some(error) => ChatEvent::Failure(error),
1786 None => Self::chunk_or_whole(frame),
1787 }),
1788 WireEvent::Unknown { event_type, value } => {
1789 if let Some(error) = envelope(value.value()) {
1790 WireEvent::Known(ChatEvent::Failure(error))
1791 } else if let (true, Some(text)) = (
1792 self.quirks.accepts_bare_string_reply,
1793 value.value().as_str(),
1794 ) {
1795 WireEvent::Known(ChatEvent::BareText(text.to_owned()))
1796 } else {
1797 WireEvent::Unknown { event_type, value }
1798 }
1799 }
1800 WireEvent::Corrupt(error) => match serde_json::from_str::<Value>(&data)
1802 .ok()
1803 .and_then(|value| envelope(&value))
1804 {
1805 Some(error) => WireEvent::Known(ChatEvent::Failure(error)),
1806 None => WireEvent::Corrupt(error),
1807 },
1808 }
1809 }
1810
1811 fn decode(
1812 &mut self,
1813 event: ChatEvent,
1814 mut out: Out<'id, Completion>,
1815 ) -> Result<Flow, ProviderError> {
1816 match event {
1817 ChatEvent::Chunk(frame) => {
1818 self.chunked = true;
1819 self.chunk(&frame, &mut out)?;
1820 Ok(Flow::More)
1821 }
1822 ChatEvent::Whole(frame) => self.whole(&frame, out),
1823 ChatEvent::Done => self.done(out),
1824 ChatEvent::BareText(text) => {
1827 self.ended = true;
1828 self.finish = Some(FinishReason::Stop);
1829 self.write(Writing::Text, &text, &mut out)?;
1830 self.end(out)
1831 }
1832 ChatEvent::Failure(error) => Err(error),
1833 }
1834 }
1835
1836 fn eof(&mut self, out: Out<'id, Completion>) -> Result<Flow, ProviderError> {
1839 self.finish(out)
1840 }
1841}
1842
1843fn normalized_usage(usage: &Value, quirks: &Quirks) -> crate::completion::Usage {
1853 let count = |pointer: &str| usage.at(pointer).and_then(Value::as_u64);
1854 let detail = |object: &str, key: &str| {
1855 count(&format!("/{object}/{key}")).or(usage.obj(object).map(|_| 0))
1856 };
1857 let (prompt, completion, total) = (
1858 count("/prompt_tokens"),
1859 count("/completion_tokens"),
1860 count("/total_tokens"),
1861 );
1862 let audio = count("/prompt_tokens_details/audio_tokens").unwrap_or(0);
1863 let input = prompt.map(|prompt| {
1864 let beside = prompt.saturating_add(audio);
1865 let accounted = beside.saturating_add(completion.unwrap_or(0));
1866 if audio != 0 && Some(accounted) == total {
1867 beside
1868 } else {
1869 prompt
1870 }
1871 });
1872 crate::completion::Usage {
1873 input_tokens: input,
1874 output_tokens: completion
1875 .or_else(|| total.map(|total| total.saturating_sub(input.unwrap_or(0)))),
1876 total_tokens: total,
1877 cached_input_tokens: detail("prompt_tokens_details", "cached_tokens")
1878 .or(count("/num_cached_tokens"))
1879 .or(count("/prompt_cache_hit_tokens")),
1880 cache_creation_input_tokens: count("/prompt_tokens_details/cache_write_tokens"),
1881 reasoning_tokens: detail("completion_tokens_details", "reasoning_tokens")
1882 .filter(|_| quirks.reliable_reasoning_count),
1883 ..Default::default()
1884 }
1885}
1886
1887impl ChatDecoder {
1888 fn chunk_or_whole(frame: Value) -> ChatEvent {
1892 let whole = match frame.str("object") {
1893 Some(object) => object == "chat.completion",
1894 None => frame
1895 .arr("choices")
1896 .iter()
1897 .any(|choice| choice.obj("message").is_some()),
1898 };
1899 if whole {
1900 ChatEvent::Whole(frame)
1901 } else {
1902 ChatEvent::Chunk(frame)
1903 }
1904 }
1905
1906 pub(crate) fn project(payload: &[u8], sink: &mut ObservationSink<'_>) {
1910 let Ok(payload) = serde_json::from_slice::<Value>(payload) else {
1911 return;
1912 };
1913 let verdict = match payload
1916 .at("/choices/0/finish_reason")
1917 .and_then(Value::as_str)
1918 {
1919 Some(reason) => AdapterVerdict {
1920 finish_reason: Some(sink.scrub(reason)),
1921 block_reason: None,
1922 detail: None,
1923 model: payload.str("model").map(|value| sink.scrub(value)),
1924 },
1925 None => AdapterVerdict::default(),
1926 };
1927 let response_id = payload.str("id").map(|value| sink.scrub(value));
1928 sink.provider(verdict, response_id);
1929 if let Some(error) = ObservedError::of(&payload) {
1930 error.emit(sink);
1931 }
1932 }
1933}
1934
1935mod document;
1936pub(crate) mod finish;
1937mod reported;
1938
1939#[cfg(test)]
1940mod tests;
1941
1942#[cfg(test)]
1943mod hard_case_tests;
1944
1945#[cfg(test)]
1946mod history_tests;
1947
1948#[cfg(test)]
1949mod request_tests;