stakpak_api/client/
mod.rs1mod provider;
10
11use crate::local::hooks::task_board_context::{TaskBoardContextHook, TaskBoardContextHookOptions};
12use crate::local::storage::LocalStorage;
13use crate::models::AgentState;
14use crate::stakpak::storage::StakpakStorage;
15use crate::stakpak::{StakpakApiClient, StakpakApiConfig};
16use crate::storage::SessionStorage;
17
18use stakpak_shared::hooks::{HookRegistry, LifecycleEvent};
19use stakpak_shared::models::llm::{LLMModel, LLMProviderConfig, ProviderConfig};
20use stakpak_shared::models::stakai_adapter::{StakAIClient, get_stakai_model_string};
21use std::sync::Arc;
22
23#[derive(Clone, Debug, Default)]
29pub struct ModelOptions {
30 pub smart_model: Option<LLMModel>,
32 pub eco_model: Option<LLMModel>,
34 pub recovery_model: Option<LLMModel>,
36}
37
38pub const DEFAULT_STAKPAK_ENDPOINT: &str = "https://apiv2.stakpak.dev";
40
41#[derive(Debug, Clone)]
43pub struct StakpakConfig {
44 pub api_key: String,
46 pub api_endpoint: String,
48}
49
50impl StakpakConfig {
51 pub fn new(api_key: impl Into<String>) -> Self {
52 Self {
53 api_key: api_key.into(),
54 api_endpoint: DEFAULT_STAKPAK_ENDPOINT.to_string(),
55 }
56 }
57
58 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
59 self.api_endpoint = endpoint.into();
60 self
61 }
62}
63
64#[derive(Debug, Default)]
66pub struct AgentClientConfig {
67 pub stakpak: Option<StakpakConfig>,
69 pub providers: LLMProviderConfig,
71 pub smart_model: Option<String>,
73 pub eco_model: Option<String>,
75 pub recovery_model: Option<String>,
77 pub store_path: Option<String>,
79 pub hook_registry: Option<HookRegistry<AgentState>>,
81}
82
83impl AgentClientConfig {
84 pub fn new() -> Self {
86 Self::default()
87 }
88
89 pub fn with_stakpak(mut self, config: StakpakConfig) -> Self {
93 self.stakpak = Some(config);
94 self
95 }
96
97 pub fn with_providers(mut self, providers: LLMProviderConfig) -> Self {
99 self.providers = providers;
100 self
101 }
102
103 pub fn with_smart_model(mut self, model: impl Into<String>) -> Self {
105 self.smart_model = Some(model.into());
106 self
107 }
108
109 pub fn with_eco_model(mut self, model: impl Into<String>) -> Self {
111 self.eco_model = Some(model.into());
112 self
113 }
114
115 pub fn with_recovery_model(mut self, model: impl Into<String>) -> Self {
117 self.recovery_model = Some(model.into());
118 self
119 }
120
121 pub fn with_store_path(mut self, path: impl Into<String>) -> Self {
123 self.store_path = Some(path.into());
124 self
125 }
126
127 pub fn with_hook_registry(mut self, registry: HookRegistry<AgentState>) -> Self {
129 self.hook_registry = Some(registry);
130 self
131 }
132}
133
134const DEFAULT_STORE_PATH: &str = ".stakpak/data/local.db";
139
140#[derive(Clone)]
147pub struct AgentClient {
148 pub(crate) stakai: StakAIClient,
150 pub(crate) stakpak_api: Option<StakpakApiClient>,
152 pub(crate) session_storage: Arc<dyn SessionStorage>,
154 pub(crate) hook_registry: Arc<HookRegistry<AgentState>>,
156 pub(crate) model_options: ModelOptions,
158 pub(crate) stakpak: Option<StakpakConfig>,
160}
161
162impl AgentClient {
163 pub async fn new(config: AgentClientConfig) -> Result<Self, String> {
165 let mut providers = config.providers.clone();
167 if let Some(stakpak) = &config.stakpak
168 && !stakpak.api_key.is_empty()
169 {
170 providers.providers.insert(
171 "stakpak".to_string(),
172 ProviderConfig::Stakpak {
173 api_key: stakpak.api_key.clone(),
174 api_endpoint: Some(stakpak.api_endpoint.clone()),
175 },
176 );
177 }
178
179 let stakai = StakAIClient::new(&providers)
181 .map_err(|e| format!("Failed to create StakAI client: {}", e))?;
182
183 let stakpak_api = if let Some(stakpak) = &config.stakpak {
185 if !stakpak.api_key.is_empty() {
186 Some(
187 StakpakApiClient::new(&StakpakApiConfig {
188 api_key: stakpak.api_key.clone(),
189 api_endpoint: stakpak.api_endpoint.clone(),
190 })
191 .map_err(|e| format!("Failed to create Stakpak API client: {}", e))?,
192 )
193 } else {
194 None
195 }
196 } else {
197 None
198 };
199
200 let session_storage: Arc<dyn SessionStorage> = if let Some(stakpak) = &config.stakpak
202 && !stakpak.api_key.is_empty()
203 {
204 Arc::new(
205 StakpakStorage::new(&stakpak.api_key, &stakpak.api_endpoint)
206 .map_err(|e| format!("Failed to create Stakpak storage: {}", e))?,
207 )
208 } else {
209 let store_path = config.store_path.clone().unwrap_or_else(|| {
210 std::env::var("HOME")
211 .map(|h| format!("{}/{}", h, DEFAULT_STORE_PATH))
212 .unwrap_or_else(|_| DEFAULT_STORE_PATH.to_string())
213 });
214 Arc::new(
215 LocalStorage::new(&store_path)
216 .await
217 .map_err(|e| format!("Failed to create local storage: {}", e))?,
218 )
219 };
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 session_storage,
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 hook_registry(&self) -> &Arc<HookRegistry<AgentState>> {
280 &self.hook_registry
281 }
282
283 pub fn model_options(&self) -> &ModelOptions {
285 &self.model_options
286 }
287
288 pub fn session_storage(&self) -> &Arc<dyn SessionStorage> {
292 &self.session_storage
293 }
294
295 pub fn get_model_string(
300 &self,
301 model: &stakpak_shared::models::integrations::openai::AgentModel,
302 ) -> LLMModel {
303 use stakpak_shared::models::integrations::openai::AgentModel;
304
305 let base_model = match model {
306 AgentModel::Smart => self.model_options.smart_model.clone().unwrap_or_else(|| {
307 LLMModel::from("anthropic/claude-sonnet-4-5-20250929".to_string())
308 }),
309 AgentModel::Eco => self.model_options.eco_model.clone().unwrap_or_else(|| {
310 LLMModel::from("anthropic/claude-haiku-4-5-20250929".to_string())
311 }),
312 AgentModel::Recovery => self
313 .model_options
314 .recovery_model
315 .clone()
316 .unwrap_or_else(|| LLMModel::from("openai/gpt-5".to_string())),
317 };
318
319 if self.has_stakpak() {
321 let model_str = get_stakai_model_string(&base_model);
323 let display_name = model_str
325 .rsplit('/')
326 .next()
327 .unwrap_or(&model_str)
328 .to_string();
329 LLMModel::Custom {
330 provider: "stakpak".to_string(),
331 model: model_str,
332 name: Some(display_name),
333 }
334 } else {
335 base_model
336 }
337 }
338}
339
340impl std::fmt::Debug for AgentClient {
342 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343 f.debug_struct("AgentClient")
344 .field("has_stakpak", &self.has_stakpak())
345 .field("model_options", &self.model_options)
346 .finish_non_exhaustive()
347 }
348}