1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[derive(Debug, Clone)]
7pub struct PolicyVersionInfo {
8 pub user_id: Uuid,
9 pub policy_version: u64,
10 pub generated_at: OffsetDateTime,
11}
12
13#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ModelCatalogEntry {
38 pub model_id: String,
40 pub provider_model_id: String,
43 pub display_name: String,
45 #[serde(default)]
47 pub description: String,
48 #[serde(default)]
50 pub version: String,
51 pub provider_id: String,
53 pub provider_display_name: String,
56 #[serde(default)]
58 pub icon: String,
59 pub tier: ModelTier,
61 #[serde(default)]
62 pub enabled: bool,
63 #[serde(default)]
65 pub multimodal_capabilities: Vec<String>,
66 pub context_window: u32,
68 pub max_output_tokens: u32,
70 pub max_input_tokens: u32,
72 pub input_tokens_credit_multiplier_micro: u64,
74 pub output_tokens_credit_multiplier_micro: u64,
76 #[serde(default)]
78 pub multiplier_display: String,
79 #[serde(default)]
81 pub estimation_budgets: EstimationBudgets,
82 pub max_retrieved_chunks_per_turn: u32,
84 #[serde(default = "default_max_tool_calls")]
86 pub max_tool_calls: u32,
87 pub general_config: ModelGeneralConfig,
89 pub preference: Option<ModelPreference>,
91 #[serde(default)]
94 pub system_prompt: String,
95 #[serde(default)]
98 pub thread_summary_prompt: String,
99}
100
101#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
103pub struct EstimationBudgets {
104 pub bytes_per_token_conservative: u32,
106 pub fixed_overhead_tokens: u32,
108 pub safety_margin_pct: u32,
110 pub image_token_budget: u32,
112 pub tool_surcharge_tokens: u32,
114 pub web_search_surcharge_tokens: u32,
116 pub code_interpreter_surcharge_tokens: u32,
118 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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct ModelPerformance {
217 pub response_latency_ms: u32,
218 pub speed_tokens_per_second: u32,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct ModelGeneralConfig {
224 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct ModelPreference {
242 pub is_default: bool,
243 pub sort_order: i32,
245}
246
247#[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#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct UserLicenseStatus {
263 pub active: bool,
266}
267
268#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct UsageTokens {
288 pub input_tokens: u64,
289 pub output_tokens: u64,
290}
291
292#[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 #[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 #[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 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 #[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 #[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 #[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 #[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}