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: 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    /// Model tier CTI identifier.
217    pub tier: String,
218    #[serde(with = "time::serde::rfc3339")]
219    pub available_from: OffsetDateTime,
220    pub max_file_size_mb: u32,
221    pub api_params: ModelApiParams,
222    pub features: ModelFeatures,
223    pub input_type: ModelInputType,
224    pub tool_support: ModelToolSupport,
225    pub supported_endpoints: ModelSupportedEndpoints,
226    pub token_policy: ModelTokenPolicy,
227    pub performance: ModelPerformance,
228}
229
230/// Per-tenant preference settings (API: `PolicyModelPreference`).
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct ModelPreference {
233    pub is_default: bool,
234    /// Display order in the UI.
235    pub sort_order: i32,
236}
237
238/// Model pricing/capability tier.
239///
240/// Serializes as `"Standard"` / `"Premium"` (`PascalCase`).
241/// Accepts lowercase aliases (`"standard"`, `"premium"`) on deserialization
242/// for compatibility with CCM and DESIGN maps.
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
244pub enum ModelTier {
245    #[serde(alias = "standard")]
246    Standard,
247    #[serde(alias = "premium")]
248    Premium,
249}
250
251/// Whether a user holds an active `CyberChat` license (API: `CheckUserLicenseResponse`).
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct UserLicenseStatus {
254    /// `true` if the user's status is `active` in the `active_users` table for this tenant.
255    /// `false` if the user is not found, or has status `invited`, `deactivated`, or `deleted`.
256    pub active: bool,
257}
258
259/// Per-user credit allocations for a specific policy version.
260/// NOT part of the immutable shared `PolicySnapshot` (DESIGN.md §5.2.6).
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct UserLimits {
263    pub user_id: Uuid,
264    pub policy_version: u64,
265    pub standard: TierLimits,
266    pub premium: TierLimits,
267}
268
269/// Credit limits for a single tier within a billing period.
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct TierLimits {
272    pub limit_daily_credits_micro: i64,
273    pub limit_monthly_credits_micro: i64,
274}
275
276/// Token usage reported by the provider.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct UsageTokens {
279    pub input_tokens: u64,
280    pub output_tokens: u64,
281}
282
283/// Canonical usage event payload published via the outbox after finalization.
284///
285/// Single canonical type — both the outbox enqueuer (infra) and the plugin
286/// `publish_usage()` method use this same struct.
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct UsageEvent {
289    pub tenant_id: Uuid,
290    pub user_id: Uuid,
291    pub chat_id: Uuid,
292    pub turn_id: Uuid,
293    pub request_id: Uuid,
294    pub effective_model: String,
295    pub selected_model: String,
296    pub terminal_state: String,
297    pub billing_outcome: String,
298    pub usage: Option<UsageTokens>,
299    pub actual_credits_micro: i64,
300    pub settlement_method: String,
301    pub policy_version_applied: i64,
302    #[serde(with = "time::serde::rfc3339")]
303    pub timestamp: OffsetDateTime,
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    // ── KillSwitches::default safety invariant ──
311    // All kill switches must default to false; a new field defaulting to true
312    // would accidentally disable functionality across all tenants.
313
314    #[test]
315    fn kill_switches_default_all_disabled() {
316        let ks = KillSwitches::default();
317        assert!(!ks.disable_premium_tier);
318        assert!(!ks.force_standard_tier);
319        assert!(!ks.disable_web_search);
320        assert!(!ks.disable_file_search);
321        assert!(!ks.disable_images);
322    }
323
324    // ── EstimationBudgets::default spec values ──
325    // These defaults are specified in DESIGN.md §B.5.2 and used as the
326    // ConfigMap fallback. Changing them silently would alter token estimation
327    // for every deployment that relies on defaults.
328
329    #[test]
330    fn estimation_budgets_default_matches_spec() {
331        let eb = EstimationBudgets::default();
332        assert_eq!(eb.bytes_per_token_conservative, 4);
333        assert_eq!(eb.fixed_overhead_tokens, 100);
334        assert_eq!(eb.safety_margin_pct, 10);
335        assert_eq!(eb.image_token_budget, 1000);
336        assert_eq!(eb.tool_surcharge_tokens, 500);
337        assert_eq!(eb.web_search_surcharge_tokens, 500);
338        assert_eq!(eb.minimal_generation_floor, 50);
339    }
340
341    // ── ModelGeneralConfig: serde(rename = "type") contract ──
342    // The upstream API sends `"type"` not `"config_type"`. If the rename
343    // attribute is removed, deserialization from the real API breaks.
344
345    fn sample_catalog_entry() -> ModelCatalogEntry {
346        ModelCatalogEntry {
347            model_id: "test-model".to_owned(),
348            provider_model_id: "test-model-v1".to_owned(),
349            display_name: "Test Model".to_owned(),
350            description: String::new(),
351            version: String::new(),
352            provider_id: "default".to_owned(),
353            provider_display_name: "Default".to_owned(),
354            icon: String::new(),
355            tier: ModelTier::Standard,
356            enabled: true,
357            multimodal_capabilities: vec![],
358            context_window: 128_000,
359            max_output_tokens: 16_384,
360            max_input_tokens: 128_000,
361            input_tokens_credit_multiplier_micro: 1_000_000,
362            output_tokens_credit_multiplier_micro: 3_000_000,
363            multiplier_display: "1x".to_owned(),
364            estimation_budgets: EstimationBudgets::default(),
365            max_retrieved_chunks_per_turn: 5,
366            general_config: sample_general_config(),
367            preference: ModelPreference {
368                is_default: false,
369                sort_order: 0,
370            },
371            system_prompt: String::new(),
372            thread_summary_prompt: String::new(),
373        }
374    }
375
376    fn sample_general_config() -> ModelGeneralConfig {
377        ModelGeneralConfig {
378            config_type: "model.general.v1".to_owned(),
379            tier: "premium".to_owned(),
380            available_from: OffsetDateTime::UNIX_EPOCH,
381            max_file_size_mb: 25,
382            api_params: ModelApiParams {
383                temperature: 0.7,
384                top_p: 1.0,
385                frequency_penalty: 0.0,
386                presence_penalty: 0.0,
387                stop: vec![],
388            },
389            features: ModelFeatures {
390                streaming: true,
391                function_calling: false,
392                structured_output: false,
393                fine_tuning: false,
394                distillation: false,
395                fim_completion: false,
396                chat_prefix_completion: false,
397            },
398            input_type: ModelInputType {
399                text: true,
400                image: false,
401                audio: false,
402                video: false,
403            },
404            tool_support: ModelToolSupport {
405                web_search: false,
406                file_search: false,
407                image_generation: false,
408                code_interpreter: false,
409                computer_use: false,
410                mcp: false,
411            },
412            supported_endpoints: ModelSupportedEndpoints {
413                chat_completions: true,
414                responses: false,
415                realtime: false,
416                assistants: false,
417                batch_api: false,
418                fine_tuning: false,
419                embeddings: false,
420                videos: false,
421                image_generation: false,
422                image_edit: false,
423                audio_speech_generation: false,
424                audio_transcription: false,
425                audio_translation: false,
426                moderations: false,
427                completions: false,
428            },
429            token_policy: ModelTokenPolicy {
430                input_tokens_credit_multiplier: 1.0,
431                output_tokens_credit_multiplier: 3.0,
432            },
433            performance: ModelPerformance {
434                response_latency_ms: 500,
435                speed_tokens_per_second: 100,
436            },
437        }
438    }
439
440    #[test]
441    fn general_config_serializes_type_not_config_type() {
442        let config = sample_general_config();
443        let json = serde_json::to_value(&config).unwrap();
444
445        assert!(json.get("type").is_some(), "expected JSON key 'type'");
446        assert!(
447            json.get("config_type").is_none(),
448            "config_type must not appear in JSON output"
449        );
450        assert_eq!(json["type"], "model.general.v1");
451    }
452
453    #[test]
454    fn general_config_serde_roundtrip_preserves_rename() {
455        let original = sample_general_config();
456        let json = serde_json::to_value(&original).unwrap();
457        let deserialized: ModelGeneralConfig = serde_json::from_value(json).unwrap();
458
459        assert_eq!(deserialized.config_type, original.config_type);
460        assert_eq!(deserialized.tier, original.tier);
461    }
462
463    // ── ModelCatalogEntry: optional fields default when absent ──
464    // Fields with `#[serde(default)]` must deserialize to sensible values
465    // when omitted from JSON, so partial configs don't fail to load.
466
467    #[test]
468    fn optional_fields_absent_in_json_deserialize_to_defaults() {
469        let mut json = serde_json::to_value(sample_catalog_entry()).unwrap();
470        let obj = json.as_object_mut().unwrap();
471        obj.remove("description");
472        obj.remove("version");
473        obj.remove("icon");
474        obj.remove("enabled");
475        obj.remove("multimodal_capabilities");
476        obj.remove("multiplier_display");
477        obj.remove("estimation_budgets");
478        obj.remove("system_prompt");
479        obj.remove("thread_summary_prompt");
480
481        let entry: ModelCatalogEntry = serde_json::from_value(json).unwrap();
482        assert!(entry.description.is_empty());
483        assert!(entry.version.is_empty());
484        assert!(entry.icon.is_empty());
485        assert!(!entry.enabled);
486        assert!(entry.multimodal_capabilities.is_empty());
487        assert!(entry.multiplier_display.is_empty());
488        assert_eq!(
489            entry.estimation_budgets.bytes_per_token_conservative,
490            EstimationBudgets::default().bytes_per_token_conservative
491        );
492        assert!(entry.system_prompt.is_empty());
493        assert!(entry.thread_summary_prompt.is_empty());
494    }
495
496    // ── ModelCatalogEntry: estimation_budgets serde contract ──
497    // `estimation_budgets` defaults to `EstimationBudgets::default()` when absent.
498
499    #[test]
500    fn estimation_budgets_absent_in_json_deserializes_to_default() {
501        let mut json = serde_json::to_value(sample_catalog_entry()).unwrap();
502        json.as_object_mut().unwrap().remove("estimation_budgets");
503
504        let entry: ModelCatalogEntry = serde_json::from_value(json).unwrap();
505        let expected = EstimationBudgets::default();
506        assert_eq!(
507            entry.estimation_budgets.bytes_per_token_conservative,
508            expected.bytes_per_token_conservative
509        );
510        assert_eq!(
511            entry.estimation_budgets.fixed_overhead_tokens,
512            expected.fixed_overhead_tokens
513        );
514        assert_eq!(
515            entry.estimation_budgets.safety_margin_pct,
516            expected.safety_margin_pct
517        );
518        assert_eq!(
519            entry.estimation_budgets.image_token_budget,
520            expected.image_token_budget
521        );
522        assert_eq!(
523            entry.estimation_budgets.tool_surcharge_tokens,
524            expected.tool_surcharge_tokens
525        );
526        assert_eq!(
527            entry.estimation_budgets.web_search_surcharge_tokens,
528            expected.web_search_surcharge_tokens
529        );
530        assert_eq!(
531            entry.estimation_budgets.minimal_generation_floor,
532            expected.minimal_generation_floor
533        );
534    }
535
536    #[test]
537    fn system_prompt_absent_in_json_deserializes_to_empty() {
538        let mut json = serde_json::to_value(sample_catalog_entry()).unwrap();
539        json.as_object_mut().unwrap().remove("system_prompt");
540
541        let entry: ModelCatalogEntry = serde_json::from_value(json).unwrap();
542        assert!(
543            entry.system_prompt.is_empty(),
544            "missing system_prompt must deserialize to empty string"
545        );
546    }
547
548    #[test]
549    fn system_prompt_roundtrips() {
550        let mut entry = sample_catalog_entry();
551        entry.system_prompt = "You are a helpful assistant.".to_owned();
552
553        let json = serde_json::to_value(&entry).unwrap();
554        assert_eq!(json["system_prompt"], "You are a helpful assistant.");
555
556        let deserialized: ModelCatalogEntry = serde_json::from_value(json).unwrap();
557        assert_eq!(deserialized.system_prompt, "You are a helpful assistant.");
558    }
559
560    // ── ModelTier serde representation ──
561    // Serializes as PascalCase ("Standard"/"Premium") for the UI/API.
562    // Accepts lowercase aliases for CCM/DESIGN compatibility.
563
564    #[test]
565    fn model_tier_serializes_as_pascal_case() {
566        let json = serde_json::to_value(ModelTier::Premium).unwrap();
567        assert_eq!(json, serde_json::json!("Premium"));
568
569        let json = serde_json::to_value(ModelTier::Standard).unwrap();
570        assert_eq!(json, serde_json::json!("Standard"));
571    }
572
573    #[test]
574    fn model_tier_deserializes_lowercase_aliases() {
575        let premium: ModelTier = serde_json::from_value(serde_json::json!("premium")).unwrap();
576        assert_eq!(premium, ModelTier::Premium);
577
578        let standard: ModelTier = serde_json::from_value(serde_json::json!("standard")).unwrap();
579        assert_eq!(standard, ModelTier::Standard);
580    }
581
582    #[test]
583    fn model_tier_rejects_unknown_casing() {
584        let result = serde_json::from_value::<ModelTier>(serde_json::json!("PREMIUM"));
585        assert!(result.is_err());
586    }
587
588    // ── KillSwitches serde roundtrip ──
589    // Verifies that enabled switches survive serialization and that
590    // the default (all-off) state roundtrips correctly.
591
592    #[test]
593    fn kill_switches_serde_roundtrip_with_enabled_switches() {
594        let ks = KillSwitches {
595            disable_premium_tier: true,
596            force_standard_tier: false,
597            disable_web_search: true,
598            disable_file_search: false,
599            disable_images: true,
600        };
601        let json = serde_json::to_value(&ks).unwrap();
602        let deserialized: KillSwitches = serde_json::from_value(json).unwrap();
603
604        assert!(deserialized.disable_premium_tier);
605        assert!(!deserialized.force_standard_tier);
606        assert!(deserialized.disable_web_search);
607        assert!(!deserialized.disable_file_search);
608        assert!(deserialized.disable_images);
609    }
610
611    #[test]
612    fn kill_switches_default_roundtrips_all_false() {
613        let ks = KillSwitches::default();
614        let json = serde_json::to_value(&ks).unwrap();
615        let deserialized: KillSwitches = serde_json::from_value(json).unwrap();
616
617        assert!(!deserialized.disable_premium_tier);
618        assert!(!deserialized.force_standard_tier);
619        assert!(!deserialized.disable_web_search);
620        assert!(!deserialized.disable_file_search);
621        assert!(!deserialized.disable_images);
622    }
623}