1#![forbid(unsafe_code)]
8
9mod defaults;
10mod error;
11mod receipts;
12
13use std::{
14 collections::HashMap,
15 path::PathBuf,
16 sync::{Arc, Mutex},
17 time::{Duration, Instant},
18};
19
20use anyhow::Context;
21use chrono::{DateTime, NaiveDate, Utc};
22pub use kcode_codex_runtime::ReasoningEffort;
23use kcode_codex_runtime::{
24 CatalogCache, Codex, CodexConfig, ErrorKind as CodexErrorKind,
25 GenerationRequest as CodexGenerationRequest, TokenUsage as CodexTokenUsage,
26 WebSearchRequest as CodexSearchRequest,
27};
28use kcode_doc_extraction::{DocumentExtractor, DocumentInput, ErrorKind as DocumentErrorKind};
29use kcode_gemini_api::{
30 CompletionStatus as GeminiCompletionStatus, Error as GeminiError, Gemini, GenerationOptions,
31 GroundedSearchRequest, MediaInput as GeminiMediaInput, MultimodalRequest, ServiceTier,
32 StructuredOutput, TextModel, ThinkingLevel, TokenUsage as GeminiTokenUsage,
33};
34use kcode_openai_api::{
35 AudioInput, Error as OpenAiError, ImageAnalysisRequest,
36 ImageAnalysisStatus as OpenAiImageStatus, ImageAnalysisUsage, ImageInput as OpenAiImageInput,
37 ImageMediaType as OpenAiImageMediaType, OpenAi,
38 TranscriptionRequest as OpenAiTranscriptionRequest, TranscriptionUsage,
39};
40use kcode_web_fetch::{ErrorKind as WebFetchErrorKind, WebFetcher};
41use serde::{Deserialize, Serialize};
42use serde_json::Value;
43use tokio::sync::watch;
44use uuid::Uuid;
45
46use defaults::*;
47pub use error::{Error, ErrorKind, Result};
48use receipts::ReceiptStore;
49pub use receipts::{DailyUsage, DailyUsageKey, Metering, TokenUsage, UsageReceipt};
50
51pub struct Config {
53 pub openai_api_key: Option<String>,
54 pub gemini_api_key: Option<String>,
55 pub codex_catalog_cache: CatalogCache,
56 pub receipt_directory: PathBuf,
57}
58
59#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct RuntimeModel {
62 pub model: String,
63 pub reasoning_effort: String,
64 pub context_window_tokens: u64,
65 pub max_input_tokens: u64,
66}
67
68#[derive(Clone)]
69pub struct Intelligence {
70 codex: Codex,
71 agent: kcode_codex_runtime_v2::Codex,
72 openai: Option<OpenAi>,
73 gemini: Option<Gemini>,
74 web_fetcher: WebFetcher,
75 document_extractor: DocumentExtractor,
76 active_operations: ActiveOperations,
77 receipts: ReceiptStore,
78}
79
80#[derive(Clone)]
82pub struct UserIntelligence {
83 service: Intelligence,
84 user_id: String,
85}
86
87#[derive(Clone, Default)]
88struct ActiveOperations {
89 senders: Arc<Mutex<HashMap<Uuid, watch::Sender<bool>>>>,
90}
91
92struct ActiveOperation {
93 id: Uuid,
94 operations: ActiveOperations,
95 cancellation: watch::Receiver<bool>,
96 owns_registration: bool,
97}
98
99pub struct AgentTurn {
100 inner: kcode_codex_runtime_v2::AgentTurn,
101 operation: ActiveOperation,
102 user_id: String,
103 model: String,
104 receipts: ReceiptStore,
105 receipt_recorded: bool,
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
109pub struct SearchRequest {
110 pub question: String,
111 pub model: String,
112 pub operation_id: Uuid,
113 pub parent_operation_id: Option<Uuid>,
114}
115
116#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub struct WebSource {
119 pub title: String,
120 pub url: String,
121}
122
123#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
124#[serde(rename_all = "camelCase")]
125pub struct SearchResponse {
126 pub answer: String,
127 pub sources: Vec<WebSource>,
128 pub model: String,
129 pub usage: Option<TokenUsage>,
130}
131
132#[derive(Clone, Debug, Eq, PartialEq)]
133pub struct FetchRequest {
134 pub url: String,
135 pub operation_id: Uuid,
136 pub parent_operation_id: Option<Uuid>,
137}
138
139#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
140#[serde(rename_all = "camelCase")]
141pub struct FetchResponse {
142 pub url: String,
143 pub title: Option<String>,
144 pub content_type: String,
145 pub content: String,
146 pub truncated: bool,
147 pub retrieved_at: DateTime<Utc>,
148}
149
150#[derive(Clone, Copy, Debug, Eq, PartialEq)]
151pub enum MediaKind {
152 Image,
153 Audio,
154 Video,
155}
156
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub struct Media {
159 pub kind: MediaKind,
160 pub bytes: Vec<u8>,
161 pub file_name: String,
162 pub content_type: String,
163}
164
165impl Media {
166 pub fn new(
167 kind: MediaKind,
168 bytes: Vec<u8>,
169 file_name: impl Into<String>,
170 content_type: impl Into<String>,
171 ) -> Result<Self> {
172 if bytes.is_empty() || bytes.len() > MAX_MEDIA_ANNOTATION_BYTES {
173 return Err(Error::invalid(format!(
174 "media must contain between 1 and {MAX_MEDIA_ANNOTATION_BYTES} bytes"
175 )));
176 }
177 let file_name = file_name.into();
178 let mut content_type = normalized_content_type(&content_type.into());
179 if kind == MediaKind::Audio && is_ogg(&file_name, &content_type) {
180 content_type = "audio/ogg".into();
181 }
182 Ok(Self {
183 kind,
184 bytes,
185 file_name,
186 content_type,
187 })
188 }
189
190 pub fn audio(
191 bytes: Vec<u8>,
192 file_name: impl Into<String>,
193 content_type: impl Into<String>,
194 ) -> Result<Self> {
195 Self::new(MediaKind::Audio, bytes, file_name, content_type)
196 }
197}
198
199#[derive(Clone, Debug, Eq, PartialEq)]
200pub struct TranscriptionRequest {
201 pub prompt: String,
202 pub model: String,
203 pub media: Media,
204 pub operation_id: Uuid,
205 pub parent_operation_id: Option<Uuid>,
206}
207
208#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
209#[serde(rename_all = "camelCase")]
210pub struct TranscriptionResponse {
211 pub model: String,
212 pub text: String,
213 pub metering: Metering,
214}
215
216#[derive(Clone, Debug, PartialEq)]
218pub struct StructuredAudioRequest {
219 pub operation: String,
220 pub prompt: String,
221 pub model: String,
222 pub media: Media,
223 pub schema: Value,
224 pub max_output_tokens: u32,
225 pub operation_id: Uuid,
226 pub parent_operation_id: Option<Uuid>,
227}
228
229#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
230#[serde(rename_all = "camelCase")]
231pub struct StructuredAudioResponse {
232 pub model: String,
233 pub text: String,
234 pub usage: TokenUsage,
235}
236
237#[derive(Clone, Debug, Eq, PartialEq)]
239pub struct TextGenerationRequest {
240 pub operation: String,
241 pub prompt: String,
242 pub model: String,
243 pub reasoning_effort: ReasoningEffort,
244 pub timeout: Duration,
245 pub operation_id: Uuid,
246 pub parent_operation_id: Option<Uuid>,
247}
248
249#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
250#[serde(rename_all = "camelCase")]
251pub struct TextGenerationResponse {
252 pub model: String,
253 pub text: String,
254 pub thread_id: String,
255 pub usage: Option<TokenUsage>,
256}
257
258#[derive(Clone, Debug, Eq, PartialEq)]
259pub struct AnnotationRequest {
260 pub prompt: String,
261 pub model: String,
262 pub media: Media,
263 pub operation_id: Uuid,
264 pub parent_operation_id: Option<Uuid>,
265}
266
267#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
268#[serde(rename_all = "camelCase")]
269pub struct AnnotationResponse {
270 pub complete: bool,
271 pub model: String,
272 pub file_name: String,
273 pub content_type: String,
274 pub text: String,
275 pub incomplete_reason: Option<String>,
276 pub usage: Option<TokenUsage>,
277}
278
279#[derive(Clone, Debug, Eq, PartialEq)]
280pub struct Document {
281 pub bytes: Vec<u8>,
282 pub file_name: String,
283 pub content_type: String,
284}
285
286#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
287#[serde(rename_all = "camelCase")]
288pub struct DocumentExtraction {
289 pub file_name: String,
290 pub content_type: String,
291 pub format: String,
292 pub text: String,
293 pub characters: usize,
294 pub truncated: bool,
295}
296
297pub async fn open(config: Config) -> anyhow::Result<(Intelligence, RuntimeModel)> {
298 let mut codex_config = CodexConfig::new(DEFAULT_MODEL);
299 codex_config.base_instruction = KENNEDY_CODEX_BASE_INSTRUCTION.into();
300 codex_config.validation_reasoning_effort = GENERATION_REASONING_EFFORT;
301 let codex = Codex::open(codex_config, config.codex_catalog_cache)
302 .await
303 .context("opening Kennedy Codex runtime")?;
304 let limits = codex
305 .catalog()
306 .model_limits(DEFAULT_MODEL)
307 .with_context(|| format!("Codex model {DEFAULT_MODEL} is absent from the catalog"))?;
308 let runtime = RuntimeModel {
309 model: DEFAULT_MODEL.into(),
310 reasoning_effort: GENERATION_REASONING_EFFORT.as_str().into(),
311 context_window_tokens: limits.context_window_tokens(),
312 max_input_tokens: limits.max_input_tokens(),
313 };
314 let agent_config = kcode_codex_runtime_v2::CodexConfig {
315 executable: codex.catalog().executable().to_owned(),
316 base_instruction: KENNEDY_CODEX_BASE_INSTRUCTION.into(),
317 model_catalog: Some(codex.catalog().path().to_owned()),
318 ..kcode_codex_runtime_v2::CodexConfig::default()
319 };
320 let agent = kcode_codex_runtime_v2::Codex::open(agent_config)
321 .await
322 .context("opening Kennedy Codex runtime v2")?;
323 let openai = config
324 .openai_api_key
325 .filter(|value| !value.trim().is_empty())
326 .map(OpenAi::open)
327 .transpose()
328 .context("opening OpenAI client")?;
329 let gemini = config
330 .gemini_api_key
331 .filter(|value| !value.trim().is_empty())
332 .map(Gemini::open)
333 .transpose()
334 .context("opening Gemini client")?;
335 Ok((
336 Intelligence {
337 codex,
338 agent,
339 openai,
340 gemini,
341 web_fetcher: WebFetcher::default(),
342 document_extractor: DocumentExtractor::default(),
343 active_operations: ActiveOperations::default(),
344 receipts: ReceiptStore::open(config.receipt_directory)
345 .map_err(anyhow::Error::new)
346 .context("opening intelligence usage receipts")?,
347 },
348 runtime,
349 ))
350}
351
352impl Intelligence {
353 pub fn for_user(&self, user_id: impl Into<String>) -> Result<UserIntelligence> {
354 let user_id = user_id.into();
355 if user_id.trim().is_empty() || user_id.chars().count() > 256 {
356 return Err(Error::invalid(
357 "user_id must contain between 1 and 256 characters",
358 ));
359 }
360 Ok(UserIntelligence {
361 service: self.clone(),
362 user_id,
363 })
364 }
365
366 pub fn cancel(&self, operation_id: Uuid) -> Result<bool> {
367 self.active_operations.cancel(operation_id)
368 }
369
370 pub fn receipts(&self) -> Result<Vec<UsageReceipt>> {
371 self.receipts.receipts()
372 }
373
374 pub fn daily_usage(
375 &self,
376 day: NaiveDate,
377 ) -> Result<std::collections::BTreeMap<DailyUsageKey, DailyUsage>> {
378 self.receipts.daily_usage(day)
379 }
380
381 pub async fn extract_document(&self, document: Document) -> Result<DocumentExtraction> {
382 extract_document(&self.document_extractor, document).await
383 }
384}
385
386impl UserIntelligence {
387 pub fn user_id(&self) -> &str {
388 &self.user_id
389 }
390
391 pub async fn start_agent_turn(
392 &self,
393 operation_id: Uuid,
394 request: kcode_codex_runtime_v2::AgentRequest,
395 ) -> Result<AgentTurn> {
396 validate_model(&request.model)?;
397 if request.previous_thread_id.is_some() {
398 return Err(Error::invalid(
399 "agent turns must submit complete input without a previous thread; this keeps each usage receipt scoped to one call",
400 ));
401 }
402 let operation = self.service.active_operations.register(operation_id)?;
403 let model = request.model.clone();
404 let inner = self.account_result(
405 "agent_turn",
406 &model,
407 self.service
408 .agent
409 .start_turn(request)
410 .await
411 .map_err(codex_v2_error),
412 )?;
413 Ok(AgentTurn {
414 inner,
415 operation,
416 user_id: self.user_id.clone(),
417 model,
418 receipts: self.service.receipts.clone(),
419 receipt_recorded: false,
420 })
421 }
422
423 pub async fn search(&self, request: SearchRequest) -> Result<SearchResponse> {
424 let question = request.question.trim();
425 if question.is_empty() || question.chars().count() > 4_000 {
426 return Err(Error::invalid(
427 "question must contain between 1 and 4000 characters",
428 ));
429 }
430 validate_model(&request.model)?;
431 let mut operation = self
432 .service
433 .active_operations
434 .register_request(request.operation_id, request.parent_operation_id)?;
435 let started = Instant::now();
436 let response = if let Some(model) = gemini_model(&request.model) {
437 let gemini = self.service.gemini.as_ref().ok_or_else(|| {
438 Error::unavailable(
439 "provider_not_configured",
440 "Gemini search is not configured.",
441 )
442 })?;
443 let result = tokio::select! {
444 _ = operation.cancelled() => Err(Error::cancelled()),
445 result = tokio::time::timeout(
446 FAST_SEARCH_TIMEOUT,
447 gemini.grounded_search_with_model(
448 model,
449 GroundedSearchRequest::new(question),
450 ),
451 ) => result
452 .map_err(|_| Error::provider("provider_timeout", "Gemini search timed out."))
453 .and_then(|result| result.map_err(gemini_error)),
454 };
455 let result = self.account_result("web_search", &request.model, result)?;
456 let interaction = result.interaction;
457 let usage = gemini_usage(&interaction.usage);
458 self.record_tokens(
459 "web_search",
460 &request.model,
461 &interaction.model,
462 Some(usage),
463 Some(interaction.id.clone()),
464 None,
465 )?;
466 SearchResponse {
467 answer: interaction
468 .text
469 .filter(|value| !value.trim().is_empty())
470 .ok_or_else(|| {
471 Error::provider("provider_error", "Gemini search returned no answer text.")
472 })?,
473 sources: result
474 .sources
475 .into_iter()
476 .map(|source| WebSource {
477 title: source.title,
478 url: source.url,
479 })
480 .collect(),
481 model: interaction.model,
482 usage: Some(usage),
483 }
484 } else {
485 let (reasoning, context, depth, timeout) = codex_search_profile(&request.model)?;
486 let result = tokio::select! {
487 _ = operation.cancelled() => Err(Error::cancelled()),
488 result = self.service.codex.web_search(CodexSearchRequest {
489 question: question.to_owned(),
490 model: request.model.clone(),
491 reasoning_effort: reasoning,
492 context,
493 depth,
494 timeout,
495 }) => result.map_err(codex_error),
496 };
497 let result = self.account_result("web_search", &request.model, result)?;
498 let usage = result.usage.as_ref().map(codex_usage);
499 self.record_tokens(
500 "web_search",
501 &request.model,
502 &request.model,
503 usage,
504 None,
505 None,
506 )?;
507 SearchResponse {
508 answer: result.answer,
509 sources: result
510 .sources
511 .into_iter()
512 .map(|source| WebSource {
513 title: source.title,
514 url: source.url,
515 })
516 .collect(),
517 model: request.model.clone(),
518 usage,
519 }
520 };
521 tracing::info!(
522 user_id = %self.user_id,
523 operation_id = %request.operation_id,
524 model = %response.model,
525 duration_ms = started.elapsed().as_millis(),
526 "Intelligence search completed"
527 );
528 Ok(response)
529 }
530
531 pub async fn transcribe_structured_audio(
533 &self,
534 request: StructuredAudioRequest,
535 ) -> Result<StructuredAudioResponse> {
536 validate_operation(&request.operation)?;
537 validate_prompt(&request.prompt)?;
538 validate_model(&request.model)?;
539 if request.media.kind != MediaKind::Audio {
540 return Err(Error::invalid(
541 "structured audio inference requires audio media",
542 ));
543 }
544 if request.max_output_tokens == 0 || request.max_output_tokens > 65_536 {
545 return Err(Error::invalid(
546 "max_output_tokens must be between 1 and 65536",
547 ));
548 }
549 let model = gemini_model(&request.model).ok_or_else(|| {
550 Error::invalid("structured audio inference requires a supported exact Gemini model")
551 })?;
552 let gemini = self.service.gemini.as_ref().ok_or_else(|| {
553 Error::unavailable(
554 "provider_not_configured",
555 "Gemini structured audio inference is not configured.",
556 )
557 })?;
558 let media = gemini_media(&request.media)?;
559 let structured_output = StructuredOutput::new(request.schema).map_err(gemini_error)?;
560 let mut provider_request = MultimodalRequest::new(request.prompt, vec![media]);
561 provider_request.options = GenerationOptions {
562 max_output_tokens: Some(request.max_output_tokens),
563 temperature: None,
564 thinking_level: Some(ThinkingLevel::High),
565 service_tier: ServiceTier::Standard,
566 };
567 provider_request.structured_output = Some(structured_output);
568 let mut operation = self
569 .service
570 .active_operations
571 .register_request(request.operation_id, request.parent_operation_id)?;
572 let result = tokio::select! {
573 _ = operation.cancelled() => Err(Error::cancelled()),
574 result = tokio::time::timeout(
575 MEDIA_ANNOTATION_TIMEOUT,
576 gemini.infer_multimodal(model, provider_request),
577 ) => result
578 .map_err(|_| Error::provider("provider_timeout", "Gemini structured audio inference timed out."))
579 .and_then(|result| result.map_err(gemini_error)),
580 };
581 let result = self.account_result(&request.operation, &request.model, result)?;
582 let usage = gemini_usage(&result.usage);
583 self.record_tokens(
584 &request.operation,
585 &request.model,
586 &result.model,
587 Some(usage),
588 Some(result.id.clone()),
589 None,
590 )?;
591 if result.status != GeminiCompletionStatus::Completed {
592 return Err(Error::provider(
593 "provider_incomplete",
594 "Gemini structured audio inference did not complete.",
595 ));
596 }
597 let text = result
598 .text
599 .filter(|text| !text.trim().is_empty())
600 .ok_or_else(|| {
601 Error::provider(
602 "provider_empty_output",
603 "Gemini structured audio inference returned no text.",
604 )
605 })?;
606 Ok(StructuredAudioResponse {
607 model: result.model,
608 text,
609 usage,
610 })
611 }
612
613 pub async fn generate_text(
615 &self,
616 request: TextGenerationRequest,
617 ) -> Result<TextGenerationResponse> {
618 validate_operation(&request.operation)?;
619 validate_model(&request.model)?;
620 if request.prompt.trim().is_empty() || request.prompt.chars().count() > 1_000_000 {
621 return Err(Error::invalid(
622 "generation prompt must contain between 1 and 1000000 characters",
623 ));
624 }
625 if request.timeout.is_zero() || request.timeout > Duration::from_secs(60 * 60) {
626 return Err(Error::invalid(
627 "generation timeout must be between 1 second and 1 hour",
628 ));
629 }
630 let mut operation = self
631 .service
632 .active_operations
633 .register_request(request.operation_id, request.parent_operation_id)?;
634 let mut provider_request =
635 CodexGenerationRequest::new(request.prompt, request.model.clone());
636 provider_request.reasoning_effort = request.reasoning_effort;
637 provider_request.ephemeral = true;
638 provider_request.timeout = request.timeout;
639 let result = tokio::select! {
640 _ = operation.cancelled() => Err(Error::cancelled()),
641 result = self.service.codex.generate(provider_request) => result.map_err(codex_error),
642 };
643 let result = self.account_result(&request.operation, &request.model, result)?;
644 let usage = result.usage.as_ref().map(codex_usage);
645 self.record_tokens(
646 &request.operation,
647 &request.model,
648 &request.model,
649 usage,
650 None,
651 Some(result.thread_id.clone()),
652 )?;
653 Ok(TextGenerationResponse {
654 model: request.model,
655 text: result.answer,
656 thread_id: result.thread_id,
657 usage,
658 })
659 }
660
661 pub async fn fetch(&self, request: FetchRequest) -> Result<FetchResponse> {
662 let mut operation = self
663 .service
664 .active_operations
665 .register_request(request.operation_id, request.parent_operation_id)?;
666 let fetched = tokio::select! {
667 _ = operation.cancelled() => Err(Error::cancelled()),
668 result = self.service.web_fetcher.fetch(&request.url) => result.map_err(web_fetch_error),
669 }?;
670 Ok(FetchResponse {
671 url: fetched.url,
672 title: fetched.title,
673 content_type: fetched.content_type,
674 content: fetched.content,
675 truncated: fetched.truncated,
676 retrieved_at: DateTime::<Utc>::from(fetched.retrieved_at),
677 })
678 }
679
680 pub async fn transcribe(&self, request: TranscriptionRequest) -> Result<TranscriptionResponse> {
681 validate_prompt(&request.prompt)?;
682 if request.media.kind != MediaKind::Audio {
683 return Err(Error::invalid("transcription requires audio media"));
684 }
685 validate_model(&request.model)?;
686 let mut operation = self
687 .service
688 .active_operations
689 .register_request(request.operation_id, request.parent_operation_id)?;
690 let response = if request.model == kcode_openai_api::GPT_4O_TRANSCRIBE {
691 let openai = self.service.openai.as_ref().ok_or_else(|| {
692 Error::unavailable(
693 "transcription_unavailable",
694 "OpenAI audio transcription is not configured.",
695 )
696 })?;
697 let input = AudioInput::new(
698 safe_audio_filename(&request.media.file_name, &request.media.content_type),
699 request.media.content_type.clone(),
700 request.media.bytes,
701 )
702 .map_err(openai_error)?;
703 let mut provider_request = OpenAiTranscriptionRequest::new(input);
704 provider_request.prompt = Some(request.prompt);
705 let result = tokio::select! {
706 _ = operation.cancelled() => Err(Error::cancelled()),
707 result = tokio::time::timeout(
708 MEDIA_ANNOTATION_TIMEOUT,
709 openai.transcribe(provider_request),
710 ) => result
711 .map_err(|_| Error::provider("provider_timeout", "OpenAI transcription timed out."))
712 .and_then(|result| result.map_err(openai_error)),
713 };
714 let result = self.account_result("transcribe_audio", &request.model, result)?;
715 let metering = result
716 .usage
717 .map(transcription_usage)
718 .unwrap_or(Metering::Unavailable);
719 self.record_metering(
720 "transcribe_audio",
721 &request.model,
722 &request.model,
723 metering.clone(),
724 None,
725 None,
726 )?;
727 TranscriptionResponse {
728 model: request.model.clone(),
729 text: result.text,
730 metering,
731 }
732 } else {
733 let model = gemini_model(&request.model).ok_or_else(|| {
734 Error::invalid("transcription model must be gpt-4o-transcribe or a supported exact Gemini model")
735 })?;
736 let gemini = self.service.gemini.as_ref().ok_or_else(|| {
737 Error::unavailable(
738 "provider_not_configured",
739 "Gemini transcription is not configured.",
740 )
741 })?;
742 let media = gemini_media(&request.media)?;
743 let result = tokio::select! {
744 _ = operation.cancelled() => Err(Error::cancelled()),
745 result = tokio::time::timeout(
746 MEDIA_ANNOTATION_TIMEOUT,
747 gemini.infer_multimodal(
748 model,
749 MultimodalRequest::new(request.prompt, vec![media]),
750 ),
751 ) => result
752 .map_err(|_| Error::provider("provider_timeout", "Gemini transcription timed out."))
753 .and_then(|result| result.map_err(gemini_error)),
754 };
755 let result = self.account_result("transcribe_audio", &request.model, result)?;
756 let metering = Metering::Tokens(gemini_usage(&result.usage));
757 self.record_metering(
758 "transcribe_audio",
759 &request.model,
760 &result.model,
761 metering.clone(),
762 Some(result.id.clone()),
763 None,
764 )?;
765 TranscriptionResponse {
766 model: result.model,
767 text: result
768 .text
769 .filter(|text| !text.trim().is_empty())
770 .ok_or_else(|| {
771 Error::provider(
772 "empty_transcription",
773 "Gemini returned no transcription text.",
774 )
775 })?,
776 metering,
777 }
778 };
779 Ok(response)
780 }
781
782 pub async fn annotate(&self, request: AnnotationRequest) -> Result<AnnotationResponse> {
783 validate_prompt(&request.prompt)?;
784 validate_model(&request.model)?;
785 let mut operation = self
786 .service
787 .active_operations
788 .register_request(request.operation_id, request.parent_operation_id)?;
789 let file_name = request.media.file_name.clone();
790 let content_type = request.media.content_type.clone();
791 let response = if let Some(model) = gemini_model(&request.model) {
792 let gemini = self.service.gemini.as_ref().ok_or_else(|| {
793 Error::unavailable(
794 "provider_not_configured",
795 "Gemini media annotation is not configured.",
796 )
797 })?;
798 let media = gemini_media(&request.media)?;
799 let result = tokio::select! {
800 _ = operation.cancelled() => Err(Error::cancelled()),
801 result = tokio::time::timeout(
802 MEDIA_ANNOTATION_TIMEOUT,
803 gemini.infer_multimodal(
804 model,
805 MultimodalRequest::new(request.prompt, vec![media]),
806 ),
807 ) => result
808 .map_err(|_| Error::provider("provider_timeout", "Gemini annotation timed out."))
809 .and_then(|result| result.map_err(gemini_error)),
810 };
811 let result = self.account_result("annotate_media", &request.model, result)?;
812 let usage = gemini_usage(&result.usage);
813 self.record_tokens(
814 "annotate_media",
815 &request.model,
816 &result.model,
817 Some(usage),
818 Some(result.id.clone()),
819 None,
820 )?;
821 AnnotationResponse {
822 complete: result.status == GeminiCompletionStatus::Completed,
823 model: result.model,
824 file_name,
825 content_type,
826 text: result
827 .text
828 .filter(|text| !text.trim().is_empty())
829 .ok_or_else(|| {
830 Error::provider("empty_annotation", "Gemini returned no annotation text.")
831 })?,
832 incomplete_reason: None,
833 usage: Some(usage),
834 }
835 } else if request.model == "gpt-5.6" {
836 if request.media.kind != MediaKind::Image {
837 return Err(Error::invalid(
838 "OpenAI media annotation accepts images only",
839 ));
840 }
841 let openai = self.service.openai.as_ref().ok_or_else(|| {
842 Error::unavailable(
843 "provider_not_configured",
844 "OpenAI media annotation is not configured.",
845 )
846 })?;
847 let image = OpenAiImageInput::new(
848 openai_image_media_type(&request.media.content_type)?,
849 request.media.bytes,
850 )
851 .map_err(openai_error)?;
852 let result = tokio::select! {
853 _ = operation.cancelled() => Err(Error::cancelled()),
854 result = tokio::time::timeout(
855 MEDIA_ANNOTATION_TIMEOUT,
856 openai.analyze_image(ImageAnalysisRequest::new(image, request.prompt)),
857 ) => result
858 .map_err(|_| Error::provider("provider_timeout", "OpenAI annotation timed out."))
859 .and_then(|result| result.map_err(openai_error)),
860 };
861 let result = self.account_result("annotate_media", &request.model, result)?;
862 let usage = result.usage.as_ref().map(openai_image_usage);
863 self.record_tokens(
864 "annotate_media",
865 &request.model,
866 &result.model,
867 usage,
868 None,
869 None,
870 )?;
871 let (complete, incomplete_reason) = match result.status {
872 OpenAiImageStatus::Completed => (true, None),
873 OpenAiImageStatus::Incomplete { reason } => (false, reason),
874 };
875 AnnotationResponse {
876 complete,
877 model: result.model,
878 file_name,
879 content_type,
880 text: result.text,
881 incomplete_reason,
882 usage,
883 }
884 } else if matches!(
885 request.model.as_str(),
886 "gpt-5.6-sol" | "gpt-5.6-terra" | "gpt-5.6-luna"
887 ) {
888 if request.media.kind != MediaKind::Image {
889 return Err(Error::invalid("Codex media annotation accepts images only"));
890 }
891 let image = kcode_codex_runtime_v2::ImageInput::new(
892 codex_image_media_type(&request.media.content_type)?,
893 request.media.bytes,
894 )
895 .map_err(codex_v2_error)?;
896 let turn_result = self
897 .service
898 .agent
899 .start_image_turn(kcode_codex_runtime_v2::ImageTurnRequest::new(
900 request.prompt,
901 request.model.clone(),
902 vec![image],
903 ))
904 .await
905 .map_err(codex_v2_error);
906 let mut turn = self.account_result("annotate_media", &request.model, turn_result)?;
907 let completed = loop {
908 let event = tokio::select! {
909 _ = operation.cancelled() => {
910 turn.cancel();
911 self.record_metering(
912 "annotate_media",
913 &request.model,
914 &request.model,
915 Metering::Unavailable,
916 None,
917 None,
918 )?;
919 return Err(Error::cancelled());
920 }
921 event = turn.next_event() => event,
922 };
923 match event {
924 Some(Ok(kcode_codex_runtime_v2::AgentEvent::ProviderInput(_))) => {}
925 Some(Ok(kcode_codex_runtime_v2::AgentEvent::ToolCall(_))) => {
926 turn.cancel();
927 self.record_metering(
928 "annotate_media",
929 &request.model,
930 &request.model,
931 Metering::Unavailable,
932 None,
933 None,
934 )?;
935 return Err(Error::provider(
936 "provider_error",
937 "A tool-free Codex image turn requested a tool.",
938 ));
939 }
940 Some(Ok(kcode_codex_runtime_v2::AgentEvent::Completed(completed))) => {
941 break completed;
942 }
943 Some(Err(error)) => {
944 self.record_metering(
945 "annotate_media",
946 &request.model,
947 &request.model,
948 Metering::Unavailable,
949 None,
950 None,
951 )?;
952 return Err(codex_v2_error(error));
953 }
954 None => {
955 self.record_metering(
956 "annotate_media",
957 &request.model,
958 &request.model,
959 Metering::Unavailable,
960 None,
961 None,
962 )?;
963 return Err(Error::provider(
964 "empty_annotation",
965 "Codex ended without annotation text.",
966 ));
967 }
968 }
969 };
970 let usage = completed.usage.as_ref().map(codex_v2_usage);
971 self.record_tokens(
972 "annotate_media",
973 &request.model,
974 &request.model,
975 usage,
976 Some(completed.turn_id.clone()),
977 Some(completed.thread_id.clone()),
978 )?;
979 AnnotationResponse {
980 complete: true,
981 model: request.model.clone(),
982 file_name,
983 content_type,
984 text: completed.answer,
985 incomplete_reason: None,
986 usage,
987 }
988 } else {
989 return Err(Error::invalid(format!(
990 "unsupported exact annotation model {}",
991 request.model
992 )));
993 };
994 Ok(response)
995 }
996
997 fn record_tokens(
998 &self,
999 operation: &str,
1000 requested_model: &str,
1001 actual_model: &str,
1002 usage: Option<TokenUsage>,
1003 provider_request_id: Option<String>,
1004 provider_thread_id: Option<String>,
1005 ) -> Result<()> {
1006 self.record_metering(
1007 operation,
1008 requested_model,
1009 actual_model,
1010 usage.map(Metering::Tokens).unwrap_or(Metering::Unavailable),
1011 provider_request_id,
1012 provider_thread_id,
1013 )
1014 }
1015
1016 fn record_metering(
1017 &self,
1018 operation: &str,
1019 requested_model: &str,
1020 actual_model: &str,
1021 metering: Metering,
1022 provider_request_id: Option<String>,
1023 provider_thread_id: Option<String>,
1024 ) -> Result<()> {
1025 let mut receipt = UsageReceipt::new(
1026 self.user_id.clone(),
1027 operation,
1028 requested_model,
1029 actual_model,
1030 metering,
1031 );
1032 receipt.provider_request_id = provider_request_id;
1033 receipt.provider_thread_id = provider_thread_id;
1034 self.service.receipts.record(&receipt)
1035 }
1036
1037 fn account_result<T>(
1038 &self,
1039 operation: &str,
1040 requested_model: &str,
1041 result: Result<T>,
1042 ) -> Result<T> {
1043 match result {
1044 Ok(value) => Ok(value),
1045 Err(error) => {
1046 self.record_metering(
1047 operation,
1048 requested_model,
1049 requested_model,
1050 Metering::Unavailable,
1051 None,
1052 None,
1053 )?;
1054 Err(error)
1055 }
1056 }
1057 }
1058}
1059
1060async fn extract_document(
1061 extractor: &DocumentExtractor,
1062 document: Document,
1063) -> Result<DocumentExtraction> {
1064 let extractor = extractor.clone();
1065 let extracted = tokio::task::spawn_blocking(move || {
1066 extractor.extract(DocumentInput {
1067 file_name: document.file_name,
1068 content_type: document.content_type,
1069 data: document.bytes,
1070 })
1071 })
1072 .await
1073 .map_err(|_| {
1074 Error::internal(
1075 "document_extraction_failed",
1076 "The document extraction worker stopped unexpectedly.",
1077 )
1078 })?
1079 .map_err(document_error)?;
1080 Ok(DocumentExtraction {
1081 file_name: extracted.file_name,
1082 content_type: extracted.content_type,
1083 format: extracted.format.as_str().into(),
1084 text: extracted.text,
1085 characters: extracted.characters,
1086 truncated: extracted.truncated,
1087 })
1088}
1089
1090impl AgentTurn {
1091 pub async fn next_event(&mut self) -> Result<Option<kcode_codex_runtime_v2::AgentEvent>> {
1092 let event = tokio::select! {
1093 _ = self.operation.cancelled() => {
1094 self.inner.cancel();
1095 self.record_unavailable()?;
1096 return Err(Error::cancelled());
1097 }
1098 event = self.inner.next_event() => match event {
1099 Some(Ok(event)) => Some(event),
1100 Some(Err(error)) => {
1101 self.record_unavailable()?;
1102 return Err(codex_v2_error(error));
1103 }
1104 None => {
1105 self.record_unavailable()?;
1106 None
1107 },
1108 }
1109 };
1110 if let Some(kcode_codex_runtime_v2::AgentEvent::Completed(completed)) = &event
1111 && !self.receipt_recorded
1112 {
1113 let mut receipt = UsageReceipt::new(
1114 self.user_id.clone(),
1115 "agent_turn",
1116 self.model.clone(),
1117 self.model.clone(),
1118 completed
1119 .usage
1120 .as_ref()
1121 .map(codex_v2_usage)
1122 .map(Metering::Tokens)
1123 .unwrap_or(Metering::Unavailable),
1124 );
1125 receipt.provider_request_id = Some(completed.turn_id.clone());
1126 receipt.provider_thread_id = Some(completed.thread_id.clone());
1127 self.receipts.record(&receipt)?;
1128 self.receipt_recorded = true;
1129 }
1130 Ok(event)
1131 }
1132
1133 fn record_unavailable(&mut self) -> Result<()> {
1134 if self.receipt_recorded {
1135 return Ok(());
1136 }
1137 let receipt = UsageReceipt::new(
1138 self.user_id.clone(),
1139 "agent_turn",
1140 self.model.clone(),
1141 self.model.clone(),
1142 Metering::Unavailable,
1143 );
1144 self.receipts.record(&receipt)?;
1145 self.receipt_recorded = true;
1146 Ok(())
1147 }
1148
1149 pub async fn respond(
1150 &self,
1151 call_id: &str,
1152 result: kcode_codex_runtime_v2::ToolResult,
1153 ) -> Result<()> {
1154 self.inner
1155 .respond(call_id, result)
1156 .await
1157 .map_err(codex_v2_error)
1158 }
1159}
1160
1161impl Drop for AgentTurn {
1162 fn drop(&mut self) {
1163 let _ = self.record_unavailable();
1164 }
1165}
1166
1167impl ActiveOperations {
1168 fn register(&self, id: Uuid) -> Result<ActiveOperation> {
1169 let (sender, cancellation) = watch::channel(false);
1170 let mut senders = self.senders.lock().map_err(|_| {
1171 Error::internal(
1172 "operation_registry_unavailable",
1173 "The operation registry is unavailable.",
1174 )
1175 })?;
1176 if senders.contains_key(&id) {
1177 return Err(Error::conflict(
1178 "operation_in_progress",
1179 "An operation with this identifier is already running.",
1180 ));
1181 }
1182 senders.insert(id, sender);
1183 Ok(ActiveOperation {
1184 id,
1185 operations: self.clone(),
1186 cancellation,
1187 owns_registration: true,
1188 })
1189 }
1190
1191 fn register_request(
1192 &self,
1193 request_id: Uuid,
1194 parent_operation_id: Option<Uuid>,
1195 ) -> Result<ActiveOperation> {
1196 if let Some(parent_id) = parent_operation_id {
1197 if request_id == parent_id {
1198 return Err(Error::invalid(
1199 "operation_id and parent_operation_id must be different",
1200 ));
1201 }
1202 let cancellation = self
1203 .senders
1204 .lock()
1205 .map_err(|_| {
1206 Error::internal(
1207 "operation_registry_unavailable",
1208 "The operation registry is unavailable.",
1209 )
1210 })?
1211 .get(&parent_id)
1212 .map(watch::Sender::subscribe)
1213 .ok_or_else(|| {
1214 Error::conflict(
1215 "parent_operation_not_running",
1216 "The parent operation is no longer running.",
1217 )
1218 })?;
1219 Ok(ActiveOperation {
1220 id: parent_id,
1221 operations: self.clone(),
1222 cancellation,
1223 owns_registration: false,
1224 })
1225 } else {
1226 self.register(request_id)
1227 }
1228 }
1229
1230 fn cancel(&self, id: Uuid) -> Result<bool> {
1231 let sender = self
1232 .senders
1233 .lock()
1234 .map_err(|_| {
1235 Error::internal(
1236 "operation_registry_unavailable",
1237 "The operation registry is unavailable.",
1238 )
1239 })?
1240 .get(&id)
1241 .cloned();
1242 Ok(sender.is_some_and(|sender| sender.send(true).is_ok()))
1243 }
1244
1245 fn remove(&self, id: Uuid) {
1246 if let Ok(mut senders) = self.senders.lock() {
1247 senders.remove(&id);
1248 }
1249 }
1250}
1251
1252impl ActiveOperation {
1253 async fn cancelled(&mut self) {
1254 if *self.cancellation.borrow() {
1255 return;
1256 }
1257 while self.cancellation.changed().await.is_ok() {
1258 if *self.cancellation.borrow() {
1259 return;
1260 }
1261 }
1262 }
1263}
1264
1265impl Drop for ActiveOperation {
1266 fn drop(&mut self) {
1267 if self.owns_registration {
1268 self.operations.remove(self.id);
1269 }
1270 }
1271}
1272
1273fn validate_model(model: &str) -> Result<()> {
1274 if model.trim().is_empty()
1275 || model.chars().count() > 128
1276 || !model
1277 .bytes()
1278 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_'))
1279 {
1280 return Err(Error::invalid(
1281 "model must be an exact safe model identifier",
1282 ));
1283 }
1284 Ok(())
1285}
1286
1287fn validate_operation(operation: &str) -> Result<()> {
1288 if operation.is_empty()
1289 || operation.len() > 64
1290 || !operation
1291 .bytes()
1292 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
1293 {
1294 return Err(Error::invalid(
1295 "operation must be a lowercase identifier of at most 64 bytes",
1296 ));
1297 }
1298 Ok(())
1299}
1300
1301fn validate_prompt(prompt: &str) -> Result<()> {
1302 if prompt.trim().is_empty() || prompt.chars().count() > MAX_MEDIA_ANNOTATION_PROMPT_CHARACTERS {
1303 return Err(Error::invalid(format!(
1304 "prompt must contain between 1 and {MAX_MEDIA_ANNOTATION_PROMPT_CHARACTERS} characters"
1305 )));
1306 }
1307 Ok(())
1308}
1309
1310fn gemini_model(model: &str) -> Option<TextModel> {
1311 match model {
1312 kcode_gemini_api::GEMINI_25_FLASH => Some(TextModel::Flash25),
1313 kcode_gemini_api::GEMINI_31_FLASH_LITE => Some(TextModel::FlashLite),
1314 kcode_gemini_api::GEMINI_31_PRO => Some(TextModel::Pro),
1315 _ => None,
1316 }
1317}
1318
1319fn codex_search_profile(
1320 model: &str,
1321) -> Result<(
1322 kcode_codex_runtime::ReasoningEffort,
1323 kcode_codex_runtime::WebSearchContext,
1324 kcode_codex_runtime::SearchDepth,
1325 Duration,
1326)> {
1327 match model {
1328 QUALITY_SEARCH_MODEL => Ok((
1329 QUALITY_SEARCH_REASONING,
1330 QUALITY_SEARCH_CONTEXT,
1331 QUALITY_SEARCH_DEPTH,
1332 QUALITY_SEARCH_TIMEOUT,
1333 )),
1334 BALANCED_SEARCH_MODEL => Ok((
1335 BALANCED_SEARCH_REASONING,
1336 BALANCED_SEARCH_CONTEXT,
1337 BALANCED_SEARCH_DEPTH,
1338 BALANCED_SEARCH_TIMEOUT,
1339 )),
1340 _ => Err(Error::invalid(
1341 "unsupported exact web-search model; use a supported Gemini model, gpt-5.6-sol, or gpt-5.6-terra",
1342 )),
1343 }
1344}
1345
1346fn normalized_content_type(value: &str) -> String {
1347 value
1348 .split(';')
1349 .next()
1350 .unwrap_or("application/octet-stream")
1351 .trim()
1352 .to_ascii_lowercase()
1353}
1354
1355fn is_ogg(file_name: &str, content_type: &str) -> bool {
1356 matches!(content_type, "audio/ogg" | "video/ogg" | "application/ogg")
1357 || file_name.rsplit_once('.').is_some_and(|(_, extension)| {
1358 matches!(
1359 extension.to_ascii_lowercase().as_str(),
1360 "ogg" | "oga" | "opus"
1361 )
1362 })
1363}
1364
1365fn safe_audio_filename(value: &str, content_type: &str) -> String {
1366 let extension = match content_type {
1367 "audio/ogg" | "audio/opus" | "application/ogg" | "video/ogg" => "ogg",
1368 "audio/wav" | "audio/x-wav" => "wav",
1369 "audio/mpeg" | "audio/mp3" => "mp3",
1370 "audio/mp4" => "mp4",
1371 "audio/webm" => "webm",
1372 "audio/flac" | "audio/x-flac" => "flac",
1373 "audio/m4a" => "m4a",
1374 _ => "audio",
1375 };
1376 let cleaned = value
1377 .chars()
1378 .filter(|character| {
1379 character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_')
1380 })
1381 .take(120)
1382 .collect::<String>();
1383 let supported = cleaned.rsplit_once('.').is_some_and(|(_, extension)| {
1384 matches!(
1385 extension.to_ascii_lowercase().as_str(),
1386 "flac"
1387 | "mp3"
1388 | "mp4"
1389 | "mpeg"
1390 | "mpga"
1391 | "m4a"
1392 | "ogg"
1393 | "oga"
1394 | "opus"
1395 | "wav"
1396 | "webm"
1397 )
1398 });
1399 if cleaned.is_empty() || !supported {
1400 format!("voice-note.{extension}")
1401 } else {
1402 cleaned
1403 }
1404}
1405
1406fn gemini_media(media: &Media) -> Result<GeminiMediaInput> {
1407 match media.kind {
1408 MediaKind::Image => GeminiMediaInput::image(&media.content_type, media.bytes.clone()),
1409 MediaKind::Audio => GeminiMediaInput::audio(&media.content_type, media.bytes.clone()),
1410 MediaKind::Video => GeminiMediaInput::video(&media.content_type, media.bytes.clone()),
1411 }
1412 .map_err(gemini_error)
1413}
1414
1415fn openai_image_media_type(content_type: &str) -> Result<OpenAiImageMediaType> {
1416 match content_type {
1417 "image/png" => Ok(OpenAiImageMediaType::Png),
1418 "image/jpeg" | "image/jpg" => Ok(OpenAiImageMediaType::Jpeg),
1419 "image/webp" => Ok(OpenAiImageMediaType::WebP),
1420 "image/gif" => Ok(OpenAiImageMediaType::Gif),
1421 _ => Err(Error::invalid(
1422 "OpenAI annotations require PNG, JPEG, WebP, or GIF",
1423 )),
1424 }
1425}
1426
1427fn codex_image_media_type(content_type: &str) -> Result<kcode_codex_runtime_v2::ImageMediaType> {
1428 match content_type {
1429 "image/png" => Ok(kcode_codex_runtime_v2::ImageMediaType::Png),
1430 "image/jpeg" | "image/jpg" => Ok(kcode_codex_runtime_v2::ImageMediaType::Jpeg),
1431 "image/webp" => Ok(kcode_codex_runtime_v2::ImageMediaType::Webp),
1432 _ => Err(Error::invalid(
1433 "Codex annotations require PNG, JPEG, or WebP",
1434 )),
1435 }
1436}
1437
1438fn gemini_usage(usage: &GeminiTokenUsage) -> TokenUsage {
1439 TokenUsage {
1440 input_tokens: usage.input_tokens.saturating_sub(usage.cached_tokens),
1441 cached_input_tokens: usage.cached_tokens,
1442 thinking_tokens: usage.thought_tokens,
1443 output_tokens: usage.output_tokens,
1444 }
1445}
1446
1447fn codex_usage(usage: &CodexTokenUsage) -> TokenUsage {
1448 TokenUsage {
1449 input_tokens: usage.input_tokens.saturating_sub(usage.cached_input_tokens),
1450 cached_input_tokens: usage.cached_input_tokens,
1451 thinking_tokens: usage.reasoning_output_tokens,
1452 output_tokens: usage
1453 .output_tokens
1454 .saturating_sub(usage.reasoning_output_tokens),
1455 }
1456}
1457
1458fn codex_v2_usage(usage: &kcode_codex_runtime_v2::TokenUsage) -> TokenUsage {
1459 TokenUsage {
1460 input_tokens: usage.input_tokens.saturating_sub(usage.cached_input_tokens),
1461 cached_input_tokens: usage.cached_input_tokens,
1462 thinking_tokens: usage.reasoning_output_tokens,
1463 output_tokens: usage
1464 .output_tokens
1465 .saturating_sub(usage.reasoning_output_tokens),
1466 }
1467}
1468
1469fn openai_image_usage(usage: &ImageAnalysisUsage) -> TokenUsage {
1470 let cached = usage.cached_input_tokens.unwrap_or(0);
1471 let thinking = usage.reasoning_output_tokens.unwrap_or(0);
1472 TokenUsage {
1473 input_tokens: usage.input_tokens.saturating_sub(cached),
1474 cached_input_tokens: cached,
1475 thinking_tokens: thinking,
1476 output_tokens: usage.output_tokens.saturating_sub(thinking),
1477 }
1478}
1479
1480fn transcription_usage(usage: TranscriptionUsage) -> Metering {
1481 match usage {
1482 TranscriptionUsage::DurationSeconds(seconds) => Metering::DurationSeconds { seconds },
1483 TranscriptionUsage::Tokens(tokens) => Metering::Tokens(TokenUsage {
1484 input_tokens: tokens.input_tokens,
1485 cached_input_tokens: 0,
1486 thinking_tokens: 0,
1487 output_tokens: tokens.output_tokens,
1488 }),
1489 }
1490}
1491
1492fn codex_error(error: kcode_codex_runtime::Error) -> Error {
1493 match error.kind() {
1494 CodexErrorKind::InvalidInput => Error::invalid(error.message()),
1495 CodexErrorKind::Authentication => {
1496 Error::unavailable("provider_not_configured", error.message())
1497 }
1498 CodexErrorKind::Unavailable => Error::unavailable("provider_unavailable", error.message()),
1499 CodexErrorKind::RateLimited | CodexErrorKind::Capacity => {
1500 Error::unavailable("provider_rate_limited", error.message())
1501 }
1502 CodexErrorKind::Timeout => Error::provider("provider_timeout", error.message()),
1503 CodexErrorKind::InputTooLarge => Error::invalid(error.message()),
1504 CodexErrorKind::EmptyOutput | CodexErrorKind::Protocol => {
1505 Error::provider("provider_error", error.message())
1506 }
1507 }
1508}
1509
1510fn codex_v2_error(error: kcode_codex_runtime_v2::Error) -> Error {
1511 use kcode_codex_runtime_v2::ErrorKind;
1512 match error.kind() {
1513 ErrorKind::InvalidInput => Error::invalid(error.message()),
1514 ErrorKind::Unavailable | ErrorKind::Authentication => {
1515 Error::unavailable("provider_unavailable", error.message())
1516 }
1517 ErrorKind::Timeout => Error::provider("provider_timeout", error.message()),
1518 ErrorKind::Protocol => Error::provider("provider_error", error.message()),
1519 ErrorKind::Cancelled => Error::cancelled(),
1520 }
1521}
1522
1523fn gemini_error(error: GeminiError) -> Error {
1524 match &error {
1525 GeminiError::InvalidApiKey => {
1526 Error::unavailable("provider_not_configured", error.to_string())
1527 }
1528 GeminiError::InvalidInput(_) => Error::invalid(error.to_string()),
1529 GeminiError::SpendingLimitReached { .. } => {
1530 Error::unavailable("provider_rate_limited", error.to_string())
1531 }
1532 GeminiError::Accounting(_)
1533 | GeminiError::Transport(_)
1534 | GeminiError::Provider { .. }
1535 | GeminiError::Protocol(_) => Error::provider("provider_error", error.to_string()),
1536 }
1537}
1538
1539fn openai_error(error: OpenAiError) -> Error {
1540 match &error {
1541 OpenAiError::InvalidApiKey => {
1542 Error::unavailable("provider_not_configured", error.to_string())
1543 }
1544 OpenAiError::InvalidInput(_) => Error::invalid(error.to_string()),
1545 OpenAiError::Transport(_) | OpenAiError::Provider { .. } | OpenAiError::Protocol(_) => {
1546 Error::provider("provider_error", error.to_string())
1547 }
1548 }
1549}
1550
1551fn web_fetch_error(error: kcode_web_fetch::Error) -> Error {
1552 match error.kind() {
1553 WebFetchErrorKind::InvalidInput | WebFetchErrorKind::UnsafeDestination => {
1554 Error::invalid(error.message())
1555 }
1556 WebFetchErrorKind::Timeout => Error::provider("web_fetch_timeout", error.message()),
1557 WebFetchErrorKind::UnsupportedContent => {
1558 Error::invalid(format!("unsupported web content: {}", error.message()))
1559 }
1560 WebFetchErrorKind::Transport
1561 | WebFetchErrorKind::HttpStatus
1562 | WebFetchErrorKind::EmptyContent => Error::provider("web_fetch_failed", error.message()),
1563 }
1564}
1565
1566fn document_error(error: kcode_doc_extraction::Error) -> Error {
1567 match error.kind() {
1568 DocumentErrorKind::InvalidInput | DocumentErrorKind::UnsupportedFormat => {
1569 Error::invalid(error.message())
1570 }
1571 DocumentErrorKind::ExtractionFailed | DocumentErrorKind::EmptyText => {
1572 Error::provider("document_extraction_failed", error.message())
1573 }
1574 }
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579 use super::*;
1580
1581 #[test]
1582 fn audio_kind_overrides_mislabeled_ogg_video_mime() {
1583 let media = Media::audio(vec![1], "voice.ogg", "video/ogg").unwrap();
1584 assert_eq!(media.kind, MediaKind::Audio);
1585 assert_eq!(media.content_type, "audio/ogg");
1586 }
1587
1588 #[test]
1589 fn exact_search_models_replace_modes() {
1590 assert!(codex_search_profile("gpt-5.6-sol").is_ok());
1591 assert!(codex_search_profile("gpt-5.6-terra").is_ok());
1592 assert!(codex_search_profile("fast").is_err());
1593 }
1594}