1use 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
33pub 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 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
834pub 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
845pub 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 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 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 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 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 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}