Skip to main content

aptu_core/config/
ai.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! AI provider configuration.
4
5use serde::{Deserialize, Serialize};
6
7/// Default `OpenRouter` model identifier.
8pub const DEFAULT_OPENROUTER_MODEL: &str = "mistralai/mistral-small-2603";
9/// Default `Gemini` model identifier.
10pub const DEFAULT_GEMINI_MODEL: &str = "gemini-3.1-flash-lite";
11
12/// Task type for model selection.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum TaskType {
15    /// Issue triage task.
16    Triage,
17    /// Pull request review task.
18    Review,
19    /// Label creation task.
20    Create,
21}
22
23/// Task-specific AI model override.
24#[derive(Debug, Deserialize, Serialize, Default, Clone)]
25#[serde(default)]
26pub struct TaskOverride {
27    /// Optional provider override for this task.
28    pub provider: Option<String>,
29    /// Optional model override for this task.
30    pub model: Option<String>,
31    /// Optional small model for routing (used when `estimated_size` < `routing_threshold_chars`).
32    #[serde(default)]
33    pub small_model: Option<String>,
34    /// Optional large model for routing (used when `estimated_size` >= `routing_threshold_chars`).
35    #[serde(default)]
36    pub large_model: Option<String>,
37    /// Optional threshold for routing between `small_model` and `large_model` (default: 60000 for review).
38    #[serde(default)]
39    pub routing_threshold_chars: Option<usize>,
40}
41
42/// Task-specific AI configuration.
43#[derive(Debug, Deserialize, Serialize, Default, Clone)]
44#[serde(default)]
45pub struct TasksConfig {
46    /// Triage task configuration.
47    pub triage: Option<TaskOverride>,
48    /// Review task configuration.
49    pub review: Option<TaskOverride>,
50    /// Create task configuration.
51    pub create: Option<TaskOverride>,
52}
53
54/// Single entry in the fallback provider chain.
55#[derive(Debug, Clone, Serialize)]
56pub struct FallbackEntry {
57    /// Provider name (e.g., "openrouter", "anthropic", "gemini").
58    pub provider: String,
59    /// Optional model override for this specific provider.
60    pub model: Option<String>,
61}
62
63impl<'de> Deserialize<'de> for FallbackEntry {
64    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
65    where
66        D: serde::Deserializer<'de>,
67    {
68        #[derive(Deserialize)]
69        #[serde(untagged)]
70        enum EntryVariant {
71            String(String),
72            Struct {
73                provider: String,
74                model: Option<String>,
75            },
76        }
77
78        match EntryVariant::deserialize(deserializer)? {
79            EntryVariant::String(provider) => Ok(FallbackEntry {
80                provider,
81                model: None,
82            }),
83            EntryVariant::Struct { provider, model } => Ok(FallbackEntry { provider, model }),
84        }
85    }
86}
87
88/// Fallback provider chain configuration.
89#[derive(Debug, Deserialize, Serialize, Clone, Default)]
90#[serde(default)]
91pub struct FallbackConfig {
92    /// Chain of fallback entries to try in order when primary fails.
93    pub chain: Vec<FallbackEntry>,
94}
95
96/// Default value for `retry_max_attempts`.
97fn default_retry_max_attempts() -> u32 {
98    3
99}
100
101/// AI provider settings.
102#[derive(Debug, Deserialize, Serialize, Clone)]
103#[serde(default)]
104pub struct AiConfig {
105    /// AI provider: one of `"gemini"`, `"openrouter"`, `"groq"`, `"cerebras"`, `"zenmux"`, or `"zai"`.
106    pub provider: String,
107    /// Model identifier.
108    pub model: String,
109    /// Request timeout in seconds.
110    pub timeout_seconds: u64,
111    /// Allow paid models (default: true).
112    pub allow_paid_models: bool,
113    /// Maximum tokens for API responses.
114    pub max_tokens: u32,
115    /// Temperature for API requests (0.0-1.0).
116    pub temperature: f32,
117    /// Circuit breaker failure threshold before opening (default: 3).
118    pub circuit_breaker_threshold: u32,
119    /// Circuit breaker reset timeout in seconds (default: 60).
120    pub circuit_breaker_reset_seconds: u64,
121    /// Maximum retry attempts for rate-limited requests (default: 3).
122    #[serde(default = "default_retry_max_attempts")]
123    pub retry_max_attempts: u32,
124    /// Task-specific model overrides.
125    pub tasks: Option<TasksConfig>,
126    /// Fallback provider chain for resilience.
127    pub fallback: Option<FallbackConfig>,
128    /// Custom guidance to override or extend default best practices.
129    ///
130    /// Allows users to provide project-specific tooling recommendations
131    /// that will be appended to the default best practices context.
132    /// Useful for enforcing project-specific choices (e.g., poetry instead of uv).
133    pub custom_guidance: Option<String>,
134    /// Enable pre-flight model validation with fuzzy matching (default: true).
135    ///
136    /// When enabled, validates that the configured model ID exists in the
137    /// cached model registry before creating an AI client. Provides helpful
138    /// suggestions if an invalid model ID is detected.
139    pub validation_enabled: bool,
140}
141
142impl Default for AiConfig {
143    fn default() -> Self {
144        Self {
145            provider: "openrouter".to_string(),
146            model: DEFAULT_OPENROUTER_MODEL.to_string(),
147            timeout_seconds: 30,
148            allow_paid_models: true,
149            max_tokens: 4096,
150            temperature: 0.3,
151            circuit_breaker_threshold: 3,
152            circuit_breaker_reset_seconds: 60,
153            retry_max_attempts: default_retry_max_attempts(),
154            tasks: None,
155            fallback: None,
156            custom_guidance: None,
157            validation_enabled: true,
158        }
159    }
160}
161
162impl AiConfig {
163    /// Resolve provider and model for a specific task type.
164    ///
165    /// Returns a tuple of (provider, model) by checking task-specific overrides first,
166    /// then falling back to the default provider and model. When `estimated_size` is provided
167    /// and routing fields (`small_model`/`large_model`) are configured, selects between
168    /// small and large models based on the routing threshold.
169    ///
170    /// # Arguments
171    ///
172    /// * `task` - The task type to resolve configuration for
173    /// * `estimated_size` - Optional estimated size of the prompt in characters (used for routing)
174    ///
175    /// # Returns
176    ///
177    /// A tuple of (`provider_name`, `model_name`) strings
178    #[must_use]
179    pub fn resolve_for_task(
180        &self,
181        task: TaskType,
182        estimated_size: Option<usize>,
183    ) -> (String, String) {
184        let task_override = match task {
185            TaskType::Triage => self.tasks.as_ref().and_then(|t| t.triage.as_ref()),
186            TaskType::Review => self.tasks.as_ref().and_then(|t| t.review.as_ref()),
187            TaskType::Create => self.tasks.as_ref().and_then(|t| t.create.as_ref()),
188        };
189
190        let provider = task_override
191            .and_then(|o| o.provider.clone())
192            .unwrap_or_else(|| self.provider.clone());
193
194        // Branch 1: Explicit model override bypasses routing entirely
195        if let Some(model) = task_override.and_then(|o| o.model.clone()) {
196            return (provider, model);
197        }
198
199        // Branch 2: Both routing fields set and estimated_size available
200        if let (Some(small), Some(large), Some(size)) = (
201            task_override.and_then(|o| o.small_model.clone()),
202            task_override.and_then(|o| o.large_model.clone()),
203            estimated_size,
204        ) {
205            let default_threshold = match task {
206                TaskType::Review => 60_000,
207                TaskType::Triage | TaskType::Create => 8_192,
208            };
209            let threshold = task_override
210                .and_then(|o| o.routing_threshold_chars)
211                .unwrap_or(default_threshold);
212            if size < threshold {
213                return (provider.clone(), small);
214            }
215            return (provider, large);
216        }
217
218        // Branch 3: Exactly one of small_model/large_model set - warn and fall through
219        let has_small = task_override.and_then(|o| o.small_model.as_ref()).is_some();
220        let has_large = task_override.and_then(|o| o.large_model.as_ref()).is_some();
221        if has_small != has_large {
222            let missing = if has_small {
223                "large_model"
224            } else {
225                "small_model"
226            };
227            tracing::warn!(
228                "TaskOverride has only one routing field set (missing {missing}); falling back to default model"
229            );
230        }
231
232        // Branch 4: Fallback to self.model
233        let model = self.model.clone();
234        (provider, model)
235    }
236}