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