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}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ModelCatalogEntry {
37 pub model_id: String,
39 pub provider_model_id: String,
42 pub display_name: String,
44 #[serde(default)]
46 pub description: String,
47 #[serde(default)]
49 pub version: String,
50 pub provider_id: String,
52 pub provider_display_name: String,
55 #[serde(default)]
57 pub icon: String,
58 pub tier: ModelTier,
60 #[serde(default)]
61 pub enabled: bool,
62 #[serde(default)]
64 pub multimodal_capabilities: Vec<String>,
65 pub context_window: u32,
67 pub max_output_tokens: u32,
69 pub max_input_tokens: u32,
71 pub input_tokens_credit_multiplier_micro: u64,
73 pub output_tokens_credit_multiplier_micro: u64,
75 #[serde(default)]
77 pub multiplier_display: String,
78 #[serde(default)]
80 pub estimation_budgets: EstimationBudgets,
81 pub max_retrieved_chunks_per_turn: u32,
83 pub general_config: ModelGeneralConfig,
85 pub preference: ModelPreference,
87 #[serde(default)]
90 pub system_prompt: String,
91 #[serde(default)]
94 pub thread_summary_prompt: String,
95}
96
97#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
99pub struct EstimationBudgets {
100 pub bytes_per_token_conservative: u32,
102 pub fixed_overhead_tokens: u32,
104 pub safety_margin_pct: u32,
106 pub image_token_budget: u32,
108 pub tool_surcharge_tokens: u32,
110 pub web_search_surcharge_tokens: u32,
112 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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct ModelPerformance {
206 pub response_latency_ms: u32,
207 pub speed_tokens_per_second: u32,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct ModelGeneralConfig {
213 #[serde(rename = "type")]
215 pub config_type: String,
216 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#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct ModelPreference {
233 pub is_default: bool,
234 pub sort_order: i32,
236}
237
238#[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#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct UserLicenseStatus {
254 pub active: bool,
257}
258
259#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct UsageTokens {
279 pub input_tokens: u64,
280 pub output_tokens: u64,
281}
282
283#[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 #[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 #[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 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 #[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 #[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 #[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 #[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}