1use std::{pin::pin, sync::Arc};
26
27use futures::{Stream, StreamExt};
28
29pub type AsyncStream<T> = std::pin::Pin<Box<dyn Stream<Item = T> + Send>>;
30
31#[cfg(test)]
32mod test_support;
33
34mod anthropic;
35pub use anthropic::AnthropicBackendConfig;
36
37mod accumulator;
38pub use accumulator::MessageAccumulator;
39
40mod driver_core;
41
42mod openai_common;
43pub use openai_common::OpenAIBackendConfig;
44
45mod openai_completions;
46mod openai_responses;
47
48mod sse_parser;
49use sse_parser::SseParser;
50
51pub trait GenerativeModel: Send + Sync {
52 fn generate(&self, input: &[Message]) -> AsyncStream<GenerationEvent>;
58}
59
60#[derive(
64 Debug,
65 Clone,
66 Copy,
67 PartialEq,
68 Eq,
69 Hash,
70 serde::Serialize,
71 serde::Deserialize,
72 schemars::JsonSchema,
73)]
74pub enum Protocol {
75 #[serde(rename = "anthropic-messages")]
76 AnthropicMessages,
77 #[serde(rename = "openai-responses")]
78 OpenAIResponses,
79 #[serde(rename = "openai-completions")]
80 OpenAICompletions,
81}
82
83impl std::fmt::Display for Protocol {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 match self {
86 Protocol::AnthropicMessages => f.write_str("anthropic-messages"),
87 Protocol::OpenAIResponses => f.write_str("openai-responses"),
88 Protocol::OpenAICompletions => f.write_str("openai-completions"),
89 }
90 }
91}
92
93#[derive(
99 Debug,
100 Clone,
101 Copy,
102 PartialEq,
103 Eq,
104 Hash,
105 serde::Serialize,
106 serde::Deserialize,
107 schemars::JsonSchema,
108)]
109#[serde(rename_all = "lowercase")]
110pub enum ThinkingMode {
111 Adaptive,
114 Budget,
117 Effort,
119 None,
121}
122
123impl std::fmt::Display for ThinkingMode {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 f.write_str(match self {
126 ThinkingMode::Adaptive => "adaptive",
127 ThinkingMode::Budget => "budget",
128 ThinkingMode::Effort => "effort",
129 ThinkingMode::None => "none",
130 })
131 }
132}
133
134impl ThinkingMode {
135 pub fn default_for(protocol: Protocol) -> Self {
137 match protocol {
138 Protocol::AnthropicMessages => ThinkingMode::Adaptive,
139 Protocol::OpenAIResponses | Protocol::OpenAICompletions => ThinkingMode::Effort,
140 }
141 }
142
143 pub fn compatible_with(self, protocol: Protocol) -> bool {
145 match protocol {
146 Protocol::AnthropicMessages => {
147 matches!(
148 self,
149 ThinkingMode::Adaptive | ThinkingMode::Budget | ThinkingMode::None
150 )
151 }
152 Protocol::OpenAIResponses | Protocol::OpenAICompletions => {
153 matches!(self, ThinkingMode::Effort | ThinkingMode::None)
154 }
155 }
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
162pub struct RetryPolicy {
163 pub max_attempts: u32,
165 pub initial_backoff: std::time::Duration,
166 pub max_backoff: std::time::Duration,
169 pub backoff_multiplier: f64,
170}
171
172impl Default for RetryPolicy {
173 fn default() -> Self {
174 Self {
175 max_attempts: 3,
176 initial_backoff: std::time::Duration::from_millis(500),
177 max_backoff: std::time::Duration::from_secs(30),
178 backoff_multiplier: 2.0,
179 }
180 }
181}
182
183impl RetryPolicy {
184 pub fn backoff(
192 &self,
193 attempt: u32,
194 retry_after: Option<std::time::Duration>,
195 ) -> std::time::Duration {
196 if attempt <= 1 {
197 return std::time::Duration::ZERO;
198 }
199 let steps = attempt.saturating_sub(2);
200 let factor = self.backoff_multiplier.max(1.0).powi(steps.min(32) as i32);
201 let millis = (self.initial_backoff.as_millis() as f64 * factor).min(u64::MAX as f64);
202 let computed = std::time::Duration::from_millis(millis as u64);
203 retry_after
204 .unwrap_or(std::time::Duration::ZERO)
205 .max(computed)
206 .min(self.max_backoff)
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct ModelSpec {
216 pub key: String,
220 pub api_id: String,
222 pub protocol: Protocol,
223 pub thinking: ThinkingMode,
224 pub context_window_tokens: u64,
226 pub max_image_base64_bytes: u64,
232 pub max_truncated_resumes: u32,
237 pub auto_compact_at_tokens: Option<u64>,
244}
245
246impl std::fmt::Display for ModelSpec {
247 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248 f.write_str(&self.key)
249 }
250}
251
252#[derive(Debug, Clone)]
255pub struct CatalogModel {
256 pub spec: ModelSpec,
257 pub backend: BackendConfig,
258 pub auth_error: Option<String>,
263}
264
265#[derive(Debug, Clone, Default)]
268pub struct ModelCatalog {
269 entries: std::collections::BTreeMap<String, CatalogModel>,
270}
271
272impl ModelCatalog {
273 pub fn new(entries: std::collections::BTreeMap<String, CatalogModel>) -> Self {
274 Self { entries }
275 }
276
277 pub fn get(&self, key: &str) -> Result<&CatalogModel, String> {
281 let Some(entry) = self.entries.get(key) else {
282 if self.entries.is_empty() {
283 return Err(format!(
284 "unknown model {key:?}: no models configured — define [models] \
285 (and [gateways]) in config.toml"
286 ));
287 }
288 return Err(format!(
289 "unknown model {key:?}; configured models: [{}]",
290 self.keys().join(", ")
291 ));
292 };
293 if let Some(err) = &entry.auth_error {
294 return Err(err.clone());
295 }
296 Ok(entry)
297 }
298
299 pub fn contains(&self, key: &str) -> bool {
301 self.entries.contains_key(key)
302 }
303
304 pub fn spec(&self, key: &str) -> Option<&ModelSpec> {
309 self.entries.get(key).map(|entry| &entry.spec)
310 }
311
312 pub fn keys(&self) -> Vec<&str> {
313 self.entries.keys().map(String::as_str).collect()
314 }
315
316 pub fn is_empty(&self) -> bool {
317 self.entries.is_empty()
318 }
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
327#[serde(rename_all = "lowercase")]
328pub enum Effort {
329 Low,
330 Medium,
331 High,
332 Max,
333}
334
335impl Effort {
336 pub fn as_str(self) -> &'static str {
338 match self {
339 Effort::Low => "low",
340 Effort::Medium => "medium",
341 Effort::High => "high",
342 Effort::Max => "max",
343 }
344 }
345
346 pub fn budget_tokens(self) -> u32 {
348 match self {
349 Effort::Low => 1_024,
350 Effort::Medium => 4_096,
351 Effort::High => 16_000,
352 Effort::Max => 64_000,
353 }
354 }
355
356 pub const DEFAULT: Effort = Effort::High;
358}
359
360impl std::fmt::Display for Effort {
361 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362 f.write_str(self.as_str())
363 }
364}
365
366impl std::str::FromStr for Effort {
367 type Err = String;
368
369 fn from_str(s: &str) -> Result<Self, Self::Err> {
370 match s.trim().to_ascii_lowercase().as_str() {
371 "low" | "l" => Ok(Effort::Low),
372 "medium" | "med" | "m" => Ok(Effort::Medium),
373 "high" | "h" => Ok(Effort::High),
374 "max" | "x" => Ok(Effort::Max),
375 other => Err(format!(
376 "unknown effort {other:?}; expected low|medium|high|max"
377 )),
378 }
379 }
380}
381
382#[derive(Debug, Clone)]
384pub enum BackendConfig {
385 Anthropic(AnthropicBackendConfig),
386 OpenAIResponses(OpenAIBackendConfig),
388 OpenAICompletions(OpenAIBackendConfig),
390}
391
392impl BackendConfig {
393 pub fn retry_policy(&self) -> RetryPolicy {
394 match self {
395 Self::Anthropic(config) => config.retry,
396 Self::OpenAIResponses(config) | Self::OpenAICompletions(config) => config.retry,
397 }
398 }
399
400 pub fn protocol(&self) -> Protocol {
401 match self {
402 BackendConfig::Anthropic(_) => Protocol::AnthropicMessages,
403 BackendConfig::OpenAIResponses(_) => Protocol::OpenAIResponses,
404 BackendConfig::OpenAICompletions(_) => Protocol::OpenAICompletions,
405 }
406 }
407}
408
409pub struct GenerativeModelConfig {
412 pub model: ModelSpec,
413 pub tools: Vec<ToolSpec>,
414 pub system_prompt: String,
415 pub backend_config: BackendConfig,
416}
417
418pub fn new(config: GenerativeModelConfig) -> Result<Arc<dyn GenerativeModel>, ModelCreationError> {
421 if config.backend_config.protocol() != config.model.protocol {
422 return Err(ModelCreationError::BadConfig(format!(
423 "model `{}` speaks {} but the backend config is for {}",
424 config.model,
425 config.model.protocol,
426 config.backend_config.protocol()
427 )));
428 }
429 match config.backend_config.clone() {
430 BackendConfig::Anthropic(backend) => {
431 let model = anthropic::AnthropicGenerativeModel::new(config, backend)?;
432 Ok(model as Arc<dyn GenerativeModel>)
433 }
434 BackendConfig::OpenAIResponses(backend) => {
435 let model = openai_responses::OpenAIResponsesGenerativeModel::new(config, backend)?;
436 Ok(model as Arc<dyn GenerativeModel>)
437 }
438 BackendConfig::OpenAICompletions(backend) => {
439 let model = openai_completions::OpenAICompletionsGenerativeModel::new(config, backend)?;
440 Ok(model as Arc<dyn GenerativeModel>)
441 }
442 }
443}
444
445#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
446pub enum Message {
447 UserMessage {
448 content: Vec<Content>,
449 },
450 ToolResults {
451 tool_use_results: Vec<ToolResult>,
452 },
453 AssistantMessage {
454 content: Vec<Content>,
455 tool_uses: Vec<ToolUse>,
456 turn_end_reason: Option<TurnEndReason>,
457 },
458}
459
460impl Message {
461 pub fn content(&self) -> impl Iterator<Item = &Content> {
462 let (content, results): (&[Content], &[ToolResult]) = match self {
463 Self::UserMessage { content } | Self::AssistantMessage { content, .. } => {
464 (content, &[])
465 }
466 Self::ToolResults { tool_use_results } => (&[], tool_use_results),
467 };
468 content
469 .iter()
470 .chain(results.iter().flat_map(|result| &result.content))
471 }
472 pub fn is_user_turn(&self) -> bool {
474 matches!(self, Self::UserMessage { content }
475 if content.is_empty() || content.iter().any(|part| !matches!(part, Content::System { .. })))
476 }
477}
478#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
479pub enum TurnEndReason {
480 EndTurn,
481 MaxTokens,
482 ToolUse,
483 Other(String),
485}
486
487#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
488pub struct ToolSpec {
489 pub name: String,
490 pub description: String,
491 pub input_schema: serde_json::Value,
492}
493
494#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
499pub struct ToolUse {
500 pub name: String,
501 pub input: serde_json::Value,
502}
503
504#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
505pub struct ToolResult {
506 pub content: Vec<Content>,
507 pub is_error: bool,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
510 pub status: Option<String>,
511}
512
513impl ToolResult {
514 pub fn ok(content: Vec<Content>) -> Self {
515 Self {
516 content,
517 is_error: false,
518 status: None,
519 }
520 }
521
522 pub fn text(text: impl Into<String>) -> Self {
523 Self {
524 content: vec![Content::Text { text: text.into() }],
525 is_error: false,
526 status: None,
527 }
528 }
529
530 pub fn err(text: impl Into<String>) -> Self {
531 Self {
532 content: vec![Content::Text { text: text.into() }],
533 is_error: true,
534 status: None,
535 }
536 }
537
538 pub fn with_status(mut self, status: impl Into<String>) -> Self {
539 self.status = Some(status.into());
540 self
541 }
542}
543
544#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
545pub enum Content {
546 Text {
547 text: String,
548 },
549 System {
553 kind: String,
554 text: String,
555 data: serde_json::Value,
556 },
557 Image {
558 source: String,
561 },
562 Thinking {
568 text: String,
569 signature: Option<String>,
571 redacted: bool,
573 },
574}
575
576pub fn answer_content(content: &[Content]) -> Vec<Content> {
578 content
579 .iter()
580 .filter(|c| matches!(c, Content::Text { .. } | Content::Image { .. }))
581 .cloned()
582 .collect()
583}
584
585pub(crate) fn wire_tool_ids(input: &[Message]) -> Result<Vec<Vec<String>>, GenerateError> {
606 if input.iter().flat_map(Message::content).any(|part| {
607 matches!(part,
608 Content::Image { source } if source.starts_with("myco-image:"))
609 }) {
610 return Err(GenerateError::ExecutionError(
611 "resolve local image references before calling a provider".into(),
612 ));
613 }
614 let mut out: Vec<Vec<String>> = Vec::with_capacity(input.len());
615 for (i, message) in input.iter().enumerate() {
616 let ids = match message {
617 Message::UserMessage { .. } => Vec::new(),
618 Message::AssistantMessage { tool_uses, .. } => {
619 (0..tool_uses.len()).map(|j| mint_tool_id(i, j)).collect()
620 }
621 Message::ToolResults { tool_use_results } => {
622 let preceding_uses = match i.checked_sub(1).map(|p| &input[p]) {
623 Some(Message::AssistantMessage { tool_uses, .. }) => tool_uses.len(),
624 _ => 0,
625 };
626 if preceding_uses != tool_use_results.len() {
627 return Err(GenerateError::ExecutionError(format!(
628 "history is malformed: message {i} carries {} tool results but the \
629 message before it has {preceding_uses} tool calls",
630 tool_use_results.len()
631 )));
632 }
633 out[i - 1].clone()
634 }
635 };
636 out.push(ids);
637 }
638 Ok(out)
639}
640
641fn mint_tool_id(message_index: usize, ordinal: usize) -> String {
649 const CAP: usize = 36 * 36 * 36 * 36;
650 assert!(message_index < CAP && ordinal < CAP);
653 let mut id = String::with_capacity(9);
654 id.push('t');
655 for n in [message_index, ordinal] {
656 for place in [36 * 36 * 36, 36 * 36, 36, 1] {
657 id.push(char::from_digit(((n / place) % 36) as u32, 36).unwrap());
658 }
659 }
660 id
661}
662
663#[derive(Debug, Clone)]
665pub enum GenerationEvent {
666 Part(MessagePart),
667 Failure(GenerationFailure),
668}
669
670impl GenerationEvent {
671 pub fn into_result(self) -> Result<MessagePart, GenerateError> {
672 match self {
673 Self::Part(part) => Ok(part),
674 Self::Failure(failure) => Err(failure.cause),
675 }
676 }
677}
678
679#[derive(Debug, Clone)]
680pub struct GenerationFailure {
681 pub cause: GenerateError,
682 pub retryable: bool,
684 pub retry_after: Option<std::time::Duration>,
685}
686
687impl GenerationFailure {
688 pub fn terminal(cause: GenerateError) -> Self {
689 Self {
690 cause,
691 retryable: false,
692 retry_after: None,
693 }
694 }
695
696 pub fn transient(cause: GenerateError, retry_after: Option<std::time::Duration>) -> Self {
697 Self {
698 cause,
699 retryable: true,
700 retry_after,
701 }
702 }
703}
704
705#[derive(Debug, Clone)]
706pub enum MessagePart {
707 MessageStart,
708 ContentStart(ContentStart),
709 ContentDelta(ContentDelta),
710 ToolUseStart(ToolUseStart),
711 ToolUseDelta(ToolUseDelta),
712 TurnEndReason(TurnEndReason),
713 Usage(TokenUsage),
715}
716
717#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
720pub struct TokenUsage {
721 #[serde(default)]
722 pub input_tokens: u64,
723 #[serde(default)]
724 pub output_tokens: u64,
725 #[serde(default)]
726 pub cached_input_tokens: u64,
727}
728
729impl TokenUsage {
730 pub fn context_tokens(self) -> u64 {
733 self.input_tokens
734 }
735
736 pub fn merge(self, next: TokenUsage) -> TokenUsage {
739 fn pick(prev: u64, next: u64) -> u64 {
740 if next != 0 { next } else { prev }
741 }
742 TokenUsage {
743 input_tokens: pick(self.input_tokens, next.input_tokens),
744 output_tokens: pick(self.output_tokens, next.output_tokens),
745 cached_input_tokens: pick(self.cached_input_tokens, next.cached_input_tokens),
746 }
747 }
748}
749
750#[derive(Debug, Clone)]
751pub enum ContentStart {
752 Text {
753 index: usize,
754 },
755 Image {
756 index: usize,
757 },
758 Thinking {
759 index: usize,
760 signature: Option<String>,
761 redacted: bool,
762 },
763}
764
765#[derive(Debug, Clone)]
766pub enum ContentDelta {
767 Text { index: usize, delta: String },
768 Image { index: usize, delta: String },
769 Thinking { index: usize, delta: String },
770}
771
772#[derive(Debug, Clone)]
773pub struct ToolUseStart {
774 pub index: usize,
775 pub name: String,
776}
777
778#[derive(Debug, Clone)]
779pub struct ToolUseDelta {
780 pub index: usize,
781 pub input_json_delta: String,
782}
783
784#[derive(Debug, Clone)]
789pub struct GenerateOutput {
790 pub content: Vec<Content>,
791 pub tool_uses: Vec<ToolUse>,
792 pub turn_end_reason: TurnEndReason,
793 pub usage: Option<TokenUsage>,
795}
796
797impl GenerateOutput {
798 pub async fn from_generation(
803 stream: impl Stream<Item = GenerationEvent>,
804 ) -> Result<Self, GenerateError> {
805 Self::from_stream(stream.map(GenerationEvent::into_result)).await
806 }
807
808 pub async fn from_stream(
809 stream: impl Stream<Item = Result<MessagePart, GenerateError>>,
810 ) -> Result<Self, GenerateError> {
811 let mut accumulator = MessageAccumulator::default();
812 let mut stream = pin!(stream);
813 while let Some(part) = stream.next().await {
814 accumulator.push(&part?)?;
815 }
816 accumulator.finish()
817 }
818}
819
820#[derive(thiserror::Error, Debug)]
821pub enum ModelCreationError {
822 #[error("Invalid configuration parameters supplied: {0}")]
823 BadConfig(String),
824
825 #[error("Uncategorized error occurred: {0}")]
826 Uncategorized(String),
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832 use crate::test_support::{assistant_tool, tool_results, user};
833
834 #[test]
839 fn wire_ids_are_nine_alphanumerics() {
840 for (i, j) in [(0, 0), (1, 3), (255, 7), (46_655, 12)] {
841 let id = mint_tool_id(i, j);
842 assert_eq!(id.len(), 9, "{id:?}");
843 assert!(id.chars().all(|c| c.is_ascii_alphanumeric()), "{id:?}");
844 }
845 }
846
847 #[test]
849 fn wire_ids_pair_results_with_uses_positionally() {
850 let input = [
851 user("hi"),
852 assistant_tool(None, "bash", serde_json::json!({})),
853 tool_results(&["ok"]),
854 ];
855 let ids = wire_tool_ids(&input).unwrap();
856 assert!(ids[0].is_empty());
857 assert_eq!(ids[1].len(), 1);
858 assert_eq!(ids[1], ids[2]);
859 }
860
861 #[test]
864 fn wire_ids_stable_as_history_grows() {
865 let mut input = vec![
866 user("hi"),
867 assistant_tool(None, "bash", serde_json::json!({})),
868 tool_results(&["ok"]),
869 ];
870 let before = wire_tool_ids(&input).unwrap();
871 input.push(user("more"));
872 input.push(assistant_tool(None, "bash", serde_json::json!({})));
873 input.push(tool_results(&["ok"]));
874 let after = wire_tool_ids(&input).unwrap();
875 assert_eq!(before[..], after[..3]);
876 assert_ne!(after[1], after[4], "distinct calls must get distinct ids");
877 }
878
879 #[test]
883 fn wire_ids_fail_loud_on_broken_pairing() {
884 let orphaned = [user("hi"), tool_results(&["ok"])];
885 assert!(wire_tool_ids(&orphaned).is_err());
886
887 let miscounted = [
888 assistant_tool(None, "bash", serde_json::json!({})),
889 tool_results(&["ok", "ok"]),
890 ];
891 assert!(wire_tool_ids(&miscounted).is_err());
892 }
893
894 #[tokio::test]
895 async fn accumulate_thinking_then_text() {
896 use futures::stream;
897
898 let parts = vec![
899 Ok(MessagePart::MessageStart),
900 Ok(MessagePart::ContentStart(ContentStart::Thinking {
901 index: 0,
902 signature: Some("sig".into()),
903 redacted: false,
904 })),
905 Ok(MessagePart::ContentDelta(ContentDelta::Thinking {
906 index: 0,
907 delta: "reason".into(),
908 })),
909 Ok(MessagePart::ContentStart(ContentStart::Text { index: 1 })),
910 Ok(MessagePart::ContentDelta(ContentDelta::Text {
911 index: 1,
912 delta: "answer".into(),
913 })),
914 Ok(MessagePart::TurnEndReason(TurnEndReason::EndTurn)),
915 ];
916 let output = GenerateOutput::from_stream(stream::iter(parts))
917 .await
918 .expect("accumulate");
919 assert_eq!(output.content.len(), 2);
920 match &output.content[0] {
921 Content::Thinking {
922 text,
923 signature,
924 redacted,
925 } => {
926 assert_eq!(text, "reason");
927 assert_eq!(signature.as_deref(), Some("sig"));
928 assert!(!*redacted);
929 }
930 other => panic!("expected thinking, got {other:?}"),
931 }
932 match &output.content[1] {
933 Content::Text { text } => assert_eq!(text, "answer"),
934 other => panic!("expected text, got {other:?}"),
935 }
936 assert_eq!(answer_content(&output.content).len(), 1);
937 }
938
939 #[test]
940 fn token_usage_merge_prefers_known_fields() {
941 let start = TokenUsage {
942 input_tokens: 2195,
943 output_tokens: 1,
944 cached_input_tokens: 2000,
945 };
946 let delta = TokenUsage {
947 input_tokens: 0,
948 output_tokens: 89,
949 cached_input_tokens: 0,
950 };
951 let merged = start.merge(delta);
952 assert_eq!(merged.input_tokens, 2195);
953 assert_eq!(merged.output_tokens, 89);
954 assert_eq!(merged.cached_input_tokens, 2000);
955 assert_eq!(merged.context_tokens(), 2195);
956 }
957
958 #[tokio::test]
959 async fn accumulate_merges_split_usage() {
960 use futures::stream;
961
962 let parts = vec![
963 Ok(MessagePart::MessageStart),
964 Ok(MessagePart::Usage(TokenUsage {
965 input_tokens: 2195,
966 output_tokens: 1,
967 cached_input_tokens: 2000,
968 })),
969 Ok(MessagePart::ContentStart(ContentStart::Text { index: 0 })),
970 Ok(MessagePart::ContentDelta(ContentDelta::Text {
971 index: 0,
972 delta: "hi".into(),
973 })),
974 Ok(MessagePart::Usage(TokenUsage {
975 input_tokens: 0,
976 output_tokens: 89,
977 cached_input_tokens: 0,
978 })),
979 Ok(MessagePart::TurnEndReason(TurnEndReason::EndTurn)),
980 ];
981 let output = GenerateOutput::from_stream(stream::iter(parts))
982 .await
983 .expect("accumulate");
984 let usage = output.usage.expect("usage present");
985 assert_eq!(usage.input_tokens, 2195);
986 assert_eq!(usage.output_tokens, 89);
987 assert_eq!(usage.cached_input_tokens, 2000);
988 assert_eq!(usage.context_tokens(), 2195);
989 }
990
991 #[test]
995 fn retry_backoff_grows_caps_and_honours_retry_after() {
996 use std::time::Duration;
997 let policy = RetryPolicy {
998 max_attempts: 6,
999 initial_backoff: Duration::from_millis(100),
1000 max_backoff: Duration::from_millis(1000),
1001 backoff_multiplier: 2.0,
1002 };
1003
1004 assert_eq!(policy.backoff(1, None), Duration::ZERO);
1006 assert_eq!(policy.backoff(2, None), Duration::from_millis(100));
1007 assert_eq!(policy.backoff(3, None), Duration::from_millis(200));
1008 assert_eq!(policy.backoff(4, None), Duration::from_millis(400));
1009 assert_eq!(policy.backoff(9, None), Duration::from_millis(1000));
1011
1012 assert_eq!(
1014 policy.backoff(2, Some(Duration::from_millis(500))),
1015 Duration::from_millis(500)
1016 );
1017 assert_eq!(
1019 policy.backoff(2, Some(Duration::from_secs(3600))),
1020 Duration::from_millis(1000)
1021 );
1022 assert_eq!(
1024 policy.backoff(3, Some(Duration::from_millis(1))),
1025 Duration::from_millis(200)
1026 );
1027 }
1028
1029 fn spec(key: &str, protocol: Protocol) -> ModelSpec {
1030 ModelSpec {
1031 key: key.into(),
1032 api_id: key.into(),
1033 protocol,
1034 thinking: ThinkingMode::default_for(protocol),
1035 context_window_tokens: 1_000_000,
1036 max_image_base64_bytes: 5 * 1024 * 1024,
1037 max_truncated_resumes: 3,
1038 auto_compact_at_tokens: None,
1039 }
1040 }
1041
1042 #[test]
1043 fn thinking_defaults_and_protocol_compatibility() {
1044 assert_eq!(
1045 ThinkingMode::default_for(Protocol::AnthropicMessages),
1046 ThinkingMode::Adaptive
1047 );
1048 assert_eq!(
1049 ThinkingMode::default_for(Protocol::OpenAIResponses),
1050 ThinkingMode::Effort
1051 );
1052 assert!(ThinkingMode::Budget.compatible_with(Protocol::AnthropicMessages));
1053 assert!(ThinkingMode::None.compatible_with(Protocol::AnthropicMessages));
1054 assert!(!ThinkingMode::Effort.compatible_with(Protocol::AnthropicMessages));
1055 assert!(ThinkingMode::None.compatible_with(Protocol::OpenAIResponses));
1056 assert_eq!(
1058 ThinkingMode::default_for(Protocol::OpenAICompletions),
1059 ThinkingMode::Effort
1060 );
1061 assert!(ThinkingMode::None.compatible_with(Protocol::OpenAICompletions));
1062 assert!(!ThinkingMode::Adaptive.compatible_with(Protocol::OpenAICompletions));
1063 assert!(!ThinkingMode::Adaptive.compatible_with(Protocol::OpenAIResponses));
1064 assert!(!ThinkingMode::Budget.compatible_with(Protocol::OpenAIResponses));
1065 }
1066
1067 #[test]
1068 fn protocol_serde_uses_config_strings() {
1069 assert_eq!(
1070 serde_json::to_value(Protocol::AnthropicMessages).unwrap(),
1071 serde_json::json!("anthropic-messages")
1072 );
1073 assert_eq!(
1074 serde_json::from_value::<Protocol>(serde_json::json!("openai-responses")).unwrap(),
1075 Protocol::OpenAIResponses
1076 );
1077 assert_eq!(
1078 serde_json::from_value::<Protocol>(serde_json::json!("openai-completions")).unwrap(),
1079 Protocol::OpenAICompletions
1080 );
1081 }
1082
1083 #[test]
1084 fn empty_catalog_get_says_no_models_configured() {
1085 let catalog = ModelCatalog::default();
1086 assert!(catalog.is_empty());
1087 let err = catalog.get("kimi-k3").unwrap_err();
1088 assert!(err.contains("no models configured"), "{err}");
1089 assert!(err.contains("[models]"), "{err}");
1090 }
1091
1092 #[test]
1093 fn catalog_get_unknown_key_lists_configured_models() {
1094 let entry = CatalogModel {
1095 spec: spec("opus", Protocol::AnthropicMessages),
1096 backend: BackendConfig::Anthropic(AnthropicBackendConfig::default()),
1097 auth_error: None,
1098 };
1099 let catalog = ModelCatalog::new([("opus".to_string(), entry)].into());
1100 let err = catalog.get("opsu").unwrap_err();
1101 assert!(err.contains("unknown model \"opsu\""), "{err}");
1102 assert!(err.contains("[opus]"), "{err}");
1103 assert!(catalog.get("opus").is_ok());
1104 }
1105
1106 #[test]
1107 fn catalog_get_reports_deferred_auth_error() {
1108 let entry = CatalogModel {
1109 spec: spec("kimi", Protocol::OpenAIResponses),
1110 backend: BackendConfig::OpenAIResponses(OpenAIBackendConfig::default()),
1111 auth_error: Some("model `kimi`: auth env:OPENROUTER_API_KEY is unset".into()),
1112 };
1113 let catalog = ModelCatalog::new([("kimi".to_string(), entry)].into());
1114 let err = catalog.get("kimi").unwrap_err();
1115 assert!(err.contains("OPENROUTER_API_KEY"), "{err}");
1116 }
1117
1118 #[test]
1119 fn new_rejects_protocol_mismatch() {
1120 let result = new(GenerativeModelConfig {
1121 model: spec("grok", Protocol::OpenAIResponses),
1122 tools: vec![],
1123 system_prompt: String::new(),
1124 backend_config: BackendConfig::Anthropic(AnthropicBackendConfig {
1125 anthropic_auth_token: "dummy".into(),
1126 ..Default::default()
1127 }),
1128 });
1129 let err = match result {
1130 Ok(_) => panic!("expected mismatch"),
1131 Err(e) => e,
1132 };
1133 assert!(err.to_string().contains("speaks openai-responses"), "{err}");
1134 }
1135
1136 #[test]
1140 fn oversized_request_is_refused_before_upload() {
1141 for limit in [1024, MAX_REQUEST_BYTES, 60_000_000] {
1142 assert!(check_request_size(limit, limit, "Anthropic").is_ok());
1143 let err = check_request_size(limit + 1, limit, "Anthropic").unwrap_err();
1144 assert!(
1145 matches!(err, GenerateError::RequestTooLargeError(_)),
1146 "{err:?}"
1147 );
1148 assert_eq!(err.recovery(), Recovery::OmitLastMessage);
1149 assert!(
1150 err.to_string().contains(&format!("limit is {limit} bytes")),
1151 "{err}"
1152 );
1153 }
1154 }
1155
1156 #[test]
1157 fn older_backend_configs_default_to_a_thirty_megabyte_request_cap() {
1158 let mut anthropic = serde_json::to_value(AnthropicBackendConfig::default()).unwrap();
1159 let mut openai = serde_json::to_value(OpenAIBackendConfig::default()).unwrap();
1160 for config in [&mut anthropic, &mut openai] {
1161 assert_eq!(config["max_request_bytes"], 30_000_000);
1162 config.as_object_mut().unwrap().remove("max_request_bytes");
1163 }
1164 assert_eq!(
1165 serde_json::from_value::<AnthropicBackendConfig>(anthropic)
1166 .unwrap()
1167 .max_request_bytes,
1168 30_000_000
1169 );
1170 assert_eq!(
1171 serde_json::from_value::<OpenAIBackendConfig>(openai)
1172 .unwrap()
1173 .max_request_bytes,
1174 30_000_000
1175 );
1176 }
1177
1178 #[test]
1181 fn provider_size_rejections_map_to_the_same_recovery() {
1182 let too_large = http_error(
1183 reqwest::StatusCode::PAYLOAD_TOO_LARGE,
1184 "HTTP 413: too big".into(),
1185 );
1186 assert_eq!(too_large.recovery(), Recovery::OmitLastMessage);
1187
1188 let named = http_error(
1190 reqwest::StatusCode::BAD_REQUEST,
1191 r#"HTTP 400: {"error":{"type":"request_too_large"}}"#.into(),
1192 );
1193 assert_eq!(named.recovery(), Recovery::OmitLastMessage);
1194
1195 let described = http_error(
1198 reqwest::StatusCode::BAD_REQUEST,
1199 r#"HTTP 400: {"error":{"code":400,"message":"The message size (31271377 bytes) \
1200 exceeds 30.000MB limit.","status":"FAILED_PRECONDITION"}}"#
1201 .into(),
1202 );
1203 assert_eq!(described.recovery(), Recovery::OmitLastMessage);
1204
1205 let extent = http_error(
1206 reqwest::StatusCode::BAD_REQUEST,
1207 r#"{"type":"error","error":{"type":"invalid_request_error","message":"messages.9.content.13.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2576 pixels"}}"#.into(),
1208 );
1209 assert_eq!(extent.recovery(), Recovery::OmitLastMessage);
1210
1211 let unrelated = http_error(
1212 reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1213 "HTTP 500: overloaded".into(),
1214 );
1215 assert_eq!(unrelated.recovery(), Recovery::Retry);
1216 }
1217
1218 #[test]
1222 fn ordinary_failures_are_not_read_as_size_rejections() {
1223 for body in [
1224 r#"HTTP 400: {"error":{"message":"max_tokens exceeds the model's limit"}}"#,
1225 r#"HTTP 400: {"error":{"message":"messages: unexpected role"}}"#,
1226 r#"HTTP 401: {"error":{"message":"invalid x-api-key"}}"#,
1227 ] {
1228 let err = http_error(reqwest::StatusCode::BAD_REQUEST, body.into());
1229 assert_eq!(err.recovery(), Recovery::Retry, "{body}");
1230 }
1231 }
1232
1233 #[test]
1234 fn message_types_serde_roundtrip() {
1235 let messages = vec![
1236 Message::UserMessage {
1237 content: vec![
1238 Content::Text { text: "hi".into() },
1239 Content::Image {
1240 source: "data".into(),
1241 },
1242 ],
1243 },
1244 Message::AssistantMessage {
1245 content: vec![Content::Text { text: "ok".into() }],
1246 tool_uses: vec![ToolUse {
1247 name: "bash".into(),
1248 input: serde_json::json!({"command": "true"}),
1249 }],
1250 turn_end_reason: Some(TurnEndReason::ToolUse),
1251 },
1252 Message::ToolResults {
1253 tool_use_results: vec![ToolResult {
1254 content: vec![Content::Text {
1255 text: "done".into(),
1256 }],
1257 is_error: false,
1258 status: None,
1259 }],
1260 },
1261 Message::AssistantMessage {
1262 content: vec![],
1263 tool_uses: vec![],
1264 turn_end_reason: Some(TurnEndReason::Other("Anthropic::PauseTurn".into())),
1265 },
1266 ];
1267 let json = serde_json::to_string(&messages).expect("serialize");
1268 let back: Vec<Message> = serde_json::from_str(&json).expect("deserialize");
1269 assert_eq!(
1270 serde_json::to_value(&back).unwrap(),
1271 serde_json::to_value(&messages).unwrap()
1272 );
1273 }
1274}
1275
1276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1285pub enum Recovery {
1286 Retry,
1289 OmitLastMessage,
1293 Stop,
1295}
1296
1297pub const MAX_REQUEST_BYTES: usize = 30_000_000;
1303
1304fn default_max_request_bytes() -> usize {
1305 MAX_REQUEST_BYTES
1306}
1307
1308#[derive(thiserror::Error, Debug, Clone)]
1309pub enum GenerateError {
1310 #[error("Something went wrong while generating a response: {0}")]
1311 ExecutionError(String),
1312
1313 #[error("Generation succeeded, but the model refused to comply: {0}")]
1314 RefusalError(String),
1315
1316 #[error("Generation succeeded, but the output was malformed or corrupted: {0}")]
1317 MalformedResponseError(String),
1318
1319 #[error("The request is too large to send: {0}")]
1322 RequestTooLargeError(String),
1323}
1324
1325impl GenerateError {
1326 pub fn recovery(&self) -> Recovery {
1327 match self {
1328 GenerateError::RequestTooLargeError(_) => Recovery::OmitLastMessage,
1329 GenerateError::ExecutionError(_)
1330 | GenerateError::RefusalError(_)
1331 | GenerateError::MalformedResponseError(_) => Recovery::Retry,
1332 }
1333 }
1334}
1335
1336pub(crate) fn check_request_size(
1341 len: usize,
1342 limit: usize,
1343 provider: &str,
1344) -> Result<(), GenerateError> {
1345 if len > limit {
1346 return Err(GenerateError::RequestTooLargeError(format!(
1347 "the {provider} request is {len} bytes; the configured limit is {limit} bytes \
1348 (max_request_bytes). Images accumulate in the conversation; reduce \
1349 attachments or compact the session before retrying. Raise the gateway's \
1350 max_request_bytes only if it supports larger requests",
1351 )));
1352 }
1353 Ok(())
1354}
1355
1356pub(crate) fn http_error(status: reqwest::StatusCode, message: String) -> GenerateError {
1367 if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE || describes_a_size_rejection(&message) {
1368 GenerateError::RequestTooLargeError(message)
1369 } else {
1370 GenerateError::ExecutionError(message)
1371 }
1372}
1373
1374fn describes_a_size_rejection(message: &str) -> bool {
1380 let message = message.to_ascii_lowercase();
1381 message.contains("too_large")
1382 || message.contains("too large")
1383 || (message.contains("size") && message.contains("exceeds"))
1384 || (message.contains("image dimensions")
1385 && message.contains("exceed")
1386 && message.contains("max allowed size"))
1387}