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