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