1#![forbid(unsafe_code)]
4
5use std::sync::Arc;
6
7use kcode_kennedy_session_kmap_services::Service as KmapService;
8use kcode_kweb_db::{Node, Provenance};
9use kcode_kweb_manager::KwebManager;
10use kcode_server_object_envelopes::StoredFile;
11use serde_json::Value;
12use uuid::Uuid;
13
14#[derive(Clone)]
15pub struct LocalServices {
16 pub load_fixed_connections: bool,
17 pub kmap: KwebManager,
18 pub intelligence: kcode_intelligence_router::Intelligence,
19 pub history: kcode_session_history::SessionHistory,
20 pub speech_classifier: Arc<kcode_speaker_system::SpeechClassifier>,
21 pub dev_tools: kcode_dev_tools::Service,
22 pub agents: kcode_agent_runtime::AgentRuntime,
23 pub telegram: kcode_telegram_session_coordinator::Service,
24}
25
26#[derive(Debug, Clone)]
27pub struct ApiError {
28 message: String,
29 pub receipt: Option<Box<kcode_intelligence_router::UsageReceipt>>,
30}
31
32impl std::fmt::Display for ApiError {
33 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 formatter.write_str(&self.message)
35 }
36}
37
38impl std::error::Error for ApiError {}
39
40#[derive(Clone)]
41pub struct Api {
42 services: Arc<LocalServices>,
43 kmap: KmapService,
44 task_board: Option<kcode_task_board::TaskBoard>,
45}
46
47impl Api {
48 pub fn new(services: LocalServices) -> Self {
49 let kmap = KmapService::new(services.kmap.clone());
50 Self {
51 services: Arc::new(services),
52 kmap,
53 task_board: None,
54 }
55 }
56
57 pub fn with_task_board(mut self, task_board: kcode_task_board::TaskBoard) -> Self {
58 self.task_board = Some(task_board);
59 self
60 }
61
62 pub fn kmap(&self) -> &KwebManager {
63 self.kmap.manager()
64 }
65
66 pub fn loads_fixed_connections(&self) -> bool {
67 self.services.load_fixed_connections
68 }
69
70 pub fn telegram(&self) -> &kcode_telegram_session_coordinator::Service {
71 &self.services.telegram
72 }
73
74 pub fn task_board(&self) -> Option<&kcode_task_board::TaskBoard> {
75 self.task_board.as_ref()
76 }
77
78 pub fn create_history_session(
79 &self,
80 input: kcode_session_history::NewSession,
81 ) -> anyhow::Result<kcode_session_history::Session> {
82 self.services.history.create_session(input)
83 }
84
85 pub fn history_session(
86 &self,
87 metadata: kcode_session_history::chatend::SessionMetadata,
88 provider_model: &str,
89 ) -> anyhow::Result<kcode_session_history::Session> {
90 self.services
91 .history
92 .open_session_with_provider_model(metadata, Some(provider_model))
93 }
94
95 pub async fn launch_session(
96 &self,
97 input: kcode_session_history::LaunchSession,
98 ) -> Result<kcode_session_history::SessionLaunch, kcode_session_history::Error> {
99 self.services.history.launch_session(input).await
100 }
101
102 pub fn kmap_node(&self, node_id: &str) -> Result<Node, ApiError> {
103 self.kmap.node(node_id).map_err(kmap_service_error)
104 }
105
106 pub fn commit_kweb_session(
107 &self,
108 input: kcode_commit_session::CommitRequest,
109 ) -> Result<kcode_commit_session::CommitReceipt, ApiError> {
110 self.kmap.commit_session(input).map_err(kmap_service_error)
111 }
112
113 pub fn kmap_file(&self, object_id: &str) -> Result<StoredFile, ApiError> {
114 self.kmap.file(object_id).map_err(kmap_service_error)
115 }
116
117 pub fn save_generated_image(
118 &self,
119 bytes: Vec<u8>,
120 file_name: &str,
121 media_type: &str,
122 model: &str,
123 ) -> Result<String, ApiError> {
124 self.kmap
125 .save_generated_image(bytes, file_name, media_type, model)
126 .map_err(kmap_service_error)
127 }
128
129 pub fn agent_runtime(&self) -> kcode_agent_runtime::AgentRuntime {
130 self.services.agents.clone()
131 }
132
133 pub async fn search(
134 &self,
135 user_id: &str,
136 request: kcode_intelligence_router::SearchRequest,
137 ) -> Result<
138 kcode_intelligence_router::Accounted<kcode_intelligence_router::SearchResponse>,
139 ApiError,
140 > {
141 self.services
142 .intelligence
143 .for_user(user_id)
144 .map_err(intelligence_error)?
145 .search(request)
146 .await
147 .map_err(intelligence_error)
148 }
149
150 pub async fn fetch(
151 &self,
152 user_id: &str,
153 request: kcode_intelligence_router::FetchRequest,
154 ) -> Result<kcode_intelligence_router::FetchResponse, ApiError> {
155 self.services
156 .intelligence
157 .for_user(user_id)
158 .map_err(intelligence_error)?
159 .fetch(request)
160 .await
161 .map_err(intelligence_error)
162 }
163
164 pub async fn managed_source_execute(
165 &self,
166 session_id: &str,
167 name: &str,
168 arguments: Value,
169 objects: Vec<Vec<u8>>,
170 ) -> Result<kcode_dev_tools::ToolExecution, ApiError> {
171 let mut execution = self
172 .services
173 .dev_tools
174 .execute(session_id.to_owned(), name.to_owned(), arguments, objects)
175 .await
176 .map_err(dev_tools_error)?;
177 let mut object_ids = Vec::with_capacity(execution.objects.len());
178 for bytes in std::mem::take(&mut execution.objects) {
179 object_ids.push(
180 self.kmap
181 .manager()
182 .store_object(
183 Provenance {
184 author: "Kennedy".into(),
185 source: "kennedy-rust-binary".into(),
186 source_created_at: chrono::Utc::now(),
187 data: "Output payload from a managed Rust-binary call.".into(),
188 },
189 bytes,
190 )
191 .map_err(kmap_error)?
192 .to_string(),
193 );
194 }
195 append_object_ids(&mut execution.text, &object_ids);
196 Ok(execution)
197 }
198
199 pub async fn execute_speech_classification_tool(
200 &self,
201 name: &str,
202 arguments: Value,
203 ) -> Result<String, ApiError> {
204 let call =
205 kcode_speaker_system::decode_ktool(name, &arguments).map_err(speech_ktool_error)?;
206 let classifier = Arc::clone(&self.services.speech_classifier);
207 tokio::task::spawn_blocking(move || classifier.execute_ktool(call))
208 .await
209 .map_err(speech_task_error)?
210 .map_err(speech_ktool_error)
211 }
212
213 pub async fn release_managed_sources(&self, session_id: &str) {
214 if let Err(error) = self.services.dev_tools.release(session_id.to_owned()).await {
215 tracing::warn!(error=%error.message, "Managed-source session release failed");
216 }
217 }
218
219 #[allow(clippy::too_many_arguments)]
220 pub async fn transcribe_audio(
221 &self,
222 user_id: &str,
223 model: &str,
224 prompt: &str,
225 bytes: Vec<u8>,
226 filename: String,
227 mime: &str,
228 temperature: Option<f32>,
229 parent_operation_id: Uuid,
230 ) -> Result<
231 kcode_intelligence_router::Accounted<kcode_intelligence_router::TranscriptionResponse>,
232 ApiError,
233 > {
234 self.services
235 .intelligence
236 .for_user(user_id)
237 .map_err(intelligence_error)?
238 .transcribe(kcode_intelligence_router::TranscriptionRequest {
239 prompt: prompt.to_owned(),
240 model: model.to_owned(),
241 media: kcode_intelligence_router::Media::audio(bytes, filename, mime)
242 .map_err(intelligence_error)?,
243 temperature,
244 operation_id: Uuid::new_v4(),
245 parent_operation_id: Some(parent_operation_id),
246 })
247 .await
248 .map_err(intelligence_error)
249 }
250
251 pub async fn extract_document(
252 &self,
253 bytes: Vec<u8>,
254 filename: String,
255 mime: &str,
256 ) -> Result<kcode_intelligence_router::DocumentExtraction, ApiError> {
257 self.services
258 .intelligence
259 .extract_document(kcode_intelligence_router::Document {
260 bytes,
261 file_name: filename,
262 content_type: mime.to_owned(),
263 })
264 .await
265 .map_err(intelligence_error)
266 }
267
268 #[allow(clippy::too_many_arguments)]
269 pub async fn annotate_media(
270 &self,
271 user_id: &str,
272 model: &str,
273 prompt: &str,
274 bytes: Vec<u8>,
275 filename: String,
276 mime: &str,
277 parent_operation_id: Uuid,
278 ) -> Result<
279 kcode_intelligence_router::Accounted<kcode_intelligence_router::AnnotationResponse>,
280 ApiError,
281 > {
282 self.services
283 .intelligence
284 .for_user(user_id)
285 .map_err(intelligence_error)?
286 .annotate(kcode_intelligence_router::AnnotationRequest {
287 prompt: prompt.to_owned(),
288 model: model.to_owned(),
289 media: media_for_annotation(bytes, filename, mime).map_err(intelligence_error)?,
290 operation_id: Uuid::new_v4(),
291 parent_operation_id: Some(parent_operation_id),
292 })
293 .await
294 .map_err(intelligence_error)
295 }
296
297 pub async fn generate_image(
298 &self,
299 user_id: &str,
300 model: &str,
301 prompt: &str,
302 references: Vec<(Vec<u8>, String, String)>,
303 parent_operation_id: Uuid,
304 ) -> Result<
305 kcode_intelligence_router::Accounted<kcode_intelligence_router::ImageResponse>,
306 ApiError,
307 > {
308 let references = references
309 .into_iter()
310 .map(|(bytes, filename, mime)| {
311 media_for_image(bytes, filename, &mime).map_err(intelligence_error)
312 })
313 .collect::<Result<Vec<_>, _>>()?;
314 self.services
315 .intelligence
316 .for_user(user_id)
317 .map_err(intelligence_error)?
318 .generate_image(kcode_intelligence_router::ImageRequest {
319 model: model.to_owned(),
320 prompt: prompt.to_owned(),
321 references,
322 operation_id: Uuid::new_v4(),
323 parent_operation_id: Some(parent_operation_id),
324 })
325 .await
326 .map_err(intelligence_error)
327 }
328}
329
330fn kmap_service_error(error: kcode_kennedy_session_kmap_services::Error) -> ApiError {
331 ApiError {
332 message: error.to_string(),
333 receipt: None,
334 }
335}
336
337fn kmap_error(error: kcode_kweb_manager::Error) -> ApiError {
338 let message = match error.kind() {
339 kcode_kweb_manager::ErrorKind::InvalidInput
340 | kcode_kweb_manager::ErrorKind::NotFound
341 | kcode_kweb_manager::ErrorKind::Conflict => error.to_string(),
342 _ => "An unexpected Kmap database error occurred.".into(),
343 };
344 ApiError {
345 message,
346 receipt: None,
347 }
348}
349
350fn intelligence_error(error: kcode_intelligence_router::Error) -> ApiError {
351 ApiError {
352 message: error.message().into(),
353 receipt: error.receipt().cloned().map(Box::new),
354 }
355}
356
357fn append_object_ids(text: &mut String, object_ids: &[String]) {
358 if object_ids.is_empty() {
359 return;
360 }
361 if !text.is_empty() && !text.ends_with('\n') {
362 text.push('\n');
363 }
364 text.push_str(&object_ids.join("\n"));
365}
366
367fn dev_tools_error(error: kcode_dev_tools::ToolError) -> ApiError {
368 ApiError {
369 message: error.message,
370 receipt: None,
371 }
372}
373
374fn speech_task_error(error: tokio::task::JoinError) -> ApiError {
375 tracing::error!(%error, "In-process speaker-classification task stopped unexpectedly");
376 ApiError {
377 message: "An unexpected Kennedy speaker-classification error occurred.".into(),
378 receipt: None,
379 }
380}
381
382fn speech_ktool_error(error: kcode_speaker_system::KtoolError) -> ApiError {
383 let kcode_speaker_system::KtoolError::Classifier(error) = error else {
384 return ApiError {
385 message: error.to_string(),
386 receipt: None,
387 };
388 };
389 let internal = matches!(
390 error,
391 kcode_speaker_system::Error::Storage(_)
392 | kcode_speaker_system::Error::CorruptStorage(_)
393 | kcode_speaker_system::Error::Model(_)
394 );
395 ApiError {
396 message: if internal {
397 tracing::error!(%error, "Speaker-classification storage failed");
398 "An unexpected Kennedy speaker-classification error occurred.".into()
399 } else {
400 error.to_string()
401 },
402 receipt: None,
403 }
404}
405
406fn media_for_annotation(
407 bytes: Vec<u8>,
408 filename: String,
409 mime: &str,
410) -> kcode_intelligence_router::Result<kcode_intelligence_router::Media> {
411 let normalized = mime
412 .split(';')
413 .next()
414 .unwrap_or("application/octet-stream")
415 .trim()
416 .to_ascii_lowercase();
417 let kind = if normalized.starts_with("image/") {
418 kcode_intelligence_router::MediaKind::Image
419 } else if normalized.starts_with("audio/")
420 || matches!(normalized.as_str(), "application/ogg" | "video/ogg")
421 || filename.rsplit_once('.').is_some_and(|(_, extension)| {
422 matches!(
423 extension.to_ascii_lowercase().as_str(),
424 "ogg" | "oga" | "opus"
425 )
426 })
427 {
428 kcode_intelligence_router::MediaKind::Audio
429 } else if normalized.starts_with("video/") {
430 kcode_intelligence_router::MediaKind::Video
431 } else {
432 return Err(kcode_intelligence_router::Error::invalid(
433 "annotation requires image, audio, or video media",
434 ));
435 };
436 kcode_intelligence_router::Media::new(kind, bytes, filename, normalized)
437}
438
439fn media_for_image(
440 bytes: Vec<u8>,
441 filename: String,
442 mime: &str,
443) -> kcode_intelligence_router::Result<kcode_intelligence_router::Media> {
444 let normalized = mime
445 .split(';')
446 .next()
447 .unwrap_or("application/octet-stream")
448 .trim()
449 .to_ascii_lowercase();
450 if !normalized.starts_with("image/") {
451 return Err(kcode_intelligence_router::Error::invalid(
452 "image references must use an image content type",
453 ));
454 }
455 kcode_intelligence_router::Media::new(
456 kcode_intelligence_router::MediaKind::Image,
457 bytes,
458 filename,
459 normalized,
460 )
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466
467 #[test]
468 fn speech_ktool_errors_keep_the_existing_public_failure_boundary() {
469 let malformed = speech_ktool_error(kcode_speaker_system::KtoolError::InvalidArguments {
470 tool: kcode_speaker_system::IDENTIFY_TOOL,
471 source: serde_json::from_str::<Value>("{").unwrap_err(),
472 });
473 assert_eq!(
474 malformed.message,
475 "decoding kcode-speaker-system/identify arguments"
476 );
477
478 let validation = speech_ktool_error(kcode_speaker_system::KtoolError::Classifier(
479 kcode_speaker_system::Error::Validation {
480 field: "speaker_id".into(),
481 message: "must not be empty".into(),
482 },
483 ));
484 assert_eq!(validation.message, "speaker_id: must not be empty");
485
486 let storage = speech_ktool_error(kcode_speaker_system::KtoolError::Classifier(
487 kcode_speaker_system::Error::Storage("private detail".into()),
488 ));
489 assert_eq!(
490 storage.message,
491 "An unexpected Kennedy speaker-classification error occurred."
492 );
493 }
494}