cratefield_adapter_openai_compatible/lib.rs
1//! `cratefield-adapter-openai-compatible`: the [`TextModel`] port over the
2//! `OpenAI` chat-completions wire (issue #560). One adapter speaks every
3//! server that answers that wire — `OpenAI` itself, Workers AI's
4//! `OpenAI`-compatible endpoint, `OpenRouter`, and local servers like `vLLM`,
5//! llama.cpp and Ollama — because the base URL is a constructor argument,
6//! not a constant.
7//!
8//! Uses the runtime's [`HttpClient`] port — no `reqwest`, no vendor SDK —
9//! so the same adapter runs on Workers and natively, and inherits that
10//! port's destination vetting, deadline and response-size caps.
11//!
12//! **Degraded mode.** When the API key is absent the adapter answers
13//! [`TextModelError::NotConfigured`] without any network call, so a
14//! pipeline stage can degrade instead of breaking. A keyless local server
15//! takes any placeholder key: the adapter always sends the
16//! `Authorization` header, and a server that never checks it ignores it.
17//!
18//! **Tools (issue #665).** The adapter carries `OpenAI`'s function-calling
19//! shape by default: a [`Prompt`]'s tools ride in the request's `tools`
20//! array, the assistant message's `tool_calls` parse into
21//! [`Completion::tool_calls`], and a conversation quoting a tool call and
22//! its result goes back out as an assistant message with `tool_calls` and
23//! one `role: "tool"` message per result. Every server behind this wire is
24//! not equally capable — a model that does not speak tools either ignores
25//! the array or rejects the request — so
26//! [`OpenAiCompatible::without_tools`] turns the capability off for one
27//! deployment: [`TextModel::supports`] then reports
28//! [`Capability::Tools`](cratefield_core::Capability) as `false`, and a
29//! tools-bearing prompt is refused with [`TextModelError::Unsupported`]
30//! before any request reaches the server.
31//!
32//! **Images (issue #628).** The wire carries images in the same `content`
33//! array text does, but not every server behind it has a vision model, so
34//! image input is **opt-in**, the mirror of tools' opt-out: a deployment
35//! calls [`OpenAiCompatible::with_images`] and [`TextModel::supports`] then
36//! reports [`Capability::Images`](cratefield_core::Capability) as `true`;
37//! off by default, an image-bearing prompt is refused with
38//! [`TextModelError::Unsupported`] before any request. Every call runs
39//! [`Prompt::check_images`] first — before the network and before the
40//! capability gate — so an over-limit prompt is refused locally rather than
41//! paid for, whatever the deployment is configured for.
42//!
43//! **Speech (issue #861).** The same wire carries audio:
44//! [`OpenAiCompatibleSpeech`] transcribes through the Whisper-shaped
45//! `audio/transcriptions` endpoint and voices text through `audio/speech`,
46//! at any base URL, under the same key and degraded-mode rules.
47//!
48//! **Streaming (issue #859).** [`TextModel::stream`] speaks the same wire
49//! natively: the request `complete` builds plus `stream: true` and
50//! `include_usage`, its Server-Sent Events decoded by core's shared
51//! [`SseDecoder`](cratefield_core::sse::SseDecoder) into
52//! [`TextDelta`](cratefield_core::TextDelta)s. Dropping the returned stream
53//! drops the response body and with it the upstream exchange, so a
54//! disconnecting caller stops the spend.
55
56#![doc = include_str!("../README.md")]
57#![forbid(unsafe_code)]
58
59use async_trait::async_trait;
60use bytes::Bytes;
61use cratefield_core::sse::{self, SseEvent};
62use cratefield_core::{
63 BoxStream, ByteStream, Capability, Clock, Completion, FinishReason, HttpClient, HttpError,
64 HttpPolicy, MAX_RESPONSE_BYTES, MAX_RESPONSE_TIMEOUT, ModelTier, Part, Prompt, Role, TextDelta,
65 TextModel, TextModelError, ToolCall, ToolChoice, ToolResult, Turn, completion_deltas,
66 encode_image, retry_after,
67};
68use futures_util::StreamExt;
69use http::header::{AUTHORIZATION, CONTENT_TYPE};
70use http::{Request, StatusCode};
71use serde_json::{Value, json};
72use std::collections::BTreeMap;
73use std::sync::Arc;
74use std::time::Duration;
75
76// The `Speech` port (issue #861): the audio wire alongside the chat wire
77// above, kept in its own module.
78mod speech;
79
80pub use speech::{DEFAULT_SPEECH_MODEL, DEFAULT_TRANSCRIPTION_MODEL, OpenAiCompatibleSpeech};
81
82/// The endpoint every request goes to unless
83/// [`OpenAiCompatible::with_base_url`] says otherwise: `OpenAI`'s own
84/// chat-completions path. Point it at `OpenRouter`
85/// (`https://openrouter.ai/api/v1`), a Workers AI `OpenAI`-compatible
86/// route, or a local server (`http://127.0.0.1:11434/v1` for Ollama;
87/// `vLLM` and llama.cpp serve a `/v1` of their own).
88pub const DEFAULT_ENDPOINT: &str = "https://api.openai.com/v1";
89
90/// The model [`OpenAiCompatible::from_env`] uses when `OPENAI_MODEL` is
91/// unset: a small chat model that accepts the `max_tokens` field this
92/// adapter sends. Overriding it has one catch on `OpenAI`'s own API: the
93/// GPT-5 and o-series models refuse `max_tokens` (they take
94/// `max_completion_tokens`, which the compatible-server ecosystem mostly
95/// does not speak), so a first-party key needs a model that still takes
96/// `max_tokens` — most non-`OpenAI` servers take both.
97pub const DEFAULT_MODEL: &str = "gpt-4o-mini";
98
99/// The name the JSON path gives the caller's schema inside
100/// `response_format` — a label the wire requires and the answer never
101/// carries; the schema itself is the caller's [`Prompt::json_schema`].
102const JSON_SCHEMA_NAME: &str = "response";
103
104/// The `OpenAI`-compatible `TextModel` over `POST {base_url}/chat/completions`.
105pub struct OpenAiCompatible {
106 http: Arc<dyn HttpClient>,
107 /// Needed only to read the HTTP-date form of `Retry-After` (issue #278).
108 /// A constructor argument rather than a builder default so a deployment
109 /// that forgets it fails to compile instead of silently retrying a
110 /// date-form 429 immediately.
111 clock: Arc<dyn Clock>,
112 api_key: Option<String>,
113 model: String,
114 base_url: String,
115 /// Whether this deployment carries tools. `true` by default — the wire
116 /// speaks function calling — and turned off with
117 /// [`OpenAiCompatible::without_tools`] for a server or model behind it
118 /// that does not.
119 tools: bool,
120 /// Whether this deployment carries image input (issue #628). `false` by
121 /// default — the wire speaks images but not every model behind it has
122 /// vision — and turned on with [`OpenAiCompatible::with_images`].
123 images: bool,
124}
125
126impl OpenAiCompatible {
127 /// `api_key: None` => every call is [`TextModelError::NotConfigured`]
128 /// (no network). A server that checks no key still gets the header:
129 /// pass a placeholder rather than wiring degraded mode by accident.
130 pub fn new(
131 http: Arc<dyn HttpClient>,
132 clock: Arc<dyn Clock>,
133 api_key: Option<String>,
134 model: impl Into<String>,
135 ) -> Self {
136 Self {
137 http,
138 clock,
139 api_key,
140 model: model.into(),
141 base_url: DEFAULT_ENDPOINT.to_owned(),
142 tools: true,
143 images: false,
144 }
145 }
146
147 /// Turns tools off for this deployment: the adapter then sends no
148 /// `tools` and reports [`Capability::Tools`] as unsupported, so a
149 /// tools-bearing prompt is refused with
150 /// [`TextModelError::Unsupported`] before any request is made. The
151 /// wire itself speaks function calling — [`OpenAiCompatible::new`]
152 /// keeps it on — so this is for a server or model behind the wire that
153 /// does not: one that would silently drop the array and answer as if no
154 /// tool was offered.
155 #[must_use]
156 pub fn without_tools(mut self) -> Self {
157 self.tools = false;
158 self
159 }
160
161 /// Turns image input on for this deployment (issue #628): the adapter
162 /// then reports [`Capability::Images`] and serialises a prompt's
163 /// [`Part::Image`]s into the wire's `content` array. Off by default —
164 /// the wire speaks images but not every model behind it has vision. The
165 /// mirror of [`OpenAiCompatible::without_tools`]: there the wire spoke
166 /// the feature and a deployment opted out; here a deployment opts in.
167 #[must_use]
168 pub fn with_images(mut self) -> Self {
169 self.images = true;
170 self
171 }
172
173 /// Points the adapter at a different chat-completions server —
174 /// `OpenRouter`, a Workers AI `OpenAI`-compatible route, or a local `vLLM`,
175 /// llama.cpp or Ollama server. A trailing slash on the base is
176 /// harmless; the wire path is appended.
177 #[must_use]
178 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
179 self.base_url = base_url.into();
180 self
181 }
182
183 /// Reads `OPENAI_API_KEY`, `OPENAI_MODEL` (default [`DEFAULT_MODEL`])
184 /// and `OPENAI_BASE_URL` (default [`DEFAULT_ENDPOINT`]) from the
185 /// process environment. On Workers the venture should read the secrets
186 /// from its `Env` and use [`OpenAiCompatible::new`] instead (`std::env`
187 /// has no Workers vars).
188 pub fn from_env(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
189 Self::new(
190 http,
191 clock,
192 std::env::var("OPENAI_API_KEY").ok(),
193 std::env::var("OPENAI_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_owned()),
194 )
195 .with_base_url(
196 std::env::var("OPENAI_BASE_URL").unwrap_or_else(|_| DEFAULT_ENDPOINT.to_owned()),
197 )
198 }
199
200 /// The chat-completions URL for the configured base: the base with one
201 /// trailing slash trimmed, then the wire path.
202 fn endpoint(&self) -> String {
203 format!("{}/chat/completions", self.base_url.trim_end_matches('/'))
204 }
205
206 /// Status → port error, the one place the provider's taxonomy meets
207 /// ours. The provider's error envelope
208 /// (`{"error":{"message":…,…}}`) is parsed once for its message; the
209 /// raw body is the fallback, so a proxy's HTML error page still leaves
210 /// the caller something to log.
211 fn map_status(status: StatusCode, body: &str, retry_after: Option<Duration>) -> TextModelError {
212 let detail = provider_message(body);
213 match status {
214 // "Later", in both shapes the wire says it: an explicit rate
215 // limit (its `Retry-After` honoured in both RFC 9110 forms,
216 // through core's parser) and a server on its side.
217 StatusCode::TOO_MANY_REQUESTS => TextModelError::Transient { retry_after },
218 status if status.is_server_error() => TextModelError::Transient { retry_after: None },
219 // The server refused the request itself: 400 a malformed or
220 // over-limit prompt, 422 content it will not process. Neither
221 // is worth a retry unchanged.
222 StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
223 TextModelError::Rejected(detail)
224 }
225 // A bad, revoked or unauthorised key is a configuration
226 // mistake, not a condition time fixes: retrying with the same
227 // key burns the rate budget and fails the same way.
228 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => TextModelError::Rejected(detail),
229 // Any other 4xx is still the request's fault (404: the model
230 // is not served here; 413: the prompt is too large).
231 status if status.is_client_error() => TextModelError::Rejected(detail),
232 // Redirects and anything unmodelled: the call did not complete.
233 _ => TextModelError::Transport(format!("unexpected status {status}: {detail}")),
234 }
235 }
236
237 /// The pre-flight refusals both halves of the port share — `complete`
238 /// and `stream` run the same gates in the same order, so a prompt
239 /// refused on one half is refused identically on the other: image
240 /// bounds first (they hold whatever this deployment is configured for),
241 /// then the key, then the capability gates. Returns the API key, so a
242 /// caller never reaches the wire without one.
243 fn preflight(&self, prompt: &Prompt) -> Result<&str, TextModelError> {
244 // An over-limit image prompt is refused first, before any request:
245 // the bounds hold whatever this deployment is configured for.
246 prompt.check_images()?;
247
248 let Some(api_key) = &self.api_key else {
249 tracing::info!(
250 provider = "openai-compatible",
251 outcome = "not_configured",
252 model = %self.model,
253 "text model outcome"
254 );
255 return Err(TextModelError::NotConfigured);
256 };
257
258 // A tools-bearing prompt to an adapter whose deployment turned
259 // tools off is refused here, before any request: sending the tools
260 // anyway and letting the model ignore them would answer as if none
261 // had been offered, hiding the mismatch. The router and
262 // `run_tool_loop` refuse this up front too; this is the adapter's
263 // own guard for a direct caller.
264 if !self.tools && !prompt.tools.is_empty() {
265 tracing::warn!(
266 provider = "openai-compatible",
267 outcome = "unsupported",
268 model = %self.model,
269 "text model outcome"
270 );
271 return Err(TextModelError::Unsupported(Capability::Tools));
272 }
273
274 // The same guard for images (issue #628): a deployment that did not
275 // opt into vision is refused an image-bearing prompt before any
276 // request.
277 if !self.images && prompt.has_images() {
278 tracing::warn!(
279 provider = "openai-compatible",
280 outcome = "unsupported",
281 model = %self.model,
282 "text model outcome"
283 );
284 return Err(TextModelError::Unsupported(Capability::Images));
285 }
286
287 Ok(api_key)
288 }
289
290 /// Serialises the wire body and frames the request: the shared headers,
291 /// and the port's widest [`HttpPolicy`] — as on the Anthropic adapter,
292 /// the port's 30 s ceiling for a model call's head, the port's body
293 /// cap, and `HttpPolicy::clamped` so a caller can tighten but never
294 /// raise.
295 fn frame(
296 &self,
297 api_key: &str,
298 payload: &ChatCompletionsRequest<'_>,
299 ) -> Result<Request<Bytes>, TextModelError> {
300 let body = serde_json::to_vec(&payload)
301 .map_err(|err| TextModelError::Transport(err.to_string()))?;
302 let mut request = Request::builder()
303 .method(http::Method::POST)
304 .uri(self.endpoint())
305 .header(CONTENT_TYPE, "application/json")
306 .header(AUTHORIZATION, format!("Bearer {api_key}"))
307 .body(Bytes::from(body))
308 .map_err(|err| TextModelError::Transport(err.to_string()))?;
309 request.extensions_mut().insert(HttpPolicy {
310 timeout: MAX_RESPONSE_TIMEOUT,
311 max_response_bytes: MAX_RESPONSE_BYTES,
312 });
313 Ok(request)
314 }
315}
316
317fn wire_role(role: Role) -> &'static str {
318 match role {
319 Role::User => "user",
320 Role::Assistant => "assistant",
321 }
322}
323
324#[derive(serde::Serialize)]
325struct ChatCompletionsRequest<'a> {
326 model: &'a str,
327 /// The field name the broadest set of compatible servers accepts.
328 /// `OpenAI`'s first-party API deprecates it in favour of
329 /// `max_completion_tokens`, and its GPT-5/o-series models refuse it
330 /// outright (see [`DEFAULT_MODEL`]); every compatible server this
331 /// adapter targets accepts `max_tokens`, and most speak nothing else.
332 max_tokens: u32,
333 messages: Vec<WireMessage>,
334 #[serde(skip_serializing_if = "Option::is_none")]
335 response_format: Option<WireResponseFormat<'a>>,
336 /// Absent when the prompt offered no tools, so a tool-free request
337 /// serialises exactly as it did before tools existed.
338 #[serde(skip_serializing_if = "Vec::is_empty")]
339 tools: Vec<WireTool<'a>>,
340 /// Absent when the prompt left the choice to the provider's default.
341 #[serde(skip_serializing_if = "Option::is_none")]
342 tool_choice: Option<Value>,
343 /// Set only by the streaming path ([`TextModel::stream`], issue #859):
344 /// absent on the buffered path, so a `complete` request body is
345 /// byte-for-byte what it always was.
346 #[serde(skip_serializing_if = "std::ops::Not::not")]
347 stream: bool,
348 /// Only ever sent beside `stream: true`: without `include_usage` the
349 /// chunk stream carries no trailing `usage` block, and the port's Usage
350 /// delta would have nothing to read.
351 #[serde(skip_serializing_if = "Option::is_none")]
352 stream_options: Option<WireStreamOptions>,
353}
354
355/// The streaming flags the delta path adds to the body `complete` builds
356/// (issue #859). `include_usage` is the point of the envelope: it asks for
357/// the final, choices-empty chunk that carries the token counts.
358#[derive(serde::Serialize)]
359struct WireStreamOptions {
360 include_usage: bool,
361}
362
363/// One message of the request. Owned rather than borrowed because a tool
364/// result may need to prefix its content (see [`wire_tool_result`]), so the
365/// content is not always a slice of the prompt.
366#[derive(serde::Serialize)]
367struct WireMessage {
368 role: &'static str,
369 /// `Some` for every plain message; `None` (an explicit `null`) only for
370 /// an assistant turn that carried no text and only tool calls — the
371 /// shape `OpenAI` documents for a call-only turn.
372 content: Option<WireContent>,
373 #[serde(skip_serializing_if = "Option::is_none")]
374 tool_calls: Option<Vec<WireToolCall>>,
375 /// Set on a `role: "tool"` message: which call it answers.
376 #[serde(skip_serializing_if = "Option::is_none")]
377 tool_call_id: Option<String>,
378}
379
380/// A message's content: a plain string for a text-only turn, an ordered
381/// array of parts once the turn carries any (issue #628). Untagged, so a
382/// text-only message serialises byte-for-byte as it did before images
383/// existed.
384#[derive(serde::Serialize)]
385#[serde(untagged)]
386enum WireContent {
387 /// A text-only message's content.
388 Text(String),
389 /// A message that carries [`Part`]s, in order.
390 Parts(Vec<WirePart>),
391}
392
393/// One part of a message's `content` array, in the `OpenAI` shape: a text
394/// object or an `image_url` object with a `data:` URL.
395#[derive(serde::Serialize)]
396#[serde(tag = "type")]
397enum WirePart {
398 #[serde(rename = "text")]
399 Text { text: String },
400 #[serde(rename = "image_url")]
401 ImageUrl { image_url: WireImageUrl },
402}
403
404/// The `image_url` envelope of an image part: a single `url`, carrying the
405/// image inline as a base64 `data:` URL.
406#[derive(serde::Serialize)]
407struct WireImageUrl {
408 url: String,
409}
410
411/// One tool offered to the model: the `{"type":"function","function":…}`
412/// envelope `OpenAI` defines for the request's `tools` array.
413#[derive(serde::Serialize)]
414struct WireTool<'a> {
415 #[serde(rename = "type")]
416 kind: &'static str,
417 function: WireToolFunction<'a>,
418}
419
420#[derive(serde::Serialize)]
421struct WireToolFunction<'a> {
422 name: &'a str,
423 description: &'a str,
424 parameters: &'a Value,
425}
426
427/// One tool call, in the shape the request and the answer share: the
428/// assistant message the caller sends back and the assistant message the
429/// server returns are the same object.
430#[derive(Default, serde::Serialize, serde::Deserialize)]
431struct WireToolCall {
432 #[serde(default)]
433 id: String,
434 #[serde(rename = "type", default)]
435 kind: Option<String>,
436 #[serde(default)]
437 function: Option<WireFunctionCall>,
438}
439
440#[derive(Default, serde::Serialize, serde::Deserialize)]
441struct WireFunctionCall {
442 #[serde(default)]
443 name: String,
444 /// A JSON **string** on the wire — the model's arguments serialised —
445 /// not an object. It is re-serialised from [`ToolCall::arguments`] on
446 /// the way out — except a [`Value::String`], which is already the raw
447 /// text and is emitted verbatim — and parsed back on the way in.
448 #[serde(default)]
449 arguments: String,
450}
451
452#[derive(serde::Serialize)]
453struct WireResponseFormat<'a> {
454 #[serde(rename = "type")]
455 kind: &'static str,
456 json_schema: WireJsonSchema<'a>,
457}
458
459#[derive(serde::Serialize)]
460struct WireJsonSchema<'a> {
461 name: &'static str,
462 schema: &'a serde_json::Value,
463 strict: bool,
464}
465
466/// The wire's `tool_choice` for a port [`ToolChoice`]: a bare string for the
467/// three prose choices, an object pinning one function for
468/// [`ToolChoice::Tool`]. `None` for a variant this adapter does not know —
469/// [`ToolChoice`] is `#[non_exhaustive]`, and a choice it cannot express is
470/// left to the provider's default rather than sent as something wrong.
471fn wire_tool_choice(choice: &ToolChoice) -> Option<Value> {
472 match choice {
473 ToolChoice::Auto => Some(json!("auto")),
474 ToolChoice::None => Some(json!("none")),
475 ToolChoice::Required => Some(json!("required")),
476 ToolChoice::Tool(name) => Some(json!({
477 "type": "function",
478 "function": {"name": name},
479 })),
480 _ => None,
481 }
482}
483
484/// One tool call on the way out: the arguments object the port holds becomes
485/// the JSON *string* the wire carries. `Value`'s own `Display` is exactly
486/// that compact JSON and cannot fail.
487///
488/// A `Value::String` is the exception: it is arguments this adapter already
489/// failed to parse out of a previous answer and kept verbatim (see
490/// [`parse_tool_call`]). Re-serialising it would double-encode the raw text
491/// into a quoted JSON string — replaying `{"location":` as `"{\"location\":"`
492/// — so it is emitted exactly as it arrived.
493fn wire_tool_call(call: &ToolCall) -> WireToolCall {
494 WireToolCall {
495 id: call.id.clone(),
496 kind: Some("function".to_owned()),
497 function: Some(WireFunctionCall {
498 name: call.name.clone(),
499 arguments: match &call.arguments {
500 Value::String(raw) => raw.clone(),
501 other => other.to_string(),
502 },
503 }),
504 }
505}
506
507/// The content of a `role: "tool"` message. The wire has no `is_error` field
508/// on a tool message, so a failed result's text is prefixed with `Error: ` —
509/// the model reads the failure in the text, which is all the wire offers.
510fn wire_tool_result(result: &ToolResult) -> String {
511 if result.is_error {
512 format!("Error: {}", result.content)
513 } else {
514 result.content.clone()
515 }
516}
517
518/// One part on the wire. An image becomes an `image_url` part whose URL is
519/// the inline `data:` form — media type and base64 bytes.
520///
521/// # Errors
522///
523/// [`TextModelError::Rejected`] for a [`Part`] variant this adapter does
524/// not know: `Part` is `#[non_exhaustive]`, so a future file reference or
525/// provider-hosted URL is refused rather than silently dropped — the same
526/// rule the Anthropic adapter's `wire_turn` applies. (Unreachable today:
527/// [`Part::Text`] and [`Part::Image`] are the only variants, and the
528/// capability gate admits no others.)
529fn wire_part(part: &Part) -> Result<WirePart, TextModelError> {
530 match part {
531 Part::Text(text) => Ok(WirePart::Text { text: text.clone() }),
532 Part::Image { media_type, bytes } => Ok(WirePart::ImageUrl {
533 image_url: WireImageUrl {
534 url: format!(
535 "data:{};base64,{}",
536 media_type.as_str(),
537 encode_image(bytes)
538 ),
539 },
540 }),
541 _ => Err(TextModelError::Rejected(
542 "the prompt carries a content part this adapter does not know how to send".to_owned(),
543 )),
544 }
545}
546
547/// A turn's content for the wire: its ordered [`Part`]s as an array when it
548/// carries any (issue #628), otherwise its text as a plain string, exactly
549/// as it serialised before images existed.
550///
551/// # Errors
552///
553/// Propagates [`wire_part`]'s refusal of a [`Part`] that cannot be
554/// serialised, so no part is ever silently dropped.
555fn wire_content(turn: &Turn) -> Result<WireContent, TextModelError> {
556 if turn.parts.is_empty() {
557 Ok(WireContent::Text(turn.content.clone()))
558 } else {
559 Ok(WireContent::Parts(
560 turn.parts
561 .iter()
562 .map(wire_part)
563 .collect::<Result<Vec<_>, _>>()?,
564 ))
565 }
566}
567
568/// Appends the wire message(s) one [`Turn`] becomes: a plain message, an
569/// assistant message that quotes its `tool_calls`, or one `role: "tool"`
570/// message per result — followed by a user message when a tool-results turn
571/// also carries text of its own.
572///
573/// # Errors
574///
575/// Propagates [`wire_content`]'s refusal of a [`Part`] this adapter cannot
576/// serialise.
577fn push_turn(messages: &mut Vec<WireMessage>, turn: &Turn) -> Result<(), TextModelError> {
578 match turn.role {
579 Role::Assistant if !turn.tool_calls.is_empty() => {
580 messages.push(WireMessage {
581 role: "assistant",
582 // A call-only turn has no text; the wire wants `null` for it,
583 // not an empty string.
584 content: (!turn.content.is_empty() || !turn.parts.is_empty())
585 .then(|| wire_content(turn))
586 .transpose()?,
587 tool_calls: Some(turn.tool_calls.iter().map(wire_tool_call).collect()),
588 tool_call_id: None,
589 });
590 }
591 Role::User if !turn.tool_results.is_empty() => {
592 for result in &turn.tool_results {
593 messages.push(WireMessage {
594 role: "tool",
595 content: Some(WireContent::Text(wire_tool_result(result))),
596 tool_calls: None,
597 tool_call_id: Some(result.tool_call_id.clone()),
598 });
599 }
600 if !turn.content.is_empty() {
601 messages.push(WireMessage {
602 role: "user",
603 content: Some(WireContent::Text(turn.content.clone())),
604 tool_calls: None,
605 tool_call_id: None,
606 });
607 }
608 }
609 _ => messages.push(WireMessage {
610 role: wire_role(turn.role),
611 content: Some(wire_content(turn)?),
612 tool_calls: None,
613 tool_call_id: None,
614 }),
615 }
616 Ok(())
617}
618
619/// Builds the wire body: the messages plus, when the prompt asked for them,
620/// a `response_format` carrying the caller's schema, the `tools` array and
621/// the `tool_choice`. Hand-rolling this JSON would be a second escaping bug
622/// waiting to happen; the prompt is user content.
623///
624/// # Errors
625///
626/// Propagates [`push_turn`]'s refusal of a [`Part`] this adapter cannot
627/// serialise.
628fn wire_request<'a>(
629 model: &'a str,
630 prompt: &'a Prompt,
631) -> Result<ChatCompletionsRequest<'a>, TextModelError> {
632 let response_format = prompt
633 .json_schema
634 .as_ref()
635 .map(|schema| WireResponseFormat {
636 kind: "json_schema",
637 json_schema: WireJsonSchema {
638 name: JSON_SCHEMA_NAME,
639 schema,
640 // `strict: false`: the port allows any draft 2020-12
641 // schema, and `OpenAI`'s strict mode answers 400 to an
642 // ordinary one (it demands `additionalProperties: false`
643 // and every property required). What the adapter enforces
644 // is the parse below; a caller who wants strict-mode
645 // guarantees supplies a strict-compatible schema and keeps
646 // their own check on the parsed answer.
647 strict: false,
648 },
649 });
650 // The system prompt is a leading message on this wire, not a
651 // parameter of its own.
652 let mut messages = Vec::with_capacity(prompt.messages.len() + 1);
653 if let Some(system) = prompt.system.as_deref() {
654 messages.push(WireMessage {
655 role: "system",
656 content: Some(WireContent::Text(system.to_owned())),
657 tool_calls: None,
658 tool_call_id: None,
659 });
660 }
661 for turn in &prompt.messages {
662 push_turn(&mut messages, turn)?;
663 }
664 let tools = prompt
665 .tools
666 .iter()
667 .map(|tool| WireTool {
668 kind: "function",
669 function: WireToolFunction {
670 name: &tool.name,
671 description: &tool.description,
672 parameters: &tool.parameters,
673 },
674 })
675 .collect();
676 Ok(ChatCompletionsRequest {
677 model,
678 max_tokens: prompt.max_tokens,
679 messages,
680 response_format,
681 tools,
682 tool_choice: prompt.tool_choice.as_ref().and_then(wire_tool_choice),
683 // The buffered defaults: the streaming override turns both on
684 // without rebuilding the rest of the body.
685 stream: false,
686 stream_options: None,
687 })
688}
689
690#[derive(serde::Deserialize)]
691struct ChatCompletionsResponse {
692 #[serde(default)]
693 model: String,
694 #[serde(default)]
695 choices: Vec<WireChoice>,
696 #[serde(default)]
697 usage: Option<WireUsage>,
698}
699
700#[derive(serde::Deserialize)]
701struct WireChoice {
702 #[serde(default)]
703 message: Option<WireAnswer>,
704 #[serde(default)]
705 finish_reason: Option<String>,
706}
707
708/// The content is `Option` rather than defaulted: servers send an explicit
709/// `null` when a refusal or a tool call replaces the text, and that must
710/// not refuse the whole response. The refusal rides in the same message
711/// — `refusal` set, `content` `null` — so it is read before any
712/// content-shaped decision.
713#[derive(serde::Deserialize)]
714struct WireAnswer {
715 #[serde(default)]
716 content: Option<String>,
717 #[serde(default)]
718 refusal: Option<String>,
719 /// The calls the model asked for. Empty for a plain answer, and for a
720 /// server that does not speak function calling.
721 #[serde(default)]
722 tool_calls: Vec<WireToolCall>,
723}
724
725#[derive(serde::Deserialize, Default)]
726struct WireUsage {
727 #[serde(default)]
728 prompt_tokens: u32,
729 #[serde(default)]
730 completion_tokens: u32,
731 /// The vendor's cache report: present when the vendor does caching at
732 /// all, absent otherwise — which is exactly the
733 /// [`Completion::cached_input_tokens`] distinction between a reported
734 /// zero and no report.
735 #[serde(default)]
736 prompt_tokens_details: Option<PromptTokensDetails>,
737}
738
739#[derive(serde::Deserialize)]
740struct PromptTokensDetails {
741 #[serde(default)]
742 cached_tokens: u32,
743}
744
745#[derive(serde::Deserialize)]
746struct ErrorEnvelope {
747 #[serde(default)]
748 error: Option<ApiErrorBody>,
749}
750
751#[derive(serde::Deserialize)]
752struct ApiErrorBody {
753 #[serde(default)]
754 message: String,
755}
756
757/// The provider's own words, from its error envelope, with the raw body as
758/// the fallback when the envelope does not parse.
759fn provider_message(body: &str) -> String {
760 match serde_json::from_str::<ErrorEnvelope>(body) {
761 Ok(parsed) => match parsed.error {
762 Some(error) if !error.message.is_empty() => error.message,
763 _ => body.to_string(),
764 },
765 Err(_) => body.to_string(),
766 }
767}
768
769/// A refusal inside an otherwise-successful response, or the server's
770/// content filter: both rejections that arrive after a 200, checked
771/// before anything reads the answer content. The provider's refusal text
772/// goes to the log, where the operator reads it the same way they read a
773/// provider error message; the error itself carries the adapter's own
774/// words, the convention of every other `Rejected` path.
775fn refusal_guard(choice: &WireChoice, model: &str) -> Option<TextModelError> {
776 if let Some(refusal) = choice
777 .message
778 .as_ref()
779 .and_then(|message| message.refusal.as_deref())
780 .filter(|refusal| !refusal.is_empty())
781 {
782 tracing::warn!(
783 provider = "openai-compatible",
784 outcome = "refused",
785 model = %model,
786 refusal = refusal,
787 "text model outcome"
788 );
789 return Some(TextModelError::Rejected(
790 "the model declined the prompt (refusal); the provider's words are in the log"
791 .to_owned(),
792 ));
793 }
794 // The content filter stopped the answer before it was one, on either
795 // path — through [`content_filter_rejected`], the very error the
796 // streamed half ends on for the same `finish_reason`.
797 if choice.finish_reason.as_deref() == Some("content_filter") {
798 return Some(content_filter_rejected(model));
799 }
800 None
801}
802
803/// The content filter's rejection, word for word the same on both halves
804/// of the port: the buffered [`refusal_guard`] answers its
805/// `finish_reason: "content_filter"` with this, and the streaming fold
806/// ends on it — so `run_tool_loop` and `run_tool_loop_stream` cannot
807/// disagree about an answer the filter stopped. `Rejected`, not
808/// `Transient`: the prompt is what tripped it, so retrying unchanged
809/// fails the same way. The provider's report goes to the log, where the
810/// operator reads it the same way they read a provider error message; the
811/// error itself carries the adapter's own words, the convention of every
812/// other `Rejected` path.
813fn content_filter_rejected(model: &str) -> TextModelError {
814 tracing::warn!(
815 provider = "openai-compatible",
816 outcome = "content_filter",
817 model = %model,
818 "text model outcome"
819 );
820 TextModelError::Rejected("the server's content filter stopped the answer".to_owned())
821}
822
823/// The completion's usage, or the defaulted zero when the server sent
824/// none — warned about, because a missing report must not silently read
825/// as a free completion in the cost accounting.
826fn reported_usage(usage: Option<WireUsage>, model: &str) -> WireUsage {
827 usage.unwrap_or_else(|| {
828 tracing::warn!(
829 provider = "openai-compatible",
830 model = %model,
831 "text model completed without a usage block; token counts default to zero"
832 );
833 WireUsage::default()
834 })
835}
836
837/// The model's tool calls, parsed from the wire. Only function calls exist
838/// on this wire; an entry naming another `type` is skipped rather than
839/// misread as one.
840fn parse_tool_calls(message: Option<&WireAnswer>) -> Vec<ToolCall> {
841 let Some(calls) = message.map(|message| message.tool_calls.as_slice()) else {
842 return Vec::new();
843 };
844 calls.iter().filter_map(parse_tool_call).collect()
845}
846
847fn parse_tool_call(call: &WireToolCall) -> Option<ToolCall> {
848 match call.kind.as_deref() {
849 None | Some("function") => {}
850 Some(_) => return None,
851 }
852 let function = call.function.as_ref()?;
853 // The arguments arrive as a JSON *string*. One that parses becomes the
854 // object the port holds; one that does not is kept as that raw string,
855 // so `run_tool_loop` refuses the call — tool arguments must be a JSON
856 // object — and feeds the error back to the model. A truncated or
857 // malformed call never reaches an executor, and never becomes a parse
858 // failure of the whole response.
859 let arguments = serde_json::from_str(&function.arguments)
860 .unwrap_or_else(|_| Value::String(function.arguments.clone()));
861 Some(ToolCall::new(
862 call.id.clone(),
863 function.name.clone(),
864 arguments,
865 ))
866}
867
868/// The `data:` payload that ends a chat-completions chunk stream. Some
869/// compatible servers omit it after a `finish_reason` + usage; a stream
870/// that ends with neither sentinel nor reason never finished.
871const DONE_SENTINEL: &str = "[DONE]";
872
873/// One chat-completion **chunk** of a streamed answer (issue #859): the
874/// same envelope as [`ChatCompletionsResponse`] with a `delta` where the
875/// full message was, and — when `include_usage` was asked for — a final,
876/// choices-empty chunk carrying only the `usage`.
877#[derive(serde::Deserialize)]
878struct ChatChunk {
879 #[serde(default)]
880 model: String,
881 #[serde(default)]
882 choices: Vec<WireChunkChoice>,
883 #[serde(default)]
884 usage: Option<WireUsage>,
885}
886
887#[derive(serde::Deserialize)]
888struct WireChunkChoice {
889 #[serde(default)]
890 delta: Option<WireDelta>,
891 #[serde(default)]
892 finish_reason: Option<String>,
893}
894
895/// A chunk's delta: whichever pieces of the answer arrived with this chunk.
896/// Both reasoning spellings the compatible-server ecosystem has shipped are
897/// read (`reasoning_content` first, `reasoning` beside it); a server that
898/// exposes neither simply never trips the arm.
899#[derive(serde::Deserialize)]
900struct WireDelta {
901 #[serde(default)]
902 content: Option<String>,
903 #[serde(default)]
904 reasoning_content: Option<String>,
905 #[serde(default)]
906 reasoning: Option<String>,
907 #[serde(default)]
908 refusal: Option<String>,
909 #[serde(default)]
910 tool_calls: Vec<WireToolCallChunk>,
911}
912
913/// One fragment of one tool call inside a chunk: the call's `index` in the
914/// completion's tool-call order, and whichever parts travelled with this
915/// fragment — id and name on the first, argument text on every one after.
916/// `index` is defaulted as well as optional: a server that streams one call
917/// at a time is allowed to omit it (the one call is index 0), while a
918/// parallel-call server must send it to keep the calls apart.
919#[derive(serde::Deserialize)]
920struct WireToolCallChunk {
921 #[serde(default)]
922 index: usize,
923 #[serde(default)]
924 id: Option<String>,
925 #[serde(rename = "type", default)]
926 kind: Option<String>,
927 #[serde(default)]
928 function: Option<WireFunctionFragment>,
929}
930
931#[derive(serde::Deserialize)]
932struct WireFunctionFragment {
933 #[serde(default)]
934 name: Option<String>,
935 #[serde(default)]
936 arguments: Option<String>,
937}
938
939/// The wire's `finish_reason` → the port's [`FinishReason`]: the four words
940/// of the vocabulary map one-to-one (`function_call`, the legacy spelling,
941/// lands on [`FinishReason::ToolUse`] beside `tool_calls`), and anything a
942/// server invents rides in [`FinishReason::Other`] for the log line.
943fn finish_reason(reason: &str) -> FinishReason {
944 match reason {
945 "stop" => FinishReason::Stop,
946 "length" => FinishReason::Length,
947 "tool_calls" | "function_call" => FinishReason::ToolUse,
948 "content_filter" => FinishReason::ContentFilter,
949 other => FinishReason::Other(other.to_owned()),
950 }
951}
952
953/// One tool call being reassembled across chunks: the wire shape
954/// [`parse_tool_call`] consumes — so a finished call is parsed by exactly
955/// the code path `complete` parses its answer with, never a second parser —
956/// plus whether the call's `ToolCallStarted` delta has gone out.
957#[derive(Default)]
958struct CallAcc {
959 wire: WireToolCall,
960 started: bool,
961}
962
963/// The streaming fold (issue #859): the SSE-decoded body plus everything
964/// the chunk sequence accumulates into — the calls keyed by wire `index`,
965/// the `finish_reason` the Finish delta waits for, the model, whether a
966/// `usage` chunk arrived — yielding [`TextDelta`]s as events dispatch. The
967/// fold owns the body, so dropping the delta stream drops the upstream
968/// exchange; nothing is spawned and nothing outlives the stream.
969struct Fold {
970 events: BoxStream<'static, Result<SseEvent, HttpError>>,
971 calls: BTreeMap<usize, CallAcc>,
972 reason: Option<FinishReason>,
973 model: String,
974 usage_seen: bool,
975 /// Deltas a chunk folded into that have not been yielded yet: one event
976 /// can produce several (a call's start and its first argument fragment
977 /// share a chunk), and the stream yields one item per poll.
978 pending: std::vec::IntoIter<TextDelta>,
979 /// Fused once the answer is over — `[DONE]`, the body's end or an
980 /// error. Nothing is pulled from `events` again.
981 done: bool,
982}
983
984impl Fold {
985 /// A fold over `events`, reporting the configured `model` unless a
986 /// chunk's own `model` names another.
987 fn new(events: BoxStream<'static, Result<SseEvent, HttpError>>, model: String) -> Self {
988 Self {
989 events,
990 calls: BTreeMap::new(),
991 reason: None,
992 model,
993 usage_seen: false,
994 pending: Vec::new().into_iter(),
995 done: false,
996 }
997 }
998
999 /// Folds one dispatched `data:` payload in.
1000 ///
1001 /// # Errors
1002 ///
1003 /// [`TextModelError::Transport`] for an in-stream error payload
1004 /// (`{"error": …}` — the head was 2xx, so the request was accepted but
1005 /// the answer died mid-generation: the same bucket as a connection cut
1006 /// off mid-body, the provider's own words in the detail) and for a
1007 /// payload that is neither JSON nor the sentinel.
1008 fn push(&mut self, data: &str) -> Result<Vec<TextDelta>, TextModelError> {
1009 if data.trim() == DONE_SENTINEL {
1010 return self.finish(true);
1011 }
1012 if let Ok(envelope) = serde_json::from_str::<ErrorEnvelope>(data)
1013 && envelope.error.is_some()
1014 {
1015 let error = TextModelError::Transport(format!(
1016 "the stream carried an error: {}",
1017 provider_message(data)
1018 ));
1019 tracing::warn!(
1020 provider = "openai-compatible",
1021 outcome = "failed",
1022 model = %self.model,
1023 error = %error,
1024 "text model outcome"
1025 );
1026 return Err(error);
1027 }
1028 let chunk: ChatChunk = serde_json::from_str(data).map_err(|err| {
1029 TextModelError::Transport(format!("stream chunk did not parse: {err}"))
1030 })?;
1031 if !chunk.model.is_empty() {
1032 self.model = chunk.model;
1033 }
1034 let mut deltas = Vec::new();
1035 // One choice per stream — the port sends no `n` — so each chunk is
1036 // the same choice's next piece. The choices-empty chunk is the
1037 // usage trailer `include_usage` asked for.
1038 if let Some(choice) = chunk.choices.first() {
1039 self.push_choice(choice, &mut deltas)?;
1040 }
1041 if let Some(usage) = chunk.usage {
1042 self.usage_seen = true;
1043 deltas.push(TextDelta::Usage {
1044 input_tokens: u64::from(usage.prompt_tokens),
1045 output_tokens: u64::from(usage.completion_tokens),
1046 // The same cache report `complete` reads, keeping "no
1047 // report" and "reported zero" distinct.
1048 cached_input_tokens: usage
1049 .prompt_tokens_details
1050 .map(|details| u64::from(details.cached_tokens)),
1051 });
1052 }
1053 Ok(deltas)
1054 }
1055
1056 /// Folds one chunk's choice into `deltas`: refusal first (a declined
1057 /// prompt never reaches the caller as text), then text, reasoning,
1058 /// tool-call fragments, and the `finish_reason` the Finish delta waits
1059 /// for.
1060 ///
1061 /// # Errors
1062 ///
1063 /// [`TextModelError::Rejected`] for a refusal, as `complete`'s
1064 /// `refusal_guard` answers the buffered one.
1065 fn push_choice(
1066 &mut self,
1067 choice: &WireChunkChoice,
1068 deltas: &mut Vec<TextDelta>,
1069 ) -> Result<(), TextModelError> {
1070 if let Some(delta) = choice.delta.as_ref() {
1071 if let Some(refusal) = delta.refusal.as_deref().filter(|r| !r.is_empty()) {
1072 tracing::warn!(
1073 provider = "openai-compatible",
1074 outcome = "refused",
1075 model = %self.model,
1076 refusal = refusal,
1077 "text model outcome"
1078 );
1079 return Err(TextModelError::Rejected(
1080 "the model declined the prompt (refusal); the provider's words are in the log"
1081 .to_owned(),
1082 ));
1083 }
1084 if let Some(text) = delta.content.as_deref().filter(|text| !text.is_empty()) {
1085 deltas.push(TextDelta::Text(text.to_owned()));
1086 }
1087 let reasoning = delta
1088 .reasoning_content
1089 .as_deref()
1090 .filter(|reasoning| !reasoning.is_empty())
1091 .or_else(|| {
1092 delta
1093 .reasoning
1094 .as_deref()
1095 .filter(|reasoning| !reasoning.is_empty())
1096 });
1097 if let Some(reasoning) = reasoning {
1098 deltas.push(TextDelta::Reasoning(reasoning.to_owned()));
1099 }
1100 for fragment in &delta.tool_calls {
1101 self.push_tool_call(deltas, fragment);
1102 }
1103 }
1104 if let Some(reason) = choice.finish_reason.as_deref() {
1105 self.reason = Some(finish_reason(reason));
1106 }
1107 Ok(())
1108 }
1109
1110 /// Folds one tool-call fragment in. The first fragment carrying an id
1111 /// or a name opens the call with a [`TextDelta::ToolCallStarted`]; every
1112 /// argument text grows it with [`TextDelta::ToolCallArguments`]; the
1113 /// pieces accumulate in the wire shape [`parse_tool_call`] consumes, so
1114 /// the finished call is the call `complete` would return. A server that
1115 /// splits id and name across fragments gets its `Started` with whatever
1116 /// had arrived — the `Finished` call always carries both.
1117 fn push_tool_call(&mut self, deltas: &mut Vec<TextDelta>, fragment: &WireToolCallChunk) {
1118 // Only function calls exist on this wire; an entry naming another
1119 // `type` is skipped rather than misread as one, as in `complete`.
1120 match fragment.kind.as_deref() {
1121 None | Some("function") => {}
1122 Some(_) => return,
1123 }
1124 let acc = self.calls.entry(fragment.index).or_default();
1125 let mut named = false;
1126 if let Some(id) = fragment.id.as_deref() {
1127 id.clone_into(&mut acc.wire.id);
1128 named = true;
1129 }
1130 if let Some(kind) = fragment.kind.as_deref() {
1131 acc.wire.kind = Some(kind.to_owned());
1132 }
1133 if let Some(function) = fragment.function.as_ref() {
1134 let wire = acc.wire.function.get_or_insert_with(Default::default);
1135 if let Some(name) = function.name.as_deref() {
1136 name.clone_into(&mut wire.name);
1137 named = true;
1138 }
1139 if let Some(chunk) = function.arguments.as_deref().filter(|c| !c.is_empty()) {
1140 wire.arguments.push_str(chunk);
1141 deltas.push(TextDelta::ToolCallArguments {
1142 index: fragment.index,
1143 chunk: chunk.to_owned(),
1144 });
1145 }
1146 }
1147 if !acc.started && named {
1148 acc.started = true;
1149 deltas.push(TextDelta::ToolCallStarted {
1150 index: fragment.index,
1151 id: acc.wire.id.clone(),
1152 name: acc
1153 .wire
1154 .function
1155 .as_ref()
1156 .map(|function| function.name.clone())
1157 .unwrap_or_default(),
1158 });
1159 }
1160 }
1161
1162 /// The finish deltas, however the stream ended: each reassembled call
1163 /// in index order — parsed by the shared [`parse_tool_call`] — then the
1164 /// one `Finish`. `sentinel` says the `[DONE]` payload arrived and
1165 /// stands in for a `finish_reason` a server never sent; a stream with
1166 /// neither never finished at all.
1167 ///
1168 /// # Errors
1169 ///
1170 /// [`TextModelError::Transport`] when neither an ending nor a reason
1171 /// arrived, and the buffered path's [`TextModelError::Rejected`] where
1172 /// `complete` refuses, so a stream may not smuggle through what the
1173 /// buffered answer refuses: a `finish_reason` of `length` beside tool
1174 /// calls (arguments cut off mid-string must never reach an executor),
1175 /// and `content_filter` — both through the very errors
1176 /// `completion_from_response` raises, so `run_tool_loop` and
1177 /// `run_tool_loop_stream` answer an answer the server stopped
1178 /// identically.
1179 fn finish(&mut self, sentinel: bool) -> Result<Vec<TextDelta>, TextModelError> {
1180 self.done = true;
1181 if self.reason.is_none() && !sentinel {
1182 return Err(TextModelError::Transport(
1183 "the stream ended without a finish_reason or [DONE] sentinel".to_owned(),
1184 ));
1185 }
1186 // Parse first, decide second: the truncation rule reads on the
1187 // calls the parser would return, exactly as `complete`'s does.
1188 let calls: Vec<(usize, ToolCall)> = self
1189 .calls
1190 .iter()
1191 .filter_map(|(index, acc)| parse_tool_call(&acc.wire).map(|call| (*index, call)))
1192 .collect();
1193 if !calls.is_empty() && self.reason.as_ref() == Some(&FinishReason::Length) {
1194 return Err(TextModelError::Rejected(
1195 "the response was truncated at max_tokens before the tool call arguments were \
1196 complete; a larger max_tokens is needed"
1197 .to_owned(),
1198 ));
1199 }
1200 // The content filter stopped the answer: the same `Rejected`
1201 // `complete`'s `refusal_guard` returns for the same `finish_reason`,
1202 // not a successful `Finish{ContentFilter}` a caller could assemble
1203 // into the completion the buffered path refuses.
1204 if self.reason.as_ref() == Some(&FinishReason::ContentFilter) {
1205 return Err(content_filter_rejected(&self.model));
1206 }
1207 if !self.usage_seen {
1208 // Said out loud, not silent — the same warning the buffered
1209 // path gives a server that omits the usage block.
1210 tracing::warn!(
1211 provider = "openai-compatible",
1212 model = %self.model,
1213 "text model completed without a usage block; token counts default to zero"
1214 );
1215 }
1216 let tool_calls = calls.len();
1217 let mut deltas: Vec<TextDelta> = calls
1218 .into_iter()
1219 .map(|(index, call)| TextDelta::ToolCallFinished { index, call })
1220 .collect();
1221 deltas.push(TextDelta::Finish {
1222 reason: self.reason.clone().unwrap_or(FinishReason::Stop),
1223 model: self.model.clone(),
1224 });
1225 tracing::info!(
1226 provider = "openai-compatible",
1227 outcome = "completed",
1228 model = %self.model,
1229 tool_calls,
1230 "text model outcome"
1231 );
1232 Ok(deltas)
1233 }
1234}
1235
1236/// The unfold state of a native stream: sending the head, folding the body,
1237/// or fused after an error.
1238enum StreamPhase {
1239 Head,
1240 Body(Fold),
1241 Done,
1242}
1243
1244/// Drains a non-2xx body for the error mapping, stopping at `cap` — the
1245/// buffered path's own ceiling, read off the request before it was sent. An
1246/// error page's worth of bytes is all [`OpenAiCompatible::map_status`] can
1247/// read; more arrives as a stream and must not outrun the bound.
1248async fn collect_bounded(mut body: ByteStream, cap: usize) -> String {
1249 let mut bytes = Vec::new();
1250 while bytes.len() < cap {
1251 match body.next().await {
1252 Some(Ok(chunk)) => bytes.extend_from_slice(&chunk),
1253 // The body's own error ends the drain: what arrived so far is
1254 // still something to log and map.
1255 Some(Err(_)) | None => break,
1256 }
1257 }
1258 String::from_utf8_lossy(&bytes).to_string()
1259}
1260
1261#[async_trait]
1262impl TextModel for OpenAiCompatible {
1263 async fn complete(&self, prompt: &Prompt) -> Result<Completion, TextModelError> {
1264 // Outcome logging in the Anthropic adapter's shape: provider,
1265 // status, outcome, model, token counts. The API key is never
1266 // logged, and neither is the prompt nor the completion — a prompt
1267 // is user content, and the operator needs none of it to see what
1268 // happened. The pre-flight refusals live in `preflight`, shared
1269 // with the streaming half of the port.
1270 let api_key = self.preflight(prompt)?;
1271
1272 let payload = wire_request(&self.model, prompt)?;
1273 let request = self.frame(api_key, &payload)?;
1274
1275 let response = self
1276 .http
1277 .send(request)
1278 .await
1279 .map_err(|err: HttpError| TextModelError::Transport(err.to_string()))?;
1280
1281 let status = response.status();
1282 // One parser for both `Retry-After` forms (issue #214/#278); the
1283 // date form needs the clock this adapter is constructed with.
1284 let retry_after = retry_after(response.headers(), self.clock.as_ref());
1285 let text = String::from_utf8_lossy(response.body()).to_string();
1286
1287 if !status.is_success() {
1288 let error = Self::map_status(status, &text, retry_after);
1289 tracing::warn!(
1290 provider = "openai-compatible",
1291 code = status.as_u16(),
1292 outcome = "failed",
1293 model = %self.model,
1294 error = %error,
1295 "text model outcome"
1296 );
1297 return Err(error);
1298 }
1299
1300 // A 2xx body that does not parse is not a "rejected": nothing about
1301 // the prompt was refused, the answer just never arrived in usable
1302 // form — the same bucket as a cut-off connection.
1303 let parsed: ChatCompletionsResponse = serde_json::from_str(&text).map_err(|err| {
1304 TextModelError::Transport(format!("model response did not parse: {err}"))
1305 })?;
1306 let completion = self.completion_from_response(parsed, prompt)?;
1307
1308 tracing::info!(
1309 provider = "openai-compatible",
1310 code = status.as_u16(),
1311 outcome = "completed",
1312 model = %completion.model,
1313 input_tokens = completion.input_tokens,
1314 output_tokens = completion.output_tokens,
1315 tool_calls = completion.tool_calls.len(),
1316 "text model outcome"
1317 );
1318 Ok(completion)
1319 }
1320
1321 /// Streams `prompt` over the same wire with `stream: true` and
1322 /// `include_usage` (issue #859): the request `complete` builds plus the
1323 /// two streaming fields, the head under the same [`HttpPolicy`] and the
1324 /// body under the port's default
1325 /// [`StreamPolicy`](cratefield_core::StreamPolicy) — the adapter
1326 /// tightens nothing, and a runtime wrapping the client clamps as
1327 /// always. A prompt asking for structured output does not stream
1328 /// natively; it takes the buffered fallback (`stream_buffered`).
1329 ///
1330 /// # Cancellation
1331 ///
1332 /// The returned stream is the only holder of the response body: one
1333 /// `unfold` state machine owns head-then-body, so dropping the stream
1334 /// drops the body and with it the upstream exchange — the contract
1335 /// [`HttpClient::send_streaming`] carries. Nothing is spawned; nothing
1336 /// outlives the stream.
1337 fn stream<'a>(
1338 &'a self,
1339 prompt: &'a Prompt,
1340 ) -> BoxStream<'a, Result<TextDelta, TextModelError>> {
1341 if prompt.json_schema.is_some() {
1342 return self.stream_buffered(prompt);
1343 }
1344 Box::pin(futures_util::stream::unfold(
1345 StreamPhase::Head,
1346 move |mut phase| async move {
1347 loop {
1348 match phase {
1349 StreamPhase::Head => {
1350 phase = match self.stream_head(prompt).await {
1351 Ok(fold) => StreamPhase::Body(fold),
1352 Err(err) => return Some((Err(err), StreamPhase::Done)),
1353 };
1354 }
1355 StreamPhase::Body(mut fold) => {
1356 if let Some(delta) = fold.pending.next() {
1357 return Some((Ok(delta), StreamPhase::Body(fold)));
1358 }
1359 if fold.done {
1360 return None;
1361 }
1362 match fold.events.next().await {
1363 Some(Ok(event)) => match fold.push(&event.data) {
1364 Ok(deltas) => fold.pending = deltas.into_iter(),
1365 Err(err) => return Some((Err(err), StreamPhase::Done)),
1366 },
1367 Some(Err(err)) => {
1368 // The body's error is the stream's error,
1369 // and the body drops here, cancelling the
1370 // upstream.
1371 return Some((
1372 Err(TextModelError::Transport(err.to_string())),
1373 StreamPhase::Done,
1374 ));
1375 }
1376 None => match fold.finish(false) {
1377 // End of body. A server that sent a
1378 // finish_reason (and its usage chunk) but
1379 // omitted the `[DONE]` sentinel still
1380 // finished; anything else is an answer
1381 // that never completed.
1382 Ok(deltas) => fold.pending = deltas.into_iter(),
1383 Err(err) => return Some((Err(err), StreamPhase::Done)),
1384 },
1385 }
1386 phase = StreamPhase::Body(fold);
1387 }
1388 StreamPhase::Done => return None,
1389 }
1390 }
1391 },
1392 ))
1393 }
1394
1395 fn supports(&self, _tier: ModelTier, capability: Capability) -> bool {
1396 // The wire speaks function calling, so tools are on unless the
1397 // deployment turned them off; it speaks images too, but a model
1398 // behind it may not have vision, so images are off until the
1399 // deployment opts in. `Capability` is `#[non_exhaustive]`: an
1400 // unknown capability is one this adapter does not claim. The tier is
1401 // the router's routing key; this adapter serves whatever tier it is
1402 // wired for, so it is ignored.
1403 (capability == Capability::Tools && self.tools)
1404 || (capability == Capability::Images && self.images)
1405 }
1406}
1407
1408impl OpenAiCompatible {
1409 /// Sends the streaming request: the shared pre-flight, the shared
1410 /// request builder with the two streaming fields turned on, the head
1411 /// bounded by the same [`HttpPolicy`] deadline `send` answers under. A
1412 /// 2xx head hands back the fold the body is consumed through; a non-2xx
1413 /// one collects its (bounded) body and maps exactly as `complete` does.
1414 async fn stream_head(&self, prompt: &Prompt) -> Result<Fold, TextModelError> {
1415 let api_key = self.preflight(prompt)?;
1416 let mut payload = wire_request(&self.model, prompt)?;
1417 payload.stream = true;
1418 payload.stream_options = Some(WireStreamOptions {
1419 include_usage: true,
1420 });
1421 let request = self.frame(api_key, &payload)?;
1422 // The head is bounded by the policy the buffered request carries;
1423 // the error path reuses its cap for the body it drains.
1424 let cap = HttpPolicy::of_request(&request).max_response_bytes;
1425
1426 let response = self
1427 .http
1428 .send_streaming(request)
1429 .await
1430 .map_err(|err: HttpError| TextModelError::Transport(err.to_string()))?;
1431 let status = response.status();
1432 if !status.is_success() {
1433 // One parser for both `Retry-After` forms (issue #214/#278), as
1434 // on the buffered path.
1435 let retry_after = retry_after(response.headers(), self.clock.as_ref());
1436 let text = collect_bounded(response.into_body(), cap).await;
1437 let error = Self::map_status(status, &text, retry_after);
1438 tracing::warn!(
1439 provider = "openai-compatible",
1440 code = status.as_u16(),
1441 outcome = "failed",
1442 model = %self.model,
1443 error = %error,
1444 "text model outcome"
1445 );
1446 return Err(error);
1447 }
1448
1449 Ok(Fold::new(
1450 sse::sse_events(response.into_body()),
1451 self.model.clone(),
1452 ))
1453 }
1454
1455 /// The buffered fallback for the one shape a chunk stream cannot carry:
1456 /// a prompt with [`Prompt::json_schema`] — structured output is a
1457 /// buffered feature, because [`Completion::json`] needs the whole
1458 /// answer to validate against the prompt's schema, which a stream that
1459 /// relayed itself away never promised (see `CompletionBuilder`). One
1460 /// `complete` round trip, decomposed by
1461 /// [`completion_deltas`](cratefield_core::completion_deltas) — the
1462 /// port's default `stream`, written out here because a default trait
1463 /// body cannot be called from an override.
1464 fn stream_buffered<'a>(
1465 &'a self,
1466 prompt: &'a Prompt,
1467 ) -> BoxStream<'a, Result<TextDelta, TextModelError>> {
1468 Box::pin(
1469 futures_util::stream::once(self.complete(prompt)).flat_map(|outcome| {
1470 futures_util::stream::iter(match outcome {
1471 Ok(completion) => completion_deltas(&completion)
1472 .into_iter()
1473 .map(Ok)
1474 .collect::<Vec<_>>(),
1475 Err(err) => vec![Err(err)],
1476 })
1477 }),
1478 )
1479 }
1480
1481 /// Turns a parsed success body into a [`Completion`]: the first choice's
1482 /// text, its tool calls, the parsed schema-shaped JSON where the prompt
1483 /// asked for it, and the usage. Every refusal that arrives inside a 200
1484 /// — the model's own, the content filter, a truncated tool call — is a
1485 /// `Rejected` error, decided here.
1486 fn completion_from_response(
1487 &self,
1488 parsed: ChatCompletionsResponse,
1489 prompt: &Prompt,
1490 ) -> Result<Completion, TextModelError> {
1491 // One choice per completion — the port sends no `n`. A well-formed
1492 // envelope with no choice is the JSON path breaking between the
1493 // server and here, not a refusal (that would have been a 4xx).
1494 let choice = parsed.choices.first().ok_or_else(|| {
1495 TextModelError::Transport("model response came back without a choice".to_owned())
1496 })?;
1497 if let Some(error) = refusal_guard(choice, &self.model) {
1498 return Err(error);
1499 }
1500 let content = choice
1501 .message
1502 .as_ref()
1503 .and_then(|message| message.content.clone())
1504 .unwrap_or_default();
1505 let tool_calls = parse_tool_calls(choice.message.as_ref());
1506
1507 // `finish_reason: "length"` with tool calls means the arguments were
1508 // cut off mid-string; such a call must never reach an executor.
1509 // `Rejected`, not `Transient`: retrying the identical request
1510 // truncates identically.
1511 if !tool_calls.is_empty() && choice.finish_reason.as_deref() == Some("length") {
1512 return Err(TextModelError::Rejected(
1513 "the response was truncated at max_tokens before the tool call arguments were \
1514 complete; a larger max_tokens is needed"
1515 .to_owned(),
1516 ));
1517 }
1518
1519 let mut json = None;
1520 // A tool-calling answer is not the schema-shaped answer a
1521 // `response_format` asked for — its content is `null` — so the JSON
1522 // path stands down while the model is still choosing a tool. The
1523 // final, tool-free answer is parsed as before.
1524 if prompt.json_schema.is_some() && tool_calls.is_empty() {
1525 // `finish_reason: "length"` cut the schema-shaped JSON off
1526 // mid-string — returning the fragment as a schema-shaped
1527 // success would be a lie. `Rejected`, not `Transient`: retrying
1528 // the identical request truncates identically; only a larger
1529 // `max_tokens` changes the outcome. (The Anthropic adapter
1530 // draws the same line on its `stop_reason`.)
1531 if choice.finish_reason.as_deref() == Some("length") {
1532 return Err(TextModelError::Rejected(
1533 "the response was truncated at max_tokens before the schema-shaped \
1534 result was complete; a larger max_tokens is needed"
1535 .to_owned(),
1536 ));
1537 }
1538 // A server that ignored `response_format` answers in prose. That
1539 // is not a transport failure: the raw content rides in `text`
1540 // with no parsed value, and the port's `complete_json` parses it
1541 // (stripping a fence), validates it and repairs it if it does not
1542 // conform — the same fallback a provider without native
1543 // structured output takes.
1544 if let Ok(parsed_json) = serde_json::from_str(&content) {
1545 json = Some(parsed_json);
1546 }
1547 }
1548
1549 // A server that answers without a `usage` block still completes,
1550 // with the counts defaulting to zero — said out loud, not silent.
1551 let usage = reported_usage(parsed.usage, &self.model);
1552 let model = if parsed.model.is_empty() {
1553 self.model.clone()
1554 } else {
1555 parsed.model
1556 };
1557 let mut completion = Completion::new(content, model).usage(
1558 u64::from(usage.prompt_tokens),
1559 u64::from(usage.completion_tokens),
1560 );
1561 if !tool_calls.is_empty() {
1562 completion = completion.tool_calls(tool_calls);
1563 }
1564 if let Some(cached) = usage.prompt_tokens_details {
1565 completion = completion.cached_input_tokens(u64::from(cached.cached_tokens));
1566 }
1567 if let Some(json) = json {
1568 completion = completion.json(json);
1569 }
1570 Ok(completion)
1571 }
1572}