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