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: Option<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 model_credential_id: Uuid,
218 pub credential_tenant_id: Uuid,
220 #[serde(with = "time::serde::rfc3339")]
221 pub available_from: OffsetDateTime,
222 pub max_file_size_mb: u32,
223 pub api_params: ModelApiParams,
224 pub features: ModelFeatures,
225 pub input_type: ModelInputType,
226 pub tool_support: ModelToolSupport,
227 pub supported_endpoints: ModelSupportedEndpoints,
228 pub token_policy: ModelTokenPolicy,
229 pub performance: ModelPerformance,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct ModelPreference {
235 pub is_default: bool,
236 pub sort_order: i32,
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
246pub enum ModelTier {
247 #[serde(alias = "standard")]
248 Standard,
249 #[serde(alias = "premium")]
250 Premium,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct UserLicenseStatus {
256 pub active: bool,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct UserLimits {
265 pub user_id: Uuid,
266 pub policy_version: u64,
267 pub standard: TierLimits,
268 pub premium: TierLimits,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct TierLimits {
274 pub limit_daily_credits_micro: i64,
275 pub limit_monthly_credits_micro: i64,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct UsageTokens {
281 pub input_tokens: u64,
282 pub output_tokens: u64,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct UsageEvent {
291 pub tenant_id: Uuid,
292 pub user_id: Uuid,
293 pub chat_id: Uuid,
294 pub turn_id: Uuid,
295 pub request_id: Uuid,
296 pub effective_model: String,
297 pub selected_model: String,
298 pub terminal_state: String,
299 pub billing_outcome: String,
300 pub usage: Option<UsageTokens>,
301 pub actual_credits_micro: i64,
302 pub settlement_method: String,
303 pub policy_version_applied: i64,
304 #[serde(with = "time::serde::rfc3339")]
305 pub timestamp: OffsetDateTime,
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
317 fn kill_switches_default_all_disabled() {
318 let ks = KillSwitches::default();
319 assert!(!ks.disable_premium_tier);
320 assert!(!ks.force_standard_tier);
321 assert!(!ks.disable_web_search);
322 assert!(!ks.disable_file_search);
323 assert!(!ks.disable_images);
324 }
325
326 #[test]
332 fn estimation_budgets_default_matches_spec() {
333 let eb = EstimationBudgets::default();
334 assert_eq!(eb.bytes_per_token_conservative, 4);
335 assert_eq!(eb.fixed_overhead_tokens, 100);
336 assert_eq!(eb.safety_margin_pct, 10);
337 assert_eq!(eb.image_token_budget, 1000);
338 assert_eq!(eb.tool_surcharge_tokens, 500);
339 assert_eq!(eb.web_search_surcharge_tokens, 500);
340 assert_eq!(eb.minimal_generation_floor, 50);
341 }
342
343 fn sample_catalog_entry() -> ModelCatalogEntry {
348 ModelCatalogEntry {
349 model_id: "test-model".to_owned(),
350 provider_model_id: "test-model-v1".to_owned(),
351 display_name: "Test Model".to_owned(),
352 description: String::new(),
353 version: String::new(),
354 provider_id: "default".to_owned(),
355 provider_display_name: "Default".to_owned(),
356 icon: String::new(),
357 tier: ModelTier::Standard,
358 enabled: true,
359 multimodal_capabilities: vec![],
360 context_window: 128_000,
361 max_output_tokens: 16_384,
362 max_input_tokens: 128_000,
363 input_tokens_credit_multiplier_micro: 1_000_000,
364 output_tokens_credit_multiplier_micro: 3_000_000,
365 multiplier_display: "1x".to_owned(),
366 estimation_budgets: EstimationBudgets::default(),
367 max_retrieved_chunks_per_turn: 5,
368 general_config: sample_general_config(),
369 preference: Some(ModelPreference {
370 is_default: false,
371 sort_order: 0,
372 }),
373 system_prompt: String::new(),
374 thread_summary_prompt: String::new(),
375 }
376 }
377
378 fn sample_general_config() -> ModelGeneralConfig {
379 ModelGeneralConfig {
380 config_type: "model.general.v1".to_owned(),
381 model_credential_id: Uuid::nil(),
382 credential_tenant_id: Uuid::nil(),
383 available_from: OffsetDateTime::UNIX_EPOCH,
384 max_file_size_mb: 25,
385 api_params: ModelApiParams {
386 temperature: 0.7,
387 top_p: 1.0,
388 frequency_penalty: 0.0,
389 presence_penalty: 0.0,
390 stop: vec![],
391 },
392 features: ModelFeatures {
393 streaming: true,
394 function_calling: false,
395 structured_output: false,
396 fine_tuning: false,
397 distillation: false,
398 fim_completion: false,
399 chat_prefix_completion: false,
400 },
401 input_type: ModelInputType {
402 text: true,
403 image: false,
404 audio: false,
405 video: false,
406 },
407 tool_support: ModelToolSupport {
408 web_search: false,
409 file_search: false,
410 image_generation: false,
411 code_interpreter: false,
412 computer_use: false,
413 mcp: false,
414 },
415 supported_endpoints: ModelSupportedEndpoints {
416 chat_completions: true,
417 responses: false,
418 realtime: false,
419 assistants: false,
420 batch_api: false,
421 fine_tuning: false,
422 embeddings: false,
423 videos: false,
424 image_generation: false,
425 image_edit: false,
426 audio_speech_generation: false,
427 audio_transcription: false,
428 audio_translation: false,
429 moderations: false,
430 completions: false,
431 },
432 token_policy: ModelTokenPolicy {
433 input_tokens_credit_multiplier: 1.0,
434 output_tokens_credit_multiplier: 3.0,
435 },
436 performance: ModelPerformance {
437 response_latency_ms: 500,
438 speed_tokens_per_second: 100,
439 },
440 }
441 }
442
443 #[test]
444 fn general_config_serializes_type_not_config_type() {
445 let config = sample_general_config();
446 let json = serde_json::to_value(&config).unwrap();
447
448 assert!(json.get("type").is_some(), "expected JSON key 'type'");
449 assert!(
450 json.get("config_type").is_none(),
451 "config_type must not appear in JSON output"
452 );
453 assert_eq!(json["type"], "model.general.v1");
454 }
455
456 #[test]
457 fn general_config_serde_roundtrip_preserves_rename() {
458 let original = sample_general_config();
459 let json = serde_json::to_value(&original).unwrap();
460 let deserialized: ModelGeneralConfig = serde_json::from_value(json).unwrap();
461
462 assert_eq!(deserialized.config_type, original.config_type);
463 assert_eq!(
464 deserialized.credential_tenant_id,
465 original.credential_tenant_id
466 );
467 }
468
469 #[test]
474 fn optional_fields_absent_in_json_deserialize_to_defaults() {
475 let mut json = serde_json::to_value(sample_catalog_entry()).unwrap();
476 let obj = json.as_object_mut().unwrap();
477 obj.remove("description");
478 obj.remove("version");
479 obj.remove("icon");
480 obj.remove("enabled");
481 obj.remove("multimodal_capabilities");
482 obj.remove("multiplier_display");
483 obj.remove("estimation_budgets");
484 obj.remove("system_prompt");
485 obj.remove("thread_summary_prompt");
486 obj.remove("preference");
487
488 let entry: ModelCatalogEntry = serde_json::from_value(json).unwrap();
489 assert!(entry.description.is_empty());
490 assert!(entry.version.is_empty());
491 assert!(entry.icon.is_empty());
492 assert!(!entry.enabled);
493 assert!(entry.preference.is_none());
494 assert!(entry.multimodal_capabilities.is_empty());
495 assert!(entry.multiplier_display.is_empty());
496 assert_eq!(
497 entry.estimation_budgets.bytes_per_token_conservative,
498 EstimationBudgets::default().bytes_per_token_conservative
499 );
500 assert!(entry.system_prompt.is_empty());
501 assert!(entry.thread_summary_prompt.is_empty());
502 }
503
504 #[test]
508 fn estimation_budgets_absent_in_json_deserializes_to_default() {
509 let mut json = serde_json::to_value(sample_catalog_entry()).unwrap();
510 json.as_object_mut().unwrap().remove("estimation_budgets");
511
512 let entry: ModelCatalogEntry = serde_json::from_value(json).unwrap();
513 let expected = EstimationBudgets::default();
514 assert_eq!(
515 entry.estimation_budgets.bytes_per_token_conservative,
516 expected.bytes_per_token_conservative
517 );
518 assert_eq!(
519 entry.estimation_budgets.fixed_overhead_tokens,
520 expected.fixed_overhead_tokens
521 );
522 assert_eq!(
523 entry.estimation_budgets.safety_margin_pct,
524 expected.safety_margin_pct
525 );
526 assert_eq!(
527 entry.estimation_budgets.image_token_budget,
528 expected.image_token_budget
529 );
530 assert_eq!(
531 entry.estimation_budgets.tool_surcharge_tokens,
532 expected.tool_surcharge_tokens
533 );
534 assert_eq!(
535 entry.estimation_budgets.web_search_surcharge_tokens,
536 expected.web_search_surcharge_tokens
537 );
538 assert_eq!(
539 entry.estimation_budgets.minimal_generation_floor,
540 expected.minimal_generation_floor
541 );
542 }
543
544 #[test]
545 fn system_prompt_absent_in_json_deserializes_to_empty() {
546 let mut json = serde_json::to_value(sample_catalog_entry()).unwrap();
547 json.as_object_mut().unwrap().remove("system_prompt");
548
549 let entry: ModelCatalogEntry = serde_json::from_value(json).unwrap();
550 assert!(
551 entry.system_prompt.is_empty(),
552 "missing system_prompt must deserialize to empty string"
553 );
554 }
555
556 #[test]
557 fn system_prompt_roundtrips() {
558 let mut entry = sample_catalog_entry();
559 entry.system_prompt = "You are a helpful assistant.".to_owned();
560
561 let json = serde_json::to_value(&entry).unwrap();
562 assert_eq!(json["system_prompt"], "You are a helpful assistant.");
563
564 let deserialized: ModelCatalogEntry = serde_json::from_value(json).unwrap();
565 assert_eq!(deserialized.system_prompt, "You are a helpful assistant.");
566 }
567
568 #[test]
573 fn model_tier_serializes_as_pascal_case() {
574 let json = serde_json::to_value(ModelTier::Premium).unwrap();
575 assert_eq!(json, serde_json::json!("Premium"));
576
577 let json = serde_json::to_value(ModelTier::Standard).unwrap();
578 assert_eq!(json, serde_json::json!("Standard"));
579 }
580
581 #[test]
582 fn model_tier_deserializes_lowercase_aliases() {
583 let premium: ModelTier = serde_json::from_value(serde_json::json!("premium")).unwrap();
584 assert_eq!(premium, ModelTier::Premium);
585
586 let standard: ModelTier = serde_json::from_value(serde_json::json!("standard")).unwrap();
587 assert_eq!(standard, ModelTier::Standard);
588 }
589
590 #[test]
591 fn model_tier_rejects_unknown_casing() {
592 let result = serde_json::from_value::<ModelTier>(serde_json::json!("PREMIUM"));
593 assert!(result.is_err());
594 }
595
596 #[test]
601 fn kill_switches_serde_roundtrip_with_enabled_switches() {
602 let ks = KillSwitches {
603 disable_premium_tier: true,
604 force_standard_tier: false,
605 disable_web_search: true,
606 disable_file_search: false,
607 disable_images: true,
608 };
609 let json = serde_json::to_value(&ks).unwrap();
610 let deserialized: KillSwitches = serde_json::from_value(json).unwrap();
611
612 assert!(deserialized.disable_premium_tier);
613 assert!(!deserialized.force_standard_tier);
614 assert!(deserialized.disable_web_search);
615 assert!(!deserialized.disable_file_search);
616 assert!(deserialized.disable_images);
617 }
618
619 #[test]
620 fn kill_switches_default_roundtrips_all_false() {
621 let ks = KillSwitches::default();
622 let json = serde_json::to_value(&ks).unwrap();
623 let deserialized: KillSwitches = serde_json::from_value(json).unwrap();
624
625 assert!(!deserialized.disable_premium_tier);
626 assert!(!deserialized.force_standard_tier);
627 assert!(!deserialized.disable_web_search);
628 assert!(!deserialized.disable_file_search);
629 assert!(!deserialized.disable_images);
630 }
631}