use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Provider {
pub id: String,
pub name: String,
#[serde(default)]
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Model {
pub id: String,
pub provider_id: String,
pub client_name: String,
pub price_input: f64,
pub price_output: f64,
pub currency: String,
pub context_window: i64,
pub created_at: String,
#[serde(default)]
pub channel_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Channel {
pub id: String,
pub name: String,
#[serde(
rename = "apiKeyRef",
serialize_with = "serialize_secret",
deserialize_with = "deserialize_secret"
)]
pub api_key: SecretString,
pub protocol: String,
pub protocols: String,
pub is_builtin: bool,
pub enabled: bool,
pub created_at: i64,
pub updated_at: i64,
pub health_status: String,
pub cooldown_until: Option<String>,
pub consecutive_failures: u32,
pub billing_type: String,
pub monthly_quota: Option<u64>,
pub quota_policy: String,
pub priority: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub force_protocol: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelMapping {
pub id: String,
pub channel_id: String,
pub client_name: String,
pub upstream_name: String,
pub billing: String,
pub pricing_json: String,
pub weight: u32,
pub enabled: bool,
#[serde(default, skip_serializing_if = "is_empty_protocols")]
pub protocols: String,
}
fn is_empty_protocols(s: &str) -> bool {
s.is_empty() || s == "[]"
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostRecord {
pub id: String,
pub channel_id: String,
#[serde(default)]
pub upstream_channel: String,
#[serde(default)]
pub upstream_model: String,
#[serde(default)]
pub request_time_ms: i64,
pub project: String,
pub user_id: String,
pub agent_type: String,
pub input_tokens: i64,
pub output_tokens: i64,
pub cache_write_tokens: i64,
pub cache_read_tokens: i64,
pub thinking_tokens: i64,
pub cost: f64,
pub schema_saved_tokens: i64,
pub response_saved_tokens: i64,
pub rtk_saved_tokens: i64,
pub pre_compress_tokens: i64,
pub post_compress_tokens: i64,
pub compression_tokens_saved: i64,
pub unit: String,
#[serde(default)]
pub pricing_snapshot_json: String,
pub timestamp: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default)]
pub before_tokens: i64,
#[serde(default)]
pub after_tokens: i64,
#[serde(default)]
pub tokens_saved: i64,
#[serde(default)]
pub compression_breakdown_json: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubscriptionFee {
pub id: i64,
pub channel_name: String,
pub month: String,
pub monthly_price: f64,
pub currency: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwitchLog {
pub id: String,
pub from_channel_id: String,
pub to_channel_id: String,
pub reason: String,
pub cost_record_id: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostFilter {
pub project_path: Option<String>,
pub model_name: Option<String>,
pub channel_name: Option<String>,
pub time_range: Option<TimeRange>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeRange {
pub start: i64,
pub end: i64,
pub project: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum CostGroupBy {
Project,
Model,
Channel,
ProjectModelMonth,
ProjectModelHour,
Hourly,
Daily,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostAggregate {
pub group_key: String,
pub total_input_tokens: i64,
pub total_output_tokens: i64,
pub total_actual_cost: f64,
pub total_compression_tokens_saved: i64,
pub request_count: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AvailableChannelInfo {
pub channel_id: String,
pub channel_name: String,
pub protocol: String,
pub protocols: String,
pub health_status: String,
pub enabled: bool,
pub models: Vec<AvailableModelInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompressionSavingsReport {
pub schema_saved_tokens: i64,
pub response_saved_tokens: i64,
pub rtk_saved_tokens: i64,
pub total_saved_tokens: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AvailableModelInfo {
pub mapping_id: String,
pub client_name: String,
pub upstream_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeedStatus {
pub local_version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_version: Option<u32>,
pub update_available: bool,
pub source: String,
pub entries: Vec<SeedEntryStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_refresh_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeedEntryStatus {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub local_sha256: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote_sha256: Option<String>,
pub changed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeedManifest {
pub version: u32,
pub min_schema_version: u32,
pub updated_at: String,
pub entries: std::collections::HashMap<String, SeedManifestEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SeedManifestEntry {
pub file: String,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProtocolEntry {
pub protocol: String,
#[serde(rename = "baseUrl")]
pub base_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rewrite_path: Option<String>,
}
fn serialize_secret<S>(secret: &SecretString, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(secret.expose_secret())
}
fn deserialize_secret<'de, D>(deserializer: D) -> Result<SecretString, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Ok(SecretString::new(s.into_boxed_str()))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_protocol_entry_deserialize_full() {
let json = r#"{"protocol":"openai_chat","baseUrl":"https://api.deepseek.com","rewritePath":"/chat/completions"}"#;
let entry: ProtocolEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.protocol, "openai_chat");
assert_eq!(entry.base_url, "https://api.deepseek.com");
assert_eq!(entry.rewrite_path, Some("/chat/completions".to_string()));
}
#[test]
fn test_protocol_entry_deserialize_without_rewrite_path() {
let json = r#"{"protocol":"openai_chat","baseUrl":"https://api.deepseek.com"}"#;
let entry: ProtocolEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.protocol, "openai_chat");
assert_eq!(entry.base_url, "https://api.deepseek.com");
assert_eq!(entry.rewrite_path, None);
}
#[test]
fn test_protocol_entry_serialize_skips_none_rewrite_path() {
let entry = ProtocolEntry {
protocol: "anthropic_messages".into(),
base_url: "https://api.anthropic.com".into(),
rewrite_path: None,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(
!json.contains("rewritePath"),
"None rewrite_path should be skipped"
);
assert!(json.contains("baseUrl"));
}
#[test]
fn test_protocol_entry_serialize_with_rewrite_path() {
let entry = ProtocolEntry {
protocol: "anthropic_messages".into(),
base_url: "https://api.anthropic.com".into(),
rewrite_path: Some("/anthropic/v1/messages".into()),
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("rewritePath"));
assert!(json.contains("/anthropic/v1/messages"));
}
#[test]
fn test_protocols_json_array_deserialize() {
let json = r#"[
{"protocol":"anthropic_messages","baseUrl":"https://api.anthropic.com","rewritePath":"/anthropic/v1/messages"},
{"protocol":"openai_chat","baseUrl":"https://api.deepseek.com"}
]"#;
let entries: Vec<ProtocolEntry> = serde_json::from_str(json).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].protocol, "anthropic_messages");
assert_eq!(entries[0].base_url, "https://api.anthropic.com");
assert_eq!(
entries[0].rewrite_path.as_deref(),
Some("/anthropic/v1/messages")
);
assert_eq!(entries[1].protocol, "openai_chat");
assert_eq!(entries[1].rewrite_path, None);
}
#[test]
fn test_channel_force_protocol_none_by_default() {
let json = r#"{"id":"test","name":"Test","apiKeyRef":"sk-test","protocol":"openai_chat","protocols":"[]","isBuiltin":false,"enabled":true,"createdAt":0,"updatedAt":0,"healthStatus":"Healthy","consecutiveFailures":0,"billingType":"Metered","monthlyQuota":null,"quotaPolicy":"fallback","priority":1}"#;
let ch: Channel = serde_json::from_str(json).unwrap();
assert_eq!(ch.force_protocol, None);
}
#[test]
fn test_channel_force_protocol_some() {
let json = r#"{"id":"test","name":"Test","apiKeyRef":"sk-test","protocol":"anthropic_messages","protocols":"[]","isBuiltin":false,"enabled":true,"createdAt":0,"updatedAt":0,"healthStatus":"Healthy","consecutiveFailures":0,"billingType":"Metered","monthlyQuota":null,"quotaPolicy":"fallback","priority":1,"forceProtocol":"openai_chat"}"#;
let ch: Channel = serde_json::from_str(json).unwrap();
assert_eq!(ch.force_protocol, Some("openai_chat".to_string()));
}
#[test]
fn test_channel_no_base_url_field() {
let json = r#"{"id":"test","name":"Test","apiKeyRef":"sk-test","protocol":"openai_chat","protocols":"[]","isBuiltin":false,"enabled":true,"createdAt":0,"updatedAt":0,"healthStatus":"Healthy","consecutiveFailures":0,"billingType":"Metered","monthlyQuota":null,"quotaPolicy":"fallback","priority":1}"#;
let ch: Channel = serde_json::from_str(json).unwrap();
assert_eq!(ch.id, "test");
}
}