stakpak_api/client/
mod.rs1mod provider;
10
11use crate::local::db;
12use crate::local::hooks::task_board_context::{TaskBoardContextHook, TaskBoardContextHookOptions};
13use crate::models::AgentState;
14use crate::stakpak::{StakpakApiClient, StakpakApiConfig};
15use libsql::Connection;
16use stakpak_shared::hooks::{HookRegistry, LifecycleEvent};
17use stakpak_shared::models::llm::{LLMModel, LLMProviderConfig, ProviderConfig};
18use stakpak_shared::models::stakai_adapter::{StakAIClient, get_stakai_model_string};
19use std::path::PathBuf;
20use std::sync::Arc;
21
22#[derive(Clone, Debug, Default)]
28pub struct ModelOptions {
29 pub smart_model: Option<LLMModel>,
31 pub eco_model: Option<LLMModel>,
33 pub recovery_model: Option<LLMModel>,
35}
36
37pub const DEFAULT_STAKPAK_ENDPOINT: &str = "https://apiv2.stakpak.dev";
39
40#[derive(Debug, Clone)]
42pub struct StakpakConfig {
43 pub api_key: String,
45 pub api_endpoint: String,
47}
48
49impl StakpakConfig {
50 pub fn new(api_key: impl Into<String>) -> Self {
51 Self {
52 api_key: api_key.into(),
53 api_endpoint: DEFAULT_STAKPAK_ENDPOINT.to_string(),
54 }
55 }
56
57 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
58 self.api_endpoint = endpoint.into();
59 self
60 }
61}
62
63#[derive(Debug, Default)]
65pub struct AgentClientConfig {
66 pub stakpak: Option<StakpakConfig>,
68 pub providers: LLMProviderConfig,
70 pub smart_model: Option<String>,
72 pub eco_model: Option<String>,
74 pub recovery_model: Option<String>,
76 pub store_path: Option<String>,
78 pub hook_registry: Option<HookRegistry<AgentState>>,
80}
81
82impl AgentClientConfig {
83 pub fn new() -> Self {
85 Self::default()
86 }
87
88 pub fn with_stakpak(mut self, config: StakpakConfig) -> Self {
92 self.stakpak = Some(config);
93 self
94 }
95
96 pub fn with_providers(mut self, providers: LLMProviderConfig) -> Self {
98 self.providers = providers;
99 self
100 }
101
102 pub fn with_smart_model(mut self, model: impl Into<String>) -> Self {
104 self.smart_model = Some(model.into());
105 self
106 }
107
108 pub fn with_eco_model(mut self, model: impl Into<String>) -> Self {
110 self.eco_model = Some(model.into());
111 self
112 }
113
114 pub fn with_recovery_model(mut self, model: impl Into<String>) -> Self {
116 self.recovery_model = Some(model.into());
117 self
118 }
119
120 pub fn with_store_path(mut self, path: impl Into<String>) -> Self {
122 self.store_path = Some(path.into());
123 self
124 }
125
126 pub fn with_hook_registry(mut self, registry: HookRegistry<AgentState>) -> Self {
128 self.hook_registry = Some(registry);
129 self
130 }
131}
132
133const DEFAULT_STORE_PATH: &str = ".stakpak/data/local.db";
138
139#[derive(Clone)]
146pub struct AgentClient {
147 pub(crate) stakai: StakAIClient,
149 pub(crate) stakpak_api: Option<StakpakApiClient>,
151 pub(crate) local_db: Connection,
153 pub(crate) hook_registry: Arc<HookRegistry<AgentState>>,
155 pub(crate) model_options: ModelOptions,
157 pub(crate) stakpak: Option<StakpakConfig>,
159}
160
161impl AgentClient {
162 pub async fn new(config: AgentClientConfig) -> Result<Self, String> {
164 let mut providers = config.providers.clone();
166 if let Some(stakpak) = &config.stakpak
167 && !stakpak.api_key.is_empty()
168 {
169 providers.providers.insert(
170 "stakpak".to_string(),
171 ProviderConfig::Stakpak {
172 api_key: stakpak.api_key.clone(),
173 api_endpoint: Some(stakpak.api_endpoint.clone()),
174 },
175 );
176 }
177
178 let stakai = StakAIClient::new(&providers)
180 .map_err(|e| format!("Failed to create StakAI client: {}", e))?;
181
182 let stakpak_api = if let Some(stakpak) = &config.stakpak {
184 if !stakpak.api_key.is_empty() {
185 Some(
186 StakpakApiClient::new(&StakpakApiConfig {
187 api_key: stakpak.api_key.clone(),
188 api_endpoint: stakpak.api_endpoint.clone(),
189 })
190 .map_err(|e| format!("Failed to create Stakpak API client: {}", e))?,
191 )
192 } else {
193 None
194 }
195 } else {
196 None
197 };
198
199 let store_path = config.store_path.map(PathBuf::from).unwrap_or_else(|| {
201 std::env::var("HOME")
202 .map(PathBuf::from)
203 .unwrap_or_default()
204 .join(DEFAULT_STORE_PATH)
205 });
206
207 if let Some(parent) = store_path.parent() {
208 std::fs::create_dir_all(parent)
209 .map_err(|e| format!("Failed to create database directory: {}", e))?;
210 }
211
212 let db = libsql::Builder::new_local(store_path.display().to_string())
213 .build()
214 .await
215 .map_err(|e| format!("Failed to open database: {}", e))?;
216 let local_db = db
217 .connect()
218 .map_err(|e| format!("Failed to connect to database: {}", e))?;
219 db::init_schema(&local_db).await?;
220
221 let model_options = ModelOptions {
223 smart_model: config.smart_model.map(LLMModel::from),
224 eco_model: config.eco_model.map(LLMModel::from),
225 recovery_model: config.recovery_model.map(LLMModel::from),
226 };
227
228 let mut hook_registry = config.hook_registry.unwrap_or_default();
230 hook_registry.register(
231 LifecycleEvent::BeforeInference,
232 Box::new(TaskBoardContextHook::new(TaskBoardContextHookOptions {
233 model_options: crate::local::ModelOptions {
234 smart_model: model_options.smart_model.clone(),
235 eco_model: model_options.eco_model.clone(),
236 recovery_model: model_options.recovery_model.clone(),
237 },
238 history_action_message_size_limit: Some(100),
239 history_action_message_keep_last_n: Some(50),
240 history_action_result_keep_last_n: Some(50),
241 })),
242 );
243 let hook_registry = Arc::new(hook_registry);
244
245 Ok(Self {
246 stakai,
247 stakpak_api,
248 local_db,
249 hook_registry,
250 model_options,
251 stakpak: config.stakpak,
252 })
253 }
254
255 pub fn has_stakpak(&self) -> bool {
257 self.stakpak_api.is_some()
258 }
259
260 pub fn get_stakpak_api_endpoint(&self) -> &str {
262 self.stakpak
263 .as_ref()
264 .map(|s| s.api_endpoint.as_str())
265 .unwrap_or(DEFAULT_STAKPAK_ENDPOINT)
266 }
267
268 pub fn stakai(&self) -> &StakAIClient {
270 &self.stakai
271 }
272
273 pub fn stakpak_api(&self) -> Option<&StakpakApiClient> {
275 self.stakpak_api.as_ref()
276 }
277
278 pub fn local_db(&self) -> &Connection {
280 &self.local_db
281 }
282
283 pub fn hook_registry(&self) -> &Arc<HookRegistry<AgentState>> {
285 &self.hook_registry
286 }
287
288 pub fn model_options(&self) -> &ModelOptions {
290 &self.model_options
291 }
292
293 pub fn get_model_string(
298 &self,
299 model: &stakpak_shared::models::integrations::openai::AgentModel,
300 ) -> LLMModel {
301 use stakpak_shared::models::integrations::openai::AgentModel;
302
303 let base_model = match model {
304 AgentModel::Smart => self.model_options.smart_model.clone().unwrap_or_else(|| {
305 LLMModel::from("anthropic/claude-sonnet-4-5-20250929".to_string())
306 }),
307 AgentModel::Eco => self.model_options.eco_model.clone().unwrap_or_else(|| {
308 LLMModel::from("anthropic/claude-haiku-4-5-20250929".to_string())
309 }),
310 AgentModel::Recovery => self
311 .model_options
312 .recovery_model
313 .clone()
314 .unwrap_or_else(|| LLMModel::from("openai/gpt-5".to_string())),
315 };
316
317 if self.has_stakpak() {
319 let model_str = get_stakai_model_string(&base_model);
321 let display_name = model_str
323 .rsplit('/')
324 .next()
325 .unwrap_or(&model_str)
326 .to_string();
327 LLMModel::Custom {
328 provider: "stakpak".to_string(),
329 model: model_str,
330 name: Some(display_name),
331 }
332 } else {
333 base_model
334 }
335 }
336}
337
338impl std::fmt::Debug for AgentClient {
340 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341 f.debug_struct("AgentClient")
342 .field("has_stakpak", &self.has_stakpak())
343 .field("model_options", &self.model_options)
344 .finish_non_exhaustive()
345 }
346}