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