1use async_trait::async_trait;
16use futures::stream::BoxStream;
17use serde::{Deserialize, Serialize};
18use serde_json::Value;
19
20pub mod gemini;
21pub mod openai;
22pub mod provider;
23pub mod tool_call;
24
25pub use provider::{
26 LlmConfigError, LlmSettings, PROVIDER_ENV_VARS, ReasoningPlan, SUPPORTED_PROVIDERS,
27 SelectedLlm, provider_from_env, provider_from_settings,
28};
29pub use tool_call::{PartialToolCall, ToolCallAccumulator};
30
31#[derive(Clone, Copy)]
36pub(crate) struct Env<'a>(&'a dyn Fn(&str) -> Option<String>);
37
38impl<'a> Env<'a> {
39 #[cfg(test)]
42 pub(crate) fn new(lookup: &'a dyn Fn(&str) -> Option<String>) -> Self {
43 Self(lookup)
44 }
45
46 pub(crate) fn os() -> Env<'static> {
48 const LOOKUP: &dyn Fn(&str) -> Option<String> = &os_lookup;
49 Env(LOOKUP)
50 }
51
52 pub(crate) fn get(&self, key: &str) -> Option<String> {
56 (self.0)(key)
57 .map(|value| value.trim().to_string())
58 .filter(|value| !value.is_empty())
59 }
60}
61
62fn os_lookup(key: &str) -> Option<String> {
63 std::env::var(key).ok()
64}
65
66#[derive(Debug, thiserror::Error)]
68pub enum LlmError {
69 #[error("API error: {0}")]
70 ApiError(String),
71 #[error("context length exceeded: {0}")]
78 ContextLengthExceeded(String),
79 #[error("Network error: {0}")]
80 NetworkError(String),
81 #[error("Serialization error: {0}")]
82 SerializationError(String),
83 #[error("Provider error: {0}")]
84 ProviderError(String),
85}
86
87pub(crate) fn describe_transport_error(error: &dyn std::error::Error) -> String {
97 let mut message = error.to_string();
98 let mut source = error.source();
99 while let Some(cause) = source {
100 message.push_str(": ");
101 message.push_str(&cause.to_string());
102 source = cause.source();
103 }
104 message
105}
106
107const CONTEXT_LENGTH_MARKERS: [&str; 8] = [
113 "context_length_exceeded",
115 "maximum context length",
117 "context length",
118 "context size",
123 "too many tokens",
125 "exceeds the maximum",
126 "exceed_context_size_error",
132 "input token count",
134];
135
136pub(crate) fn classify_api_error(message: String) -> LlmError {
142 let haystack = message.to_lowercase();
143 if CONTEXT_LENGTH_MARKERS
144 .iter()
145 .any(|marker| haystack.contains(marker))
146 {
147 return LlmError::ContextLengthExceeded(message);
148 }
149 LlmError::ApiError(message)
150}
151
152#[derive(Clone, Default)]
168pub(crate) struct ReasoningSupport(std::sync::Arc<std::sync::atomic::AtomicBool>);
169
170impl ReasoningSupport {
171 pub(crate) fn refused(&self) -> bool {
173 self.0.load(std::sync::atomic::Ordering::Relaxed)
174 }
175
176 pub(crate) fn record_refusal(&self) {
179 self.0.store(true, std::sync::atomic::Ordering::Relaxed);
180 }
181}
182
183pub(crate) fn refuses_reasoning(status: u16, body: &str, field: &str) -> bool {
195 status == 400 && body.to_lowercase().contains(field)
196}
197
198#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
205pub struct TokenUsage {
206 pub prompt_tokens: Option<u32>,
208 pub completion_tokens: Option<u32>,
211 pub reasoning_tokens: Option<u32>,
214 pub total_tokens: Option<u32>,
218}
219
220impl TokenUsage {
221 pub fn is_empty(&self) -> bool {
224 *self == Self::default()
225 }
226}
227
228impl std::fmt::Display for TokenUsage {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 let field = |value: Option<u32>| match value {
231 Some(count) => count.to_string(),
232 None => "?".to_string(),
233 };
234 write!(
235 f,
236 "prompt={} completion={} reasoning={} total={}",
237 field(self.prompt_tokens),
238 field(self.completion_tokens),
239 field(self.reasoning_tokens),
240 field(self.total_tokens)
241 )
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
247#[serde(rename_all = "lowercase")]
248pub enum MessageRole {
249 System,
250 User,
251 Assistant,
252 Tool,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ToolDefinition {
258 pub name: String,
259 pub description: String,
260 pub parameters: Value, }
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ToolCall {
266 pub id: String, pub name: String,
268 pub arguments: String, }
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct ChatMessage {
274 pub role: MessageRole,
275 #[serde(skip_serializing_if = "Option::is_none")]
276 pub content: Option<String>,
277 #[serde(skip_serializing_if = "Option::is_none")]
278 pub tool_calls: Option<Vec<ToolCall>>,
279 #[serde(skip_serializing_if = "Option::is_none")]
280 pub tool_call_id: Option<String>,
281 #[serde(skip_serializing_if = "Option::is_none")]
282 pub name: Option<String>,
283}
284
285impl ChatMessage {
286 pub fn system(content: impl Into<String>) -> Self {
287 Self {
288 role: MessageRole::System,
289 content: Some(content.into()),
290 tool_calls: None,
291 tool_call_id: None,
292 name: None,
293 }
294 }
295
296 pub fn user(content: impl Into<String>) -> Self {
297 Self {
298 role: MessageRole::User,
299 content: Some(content.into()),
300 tool_calls: None,
301 tool_call_id: None,
302 name: None,
303 }
304 }
305
306 pub fn assistant(content: impl Into<String>) -> Self {
307 Self {
308 role: MessageRole::Assistant,
309 content: Some(content.into()),
310 tool_calls: None,
311 tool_call_id: None,
312 name: None,
313 }
314 }
315
316 pub fn tool_result(
317 tool_call_id: impl Into<String>,
318 name: impl Into<String>,
319 content: impl Into<String>,
320 ) -> Self {
321 Self {
322 role: MessageRole::Tool,
323 content: Some(content.into()),
324 tool_calls: None,
325 tool_call_id: Some(tool_call_id.into()),
326 name: Some(name.into()),
327 }
328 }
329}
330
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub enum ReasoningEffort {
334 Low,
335 Medium,
336 High,
337}
338
339impl ReasoningEffort {
340 pub fn as_str(self) -> &'static str {
342 match self {
343 ReasoningEffort::Low => "low",
344 ReasoningEffort::Medium => "medium",
345 ReasoningEffort::High => "high",
346 }
347 }
348}
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum Reasoning {
362 Off,
366 Effort(ReasoningEffort),
368 Budget(u32),
370}
371
372const REASONING_EXPECTED: &str =
375 r#""off", "low", "medium", "high", or a number of reasoning tokens"#;
376
377impl std::str::FromStr for Reasoning {
378 type Err = String;
379
380 fn from_str(s: &str) -> Result<Self, Self::Err> {
383 match s.trim() {
384 "off" => Ok(Reasoning::Off),
385 "low" => Ok(Reasoning::Effort(ReasoningEffort::Low)),
386 "medium" => Ok(Reasoning::Effort(ReasoningEffort::Medium)),
387 "high" => Ok(Reasoning::Effort(ReasoningEffort::High)),
388 budget => budget
389 .parse()
390 .map(Reasoning::Budget)
391 .map_err(|_| format!("expected {REASONING_EXPECTED}; got {s:?}")),
392 }
393 }
394}
395
396impl std::fmt::Display for Reasoning {
397 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398 match self {
399 Reasoning::Off => f.write_str("off"),
400 Reasoning::Effort(effort) => f.write_str(effort.as_str()),
401 Reasoning::Budget(tokens) => write!(f, "{tokens}"),
402 }
403 }
404}
405
406impl Serialize for Reasoning {
407 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
408 match self {
409 Reasoning::Budget(tokens) => serializer.serialize_u32(*tokens),
412 level => serializer.serialize_str(&level.to_string()),
413 }
414 }
415}
416
417impl<'de> Deserialize<'de> for Reasoning {
418 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
422 use serde::de::{Error, Unexpected, Visitor};
423
424 struct ReasoningVisitor;
425
426 impl Visitor<'_> for ReasoningVisitor {
427 type Value = Reasoning;
428
429 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430 f.write_str(REASONING_EXPECTED)
431 }
432
433 fn visit_str<E: Error>(self, value: &str) -> Result<Reasoning, E> {
434 value
435 .parse()
436 .map_err(|_| E::invalid_value(Unexpected::Str(value), &self))
437 }
438
439 fn visit_u64<E: Error>(self, value: u64) -> Result<Reasoning, E> {
440 u32::try_from(value)
441 .map(Reasoning::Budget)
442 .map_err(|_| E::invalid_value(Unexpected::Unsigned(value), &self))
443 }
444
445 fn visit_i64<E: Error>(self, value: i64) -> Result<Reasoning, E> {
446 u32::try_from(value)
447 .map(Reasoning::Budget)
448 .map_err(|_| E::invalid_value(Unexpected::Signed(value), &self))
449 }
450 }
451
452 deserializer.deserialize_any(ReasoningVisitor)
453 }
454}
455
456#[derive(Debug, Clone)]
458pub struct LlmRequest {
459 pub messages: Vec<ChatMessage>,
460 pub tools: Option<Vec<ToolDefinition>>,
461 pub temperature: Option<f32>,
462 pub max_tokens: Option<u32>,
463 pub force_json: bool,
464 pub reasoning: Option<Reasoning>,
467}
468
469impl LlmRequest {
470 pub fn new(messages: Vec<ChatMessage>) -> Self {
471 Self {
472 messages,
473 tools: None,
474 temperature: None,
475 max_tokens: None,
476 force_json: false,
477 reasoning: None,
478 }
479 }
480
481 pub fn reasoning(mut self, reasoning: Reasoning) -> Self {
482 self.reasoning = Some(reasoning);
483 self
484 }
485
486 pub fn temperature(mut self, temp: f32) -> Self {
487 self.temperature = Some(temp);
488 self
489 }
490
491 pub fn max_tokens(mut self, tokens: u32) -> Self {
492 self.max_tokens = Some(tokens);
493 self
494 }
495
496 pub fn tools(mut self, tools: Vec<ToolDefinition>) -> Self {
497 self.tools = Some(tools);
498 self
499 }
500
501 pub fn force_json(mut self, force: bool) -> Self {
502 self.force_json = force;
503 self
504 }
505}
506
507#[derive(Debug, Clone)]
509pub struct LlmResponse {
510 pub content: Option<String>,
511 pub tool_calls: Option<Vec<ToolCall>>,
512 pub reasoning: Option<String>,
516 pub usage: Option<TokenUsage>,
518}
519
520#[derive(Debug, Clone)]
522pub enum LlmStreamEvent {
523 ContentChunk(String),
524 Reasoning(String),
527 ToolCallChunk {
528 id: String,
529 name: Option<String>,
530 arguments: String,
531 },
532 ToolCall(ToolCall),
533 Usage(TokenUsage),
537}
538
539#[async_trait]
541pub trait LlmProvider: Send + Sync {
542 async fn chat_completion(&self, request: LlmRequest) -> Result<LlmResponse, LlmError>;
544
545 async fn chat_completion_stream(
547 &self,
548 request: LlmRequest,
549 ) -> Result<BoxStream<'static, Result<LlmStreamEvent, LlmError>>, LlmError>;
550}
551
552#[cfg(test)]
553mod error_tests {
554 use super::*;
555
556 #[test]
559 fn an_over_long_request_is_classified_as_a_context_length_failure() {
560 let bodies = [
561 r#"OpenAI API error (400): {"error":{"message":"This model's maximum context length is 128000 tokens","code":"context_length_exceeded"}}"#,
562 "OpenAI stream error (400): Requested 200000 tokens, exceeds the maximum for this model",
563 r#"Gemini API error (400): {"error":{"status":"INVALID_ARGUMENT","message":"The input token count (1200000) exceeds the maximum"}}"#,
564 "OpenAI API error (400): too many tokens in prompt",
565 r#"OpenAI stream error (400 Bad Request): {"error":{"code":400,"message":"request (40089 tokens) exceeds the available context size (32768 tokens), try increasing it","type":"exceed_context_size_error","n_prompt_tokens":40089,"n_ctx":32768}}"#,
569 ];
570 for body in bodies {
571 assert!(
572 matches!(
573 classify_api_error(body.to_string()),
574 LlmError::ContextLengthExceeded(_)
575 ),
576 "should classify as context length: {body}"
577 );
578 }
579 }
580
581 #[test]
584 fn other_failures_stay_api_errors() {
585 let bodies = [
586 r#"OpenAI API error (401): {"error":{"message":"Incorrect API key provided"}}"#,
587 "OpenAI API error (429): Rate limit reached for requests",
588 "Gemini API error (503): The model is overloaded",
589 ];
590 for body in bodies {
591 assert!(
592 matches!(classify_api_error(body.to_string()), LlmError::ApiError(_)),
593 "should stay an API error: {body}"
594 );
595 }
596 }
597
598 #[test]
603 fn a_refused_reasoning_parameter_is_recognized_from_the_body() {
604 let openai = [
605 r#"{"error":{"message":"Unsupported parameter: 'reasoning_effort' is not supported with this model.","type":"invalid_request_error","param":"reasoning_effort","code":"unsupported_parameter"}}"#,
606 r#"{"error":{"message":"Invalid value: 'none'. Supported values are: 'low', 'medium' and 'high'.","type":"invalid_request_error","param":"reasoning_effort","code":"invalid_value"}}"#,
607 r#"{"error":{"message":"Unrecognized request argument supplied: reasoning_effort"}}"#,
608 ];
609 for body in openai {
610 assert!(refuses_reasoning(400, body, "reasoning"), "{body}");
611 }
612
613 let gemini = [
614 r#"{"error":{"code":400,"message":"Invalid JSON payload received. Unknown name \"thinkingLevel\" at 'generation_config': Cannot find field.","status":"INVALID_ARGUMENT"}}"#,
615 r#"{"error":{"code":400,"message":"Budget 128 is invalid. thinkingBudget must be 0 or in the range [128, 32768]","status":"INVALID_ARGUMENT"}}"#,
616 ];
617 for body in gemini {
618 assert!(refuses_reasoning(400, body, "thinking"), "{body}");
619 }
620 }
621
622 #[test]
627 fn other_failures_are_not_read_as_a_refusal() {
628 assert!(!refuses_reasoning(
629 400,
630 r#"{"error":{"message":"Incorrect API key provided"}}"#,
631 "reasoning"
632 ));
633 assert!(!refuses_reasoning(
634 429,
635 r#"{"error":{"message":"Rate limit reached"}}"#,
636 "reasoning"
637 ));
638 assert!(!refuses_reasoning(
639 503,
640 r#"{"error":{"message":"reasoning_effort backend unavailable"}}"#,
641 "reasoning"
642 ));
643 }
644
645 #[test]
648 fn a_recorded_refusal_is_shared() {
649 let support = ReasoningSupport::default();
650 let clone = support.clone();
651 assert!(!support.refused());
652 clone.record_refusal();
653 assert!(support.refused());
654 }
655
656 #[test]
657 fn usage_with_nothing_reported_reads_as_empty() {
658 assert!(TokenUsage::default().is_empty());
659 assert!(
660 !TokenUsage {
661 prompt_tokens: Some(0),
662 ..Default::default()
663 }
664 .is_empty(),
665 "a reported zero is a report, not an absence"
666 );
667 }
668}