Skip to main content

provider_core/
openai_compat_chat.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//! Transport-independent OpenAI-compatible Chat Completions adapter shared by
4//! the follow-on providers (PRD-001s) and the DeepSeek direct API phase
5//! (PRD-001y S1): Qwen/DashScope, xAI Grok, Z.ai/GLM, Moonshot/Kimi, and
6//! DeepSeek (`api.deepseek.com`).
7//!
8//! Each provider exposes an OpenAI-compatible chat-completions wire API with
9//! SSE streaming (`include_usage: true`, `data: [DONE]` terminal). The
10//! providers differ only in base URL, path, and model-id namespace, all of
11//! which the route identity carries. This adapter is therefore provider
12//! neutral: it validates that the route is one of the follow-on phases and
13//! builds the request from the route's own endpoint and model id.
14//!
15//! The wire behavior is deliberately strict and matches the OpenRouter adapter
16//! contract: usage appears at most once, the terminal is required and
17//! duplicated once, and every provider error after a visible or ambiguous
18//! boundary denies replay.
19
20use crate::ollama_chat::{
21    BoundedRetryAfter, HttpMethod, NetworkPolicy, ReplayBoundary, ReplayDecision,
22};
23use router_protocol::{
24    AttemptId, CanonicalRequest, ContentBlock, InputItem, MessageRole, PhaseKind, RouteIdentity,
25    RouteIdentityError, ToolChoice, TransportKind,
26};
27use serde_json::{Map, Value, json};
28use std::collections::{BTreeMap, HashSet};
29use std::fmt;
30
31pub const DEFAULT_MAX_SSE_EVENT_BYTES: usize = 256 * 1024;
32
33/// The follow-on phase kinds this adapter serves.
34pub const fn is_follow_on_phase(phase: PhaseKind) -> bool {
35    matches!(
36        phase,
37        PhaseKind::QwenSubscription
38            | PhaseKind::QwenApi
39            | PhaseKind::GrokSubscription
40            | PhaseKind::GrokApi
41            | PhaseKind::ZaiSubscription
42            | PhaseKind::ZaiApi
43            | PhaseKind::KimiSubscription
44            | PhaseKind::KimiApi
45            | PhaseKind::DeepseekApi
46    )
47}
48
49#[derive(Clone, PartialEq, Eq)]
50pub struct OpenAiCompatSecretReferenceMarker(String);
51
52impl OpenAiCompatSecretReferenceMarker {
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56}
57
58impl fmt::Debug for OpenAiCompatSecretReferenceMarker {
59    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
60        formatter.write_str("OpenAiCompatSecretReferenceMarker([REDACTED])")
61    }
62}
63
64#[derive(Clone, PartialEq)]
65pub struct OpenAiCompatRequestSpec {
66    pub method: HttpMethod,
67    pub origin: String,
68    pub path: String,
69    pub content_type: &'static str,
70    pub accept: &'static str,
71    pub bearer_secret: OpenAiCompatSecretReferenceMarker,
72    pub network: NetworkPolicy,
73    body: Value,
74}
75
76impl OpenAiCompatRequestSpec {
77    pub fn body(&self) -> &Value {
78        &self.body
79    }
80
81    pub fn body_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
82        serde_json::to_vec(&self.body)
83    }
84}
85
86impl fmt::Debug for OpenAiCompatRequestSpec {
87    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
88        formatter
89            .debug_struct("OpenAiCompatRequestSpec")
90            .field("method", &self.method)
91            .field("origin", &self.origin)
92            .field("path", &self.path)
93            .field("content_type", &self.content_type)
94            .field("accept", &self.accept)
95            .field("bearer_secret", &self.bearer_secret)
96            .field("network", &self.network)
97            .field("body", &"[REDACTED]")
98            .finish()
99    }
100}
101
102#[derive(Clone, Copy, Debug, PartialEq, Eq)]
103pub enum OpenAiCompatRequestErrorKind {
104    InvalidRouteIdentity,
105    UnsupportedRoute,
106    InvalidCanonicalRequest,
107    AmbiguousToolName,
108}
109
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub struct OpenAiCompatRequestError {
112    pub kind: OpenAiCompatRequestErrorKind,
113}
114
115impl fmt::Display for OpenAiCompatRequestError {
116    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117        formatter.write_str("the follow-on chat request could not be prepared")
118    }
119}
120
121impl std::error::Error for OpenAiCompatRequestError {}
122
123impl From<RouteIdentityError> for OpenAiCompatRequestError {
124    fn from(_: RouteIdentityError) -> Self {
125        Self {
126            kind: OpenAiCompatRequestErrorKind::InvalidRouteIdentity,
127        }
128    }
129}
130
131pub fn build_request(
132    route: &RouteIdentity,
133    request: &CanonicalRequest,
134) -> Result<OpenAiCompatRequestSpec, OpenAiCompatRequestError> {
135    route.validate()?;
136    if !is_follow_on_phase(route.phase) || route.transport != TransportKind::DirectHttps {
137        return Err(OpenAiCompatRequestError {
138            kind: OpenAiCompatRequestErrorKind::UnsupportedRoute,
139        });
140    }
141    request.validate().map_err(|_| OpenAiCompatRequestError {
142        kind: OpenAiCompatRequestErrorKind::InvalidCanonicalRequest,
143    })?;
144    let secret_ref = route
145        .credential_ref
146        .clone()
147        .ok_or(OpenAiCompatRequestError {
148            kind: OpenAiCompatRequestErrorKind::InvalidRouteIdentity,
149        })?;
150
151    Ok(OpenAiCompatRequestSpec {
152        method: HttpMethod::Post,
153        origin: route.endpoint.origin.clone(),
154        path: route.endpoint.path.clone(),
155        content_type: "application/json",
156        accept: "text/event-stream",
157        bearer_secret: OpenAiCompatSecretReferenceMarker(secret_ref),
158        network: NetworkPolicy {
159            allow_proxy: false,
160            allow_dns: true,
161            follow_redirects: false,
162            require_tls: true,
163        },
164        body: build_body(route, request)?,
165    })
166}
167
168fn build_body(
169    route: &RouteIdentity,
170    request: &CanonicalRequest,
171) -> Result<Value, OpenAiCompatRequestError> {
172    let mut object = Map::new();
173    object.insert("model".to_owned(), Value::String(route.model_id.clone()));
174    object.insert("messages".to_owned(), Value::Array(build_messages(request)));
175    object.insert("stream".to_owned(), Value::Bool(true));
176    object.insert("stream_options".to_owned(), json!({"include_usage": true}));
177
178    // `tool_choice` is only valid alongside a `tools` array for OpenAI-compat
179    // providers; emit both or neither.
180    let emit_tools = !request.tools.is_empty() && request.tool_choice != ToolChoice::None;
181    if emit_tools {
182        object.insert("tool_choice".to_owned(), openai_compat_tool_choice(request));
183        let mut names = HashSet::new();
184        let mut tools = Vec::with_capacity(request.tools.len());
185        for tool in &request.tools {
186            let name = provider_tool_name(tool.namespace.as_deref(), &tool.name);
187            if !names.insert(name.clone()) {
188                return Err(OpenAiCompatRequestError {
189                    kind: OpenAiCompatRequestErrorKind::AmbiguousToolName,
190                });
191            }
192            tools.push(json!({
193                "type": "function",
194                "function": {
195                    "name": name,
196                    "description": tool.description,
197                    "parameters": tool.parameters,
198                    "strict": tool.strict,
199                }
200            }));
201        }
202        object.insert("tools".to_owned(), Value::Array(tools));
203    }
204    if let Some(maximum) = request.max_output_tokens {
205        object.insert("max_tokens".to_owned(), Value::from(maximum));
206    }
207    if let Some(format) = &request.structured_output {
208        object.insert(
209            "response_format".to_owned(),
210            json!({
211                "type": "json_schema",
212                "json_schema": {
213                    "name": format.name,
214                    "strict": format.strict,
215                    "schema": format.schema,
216                }
217            }),
218        );
219    }
220    Ok(Value::Object(object))
221}
222
223fn build_messages(request: &CanonicalRequest) -> Vec<Value> {
224    request
225        .input
226        .iter()
227        .map(|item| match item {
228            InputItem::Message { role, content } => json!({
229                "role": role_name(*role),
230                "content": openai_compat_content(content),
231            }),
232            InputItem::FunctionCall {
233                call_id,
234                namespace,
235                name,
236                arguments,
237            } => json!({
238                "role": "assistant",
239                "content": null,
240                "tool_calls": [{
241                    "id": call_id.as_str(),
242                    "type": "function",
243                    "function": {
244                        "name": provider_tool_name(namespace.as_deref(), name),
245                        "arguments": arguments.to_string(),
246                    }
247                }]
248            }),
249            InputItem::FunctionCallOutput { call_id, output } => json!({
250                "role": "tool",
251                "tool_call_id": call_id.as_str(),
252                "content": output.to_string(),
253            }),
254        })
255        .collect()
256}
257
258fn openai_compat_content(content: &[ContentBlock]) -> Value {
259    if content
260        .iter()
261        .all(|block| matches!(block, ContentBlock::Text { .. }))
262    {
263        let text = content
264            .iter()
265            .filter_map(|block| match block {
266                ContentBlock::Text { text } => Some(text.as_str()),
267                ContentBlock::ImageReference { .. } => None,
268            })
269            .collect::<String>();
270        return Value::String(text);
271    }
272    Value::Array(
273        content
274            .iter()
275            .map(|block| match block {
276                ContentBlock::Text { text } => json!({"type": "text", "text": text}),
277                ContentBlock::ImageReference { url } => {
278                    json!({"type": "image_url", "image_url": {"url": url}})
279                }
280            })
281            .collect(),
282    )
283}
284
285const fn role_name(role: MessageRole) -> &'static str {
286    match role {
287        MessageRole::Developer => "developer",
288        MessageRole::System => "system",
289        MessageRole::User => "user",
290        MessageRole::Assistant => "assistant",
291    }
292}
293
294fn provider_tool_name(namespace: Option<&str>, name: &str) -> String {
295    namespace.map_or_else(
296        || name.to_owned(),
297        |namespace| format!("{namespace}__{name}"),
298    )
299}
300
301fn openai_compat_tool_choice(request: &CanonicalRequest) -> Value {
302    match &request.tool_choice {
303        ToolChoice::Auto => Value::String("auto".to_owned()),
304        ToolChoice::None => Value::String("none".to_owned()),
305        ToolChoice::Required => Value::String("required".to_owned()),
306        ToolChoice::Named(name) => {
307            let provider_name = request
308                .tools
309                .iter()
310                .find(|tool| tool.name == *name)
311                .map_or_else(
312                    || name.clone(),
313                    |tool| provider_tool_name(tool.namespace.as_deref(), &tool.name),
314                );
315            json!({"type":"function","function":{"name":provider_name}})
316        }
317    }
318}
319
320#[derive(Clone, Debug, PartialEq, Eq)]
321pub struct OpenAiCompatUsage {
322    pub prompt_tokens: u64,
323    pub completion_tokens: u64,
324    pub total_tokens: u64,
325}
326
327#[derive(Clone, Debug, PartialEq, Eq)]
328pub enum OpenAiCompatStreamEvent {
329    ContentDelta {
330        text: String,
331    },
332    ToolCallDelta {
333        id: String,
334        ordinal: u64,
335        index: u64,
336        name: Option<String>,
337        arguments: Option<String>,
338    },
339    FinishReason {
340        reason: String,
341    },
342    Usage(OpenAiCompatUsage),
343    Done,
344}
345
346#[derive(Clone, Copy, Debug, PartialEq, Eq)]
347pub enum OpenAiCompatStreamErrorKind {
348    InvalidUtf8,
349    InvalidJson,
350    InvalidFrame,
351    EventTooLarge,
352    ProviderError,
353    MissingUsage,
354    DuplicateUsage,
355    MissingTerminal,
356    DuplicateTerminal,
357    DataAfterTerminal,
358    ParserFailed,
359}
360
361#[derive(Clone, Copy, Debug, PartialEq, Eq)]
362pub struct OpenAiCompatStreamError {
363    pub kind: OpenAiCompatStreamErrorKind,
364    pub byte_offset: u64,
365}
366
367impl fmt::Display for OpenAiCompatStreamError {
368    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
369        formatter.write_str("the OpenAI-compatible response stream was invalid")
370    }
371}
372
373impl std::error::Error for OpenAiCompatStreamError {}
374
375#[derive(Clone, Debug)]
376struct ToolState {
377    id: String,
378    ordinal: u64,
379}
380
381pub struct OpenAiCompatSseParser {
382    attempt_ref: String,
383    line_buffer: Vec<u8>,
384    data_lines: Vec<String>,
385    data_bytes: usize,
386    max_event_bytes: usize,
387    byte_offset: u64,
388    tool_states: BTreeMap<u64, ToolState>,
389    next_tool_ordinal: u64,
390    usage_seen: bool,
391    terminal: bool,
392    failed: bool,
393    replay_guard: OpenAiCompatReplayGuard,
394}
395
396impl fmt::Debug for OpenAiCompatSseParser {
397    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
398        formatter
399            .debug_struct("OpenAiCompatSseParser")
400            .field("buffered_line_bytes", &self.line_buffer.len())
401            .field("data_bytes", &self.data_bytes)
402            .field("max_event_bytes", &self.max_event_bytes)
403            .field("byte_offset", &self.byte_offset)
404            .field("tool_count", &self.tool_states.len())
405            .field("usage_seen", &self.usage_seen)
406            .field("terminal", &self.terminal)
407            .field("failed", &self.failed)
408            .field("replay_decision", &self.replay_guard.decision())
409            .finish()
410    }
411}
412
413impl OpenAiCompatSseParser {
414    pub fn new(attempt_id: &AttemptId) -> Self {
415        Self::with_max_event_bytes(attempt_id, DEFAULT_MAX_SSE_EVENT_BYTES)
416    }
417
418    pub fn with_max_event_bytes(attempt_id: &AttemptId, max_event_bytes: usize) -> Self {
419        Self {
420            attempt_ref: attempt_id.as_str().to_owned(),
421            line_buffer: Vec::new(),
422            data_lines: Vec::new(),
423            data_bytes: 0,
424            max_event_bytes: max_event_bytes.max(1),
425            byte_offset: 0,
426            tool_states: BTreeMap::new(),
427            next_tool_ordinal: 0,
428            usage_seen: false,
429            terminal: false,
430            failed: false,
431            replay_guard: OpenAiCompatReplayGuard::default(),
432        }
433    }
434
435    pub fn mark_body_written(&mut self) {
436        self.replay_guard.mark_body_written();
437    }
438
439    pub fn mark_forwarded_tool_result(&mut self) {
440        self.replay_guard.mark_forwarded_tool_result();
441    }
442
443    pub fn mark_transport_outcome_unknown(&mut self) {
444        self.replay_guard.mark_transport_outcome_unknown();
445    }
446
447    pub fn replay_decision(&self) -> ReplayDecision {
448        self.replay_guard.decision()
449    }
450
451    pub fn feed(
452        &mut self,
453        bytes: &[u8],
454    ) -> Result<Vec<OpenAiCompatStreamEvent>, OpenAiCompatStreamError> {
455        if self.failed {
456            return Err(self.error(OpenAiCompatStreamErrorKind::ParserFailed));
457        }
458        let mut events = Vec::new();
459        for &byte in bytes {
460            self.byte_offset = self.byte_offset.saturating_add(1);
461            if byte == b'\n' {
462                let mut line = std::mem::take(&mut self.line_buffer);
463                if line.last() == Some(&b'\r') {
464                    line.pop();
465                }
466                if let Err(error) = self.process_line(&line, &mut events) {
467                    self.failed = true;
468                    return Err(error);
469                }
470            } else {
471                self.line_buffer.push(byte);
472                if self.line_buffer.len() > self.max_event_bytes {
473                    self.failed = true;
474                    return Err(self.error(OpenAiCompatStreamErrorKind::EventTooLarge));
475                }
476            }
477        }
478        Ok(events)
479    }
480
481    pub fn finish(&mut self) -> Result<Vec<OpenAiCompatStreamEvent>, OpenAiCompatStreamError> {
482        if self.failed {
483            return Err(self.error(OpenAiCompatStreamErrorKind::ParserFailed));
484        }
485        let mut events = Vec::new();
486        if !self.line_buffer.is_empty() {
487            let mut line = std::mem::take(&mut self.line_buffer);
488            if line.last() == Some(&b'\r') {
489                line.pop();
490            }
491            if let Err(error) = self.process_line(&line, &mut events) {
492                self.failed = true;
493                return Err(error);
494            }
495        }
496        if !self.data_lines.is_empty()
497            && let Err(error) = self.dispatch_event(&mut events)
498        {
499            self.failed = true;
500            return Err(error);
501        }
502        if !self.terminal {
503            self.failed = true;
504            return Err(self.error(OpenAiCompatStreamErrorKind::MissingTerminal));
505        }
506        Ok(events)
507    }
508
509    fn process_line(
510        &mut self,
511        line: &[u8],
512        events: &mut Vec<OpenAiCompatStreamEvent>,
513    ) -> Result<(), OpenAiCompatStreamError> {
514        let line = std::str::from_utf8(line)
515            .map_err(|_| self.error(OpenAiCompatStreamErrorKind::InvalidUtf8))?;
516        if line.is_empty() {
517            return self.dispatch_event(events);
518        }
519        if line.starts_with(':') {
520            return Ok(());
521        }
522        let (field, value) = line.split_once(':').unwrap_or((line, ""));
523        if field != "data" {
524            return Ok(());
525        }
526        let value = value.strip_prefix(' ').unwrap_or(value);
527        let separator = usize::from(!self.data_lines.is_empty());
528        self.data_bytes = self
529            .data_bytes
530            .saturating_add(value.len())
531            .saturating_add(separator);
532        if self.data_bytes > self.max_event_bytes {
533            return Err(self.error(OpenAiCompatStreamErrorKind::EventTooLarge));
534        }
535        self.data_lines.push(value.to_owned());
536        Ok(())
537    }
538
539    fn dispatch_event(
540        &mut self,
541        events: &mut Vec<OpenAiCompatStreamEvent>,
542    ) -> Result<(), OpenAiCompatStreamError> {
543        if self.data_lines.is_empty() {
544            return Ok(());
545        }
546        let data = self.data_lines.join("\n");
547        self.data_lines.clear();
548        self.data_bytes = 0;
549        if data == "[DONE]" {
550            if self.terminal {
551                return Err(self.error(OpenAiCompatStreamErrorKind::DuplicateTerminal));
552            }
553            if !self.usage_seen {
554                return Err(self.error(OpenAiCompatStreamErrorKind::MissingUsage));
555            }
556            self.terminal = true;
557            events.push(OpenAiCompatStreamEvent::Done);
558            return Ok(());
559        }
560        if self.terminal {
561            return Err(self.error(OpenAiCompatStreamErrorKind::DataAfterTerminal));
562        }
563        let value: Value = serde_json::from_str(&data)
564            .map_err(|_| self.error(OpenAiCompatStreamErrorKind::InvalidJson))?;
565        let object = value
566            .as_object()
567            .ok_or_else(|| self.error(OpenAiCompatStreamErrorKind::InvalidFrame))?;
568        if object.contains_key("error") {
569            return Err(self.error(OpenAiCompatStreamErrorKind::ProviderError));
570        }
571        self.parse_usage(object, events)?;
572        self.parse_choices(object, events)
573    }
574
575    fn parse_usage(
576        &mut self,
577        object: &Map<String, Value>,
578        events: &mut Vec<OpenAiCompatStreamEvent>,
579    ) -> Result<(), OpenAiCompatStreamError> {
580        let Some(usage) = object.get("usage") else {
581            return Ok(());
582        };
583        if usage.is_null() {
584            return Ok(());
585        }
586        if self.usage_seen {
587            return Err(self.error(OpenAiCompatStreamErrorKind::DuplicateUsage));
588        }
589        let usage = usage
590            .as_object()
591            .ok_or_else(|| self.error(OpenAiCompatStreamErrorKind::InvalidFrame))?;
592        let prompt_tokens = required_u64(usage, "prompt_tokens", self.byte_offset)?;
593        let completion_tokens = required_u64(usage, "completion_tokens", self.byte_offset)?;
594        let total_tokens = required_u64(usage, "total_tokens", self.byte_offset)?;
595        self.usage_seen = true;
596        events.push(OpenAiCompatStreamEvent::Usage(OpenAiCompatUsage {
597            prompt_tokens,
598            completion_tokens,
599            total_tokens,
600        }));
601        Ok(())
602    }
603
604    fn parse_choices(
605        &mut self,
606        object: &Map<String, Value>,
607        events: &mut Vec<OpenAiCompatStreamEvent>,
608    ) -> Result<(), OpenAiCompatStreamError> {
609        let Some(choices) = object.get("choices") else {
610            return Ok(());
611        };
612        let choices = choices
613            .as_array()
614            .ok_or_else(|| self.error(OpenAiCompatStreamErrorKind::InvalidFrame))?;
615        for choice in choices {
616            let choice = choice
617                .as_object()
618                .ok_or_else(|| self.error(OpenAiCompatStreamErrorKind::InvalidFrame))?;
619            if choice.contains_key("error") {
620                return Err(self.error(OpenAiCompatStreamErrorKind::ProviderError));
621            }
622            if let Some(delta) = choice.get("delta") {
623                let delta = delta
624                    .as_object()
625                    .ok_or_else(|| self.error(OpenAiCompatStreamErrorKind::InvalidFrame))?;
626                if delta.contains_key("error") {
627                    return Err(self.error(OpenAiCompatStreamErrorKind::ProviderError));
628                }
629                if let Some(content) = delta.get("content")
630                    && !content.is_null()
631                {
632                    let text = content
633                        .as_str()
634                        .ok_or_else(|| self.error(OpenAiCompatStreamErrorKind::InvalidFrame))?;
635                    if !text.is_empty() {
636                        let event = OpenAiCompatStreamEvent::ContentDelta {
637                            text: text.to_owned(),
638                        };
639                        self.replay_guard.observe(&event);
640                        events.push(event);
641                    }
642                }
643                if let Some(tool_calls) = delta.get("tool_calls") {
644                    self.parse_tool_calls(tool_calls, events)?;
645                }
646            }
647            if let Some(reason) = choice.get("finish_reason")
648                && !reason.is_null()
649            {
650                let reason = reason
651                    .as_str()
652                    .ok_or_else(|| self.error(OpenAiCompatStreamErrorKind::InvalidFrame))?;
653                if reason == "error" {
654                    return Err(self.error(OpenAiCompatStreamErrorKind::ProviderError));
655                }
656                events.push(OpenAiCompatStreamEvent::FinishReason {
657                    reason: reason.to_owned(),
658                });
659            }
660        }
661        Ok(())
662    }
663
664    fn parse_tool_calls(
665        &mut self,
666        value: &Value,
667        events: &mut Vec<OpenAiCompatStreamEvent>,
668    ) -> Result<(), OpenAiCompatStreamError> {
669        let calls = value
670            .as_array()
671            .ok_or_else(|| self.error(OpenAiCompatStreamErrorKind::InvalidFrame))?;
672        for call in calls {
673            let call = call
674                .as_object()
675                .ok_or_else(|| self.error(OpenAiCompatStreamErrorKind::InvalidFrame))?;
676            let index = call
677                .get("index")
678                .and_then(Value::as_u64)
679                .ok_or_else(|| self.error(OpenAiCompatStreamErrorKind::InvalidFrame))?;
680            let incoming_id = call.get("id").and_then(Value::as_str);
681            let state = if let Some(state) = self.tool_states.get(&index) {
682                if incoming_id.is_some_and(|id| id != state.id) {
683                    return Err(self.error(OpenAiCompatStreamErrorKind::InvalidFrame));
684                }
685                state.clone()
686            } else {
687                let ordinal = self.next_tool_ordinal;
688                self.next_tool_ordinal = self.next_tool_ordinal.saturating_add(1);
689                let state = ToolState {
690                    id: incoming_id.map_or_else(
691                        || synthesized_tool_id(&self.attempt_ref, ordinal),
692                        str::to_owned,
693                    ),
694                    ordinal,
695                };
696                self.tool_states.insert(index, state.clone());
697                state
698            };
699            let function = call.get("function").and_then(Value::as_object);
700            let name = function
701                .and_then(|function| function.get("name"))
702                .and_then(Value::as_str)
703                .map(str::to_owned);
704            let arguments = function
705                .and_then(|function| function.get("arguments"))
706                .and_then(Value::as_str)
707                .map(str::to_owned);
708            if name.is_some() || arguments.is_some() || incoming_id.is_some() {
709                let event = OpenAiCompatStreamEvent::ToolCallDelta {
710                    id: state.id,
711                    ordinal: state.ordinal,
712                    index,
713                    name,
714                    arguments,
715                };
716                self.replay_guard.observe(&event);
717                events.push(event);
718            }
719        }
720        Ok(())
721    }
722
723    fn error(&self, kind: OpenAiCompatStreamErrorKind) -> OpenAiCompatStreamError {
724        OpenAiCompatStreamError {
725            kind,
726            byte_offset: self.byte_offset,
727        }
728    }
729}
730
731fn required_u64(
732    object: &Map<String, Value>,
733    name: &str,
734    byte_offset: u64,
735) -> Result<u64, OpenAiCompatStreamError> {
736    object
737        .get(name)
738        .and_then(Value::as_u64)
739        .ok_or(OpenAiCompatStreamError {
740            kind: OpenAiCompatStreamErrorKind::InvalidFrame,
741            byte_offset,
742        })
743}
744
745fn synthesized_tool_id(attempt_ref: &str, ordinal: u64) -> String {
746    const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
747    const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
748    let mut value = FNV_OFFSET;
749    for byte in attempt_ref
750        .bytes()
751        .chain(*b":")
752        .chain(ordinal.to_be_bytes())
753    {
754        value ^= u64::from(byte);
755        value = value.wrapping_mul(FNV_PRIME);
756    }
757    format!("call_{value:016x}")
758}
759
760#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
761pub struct OpenAiCompatReplayGuard {
762    boundary: Option<ReplayBoundary>,
763    body_written: bool,
764}
765
766impl OpenAiCompatReplayGuard {
767    pub fn mark_body_written(&mut self) {
768        self.body_written = true;
769    }
770
771    pub fn observe(&mut self, event: &OpenAiCompatStreamEvent) {
772        match event {
773            OpenAiCompatStreamEvent::ContentDelta { text } if !text.is_empty() => {
774                self.cross(ReplayBoundary::PublicContent);
775            }
776            OpenAiCompatStreamEvent::ToolCallDelta { .. } => {
777                self.cross(ReplayBoundary::VisibleToolCall);
778            }
779            OpenAiCompatStreamEvent::ContentDelta { .. }
780            | OpenAiCompatStreamEvent::FinishReason { .. }
781            | OpenAiCompatStreamEvent::Usage(_)
782            | OpenAiCompatStreamEvent::Done => {}
783        }
784    }
785
786    pub fn mark_forwarded_tool_result(&mut self) {
787        self.cross(ReplayBoundary::ForwardedToolResult);
788    }
789
790    pub fn mark_transport_outcome_unknown(&mut self) {
791        if self.body_written {
792            self.cross(ReplayBoundary::AmbiguousDispatch);
793        }
794    }
795
796    pub fn decision(&self) -> ReplayDecision {
797        self.boundary
798            .map_or(ReplayDecision::Allowed, ReplayDecision::Denied)
799    }
800
801    fn cross(&mut self, boundary: ReplayBoundary) {
802        if self.boundary.is_none() {
803            self.boundary = Some(boundary);
804        }
805    }
806}
807
808#[derive(Clone, Copy, Debug, PartialEq, Eq)]
809pub enum OpenAiCompatFailureClass {
810    InvalidRequest,
811    Authentication,
812    PaymentRequired,
813    Authorization,
814    NotFound,
815    Timeout,
816    Unprocessable,
817    RateLimited,
818    UpstreamServer,
819    UnexpectedStatus,
820    MismatchedContentType,
821    RedirectRejected,
822    Cancelled,
823    TransportTimeout,
824    AmbiguousDispatch,
825}
826
827#[derive(Clone, Copy, Debug, PartialEq, Eq)]
828pub struct OpenAiCompatFailure {
829    pub class: OpenAiCompatFailureClass,
830    pub status: Option<u16>,
831    pub retry_after: Option<BoundedRetryAfter>,
832}
833
834/// The only retry authorization exposed by the follow-on adapter.  This is
835/// intentionally a policy decision rather than a transport convenience: the
836/// caller still owns the budget reservation and must record the result.
837pub const AUTHORIZED_RATE_LIMIT_RETRY_AFTER_MAX_SECONDS: u32 = 2;
838
839#[derive(Clone, Copy, Debug, PartialEq, Eq)]
840pub enum OpenAiCompatRetryDecision {
841    RetryOnce { retry_after_seconds: u32 },
842    NoRetry,
843}
844
845/// Applies the same deliberately narrow 429 policy as the OpenRouter adapter.
846pub fn authorized_rate_limit_retry(
847    failure: OpenAiCompatFailure,
848    replay: ReplayDecision,
849    retry_already_used: bool,
850) -> OpenAiCompatRetryDecision {
851    let retry_after_seconds = failure
852        .retry_after
853        .filter(|retry_after| !retry_after.capped)
854        .map(|retry_after| retry_after.seconds);
855    if retry_already_used
856        || !matches!(failure.class, OpenAiCompatFailureClass::RateLimited)
857        || !matches!(replay, ReplayDecision::Allowed)
858        || retry_after_seconds
859            .is_none_or(|seconds| seconds > AUTHORIZED_RATE_LIMIT_RETRY_AFTER_MAX_SECONDS)
860    {
861        return OpenAiCompatRetryDecision::NoRetry;
862    }
863    match retry_after_seconds {
864        Some(retry_after_seconds) => OpenAiCompatRetryDecision::RetryOnce {
865            retry_after_seconds,
866        },
867        None => OpenAiCompatRetryDecision::NoRetry,
868    }
869}
870
871pub fn normalize_http_response(
872    status: u16,
873    content_type: Option<&str>,
874    retry_after: Option<&str>,
875    redirected: bool,
876) -> Result<(), OpenAiCompatFailure> {
877    if redirected || (300..400).contains(&status) {
878        return Err(failure(
879            OpenAiCompatFailureClass::RedirectRejected,
880            Some(status),
881            retry_after,
882        ));
883    }
884    if status != 200 {
885        let class = match status {
886            400 => OpenAiCompatFailureClass::InvalidRequest,
887            401 => OpenAiCompatFailureClass::Authentication,
888            402 => OpenAiCompatFailureClass::PaymentRequired,
889            403 => OpenAiCompatFailureClass::Authorization,
890            404 => OpenAiCompatFailureClass::NotFound,
891            408 => OpenAiCompatFailureClass::Timeout,
892            422 => OpenAiCompatFailureClass::Unprocessable,
893            429 => OpenAiCompatFailureClass::RateLimited,
894            500..=599 => OpenAiCompatFailureClass::UpstreamServer,
895            _ => OpenAiCompatFailureClass::UnexpectedStatus,
896        };
897        return Err(failure(class, Some(status), retry_after));
898    }
899    let media_type = content_type
900        .and_then(|value| value.split(';').next())
901        .map(str::trim);
902    if media_type != Some("text/event-stream") {
903        return Err(failure(
904            OpenAiCompatFailureClass::MismatchedContentType,
905            Some(status),
906            None,
907        ));
908    }
909    Ok(())
910}
911
912#[derive(Clone, Copy, Debug, PartialEq, Eq)]
913pub enum BodyWriteState {
914    NotWritten,
915    Written,
916    Unknown,
917}
918
919#[derive(Clone, Copy, Debug, PartialEq, Eq)]
920pub enum TransportFailureSignal {
921    Cancelled,
922    Timeout,
923}
924
925pub fn normalize_transport_failure(
926    signal: TransportFailureSignal,
927    body_write: BodyWriteState,
928) -> OpenAiCompatFailure {
929    let class = match body_write {
930        BodyWriteState::Written | BodyWriteState::Unknown => {
931            OpenAiCompatFailureClass::AmbiguousDispatch
932        }
933        BodyWriteState::NotWritten => match signal {
934            TransportFailureSignal::Cancelled => OpenAiCompatFailureClass::Cancelled,
935            TransportFailureSignal::Timeout => OpenAiCompatFailureClass::TransportTimeout,
936        },
937    };
938    failure(class, None, None)
939}
940
941fn failure(
942    class: OpenAiCompatFailureClass,
943    status: Option<u16>,
944    retry_after: Option<&str>,
945) -> OpenAiCompatFailure {
946    OpenAiCompatFailure {
947        class,
948        status,
949        retry_after: BoundedRetryAfter::parse(retry_after),
950    }
951}
952
953#[cfg(test)]
954mod tests {
955    use super::*;
956    use router_protocol::{
957        BillingAuthorityClass, CapabilityRequirements, CatalogSourceKind, CredentialSourceKind,
958        EndpointIdentity, ExecutionLocation, FunctionTool, HarnessInstanceId, M1_WIRE_VERSION,
959        MessageRole, ModelNamespace, PhaseKind, PhasePlacement, ProviderService, ReasoningIntent,
960        SessionId, TurnId,
961    };
962
963    fn route() -> RouteIdentity {
964        RouteIdentity {
965            phase: PhaseKind::KimiApi,
966            placement: PhasePlacement::Builtin,
967            provider: ProviderService::MoonshotKimi,
968            transport: TransportKind::DirectHttps,
969            endpoint: EndpointIdentity {
970                origin: router_protocol::MOONSHOT_ORIGIN.to_owned(),
971                path: router_protocol::MOONSHOT_CHAT_PATH.to_owned(),
972            },
973            credential_source: CredentialSourceKind::SecretRef,
974            credential_ref: Some("env:STEADYMUX_KIMI_KEY".to_owned()),
975            credential_refs: vec!["env:STEADYMUX_KIMI_KEY".to_owned()],
976            catalog_source: CatalogSourceKind::KimiCatalog,
977            catalog_ref: "catalog:kimi:verified".to_owned(),
978            billing_authority: BillingAuthorityClass::KimiMeteredApi,
979            model_namespace: ModelNamespace::MoonshotKimi,
980            model_id: "kimi-k2.6".to_owned(),
981            execution_location: ExecutionLocation::RemoteCloud,
982            account_ref: "account:kimi".to_owned(),
983            capacity_domain_ref: "capacity:kimi".to_owned(),
984            local_execution_proof: false,
985            approved_local_inventory: Vec::new(),
986            reasoning_modes: Vec::new(),
987        }
988    }
989
990    fn request(prompt: &str) -> CanonicalRequest {
991        CanonicalRequest {
992            wire_version: M1_WIRE_VERSION.to_owned(),
993            model: "router-auto".to_owned(),
994            stream: true,
995            harness_instance: HarnessInstanceId::parse("fixture-cli").expect("fixture"),
996            session_id: SessionId::parse("session-kimi").expect("fixture"),
997            turn_id: TurnId::parse("turn-kimi").expect("fixture"),
998            input: vec![InputItem::Message {
999                role: MessageRole::User,
1000                content: vec![ContentBlock::Text {
1001                    text: prompt.to_owned(),
1002                }],
1003            }],
1004            tools: vec![FunctionTool {
1005                namespace: Some("weather".to_owned()),
1006                name: "lookup".to_owned(),
1007                description: "lookup weather".to_owned(),
1008                parameters: json!({"type":"object"}),
1009                strict: true,
1010            }],
1011            tool_choice: ToolChoice::Auto,
1012            structured_output: None,
1013            reasoning: ReasoningIntent::None,
1014            max_output_tokens: Some(64),
1015            max_spend_micro_usd: None,
1016            capabilities: CapabilityRequirements::default(),
1017        }
1018    }
1019
1020    #[test]
1021    fn exact_request_has_redacted_secret_and_prompt_and_no_openrouter_provider_block() {
1022        let spec = build_request(&route(), &request("prompt-canary")).expect("request");
1023        assert_eq!(spec.method, HttpMethod::Post);
1024        assert_eq!(spec.origin, router_protocol::MOONSHOT_ORIGIN);
1025        assert_eq!(spec.path, router_protocol::MOONSHOT_CHAT_PATH);
1026        assert_eq!(spec.accept, "text/event-stream");
1027        assert!(spec.network.require_tls);
1028        assert!(!spec.network.follow_redirects);
1029        assert_eq!(
1030            spec.body(),
1031            &json!({
1032                "model":"kimi-k2.6",
1033                "messages":[{"role":"user","content":"prompt-canary"}],
1034                "stream":true,
1035                "stream_options":{"include_usage":true},
1036                "tool_choice":"auto",
1037                "tools":[{"type":"function","function":{
1038                    "name":"weather__lookup",
1039                    "description":"lookup weather",
1040                    "parameters":{"type":"object"},
1041                    "strict":true
1042                }}],
1043                "max_tokens":64
1044            })
1045        );
1046        assert_eq!(spec.bearer_secret.as_str(), "env:STEADYMUX_KIMI_KEY");
1047        let debug = format!("{spec:?}");
1048        assert!(!debug.contains("STEADYMUX_KIMI_KEY"));
1049        assert!(!debug.contains("prompt-canary"));
1050        assert_eq!(debug.matches("REDACTED").count(), 2);
1051    }
1052
1053    #[test]
1054    fn tool_choice_emitted_only_alongside_tools() {
1055        // Empty tools with ToolChoice::Required must not emit a bare
1056        // `tool_choice` (invalid for OpenAI-compat providers).
1057        let mut no_tools = request("no-tools");
1058        no_tools.tools = Vec::new();
1059        no_tools.tool_choice = ToolChoice::Required;
1060        let body = build_body(&route(), &no_tools).expect("body");
1061        assert!(body.get("tools").is_none());
1062        assert!(body.get("tool_choice").is_none());
1063
1064        // Tools with a named tool choice emit both.
1065        let mut named = request("named-tool");
1066        named.tool_choice = ToolChoice::Named("weather__lookup".to_owned());
1067        let body = build_body(&route(), &named).expect("body");
1068        assert!(body.get("tools").is_some());
1069        assert_eq!(
1070            body.get("tool_choice"),
1071            Some(&json!({"type": "function", "function": {"name": "weather__lookup"}}))
1072        );
1073    }
1074
1075    #[test]
1076    fn non_follow_on_route_is_rejected() {
1077        let mut identity = route();
1078        identity.phase = PhaseKind::OpenrouterApi;
1079        let error = build_request(&identity, &request("x")).expect_err("inconsistent route");
1080        assert_eq!(
1081            error.kind,
1082            OpenAiCompatRequestErrorKind::InvalidRouteIdentity
1083        );
1084
1085        let mut openrouter = route();
1086        openrouter.phase = PhaseKind::OpenrouterApi;
1087        openrouter.provider = ProviderService::Openrouter;
1088        openrouter.endpoint = EndpointIdentity {
1089            origin: "https://openrouter.ai".to_owned(),
1090            path: "/api/v1/chat/completions".to_owned(),
1091        };
1092        openrouter.catalog_source = CatalogSourceKind::OpenrouterCatalog;
1093        openrouter.billing_authority = BillingAuthorityClass::OpenrouterMeteredApi;
1094        openrouter.model_namespace = ModelNamespace::Openrouter;
1095        openrouter.model_id = "openrouter/auto".to_owned();
1096        assert_eq!(
1097            build_request(&openrouter, &request("x"))
1098                .expect_err("unsupported")
1099                .kind,
1100            OpenAiCompatRequestErrorKind::UnsupportedRoute
1101        );
1102    }
1103
1104    fn deepseek_route() -> RouteIdentity {
1105        let mut identity = route();
1106        identity.phase = PhaseKind::DeepseekApi;
1107        identity.provider = ProviderService::Deepseek;
1108        identity.endpoint = EndpointIdentity {
1109            origin: router_protocol::DEEPSEEK_ORIGIN.to_owned(),
1110            path: router_protocol::DEEPSEEK_CHAT_PATH.to_owned(),
1111        };
1112        identity.catalog_source = CatalogSourceKind::DeepseekCatalog;
1113        identity.billing_authority = BillingAuthorityClass::DeepseekMeteredApi;
1114        identity.model_namespace = ModelNamespace::Deepseek;
1115        identity.credential_ref = Some("env:STEADYMUX_DEEPSEEK_KEY".to_owned());
1116        identity.credential_refs = vec!["env:STEADYMUX_DEEPSEEK_KEY".to_owned()];
1117        identity
1118    }
1119
1120    #[test]
1121    fn deepseek_route_builds_verified_wire_shape() {
1122        // PRD-001y AC-02: the DeepSeek direct path is an OpenAI-compatible
1123        // chat route against `https://api.deepseek.com/chat/completions` with a
1124        // bearer secret reference, verified at build time against the published
1125        // API documentation. The model id comes from the route (catalog
1126        // driven), never from a literal in this adapter.
1127        let identity = deepseek_route();
1128        identity.validate().expect("valid deepseek route");
1129        let spec = build_request(&identity, &request("deepseek-canary")).expect("request");
1130        assert_eq!(spec.method, HttpMethod::Post);
1131        assert_eq!(spec.origin, router_protocol::DEEPSEEK_ORIGIN);
1132        assert_eq!(spec.path, router_protocol::DEEPSEEK_CHAT_PATH);
1133        assert_eq!(spec.accept, "text/event-stream");
1134        assert_eq!(spec.bearer_secret.as_str(), "env:STEADYMUX_DEEPSEEK_KEY");
1135        assert!(spec.network.require_tls);
1136        assert_eq!(
1137            spec.body().get("model"),
1138            Some(&json!("kimi-k2.6")),
1139            "the request model must come from the route identity, not a literal"
1140        );
1141    }
1142
1143    #[test]
1144    fn deepseek_adapter_has_no_hardcoded_model_id_and_catalog_stays_reachable() {
1145        // PR-001y AC-03: model ids are catalog-driven, never hardcoded. The
1146        // adapter source must not contain any DeepSeek model literal, and the
1147        // OpenRouter catalog entries that keep DeepSeek reachable must remain
1148        // intact in the daemon capsule.
1149        // The non-test adapter body is the production code that must never
1150        // carry a hardcoded model id. The forbidden literals are assembled
1151        // from fragments so the test module itself never contains the exact
1152        // model strings it is checking for (the file is read via
1153        // `include_str!` above).
1154        let adapter_source = include_str!("openai_compat_chat.rs");
1155        let adapter_core = adapter_source
1156            .split("#[cfg(test)]")
1157            .next()
1158            .expect("adapter body precedes the test module");
1159        let forbidden = [
1160            concat!("deepseek", "-", "chat"),
1161            concat!("deepseek", "-", "reasoner"),
1162            concat!("deepseek", "-", "v4", "-", "flash"),
1163            concat!("deepseek", "-", "v4", "-", "pro"),
1164            concat!("deepseek", "-", "r1"),
1165        ];
1166        for model_literal in forbidden {
1167            assert!(
1168                !adapter_core.contains(model_literal),
1169                "adapter hardcodes the model literal {model_literal}"
1170            );
1171        }
1172        assert!(is_follow_on_phase(PhaseKind::DeepseekApi));
1173
1174        // The OpenRouter catalog capsule still hosts the three DeepSeek
1175        // entries and the Ollama Cloud capsule the DeepSeek cloud alias, so
1176        // DeepSeek stays catalog-reachable without a native model literal.
1177        let openrouter_capsule =
1178            include_str!("../../router-daemon/src/openrouter_phase_capsule.rs");
1179        for entry in [
1180            "deepseek/deepseek-v4-flash",
1181            "deepseek/deepseek-v4-pro",
1182            "deepseek/deepseek-r1",
1183        ] {
1184            assert!(
1185                openrouter_capsule.contains(entry),
1186                "OpenRouter catalog lost the DeepSeek entry {entry}"
1187            );
1188        }
1189        let ollama_capsule = include_str!("../../router-daemon/src/ollama_cloud_phase_capsule.rs");
1190        assert!(
1191            ollama_capsule.contains("deepseek-v4-flash:cloud"),
1192            "Ollama Cloud catalog lost the DeepSeek cloud alias"
1193        );
1194    }
1195
1196    #[test]
1197    fn sse_parses_fragmented_stream_usage_once_and_terminal_exactly_once() {
1198        let transcript = concat!(
1199            ": heartbeat\r\n\r\n",
1200            "data: {\"choices\":\r\n",
1201            "data: [{\"delta\":{\"content\":\"hé\"},\"finish_reason\":null}]}\r\n\r\n",
1202            "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-provider\",\"function\":{\"name\":\"weather__lookup\",\"arguments\":\"{\"}}]}}]}\r\n\r\n",
1203            "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"city\\\":\\\"Paris\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\r\n\r\n",
1204            "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":7,\"total_tokens\":18}}\r\n\r\n",
1205            "data: [DONE]"
1206        );
1207        let attempt = AttemptId::parse("attempt-kimi").expect("attempt");
1208        let mut parser = OpenAiCompatSseParser::new(&attempt);
1209        let mut fragmented = Vec::new();
1210        for byte in transcript.as_bytes() {
1211            fragmented.extend(parser.feed(std::slice::from_ref(byte)).expect("fragment"));
1212        }
1213        fragmented.extend(parser.finish().expect("complete"));
1214        assert!(matches!(
1215            &fragmented[0],
1216            OpenAiCompatStreamEvent::ContentDelta { text } if text == "hé"
1217        ));
1218        assert_eq!(
1219            fragmented
1220                .iter()
1221                .filter(|event| matches!(event, OpenAiCompatStreamEvent::Usage(_)))
1222                .count(),
1223            1
1224        );
1225        assert!(matches!(
1226            fragmented.last(),
1227            Some(OpenAiCompatStreamEvent::Done)
1228        ));
1229        let mut whole = OpenAiCompatSseParser::new(&attempt);
1230        let mut whole_events = whole.feed(transcript.as_bytes()).expect("whole stream");
1231        whole_events.extend(whole.finish().expect("complete"));
1232        assert_eq!(fragmented, whole_events);
1233    }
1234
1235    #[test]
1236    fn usage_and_terminal_are_each_exactly_once() {
1237        let attempt = AttemptId::parse("attempt-terminal").expect("attempt");
1238        let usage = b"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n";
1239        let mut duplicate_usage = OpenAiCompatSseParser::new(&attempt);
1240        duplicate_usage.feed(usage).expect("first usage");
1241        assert_eq!(
1242            duplicate_usage
1243                .feed(usage)
1244                .expect_err("duplicate usage")
1245                .kind,
1246            OpenAiCompatStreamErrorKind::DuplicateUsage
1247        );
1248        let mut missing_usage = OpenAiCompatSseParser::new(&attempt);
1249        assert_eq!(
1250            missing_usage
1251                .feed(b"data: [DONE]\n\n")
1252                .expect_err("usage required")
1253                .kind,
1254            OpenAiCompatStreamErrorKind::MissingUsage
1255        );
1256        let mut duplicate_terminal = OpenAiCompatSseParser::new(&attempt);
1257        duplicate_terminal.feed(usage).expect("usage");
1258        duplicate_terminal
1259            .feed(b"data: [DONE]\n\n")
1260            .expect("terminal");
1261        assert_eq!(
1262            duplicate_terminal
1263                .feed(b"data: [DONE]\n\n")
1264                .expect_err("duplicate terminal")
1265                .kind,
1266            OpenAiCompatStreamErrorKind::DuplicateTerminal
1267        );
1268    }
1269
1270    #[test]
1271    fn status_and_transport_failures_include_429_and_ambiguity() {
1272        for (status, class) in [
1273            (400, OpenAiCompatFailureClass::InvalidRequest),
1274            (401, OpenAiCompatFailureClass::Authentication),
1275            (403, OpenAiCompatFailureClass::Authorization),
1276            (429, OpenAiCompatFailureClass::RateLimited),
1277            (503, OpenAiCompatFailureClass::UpstreamServer),
1278        ] {
1279            let failure =
1280                normalize_http_response(status, None, Some("999999"), false).expect_err("failure");
1281            assert_eq!(failure.class, class);
1282            assert!(failure.retry_after.is_some_and(|retry| retry.capped));
1283        }
1284        assert!(
1285            normalize_http_response(200, Some("text/event-stream; charset=utf-8"), None, false)
1286                .is_ok()
1287        );
1288        assert_eq!(
1289            normalize_http_response(200, Some("application/json"), None, false)
1290                .expect_err("content type")
1291                .class,
1292            OpenAiCompatFailureClass::MismatchedContentType
1293        );
1294        assert_eq!(
1295            normalize_transport_failure(TransportFailureSignal::Timeout, BodyWriteState::Written,)
1296                .class,
1297            OpenAiCompatFailureClass::AmbiguousDispatch
1298        );
1299        assert_eq!(
1300            normalize_transport_failure(
1301                TransportFailureSignal::Cancelled,
1302                BodyWriteState::NotWritten,
1303            )
1304            .class,
1305            OpenAiCompatFailureClass::Cancelled
1306        );
1307    }
1308
1309    #[test]
1310    fn rate_limit_retry_requires_one_short_explicit_delay_before_visibility() {
1311        let rate_limited =
1312            normalize_http_response(429, None, Some("2"), false).expect_err("rate limited");
1313        assert_eq!(
1314            authorized_rate_limit_retry(rate_limited, ReplayDecision::Allowed, false),
1315            OpenAiCompatRetryDecision::RetryOnce {
1316                retry_after_seconds: 2
1317            }
1318        );
1319        let too_long =
1320            normalize_http_response(429, None, Some("3"), false).expect_err("rate limited");
1321        assert_eq!(
1322            authorized_rate_limit_retry(too_long, ReplayDecision::Allowed, false),
1323            OpenAiCompatRetryDecision::NoRetry
1324        );
1325        let post_visible =
1326            normalize_http_response(429, None, Some("2"), false).expect_err("rate limited");
1327        assert_eq!(
1328            authorized_rate_limit_retry(
1329                post_visible,
1330                ReplayDecision::Denied(ReplayBoundary::PublicContent),
1331                false,
1332            ),
1333            OpenAiCompatRetryDecision::NoRetry
1334        );
1335    }
1336}