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 #[serde(default = "default_max_tool_calls")]
85 pub max_tool_calls: u32,
86 pub general_config: ModelGeneralConfig,
88 pub preference: Option<ModelPreference>,
90 #[serde(default)]
93 pub system_prompt: String,
94 #[serde(default)]
97 pub thread_summary_prompt: String,
98}
99
100#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
102pub struct EstimationBudgets {
103 pub bytes_per_token_conservative: u32,
105 pub fixed_overhead_tokens: u32,
107 pub safety_margin_pct: u32,
109 pub image_token_budget: u32,
111 pub tool_surcharge_tokens: u32,
113 pub web_search_surcharge_tokens: u32,
115 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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct ModelPerformance {
213 pub response_latency_ms: u32,
214 pub speed_tokens_per_second: u32,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct ModelGeneralConfig {
220 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ModelPreference {
238 pub is_default: bool,
239 pub sort_order: i32,
241}
242
243#[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#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct UserLicenseStatus {
259 pub active: bool,
262}
263
264#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct UsageTokens {
284 pub input_tokens: u64,
285 pub output_tokens: u64,
286}
287
288#[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 #[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 #[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 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 #[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 #[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 #[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 #[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}