Skip to main content

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}