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 #[serde(default)]
173 pub max_elapsed: Option<std::time::Duration>,
174}
175
176impl Default for RetryPolicy {
177 fn default() -> Self {
178 Self {
179 max_attempts: 3,
180 initial_backoff: std::time::Duration::from_millis(500),
181 max_backoff: std::time::Duration::from_secs(30),
182 backoff_multiplier: 2.0,
183 max_elapsed: None,
184 }
185 }
186}
187
188impl RetryPolicy {
189 pub fn backoff(
198 &self,
199 attempt: u32,
200 retry_after: Option<std::time::Duration>,
201 ) -> std::time::Duration {
202 if attempt <= 1 {
203 return std::time::Duration::ZERO;
204 }
205 let steps = attempt.saturating_sub(2);
206 let factor = self.backoff_multiplier.max(1.0).powi(steps.min(32) as i32);
207 let millis = (self.initial_backoff.as_millis() as f64 * factor).min(u64::MAX as f64);
208 let computed = std::time::Duration::from_millis(millis as u64);
209 retry_after
210 .unwrap_or(std::time::Duration::ZERO)
211 .max(computed.min(self.max_backoff))
212 }
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct ModelSpec {
221 pub key: String,
225 pub api_id: String,
227 pub protocol: Protocol,
228 pub thinking: ThinkingMode,
229 pub context_window_tokens: u64,
231 pub max_image_base64_bytes: u64,
237 pub max_truncated_resumes: u32,
242 pub auto_compact_at_tokens: Option<u64>,
250}
251
252impl std::fmt::Display for ModelSpec {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 f.write_str(&self.key)
255 }
256}
257
258#[derive(Debug, Clone)]
261pub struct CatalogModel {
262 pub spec: ModelSpec,
263 pub backend: BackendConfig,
264 pub auth_error: Option<String>,
269}
270
271#[derive(Debug, Clone, Default)]
274pub struct ModelCatalog {
275 entries: std::collections::BTreeMap<String, CatalogModel>,
276}
277
278impl ModelCatalog {
279 pub fn new(entries: std::collections::BTreeMap<String, CatalogModel>) -> Self {
280 Self { entries }
281 }
282
283 pub fn get(&self, key: &str) -> Result<&CatalogModel, String> {
287 let Some(entry) = self.entries.get(key) else {
288 if self.entries.is_empty() {
289 return Err(format!(
290 "unknown model {key:?}: no models configured — define [models] \
291 (and [gateways]) in config.toml"
292 ));
293 }
294 return Err(format!(
295 "unknown model {key:?}; configured models: [{}]",
296 self.keys().join(", ")
297 ));
298 };
299 if let Some(err) = &entry.auth_error {
300 return Err(err.clone());
301 }
302 Ok(entry)
303 }
304
305 pub fn contains(&self, key: &str) -> bool {
307 self.entries.contains_key(key)
308 }
309
310 pub fn spec(&self, key: &str) -> Option<&ModelSpec> {
315 self.entries.get(key).map(|entry| &entry.spec)
316 }
317
318 pub fn keys(&self) -> Vec<&str> {
319 self.entries.keys().map(String::as_str).collect()
320 }
321
322 pub fn is_empty(&self) -> bool {
323 self.entries.is_empty()
324 }
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
333#[serde(rename_all = "lowercase")]
334pub enum Effort {
335 Low,
336 Medium,
337 High,
338 Max,
339}
340
341impl Effort {
342 pub fn as_str(self) -> &'static str {
344 match self {
345 Effort::Low => "low",
346 Effort::Medium => "medium",
347 Effort::High => "high",
348 Effort::Max => "max",
349 }
350 }
351
352 pub fn budget_tokens(self) -> u32 {
354 match self {
355 Effort::Low => 1_024,
356 Effort::Medium => 4_096,
357 Effort::High => 16_000,
358 Effort::Max => 64_000,
359 }
360 }
361
362 pub const DEFAULT: Effort = Effort::High;
364}
365
366impl std::fmt::Display for Effort {
367 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368 f.write_str(self.as_str())
369 }
370}
371
372impl std::str::FromStr for Effort {
373 type Err = String;
374
375 fn from_str(s: &str) -> Result<Self, Self::Err> {
376 match s.trim().to_ascii_lowercase().as_str() {
377 "low" | "l" => Ok(Effort::Low),
378 "medium" | "med" | "m" => Ok(Effort::Medium),
379 "high" | "h" => Ok(Effort::High),
380 "max" | "x" => Ok(Effort::Max),
381 other => Err(format!(
382 "unknown effort {other:?}; expected low|medium|high|max"
383 )),
384 }
385 }
386}
387
388#[derive(Debug, Clone)]
390pub enum BackendConfig {
391 Anthropic(AnthropicBackendConfig),
392 OpenAIResponses(OpenAIBackendConfig),
394 OpenAICompletions(OpenAIBackendConfig),
396}
397
398impl BackendConfig {
399 pub fn retry_policy(&self) -> RetryPolicy {
400 match self {
401 Self::Anthropic(config) => config.retry,
402 Self::OpenAIResponses(config) | Self::OpenAICompletions(config) => config.retry,
403 }
404 }
405
406 pub fn protocol(&self) -> Protocol {
407 match self {
408 BackendConfig::Anthropic(_) => Protocol::AnthropicMessages,
409 BackendConfig::OpenAIResponses(_) => Protocol::OpenAIResponses,
410 BackendConfig::OpenAICompletions(_) => Protocol::OpenAICompletions,
411 }
412 }
413}
414
415pub struct GenerativeModelConfig {
418 pub model: ModelSpec,
419 pub tools: Vec<ToolSpec>,
420 pub system_prompt: String,
421 pub backend_config: BackendConfig,
422}
423
424pub fn new(config: GenerativeModelConfig) -> Result<Arc<dyn GenerativeModel>, ModelCreationError> {
427 if config.backend_config.protocol() != config.model.protocol {
428 return Err(ModelCreationError::BadConfig(format!(
429 "model `{}` speaks {} but the backend config is for {}",
430 config.model,
431 config.model.protocol,
432 config.backend_config.protocol()
433 )));
434 }
435 match config.backend_config.clone() {
436 BackendConfig::Anthropic(backend) => {
437 let model = anthropic::AnthropicGenerativeModel::new(config, backend)?;
438 Ok(model as Arc<dyn GenerativeModel>)
439 }
440 BackendConfig::OpenAIResponses(backend) => {
441 let model = openai_responses::OpenAIResponsesGenerativeModel::new(config, backend)?;
442 Ok(model as Arc<dyn GenerativeModel>)
443 }
444 BackendConfig::OpenAICompletions(backend) => {
445 let model = openai_completions::OpenAICompletionsGenerativeModel::new(config, backend)?;
446 Ok(model as Arc<dyn GenerativeModel>)
447 }
448 }
449}
450
451#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
452pub enum Message {
453 UserMessage {
454 content: Vec<Content>,
455 },
456 ToolResults {
457 tool_use_results: Vec<ToolResult>,
458 },
459 AssistantMessage {
460 content: Vec<Content>,
461 tool_uses: Vec<ToolUse>,
462 turn_end_reason: Option<TurnEndReason>,
463 },
464}
465
466impl Message {
467 pub fn content(&self) -> impl Iterator<Item = &Content> {
468 let (content, results): (&[Content], &[ToolResult]) = match self {
469 Self::UserMessage { content } | Self::AssistantMessage { content, .. } => {
470 (content, &[])
471 }
472 Self::ToolResults { tool_use_results } => (&[], tool_use_results),
473 };
474 content
475 .iter()
476 .chain(results.iter().flat_map(|result| &result.content))
477 }
478 pub fn is_user_turn(&self) -> bool {
480 matches!(self, Self::UserMessage { content }
481 if content.is_empty() || content.iter().any(|part| !matches!(part, Content::System { .. })))
482 }
483}
484#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
485pub enum TurnEndReason {
486 EndTurn,
487 MaxTokens,
488 ToolUse,
489 Other(String),
491}
492
493#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
494pub struct ToolSpec {
495 pub name: String,
496 pub description: String,
497 pub input_schema: serde_json::Value,
498}
499
500#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
505pub struct ToolUse {
506 pub name: String,
507 pub input: serde_json::Value,
508 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub thought_signature: Option<String>,
515}
516
517#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
518pub struct ToolResourceRef {
519 pub id: String,
520 pub instance_id: String,
522}
523
524#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
525pub struct ToolResult {
526 pub content: Vec<Content>,
527 pub is_error: bool,
528 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub status: Option<String>,
531 #[serde(default, skip_serializing_if = "Option::is_none")]
534 pub resource: Option<ToolResourceRef>,
535}
536
537impl ToolResult {
538 pub fn ok(content: Vec<Content>) -> Self {
539 Self {
540 content,
541 is_error: false,
542 status: None,
543 resource: None,
544 }
545 }
546
547 pub fn text(text: impl Into<String>) -> Self {
548 Self {
549 content: vec![Content::Text { text: text.into() }],
550 is_error: false,
551 status: None,
552 resource: None,
553 }
554 }
555
556 pub fn err(text: impl Into<String>) -> Self {
557 Self {
558 content: vec![Content::Text { text: text.into() }],
559 is_error: true,
560 status: None,
561 resource: None,
562 }
563 }
564
565 pub fn with_status(mut self, status: impl Into<String>) -> Self {
566 self.status = Some(status.into());
567 self
568 }
569}
570
571#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
572pub enum Content {
573 Text {
574 text: String,
575 },
576 System {
580 kind: String,
581 text: String,
582 data: serde_json::Value,
583 },
584 Image {
585 source: String,
588 },
589 Thinking {
595 text: String,
596 signature: Option<String>,
598 redacted: bool,
600 },
601}
602
603pub fn answer_content(content: &[Content]) -> Vec<Content> {
605 content
606 .iter()
607 .filter(|c| matches!(c, Content::Text { .. } | Content::Image { .. }))
608 .cloned()
609 .collect()
610}
611
612pub fn check_image_size(encoded_bytes: u64, limit: u64) -> Result<(), GenerateError> {
614 if encoded_bytes > limit {
615 return Err(GenerateError::RequestTooLargeError(format!(
616 "image has {encoded_bytes} base64 bytes; the current model's max_image_base64_bytes is {limit}. Resize the image, compact the session, or select a model with a larger image limit"
617 )));
618 }
619 Ok(())
620}
621
622pub fn validate_image_sizes(input: &[Message], limit: u64) -> Result<(), GenerateError> {
626 for part in input.iter().flat_map(Message::content) {
627 if let Content::Image { source } = part {
628 if source.starts_with("http://") || source.starts_with("https://") {
629 continue;
630 }
631 if source.starts_with("myco-image:") {
632 return Err(GenerateError::ExecutionError(
633 "resolve local image references before calling a provider".into(),
634 ));
635 }
636 let payload = source
637 .split_once(',')
638 .map_or(source.as_str(), |(_, data)| data);
639 check_image_size(payload.len() as u64, limit)?;
640 }
641 }
642 Ok(())
643}
644
645pub(crate) fn wire_tool_ids(input: &[Message]) -> Result<Vec<Vec<String>>, GenerateError> {
666 if input.iter().flat_map(Message::content).any(|part| {
667 matches!(part,
668 Content::Image { source } if source.starts_with("myco-image:"))
669 }) {
670 return Err(GenerateError::ExecutionError(
671 "resolve local image references before calling a provider".into(),
672 ));
673 }
674 let mut out: Vec<Vec<String>> = Vec::with_capacity(input.len());
675 for (i, message) in input.iter().enumerate() {
676 let ids = match message {
677 Message::UserMessage { .. } => Vec::new(),
678 Message::AssistantMessage { tool_uses, .. } => {
679 (0..tool_uses.len()).map(|j| mint_tool_id(i, j)).collect()
680 }
681 Message::ToolResults { tool_use_results } => {
682 let preceding_uses = match i.checked_sub(1).map(|p| &input[p]) {
683 Some(Message::AssistantMessage { tool_uses, .. }) => tool_uses.len(),
684 _ => 0,
685 };
686 if preceding_uses != tool_use_results.len() {
687 return Err(GenerateError::ExecutionError(format!(
688 "history is malformed: message {i} carries {} tool results but the \
689 message before it has {preceding_uses} tool calls",
690 tool_use_results.len()
691 )));
692 }
693 out[i - 1].clone()
694 }
695 };
696 out.push(ids);
697 }
698 Ok(out)
699}
700
701fn mint_tool_id(message_index: usize, ordinal: usize) -> String {
709 const CAP: usize = 36 * 36 * 36 * 36;
710 assert!(message_index < CAP && ordinal < CAP);
713 let mut id = String::with_capacity(9);
714 id.push('t');
715 for n in [message_index, ordinal] {
716 for place in [36 * 36 * 36, 36 * 36, 36, 1] {
717 id.push(char::from_digit(((n / place) % 36) as u32, 36).unwrap());
718 }
719 }
720 id
721}
722
723#[derive(Debug, Clone)]
725pub enum GenerationEvent {
726 Part(MessagePart),
727 Failure(GenerationFailure),
728}
729
730impl GenerationEvent {
731 pub fn into_result(self) -> Result<MessagePart, GenerateError> {
732 match self {
733 Self::Part(part) => Ok(part),
734 Self::Failure(failure) => Err(failure.cause),
735 }
736 }
737}
738
739#[derive(Debug, Clone)]
740pub struct GenerationFailure {
741 pub cause: GenerateError,
742 pub retryable: bool,
745 pub retry_after: Option<std::time::Duration>,
746}
747
748impl GenerationFailure {
749 pub fn terminal(cause: GenerateError) -> Self {
750 Self {
751 cause,
752 retryable: false,
753 retry_after: None,
754 }
755 }
756
757 pub fn transient(cause: GenerateError, retry_after: Option<std::time::Duration>) -> Self {
758 Self {
759 cause,
760 retryable: true,
761 retry_after,
762 }
763 }
764}
765
766impl From<GenerateError> for GenerationFailure {
767 fn from(cause: GenerateError) -> Self {
768 Self::terminal(cause)
769 }
770}
771
772#[derive(Debug, Clone)]
773pub enum MessagePart {
774 MessageStart,
775 ContentStart(ContentStart),
776 ContentDelta(ContentDelta),
777 ToolUseStart(ToolUseStart),
778 ToolUseDelta(ToolUseDelta),
779 TurnEndReason(TurnEndReason),
780 Usage(TokenUsage),
782}
783
784#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
787pub struct TokenUsage {
788 #[serde(default)]
789 pub input_tokens: u64,
790 #[serde(default)]
791 pub output_tokens: u64,
792 #[serde(default)]
793 pub cached_input_tokens: u64,
794}
795
796impl TokenUsage {
797 pub fn context_tokens(self) -> u64 {
800 self.input_tokens
801 }
802
803 pub fn merge(self, next: TokenUsage) -> TokenUsage {
806 fn pick(prev: u64, next: u64) -> u64 {
807 if next != 0 { next } else { prev }
808 }
809 TokenUsage {
810 input_tokens: pick(self.input_tokens, next.input_tokens),
811 output_tokens: pick(self.output_tokens, next.output_tokens),
812 cached_input_tokens: pick(self.cached_input_tokens, next.cached_input_tokens),
813 }
814 }
815}
816
817#[derive(Debug, Clone)]
818pub enum ContentStart {
819 Text {
820 index: usize,
821 },
822 Image {
823 index: usize,
824 },
825 Thinking {
826 index: usize,
827 signature: Option<String>,
828 redacted: bool,
829 },
830}
831
832#[derive(Debug, Clone)]
833pub enum ContentDelta {
834 Text {
835 index: usize,
836 delta: String,
837 },
838 Image {
839 index: usize,
840 delta: String,
841 },
842 Thinking {
843 index: usize,
844 delta: String,
845 },
846 Signature {
849 index: usize,
850 delta: String,
851 },
852}
853
854#[derive(Debug, Clone)]
855pub struct ToolUseStart {
856 pub index: usize,
857 pub name: String,
858 pub thought_signature: Option<String>,
861}
862
863#[derive(Debug, Clone)]
864pub struct ToolUseDelta {
865 pub index: usize,
866 pub input_json_delta: String,
867}
868
869#[derive(Debug, Clone)]
874pub struct GenerateOutput {
875 pub content: Vec<Content>,
876 pub tool_uses: Vec<ToolUse>,
877 pub turn_end_reason: TurnEndReason,
878 pub usage: Option<TokenUsage>,
880}
881
882impl GenerateOutput {
883 pub async fn from_generation(
888 stream: impl Stream<Item = GenerationEvent>,
889 ) -> Result<Self, GenerateError> {
890 Self::from_stream(stream.map(GenerationEvent::into_result)).await
891 }
892
893 pub async fn from_stream(
894 stream: impl Stream<Item = Result<MessagePart, GenerateError>>,
895 ) -> Result<Self, GenerateError> {
896 let mut accumulator = MessageAccumulator::default();
897 let mut stream = pin!(stream);
898 while let Some(part) = stream.next().await {
899 accumulator.push(&part?)?;
900 }
901 accumulator.finish()
902 }
903}
904
905#[derive(thiserror::Error, Debug)]
906pub enum ModelCreationError {
907 #[error("Invalid configuration parameters supplied: {0}")]
908 BadConfig(String),
909
910 #[error("Uncategorized error occurred: {0}")]
911 Uncategorized(String),
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917 use crate::test_support::{assistant_tool, tool_results, user};
918
919 #[test]
924 fn wire_ids_are_nine_alphanumerics() {
925 for (i, j) in [(0, 0), (1, 3), (255, 7), (46_655, 12)] {
926 let id = mint_tool_id(i, j);
927 assert_eq!(id.len(), 9, "{id:?}");
928 assert!(id.chars().all(|c| c.is_ascii_alphanumeric()), "{id:?}");
929 }
930 }
931
932 #[test]
934 fn wire_ids_pair_results_with_uses_positionally() {
935 let input = [
936 user("hi"),
937 assistant_tool(None, "bash", serde_json::json!({})),
938 tool_results(&["ok"]),
939 ];
940 let ids = wire_tool_ids(&input).unwrap();
941 assert!(ids[0].is_empty());
942 assert_eq!(ids[1].len(), 1);
943 assert_eq!(ids[1], ids[2]);
944 }
945
946 #[test]
949 fn wire_ids_stable_as_history_grows() {
950 let mut input = vec![
951 user("hi"),
952 assistant_tool(None, "bash", serde_json::json!({})),
953 tool_results(&["ok"]),
954 ];
955 let before = wire_tool_ids(&input).unwrap();
956 input.push(user("more"));
957 input.push(assistant_tool(None, "bash", serde_json::json!({})));
958 input.push(tool_results(&["ok"]));
959 let after = wire_tool_ids(&input).unwrap();
960 assert_eq!(before[..], after[..3]);
961 assert_ne!(after[1], after[4], "distinct calls must get distinct ids");
962 }
963
964 #[test]
968 fn wire_ids_fail_loud_on_broken_pairing() {
969 let orphaned = [user("hi"), tool_results(&["ok"])];
970 assert!(wire_tool_ids(&orphaned).is_err());
971
972 let miscounted = [
973 assistant_tool(None, "bash", serde_json::json!({})),
974 tool_results(&["ok", "ok"]),
975 ];
976 assert!(wire_tool_ids(&miscounted).is_err());
977 }
978
979 #[tokio::test]
980 async fn accumulate_thinking_then_text() {
981 use futures::stream;
982
983 let parts = vec![
984 Ok(MessagePart::MessageStart),
985 Ok(MessagePart::ContentStart(ContentStart::Thinking {
986 index: 0,
987 signature: Some("sig".into()),
988 redacted: false,
989 })),
990 Ok(MessagePart::ContentDelta(ContentDelta::Thinking {
991 index: 0,
992 delta: "reason".into(),
993 })),
994 Ok(MessagePart::ContentStart(ContentStart::Text { index: 1 })),
995 Ok(MessagePart::ContentDelta(ContentDelta::Text {
996 index: 1,
997 delta: "answer".into(),
998 })),
999 Ok(MessagePart::TurnEndReason(TurnEndReason::EndTurn)),
1000 ];
1001 let output = GenerateOutput::from_stream(stream::iter(parts))
1002 .await
1003 .expect("accumulate");
1004 assert_eq!(output.content.len(), 2);
1005 match &output.content[0] {
1006 Content::Thinking {
1007 text,
1008 signature,
1009 redacted,
1010 } => {
1011 assert_eq!(text, "reason");
1012 assert_eq!(signature.as_deref(), Some("sig"));
1013 assert!(!*redacted);
1014 }
1015 other => panic!("expected thinking, got {other:?}"),
1016 }
1017 match &output.content[1] {
1018 Content::Text { text } => assert_eq!(text, "answer"),
1019 other => panic!("expected text, got {other:?}"),
1020 }
1021 assert_eq!(answer_content(&output.content).len(), 1);
1022 }
1023
1024 #[test]
1025 fn token_usage_merge_prefers_known_fields() {
1026 let start = TokenUsage {
1027 input_tokens: 2195,
1028 output_tokens: 1,
1029 cached_input_tokens: 2000,
1030 };
1031 let delta = TokenUsage {
1032 input_tokens: 0,
1033 output_tokens: 89,
1034 cached_input_tokens: 0,
1035 };
1036 let merged = start.merge(delta);
1037 assert_eq!(merged.input_tokens, 2195);
1038 assert_eq!(merged.output_tokens, 89);
1039 assert_eq!(merged.cached_input_tokens, 2000);
1040 assert_eq!(merged.context_tokens(), 2195);
1041 }
1042
1043 #[tokio::test]
1044 async fn accumulate_merges_split_usage() {
1045 use futures::stream;
1046
1047 let parts = vec![
1048 Ok(MessagePart::MessageStart),
1049 Ok(MessagePart::Usage(TokenUsage {
1050 input_tokens: 2195,
1051 output_tokens: 1,
1052 cached_input_tokens: 2000,
1053 })),
1054 Ok(MessagePart::ContentStart(ContentStart::Text { index: 0 })),
1055 Ok(MessagePart::ContentDelta(ContentDelta::Text {
1056 index: 0,
1057 delta: "hi".into(),
1058 })),
1059 Ok(MessagePart::Usage(TokenUsage {
1060 input_tokens: 0,
1061 output_tokens: 89,
1062 cached_input_tokens: 0,
1063 })),
1064 Ok(MessagePart::TurnEndReason(TurnEndReason::EndTurn)),
1065 ];
1066 let output = GenerateOutput::from_stream(stream::iter(parts))
1067 .await
1068 .expect("accumulate");
1069 let usage = output.usage.expect("usage present");
1070 assert_eq!(usage.input_tokens, 2195);
1071 assert_eq!(usage.output_tokens, 89);
1072 assert_eq!(usage.cached_input_tokens, 2000);
1073 assert_eq!(usage.context_tokens(), 2195);
1074 }
1075
1076 #[test]
1080 fn retry_backoff_grows_caps_and_honours_retry_after() {
1081 use std::time::Duration;
1082 let policy = RetryPolicy {
1083 max_attempts: 6,
1084 initial_backoff: Duration::from_millis(100),
1085 max_backoff: Duration::from_millis(1000),
1086 backoff_multiplier: 2.0,
1087 max_elapsed: None,
1088 };
1089
1090 assert_eq!(policy.backoff(1, None), Duration::ZERO);
1092 assert_eq!(policy.backoff(2, None), Duration::from_millis(100));
1093 assert_eq!(policy.backoff(3, None), Duration::from_millis(200));
1094 assert_eq!(policy.backoff(4, None), Duration::from_millis(400));
1095 assert_eq!(policy.backoff(9, None), Duration::from_millis(1000));
1097
1098 assert_eq!(
1100 policy.backoff(2, Some(Duration::from_millis(500))),
1101 Duration::from_millis(500)
1102 );
1103 assert_eq!(
1105 policy.backoff(2, Some(Duration::from_secs(3600))),
1106 Duration::from_secs(3600)
1107 );
1108 assert_eq!(
1110 policy.backoff(3, Some(Duration::from_millis(1))),
1111 Duration::from_millis(200)
1112 );
1113 }
1114
1115 fn spec(key: &str, protocol: Protocol) -> ModelSpec {
1116 ModelSpec {
1117 key: key.into(),
1118 api_id: key.into(),
1119 protocol,
1120 thinking: ThinkingMode::default_for(protocol),
1121 context_window_tokens: 1_000_000,
1122 max_image_base64_bytes: 5 * 1024 * 1024,
1123 max_truncated_resumes: 3,
1124 auto_compact_at_tokens: None,
1125 }
1126 }
1127
1128 #[test]
1129 fn thinking_defaults_and_protocol_compatibility() {
1130 assert_eq!(
1131 ThinkingMode::default_for(Protocol::AnthropicMessages),
1132 ThinkingMode::Adaptive
1133 );
1134 assert_eq!(
1135 ThinkingMode::default_for(Protocol::OpenAIResponses),
1136 ThinkingMode::Effort
1137 );
1138 assert!(ThinkingMode::Budget.compatible_with(Protocol::AnthropicMessages));
1139 assert!(ThinkingMode::None.compatible_with(Protocol::AnthropicMessages));
1140 assert!(!ThinkingMode::Effort.compatible_with(Protocol::AnthropicMessages));
1141 assert!(ThinkingMode::None.compatible_with(Protocol::OpenAIResponses));
1142 assert_eq!(
1144 ThinkingMode::default_for(Protocol::OpenAICompletions),
1145 ThinkingMode::Effort
1146 );
1147 assert!(ThinkingMode::None.compatible_with(Protocol::OpenAICompletions));
1148 assert!(!ThinkingMode::Adaptive.compatible_with(Protocol::OpenAICompletions));
1149 assert!(!ThinkingMode::Adaptive.compatible_with(Protocol::OpenAIResponses));
1150 assert!(!ThinkingMode::Budget.compatible_with(Protocol::OpenAIResponses));
1151 }
1152
1153 #[test]
1154 fn protocol_serde_uses_config_strings() {
1155 assert_eq!(
1156 serde_json::to_value(Protocol::AnthropicMessages).unwrap(),
1157 serde_json::json!("anthropic-messages")
1158 );
1159 assert_eq!(
1160 serde_json::from_value::<Protocol>(serde_json::json!("openai-responses")).unwrap(),
1161 Protocol::OpenAIResponses
1162 );
1163 assert_eq!(
1164 serde_json::from_value::<Protocol>(serde_json::json!("openai-completions")).unwrap(),
1165 Protocol::OpenAICompletions
1166 );
1167 }
1168
1169 #[test]
1170 fn empty_catalog_get_says_no_models_configured() {
1171 let catalog = ModelCatalog::default();
1172 assert!(catalog.is_empty());
1173 let err = catalog.get("kimi-k3").unwrap_err();
1174 assert!(err.contains("no models configured"), "{err}");
1175 assert!(err.contains("[models]"), "{err}");
1176 }
1177
1178 #[test]
1179 fn catalog_get_unknown_key_lists_configured_models() {
1180 let entry = CatalogModel {
1181 spec: spec("opus", Protocol::AnthropicMessages),
1182 backend: BackendConfig::Anthropic(AnthropicBackendConfig::default()),
1183 auth_error: None,
1184 };
1185 let catalog = ModelCatalog::new([("opus".to_string(), entry)].into());
1186 let err = catalog.get("opsu").unwrap_err();
1187 assert!(err.contains("unknown model \"opsu\""), "{err}");
1188 assert!(err.contains("[opus]"), "{err}");
1189 assert!(catalog.get("opus").is_ok());
1190 }
1191
1192 #[test]
1193 fn catalog_get_reports_deferred_auth_error() {
1194 let entry = CatalogModel {
1195 spec: spec("kimi", Protocol::OpenAIResponses),
1196 backend: BackendConfig::OpenAIResponses(OpenAIBackendConfig::default()),
1197 auth_error: Some("model `kimi`: auth env:OPENROUTER_API_KEY is unset".into()),
1198 };
1199 let catalog = ModelCatalog::new([("kimi".to_string(), entry)].into());
1200 let err = catalog.get("kimi").unwrap_err();
1201 assert!(err.contains("OPENROUTER_API_KEY"), "{err}");
1202 }
1203
1204 #[test]
1205 fn new_rejects_protocol_mismatch() {
1206 let result = new(GenerativeModelConfig {
1207 model: spec("grok", Protocol::OpenAIResponses),
1208 tools: vec![],
1209 system_prompt: String::new(),
1210 backend_config: BackendConfig::Anthropic(AnthropicBackendConfig {
1211 anthropic_auth_token: "dummy".into(),
1212 ..Default::default()
1213 }),
1214 });
1215 let err = match result {
1216 Ok(_) => panic!("expected mismatch"),
1217 Err(e) => e,
1218 };
1219 assert!(err.to_string().contains("speaks openai-responses"), "{err}");
1220 }
1221
1222 #[test]
1226 fn oversized_request_is_refused_before_upload() {
1227 for limit in [1024, MAX_REQUEST_BYTES, 60_000_000] {
1228 assert!(check_request_size(limit, limit, "Anthropic").is_ok());
1229 let err = check_request_size(limit + 1, limit, "Anthropic").unwrap_err();
1230 assert!(
1231 matches!(err, GenerateError::RequestTooLargeError(_)),
1232 "{err:?}"
1233 );
1234 assert_eq!(err.recovery(), Recovery::OmitLastMessage);
1235 assert!(
1236 err.to_string().contains(&format!("limit is {limit} bytes")),
1237 "{err}"
1238 );
1239 }
1240 }
1241
1242 #[test]
1243 fn older_backend_configs_default_to_a_thirty_megabyte_request_cap() {
1244 let mut anthropic = serde_json::to_value(AnthropicBackendConfig::default()).unwrap();
1245 let mut openai = serde_json::to_value(OpenAIBackendConfig::default()).unwrap();
1246 for config in [&mut anthropic, &mut openai] {
1247 assert_eq!(config["max_request_bytes"], 30_000_000);
1248 config.as_object_mut().unwrap().remove("max_request_bytes");
1249 }
1250 assert_eq!(
1251 serde_json::from_value::<AnthropicBackendConfig>(anthropic)
1252 .unwrap()
1253 .max_request_bytes,
1254 30_000_000
1255 );
1256 assert_eq!(
1257 serde_json::from_value::<OpenAIBackendConfig>(openai)
1258 .unwrap()
1259 .max_request_bytes,
1260 30_000_000
1261 );
1262 }
1263
1264 #[test]
1267 fn provider_size_rejections_map_to_the_same_recovery() {
1268 let too_large = http_error(
1269 reqwest::StatusCode::PAYLOAD_TOO_LARGE,
1270 "HTTP 413: too big".into(),
1271 );
1272 assert_eq!(too_large.recovery(), Recovery::OmitLastMessage);
1273
1274 let named = http_error(
1276 reqwest::StatusCode::BAD_REQUEST,
1277 r#"HTTP 400: {"error":{"type":"request_too_large"}}"#.into(),
1278 );
1279 assert_eq!(named.recovery(), Recovery::OmitLastMessage);
1280
1281 let described = http_error(
1284 reqwest::StatusCode::BAD_REQUEST,
1285 r#"HTTP 400: {"error":{"code":400,"message":"The message size (31271377 bytes) \
1286 exceeds 30.000MB limit.","status":"FAILED_PRECONDITION"}}"#
1287 .into(),
1288 );
1289 assert_eq!(described.recovery(), Recovery::OmitLastMessage);
1290
1291 let extent = http_error(
1292 reqwest::StatusCode::BAD_REQUEST,
1293 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(),
1294 );
1295 assert_eq!(extent.recovery(), Recovery::OmitLastMessage);
1296
1297 let unrelated = http_error(
1298 reqwest::StatusCode::INTERNAL_SERVER_ERROR,
1299 "HTTP 500: overloaded".into(),
1300 );
1301 assert_eq!(unrelated.recovery(), Recovery::Retry);
1302 }
1303
1304 #[test]
1308 fn ordinary_failures_are_not_read_as_size_rejections() {
1309 for body in [
1310 r#"HTTP 400: {"error":{"message":"max_tokens exceeds the model's limit"}}"#,
1311 r#"HTTP 400: {"error":{"message":"messages: unexpected role"}}"#,
1312 r#"HTTP 401: {"error":{"message":"invalid x-api-key"}}"#,
1313 ] {
1314 let err = http_error(reqwest::StatusCode::BAD_REQUEST, body.into());
1315 assert_eq!(err.recovery(), Recovery::Retry, "{body}");
1316 }
1317 }
1318
1319 #[test]
1320 fn message_types_serde_roundtrip() {
1321 let messages = vec![
1322 Message::UserMessage {
1323 content: vec![
1324 Content::Text { text: "hi".into() },
1325 Content::Image {
1326 source: "data".into(),
1327 },
1328 ],
1329 },
1330 Message::AssistantMessage {
1331 content: vec![Content::Text { text: "ok".into() }],
1332 tool_uses: vec![ToolUse {
1333 name: "bash".into(),
1334 input: serde_json::json!({"command": "true"}),
1335 thought_signature: None,
1336 }],
1337 turn_end_reason: Some(TurnEndReason::ToolUse),
1338 },
1339 Message::ToolResults {
1340 tool_use_results: vec![ToolResult {
1341 content: vec![Content::Text {
1342 text: "done".into(),
1343 }],
1344 is_error: false,
1345 status: None,
1346 resource: None,
1347 }],
1348 },
1349 Message::AssistantMessage {
1350 content: vec![],
1351 tool_uses: vec![],
1352 turn_end_reason: Some(TurnEndReason::Other("Anthropic::PauseTurn".into())),
1353 },
1354 ];
1355 let json = serde_json::to_string(&messages).expect("serialize");
1356 let back: Vec<Message> = serde_json::from_str(&json).expect("deserialize");
1357 assert_eq!(
1358 serde_json::to_value(&back).unwrap(),
1359 serde_json::to_value(&messages).unwrap()
1360 );
1361 }
1362}
1363
1364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1373pub enum Recovery {
1374 Retry,
1377 OmitLastMessage,
1381 Stop,
1383}
1384
1385pub const MAX_REQUEST_BYTES: usize = 30_000_000;
1391
1392fn default_max_request_bytes() -> usize {
1393 MAX_REQUEST_BYTES
1394}
1395
1396#[derive(thiserror::Error, Debug, Clone)]
1397pub enum GenerateError {
1398 #[error("Something went wrong while generating a response: {0}")]
1399 ExecutionError(String),
1400
1401 #[error("Generation succeeded, but the model refused to comply: {0}")]
1402 RefusalError(String),
1403
1404 #[error("Generation succeeded, but the output was malformed or corrupted: {0}")]
1405 MalformedResponseError(String),
1406
1407 #[error("The request is too large to send: {0}")]
1410 RequestTooLargeError(String),
1411}
1412
1413impl GenerateError {
1414 pub fn recovery(&self) -> Recovery {
1415 match self {
1416 GenerateError::RequestTooLargeError(_) => Recovery::OmitLastMessage,
1417 GenerateError::ExecutionError(_)
1418 | GenerateError::RefusalError(_)
1419 | GenerateError::MalformedResponseError(_) => Recovery::Retry,
1420 }
1421 }
1422}
1423
1424pub(crate) fn check_request_size(
1429 len: usize,
1430 limit: usize,
1431 provider: &str,
1432) -> Result<(), GenerateError> {
1433 if len > limit {
1434 return Err(GenerateError::RequestTooLargeError(format!(
1435 "the {provider} request is {len} bytes; the configured limit is {limit} bytes \
1436 (max_request_bytes). Images accumulate in the conversation; reduce \
1437 attachments or compact the session before retrying. Raise the gateway's \
1438 max_request_bytes only if it supports larger requests",
1439 )));
1440 }
1441 Ok(())
1442}
1443
1444pub(crate) fn http_error(status: reqwest::StatusCode, message: String) -> GenerateError {
1455 if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE || describes_a_size_rejection(&message) {
1456 GenerateError::RequestTooLargeError(message)
1457 } else {
1458 GenerateError::ExecutionError(message)
1459 }
1460}
1461
1462fn describes_a_size_rejection(message: &str) -> bool {
1468 let message = message.to_ascii_lowercase();
1469 message.contains("too_large")
1470 || message.contains("too large")
1471 || (message.contains("size") && message.contains("exceeds"))
1472 || (message.contains("image dimensions")
1473 && message.contains("exceed")
1474 && message.contains("max allowed size"))
1475}