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