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