Skip to main content

mini_chat_sdk/
models.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5/// Current policy version metadata for a user.
6#[derive(Debug, Clone)]
7pub struct PolicyVersionInfo {
8    pub user_id: Uuid,
9    pub policy_version: u64,
10    pub generated_at: OffsetDateTime,
11}
12
13/// Full policy snapshot for a given version, including the model catalog
14/// and kill switches (API: `PolicyByVersionResponse`).
15#[derive(Debug, Clone)]
16pub struct PolicySnapshot {
17    pub user_id: Uuid,
18    pub policy_version: u64,
19    pub model_catalog: Vec<ModelCatalogEntry>,
20    pub kill_switches: KillSwitches,
21}
22
23/// Tenant-level kill switches from the policy snapshot.
24#[allow(clippy::struct_excessive_bools)]
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26pub struct KillSwitches {
27    pub disable_premium_tier: bool,
28    pub force_standard_tier: bool,
29    pub disable_web_search: bool,
30    pub disable_file_search: bool,
31    pub disable_images: bool,
32}
33
34/// A single model in the catalog (API: `PolicyModelCatalogItem`).
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ModelCatalogEntry {
37    /// Provider-level model identifier (e.g. "gpt-4").
38    pub model_id: String,
39    /// The model ID on the provider side (e.g., `"gpt-5.2"` for `OpenAI`,
40    /// `"claude-opus-4-6"` for Anthropic). Sent in LLM API requests.
41    pub provider_model_id: String,
42    /// Display name shown in UI (may differ from `name`).
43    pub display_name: String,
44    /// Short description of the model.
45    #[serde(default)]
46    pub description: String,
47    /// Model version string.
48    #[serde(default)]
49    pub version: String,
50    /// LLM provider CTI identifier.
51    pub provider_id: String,
52    /// Routing identifier for provider resolution. Maps to a key in
53    /// `MiniChatConfig.providers`. Values: `"openai"`, `"azure_openai"`.
54    pub provider_display_name: String,
55    /// URL to model icon.
56    #[serde(default)]
57    pub icon: String,
58    /// Model tier (standard or premium).
59    pub tier: ModelTier,
60    #[serde(default)]
61    pub enabled: bool,
62    /// Multimodal capability flags, e.g. `VISION_INPUT`, `IMAGE_GENERATION`.
63    #[serde(default)]
64    pub multimodal_capabilities: Vec<String>,
65    /// Maximum context window size in tokens.
66    pub context_window: u32,
67    /// Maximum output tokens the model can generate.
68    pub max_output_tokens: u32,
69    /// Maximum input tokens per request.
70    pub max_input_tokens: u32,
71    /// Credit multiplier for input tokens (micro-credits per 1000 tokens).
72    pub input_tokens_credit_multiplier_micro: u64,
73    /// Credit multiplier for output tokens (micro-credits per 1000 tokens).
74    pub output_tokens_credit_multiplier_micro: u64,
75    /// Human-readable multiplier display string (e.g. "1x", "3x").
76    #[serde(default)]
77    pub multiplier_display: String,
78    /// Per-model token estimation budgets for preflight reserve.
79    #[serde(default)]
80    pub estimation_budgets: EstimationBudgets,
81    /// Top-k chunks returned by similarity search per `file_search` call.
82    pub max_retrieved_chunks_per_turn: u32,
83    /// Full general config captured at snapshot time.
84    pub general_config: ModelGeneralConfig,
85    /// Tenant preference settings captured at snapshot time.
86    pub preference: Option<ModelPreference>,
87    /// System prompt sent as `instructions` in every LLM request for this model.
88    /// Empty string = no system instructions.
89    #[serde(default)]
90    pub system_prompt: String,
91    /// Prompt template used when generating thread summaries for this model.
92    /// Plumbed through the stack for future use by the summary generation job.
93    #[serde(default)]
94    pub thread_summary_prompt: String,
95}
96
97/// Per-model token estimation budget parameters (API: `PolicyModelEstimationBudgets`).
98#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
99pub struct EstimationBudgets {
100    /// Conservative bytes-per-token ratio for text estimation.
101    pub bytes_per_token_conservative: u32,
102    /// Constant overhead for protocol/framing tokens.
103    pub fixed_overhead_tokens: u32,
104    /// Percentage safety margin applied to text estimation (e.g. 10 means 10%).
105    pub safety_margin_pct: u32,
106    /// Tokens per image for vision surcharge.
107    pub image_token_budget: u32,
108    /// Fixed token overhead when `file_search` tool is included.
109    pub tool_surcharge_tokens: u32,
110    /// Fixed token overhead when `web_search` is enabled.
111    pub web_search_surcharge_tokens: u32,
112    /// Minimum generation token budget guaranteed regardless of input estimates.
113    pub minimal_generation_floor: u32,
114}
115
116impl Default for EstimationBudgets {
117    fn default() -> Self {
118        Self {
119            bytes_per_token_conservative: 4,
120            fixed_overhead_tokens: 100,
121            safety_margin_pct: 10,
122            image_token_budget: 1000,
123            tool_surcharge_tokens: 500,
124            web_search_surcharge_tokens: 500,
125            minimal_generation_floor: 50,
126        }
127    }
128}
129
130/// LLM API inference parameters (API: `PolicyModelApiParams`).
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct ModelApiParams {
133    pub temperature: f64,
134    pub top_p: f64,
135    pub frequency_penalty: f64,
136    pub presence_penalty: f64,
137    pub stop: Vec<String>,
138}
139
140/// Feature capability flags (API: `PolicyModelFeatures`).
141#[allow(clippy::struct_excessive_bools)]
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct ModelFeatures {
144    pub streaming: bool,
145    pub function_calling: bool,
146    pub structured_output: bool,
147    pub fine_tuning: bool,
148    pub distillation: bool,
149    pub fim_completion: bool,
150    pub chat_prefix_completion: bool,
151}
152
153/// Supported input modalities (API: `PolicyModelInputType`).
154#[allow(clippy::struct_excessive_bools)]
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ModelInputType {
157    pub text: bool,
158    pub image: bool,
159    pub audio: bool,
160    pub video: bool,
161}
162
163/// Tool support flags (API: `PolicyModelToolSupport`).
164#[allow(clippy::struct_excessive_bools)]
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct ModelToolSupport {
167    pub web_search: bool,
168    pub file_search: bool,
169    pub image_generation: bool,
170    pub code_interpreter: bool,
171    pub computer_use: bool,
172    pub mcp: bool,
173}
174
175/// Supported API endpoints (API: `PolicyModelSupportedEndpoints`).
176#[allow(clippy::struct_excessive_bools)]
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ModelSupportedEndpoints {
179    pub chat_completions: bool,
180    pub responses: bool,
181    pub realtime: bool,
182    pub assistants: bool,
183    pub batch_api: bool,
184    pub fine_tuning: bool,
185    pub embeddings: bool,
186    pub videos: bool,
187    pub image_generation: bool,
188    pub image_edit: bool,
189    pub audio_speech_generation: bool,
190    pub audio_transcription: bool,
191    pub audio_translation: bool,
192    pub moderations: bool,
193    pub completions: bool,
194}
195
196/// Token credit multipliers (API: `PolicyModelTokenPolicy`).
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct ModelTokenPolicy {
199    pub input_tokens_credit_multiplier: f64,
200    pub output_tokens_credit_multiplier: f64,
201}
202
203/// Estimated performance characteristics (API: `PolicyModelPerformance`).
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct ModelPerformance {
206    pub response_latency_ms: u32,
207    pub speed_tokens_per_second: u32,
208}
209
210/// General configuration from Settings Service (API: `PolicyModelGeneralConfig`).
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct ModelGeneralConfig {
213    /// CTI type identifier of the config.
214    #[serde(rename = "type")]
215    pub config_type: String,
216    /// Credential UUID used for this model.
217    pub model_credential_id: Uuid,
218    /// Tenant ID of the credential used for this model.
219    pub credential_tenant_id: Uuid,
220    #[serde(with = "time::serde::rfc3339")]
221    pub available_from: OffsetDateTime,
222    pub max_file_size_mb: u32,
223    pub api_params: ModelApiParams,
224    pub features: ModelFeatures,
225    pub input_type: ModelInputType,
226    pub tool_support: ModelToolSupport,
227    pub supported_endpoints: ModelSupportedEndpoints,
228    pub token_policy: ModelTokenPolicy,
229    pub performance: ModelPerformance,
230}
231
232/// Per-tenant preference settings (API: `PolicyModelPreference`).
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct ModelPreference {
235    pub is_default: bool,
236    /// Display order in the UI.
237    pub sort_order: i32,
238}
239
240/// Model pricing/capability tier.
241///
242/// Serializes as `"Standard"` / `"Premium"` (`PascalCase`).
243/// Accepts lowercase aliases (`"standard"`, `"premium"`) on deserialization
244/// for compatibility with CCM and DESIGN maps.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246pub enum ModelTier {
247    #[serde(alias = "standard")]
248    Standard,
249    #[serde(alias = "premium")]
250    Premium,
251}
252
253/// Whether a user holds an active `CyberChat` license (API: `CheckUserLicenseResponse`).
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct UserLicenseStatus {
256    /// `true` if the user's status is `active` in the `active_users` table for this tenant.
257    /// `false` if the user is not found, or has status `invited`, `deactivated`, or `deleted`.
258    pub active: bool,
259}
260
261/// Per-user credit allocations for a specific policy version.
262/// NOT part of the immutable shared `PolicySnapshot` (DESIGN.md §5.2.6).
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct UserLimits {
265    pub user_id: Uuid,
266    pub policy_version: u64,
267    pub standard: TierLimits,
268    pub premium: TierLimits,
269}
270
271/// Credit limits for a single tier within a billing period.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct TierLimits {
274    pub limit_daily_credits_micro: i64,
275    pub limit_monthly_credits_micro: i64,
276}
277
278/// Token usage reported by the provider.
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct UsageTokens {
281    pub input_tokens: u64,
282    pub output_tokens: u64,
283}
284
285/// Canonical usage event payload published via the outbox after finalization.
286///
287/// Single canonical type — both the outbox enqueuer (infra) and the plugin
288/// `publish_usage()` method use this same struct.
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct UsageEvent {
291    pub tenant_id: Uuid,
292    pub user_id: Uuid,
293    pub chat_id: Uuid,
294    pub turn_id: Uuid,
295    pub request_id: Uuid,
296    pub effective_model: String,
297    pub selected_model: String,
298    pub terminal_state: String,
299    pub billing_outcome: String,
300    pub usage: Option<UsageTokens>,
301    pub actual_credits_micro: i64,
302    pub settlement_method: String,
303    pub policy_version_applied: i64,
304    #[serde(with = "time::serde::rfc3339")]
305    pub timestamp: OffsetDateTime,
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    // ── KillSwitches::default safety invariant ──
313    // All kill switches must default to false; a new field defaulting to true
314    // would accidentally disable functionality across all tenants.
315
316    #[test]
317    fn kill_switches_default_all_disabled() {
318        let ks = KillSwitches::default();
319        assert!(!ks.disable_premium_tier);
320        assert!(!ks.force_standard_tier);
321        assert!(!ks.disable_web_search);
322        assert!(!ks.disable_file_search);
323        assert!(!ks.disable_images);
324    }
325
326    // ── EstimationBudgets::default spec values ──
327    // These defaults are specified in DESIGN.md §B.5.2 and used as the
328    // ConfigMap fallback. Changing them silently would alter token estimation
329    // for every deployment that relies on defaults.
330
331    #[test]
332    fn estimation_budgets_default_matches_spec() {
333        let eb = EstimationBudgets::default();
334        assert_eq!(eb.bytes_per_token_conservative, 4);
335        assert_eq!(eb.fixed_overhead_tokens, 100);
336        assert_eq!(eb.safety_margin_pct, 10);
337        assert_eq!(eb.image_token_budget, 1000);
338        assert_eq!(eb.tool_surcharge_tokens, 500);
339        assert_eq!(eb.web_search_surcharge_tokens, 500);
340        assert_eq!(eb.minimal_generation_floor, 50);
341    }
342
343    // ── ModelGeneralConfig: serde(rename = "type") contract ──
344    // The upstream API sends `"type"` not `"config_type"`. If the rename
345    // attribute is removed, deserialization from the real API breaks.
346
347    fn sample_catalog_entry() -> ModelCatalogEntry {
348        ModelCatalogEntry {
349            model_id: "test-model".to_owned(),
350            provider_model_id: "test-model-v1".to_owned(),
351            display_name: "Test Model".to_owned(),
352            description: String::new(),
353            version: String::new(),
354            provider_id: "default".to_owned(),
355            provider_display_name: "Default".to_owned(),
356            icon: String::new(),
357            tier: ModelTier::Standard,
358            enabled: true,
359            multimodal_capabilities: vec![],
360            context_window: 128_000,
361            max_output_tokens: 16_384,
362            max_input_tokens: 128_000,
363            input_tokens_credit_multiplier_micro: 1_000_000,
364            output_tokens_credit_multiplier_micro: 3_000_000,
365            multiplier_display: "1x".to_owned(),
366            estimation_budgets: EstimationBudgets::default(),
367            max_retrieved_chunks_per_turn: 5,
368            general_config: sample_general_config(),
369            preference: Some(ModelPreference {
370                is_default: false,
371                sort_order: 0,
372            }),
373            system_prompt: String::new(),
374            thread_summary_prompt: String::new(),
375        }
376    }
377
378    fn sample_general_config() -> ModelGeneralConfig {
379        ModelGeneralConfig {
380            config_type: "model.general.v1".to_owned(),
381            model_credential_id: Uuid::nil(),
382            credential_tenant_id: Uuid::nil(),
383            available_from: OffsetDateTime::UNIX_EPOCH,
384            max_file_size_mb: 25,
385            api_params: ModelApiParams {
386                temperature: 0.7,
387                top_p: 1.0,
388                frequency_penalty: 0.0,
389                presence_penalty: 0.0,
390                stop: vec![],
391            },
392            features: ModelFeatures {
393                streaming: true,
394                function_calling: false,
395                structured_output: false,
396                fine_tuning: false,
397                distillation: false,
398                fim_completion: false,
399                chat_prefix_completion: false,
400            },
401            input_type: ModelInputType {
402                text: true,
403                image: false,
404                audio: false,
405                video: false,
406            },
407            tool_support: ModelToolSupport {
408                web_search: false,
409                file_search: false,
410                image_generation: false,
411                code_interpreter: false,
412                computer_use: false,
413                mcp: false,
414            },
415            supported_endpoints: ModelSupportedEndpoints {
416                chat_completions: true,
417                responses: false,
418                realtime: false,
419                assistants: false,
420                batch_api: false,
421                fine_tuning: false,
422                embeddings: false,
423                videos: false,
424                image_generation: false,
425                image_edit: false,
426                audio_speech_generation: false,
427                audio_transcription: false,
428                audio_translation: false,
429                moderations: false,
430                completions: false,
431            },
432            token_policy: ModelTokenPolicy {
433                input_tokens_credit_multiplier: 1.0,
434                output_tokens_credit_multiplier: 3.0,
435            },
436            performance: ModelPerformance {
437                response_latency_ms: 500,
438                speed_tokens_per_second: 100,
439            },
440        }
441    }
442
443    #[test]
444    fn general_config_serializes_type_not_config_type() {
445        let config = sample_general_config();
446        let json = serde_json::to_value(&config).unwrap();
447
448        assert!(json.get("type").is_some(), "expected JSON key 'type'");
449        assert!(
450            json.get("config_type").is_none(),
451            "config_type must not appear in JSON output"
452        );
453        assert_eq!(json["type"], "model.general.v1");
454    }
455
456    #[test]
457    fn general_config_serde_roundtrip_preserves_rename() {
458        let original = sample_general_config();
459        let json = serde_json::to_value(&original).unwrap();
460        let deserialized: ModelGeneralConfig = serde_json::from_value(json).unwrap();
461
462        assert_eq!(deserialized.config_type, original.config_type);
463        assert_eq!(
464            deserialized.credential_tenant_id,
465            original.credential_tenant_id
466        );
467    }
468
469    // ── ModelCatalogEntry: optional fields default when absent ──
470    // Fields with `#[serde(default)]` must deserialize to sensible values
471    // when omitted from JSON, so partial configs don't fail to load.
472
473    #[test]
474    fn optional_fields_absent_in_json_deserialize_to_defaults() {
475        let mut json = serde_json::to_value(sample_catalog_entry()).unwrap();
476        let obj = json.as_object_mut().unwrap();
477        obj.remove("description");
478        obj.remove("version");
479        obj.remove("icon");
480        obj.remove("enabled");
481        obj.remove("multimodal_capabilities");
482        obj.remove("multiplier_display");
483        obj.remove("estimation_budgets");
484        obj.remove("system_prompt");
485        obj.remove("thread_summary_prompt");
486        obj.remove("preference");
487
488        let entry: ModelCatalogEntry = serde_json::from_value(json).unwrap();
489        assert!(entry.description.is_empty());
490        assert!(entry.version.is_empty());
491        assert!(entry.icon.is_empty());
492        assert!(!entry.enabled);
493        assert!(entry.preference.is_none());
494        assert!(entry.multimodal_capabilities.is_empty());
495        assert!(entry.multiplier_display.is_empty());
496        assert_eq!(
497            entry.estimation_budgets.bytes_per_token_conservative,
498            EstimationBudgets::default().bytes_per_token_conservative
499        );
500        assert!(entry.system_prompt.is_empty());
501        assert!(entry.thread_summary_prompt.is_empty());
502    }
503
504    // ── ModelCatalogEntry: estimation_budgets serde contract ──
505    // `estimation_budgets` defaults to `EstimationBudgets::default()` when absent.
506
507    #[test]
508    fn estimation_budgets_absent_in_json_deserializes_to_default() {
509        let mut json = serde_json::to_value(sample_catalog_entry()).unwrap();
510        json.as_object_mut().unwrap().remove("estimation_budgets");
511
512        let entry: ModelCatalogEntry = serde_json::from_value(json).unwrap();
513        let expected = EstimationBudgets::default();
514        assert_eq!(
515            entry.estimation_budgets.bytes_per_token_conservative,
516            expected.bytes_per_token_conservative
517        );
518        assert_eq!(
519            entry.estimation_budgets.fixed_overhead_tokens,
520            expected.fixed_overhead_tokens
521        );
522        assert_eq!(
523            entry.estimation_budgets.safety_margin_pct,
524            expected.safety_margin_pct
525        );
526        assert_eq!(
527            entry.estimation_budgets.image_token_budget,
528            expected.image_token_budget
529        );
530        assert_eq!(
531            entry.estimation_budgets.tool_surcharge_tokens,
532            expected.tool_surcharge_tokens
533        );
534        assert_eq!(
535            entry.estimation_budgets.web_search_surcharge_tokens,
536            expected.web_search_surcharge_tokens
537        );
538        assert_eq!(
539            entry.estimation_budgets.minimal_generation_floor,
540            expected.minimal_generation_floor
541        );
542    }
543
544    #[test]
545    fn system_prompt_absent_in_json_deserializes_to_empty() {
546        let mut json = serde_json::to_value(sample_catalog_entry()).unwrap();
547        json.as_object_mut().unwrap().remove("system_prompt");
548
549        let entry: ModelCatalogEntry = serde_json::from_value(json).unwrap();
550        assert!(
551            entry.system_prompt.is_empty(),
552            "missing system_prompt must deserialize to empty string"
553        );
554    }
555
556    #[test]
557    fn system_prompt_roundtrips() {
558        let mut entry = sample_catalog_entry();
559        entry.system_prompt = "You are a helpful assistant.".to_owned();
560
561        let json = serde_json::to_value(&entry).unwrap();
562        assert_eq!(json["system_prompt"], "You are a helpful assistant.");
563
564        let deserialized: ModelCatalogEntry = serde_json::from_value(json).unwrap();
565        assert_eq!(deserialized.system_prompt, "You are a helpful assistant.");
566    }
567
568    // ── ModelTier serde representation ──
569    // Serializes as PascalCase ("Standard"/"Premium") for the UI/API.
570    // Accepts lowercase aliases for CCM/DESIGN compatibility.
571
572    #[test]
573    fn model_tier_serializes_as_pascal_case() {
574        let json = serde_json::to_value(ModelTier::Premium).unwrap();
575        assert_eq!(json, serde_json::json!("Premium"));
576
577        let json = serde_json::to_value(ModelTier::Standard).unwrap();
578        assert_eq!(json, serde_json::json!("Standard"));
579    }
580
581    #[test]
582    fn model_tier_deserializes_lowercase_aliases() {
583        let premium: ModelTier = serde_json::from_value(serde_json::json!("premium")).unwrap();
584        assert_eq!(premium, ModelTier::Premium);
585
586        let standard: ModelTier = serde_json::from_value(serde_json::json!("standard")).unwrap();
587        assert_eq!(standard, ModelTier::Standard);
588    }
589
590    #[test]
591    fn model_tier_rejects_unknown_casing() {
592        let result = serde_json::from_value::<ModelTier>(serde_json::json!("PREMIUM"));
593        assert!(result.is_err());
594    }
595
596    // ── KillSwitches serde roundtrip ──
597    // Verifies that enabled switches survive serialization and that
598    // the default (all-off) state roundtrips correctly.
599
600    #[test]
601    fn kill_switches_serde_roundtrip_with_enabled_switches() {
602        let ks = KillSwitches {
603            disable_premium_tier: true,
604            force_standard_tier: false,
605            disable_web_search: true,
606            disable_file_search: false,
607            disable_images: true,
608        };
609        let json = serde_json::to_value(&ks).unwrap();
610        let deserialized: KillSwitches = serde_json::from_value(json).unwrap();
611
612        assert!(deserialized.disable_premium_tier);
613        assert!(!deserialized.force_standard_tier);
614        assert!(deserialized.disable_web_search);
615        assert!(!deserialized.disable_file_search);
616        assert!(deserialized.disable_images);
617    }
618
619    #[test]
620    fn kill_switches_default_roundtrips_all_false() {
621        let ks = KillSwitches::default();
622        let json = serde_json::to_value(&ks).unwrap();
623        let deserialized: KillSwitches = serde_json::from_value(json).unwrap();
624
625        assert!(!deserialized.disable_premium_tier);
626        assert!(!deserialized.force_standard_tier);
627        assert!(!deserialized.disable_web_search);
628        assert!(!deserialized.disable_file_search);
629        assert!(!deserialized.disable_images);
630    }
631}