Skip to main content

kcode_intelligence_router/
lib.rs

1//! Typed, in-process intelligence operations for Kennedy.
2//!
3//! This crate has no HTTP router, health endpoint, provider catalog, or JSON
4//! request dispatch. Callers choose exact models and bind every provider call
5//! to a user before invoking it.
6
7#![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
51/// Construction inputs for the intelligence library.
52pub 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/// The configured Kennedy model, returned directly instead of through a catalog endpoint.
60#[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/// An intelligence handle that cannot make a provider call without user attribution.
81#[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/// One structured Gemini audio call with exact model and accounting attribution.
217#[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/// One tool-free Codex text generation call.
238#[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    /// Performs one structured Gemini audio call and records its provider usage.
532    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    /// Performs one tool-free Codex generation and records its provider usage.
614    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}