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