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    /// `OpenRouter` data collection setting: `"allow"` or `"deny"` (default: `"deny"`).
141    pub openrouter_data_collection: String,
142    /// `OpenRouter` Zero Data Retention requirement (default: `true`).
143    pub openrouter_zdr: bool,
144}
145
146impl Default for AiConfig {
147    fn default() -> Self {
148        Self {
149            provider: "openrouter".to_string(),
150            model: DEFAULT_OPENROUTER_MODEL.to_string(),
151            timeout_seconds: 30,
152            allow_paid_models: true,
153            max_tokens: 4096,
154            temperature: 0.3,
155            circuit_breaker_threshold: 3,
156            circuit_breaker_reset_seconds: 60,
157            retry_max_attempts: default_retry_max_attempts(),
158            tasks: None,
159            fallback: None,
160            custom_guidance: None,
161            validation_enabled: true,
162            openrouter_data_collection: "deny".to_string(),
163            openrouter_zdr: true,
164        }
165    }
166}
167
168impl AiConfig {
169    /// Resolve provider and model for a specific task type.
170    ///
171    /// Returns a tuple of (provider, model) by checking task-specific overrides first,
172    /// then falling back to the default provider and model. When `estimated_size` is provided
173    /// and routing fields (`small_model`/`large_model`) are configured, selects between
174    /// small and large models based on the routing threshold.
175    ///
176    /// # Arguments
177    ///
178    /// * `task` - The task type to resolve configuration for
179    /// * `estimated_size` - Optional estimated size of the prompt in characters (used for routing)
180    ///
181    /// # Returns
182    ///
183    /// A tuple of (`provider_name`, `model_name`) strings
184    #[must_use]
185    pub fn resolve_for_task(
186        &self,
187        task: TaskType,
188        estimated_size: Option<usize>,
189    ) -> (String, String) {
190        let task_override = match task {
191            TaskType::Triage => self.tasks.as_ref().and_then(|t| t.triage.as_ref()),
192            TaskType::Review => self.tasks.as_ref().and_then(|t| t.review.as_ref()),
193            TaskType::Create => self.tasks.as_ref().and_then(|t| t.create.as_ref()),
194        };
195
196        let provider = task_override
197            .and_then(|o| o.provider.clone())
198            .unwrap_or_else(|| self.provider.clone());
199
200        // Branch 1: Explicit model override bypasses routing entirely
201        if let Some(model) = task_override.and_then(|o| o.model.clone()) {
202            return (provider, model);
203        }
204
205        // Branch 2: Both routing fields set and estimated_size available
206        if let (Some(small), Some(large), Some(size)) = (
207            task_override.and_then(|o| o.small_model.clone()),
208            task_override.and_then(|o| o.large_model.clone()),
209            estimated_size,
210        ) {
211            let default_threshold = match task {
212                TaskType::Review => 60_000,
213                TaskType::Triage | TaskType::Create => 8_192,
214            };
215            let threshold = task_override
216                .and_then(|o| o.routing_threshold_chars)
217                .unwrap_or(default_threshold);
218            if size < threshold {
219                return (provider.clone(), small);
220            }
221            return (provider, large);
222        }
223
224        // Branch 3: Exactly one of small_model/large_model set - warn and fall through
225        let has_small = task_override.and_then(|o| o.small_model.as_ref()).is_some();
226        let has_large = task_override.and_then(|o| o.large_model.as_ref()).is_some();
227        if has_small != has_large {
228            let missing = if has_small {
229                "large_model"
230            } else {
231                "small_model"
232            };
233            tracing::warn!(
234                "TaskOverride has only one routing field set (missing {missing}); falling back to default model"
235            );
236        }
237
238        // Branch 4: Fallback to self.model
239        let model = self.model.clone();
240        (provider, model)
241    }
242}