Skip to main content

provider_core/
openrouter_chat.rs

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