Skip to main content

latch_billing/
identity.rs

1//! Identity module - defines billing subjects, correlation IDs, and idempotency keys.
2//!
3//! The key design decision here is that `UsageEventId` encapsulates
4//! the idempotency key generation logic, preventing it from being
5//! scattered across adapter layers.
6
7use serde::{Deserialize, Serialize};
8
9/// Billing subject - who is being billed for this usage.
10///
11/// All fields are optional to support different billing models:
12/// - API key-based billing: use `api_key_id`
13/// - User-based billing: use `end_user_id`
14/// - Organization-based billing: use `org_id` + `tenant_id`
15/// - Feature-based billing: use `feature`
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17pub struct BillingSubject {
18    /// Top-level tenant (e.g., enterprise customer).
19    pub tenant_id: Option<String>,
20
21    /// Organization within the tenant.
22    pub org_id: Option<String>,
23
24    /// Project within the organization.
25    pub project_id: Option<String>,
26
27    /// API key used for this request.
28    pub api_key_id: Option<String>,
29
30    /// End user (for user-level billing).
31    pub end_user_id: Option<String>,
32
33    /// Feature being used (for feature-based billing).
34    pub feature: Option<String>,
35}
36
37/// Idempotency key for usage observations.
38///
39/// This is the core of our deduplication strategy. The key insight is
40/// that a single `request_id` is NOT sufficient - retry attempts and
41/// fallback chains can produce multiple observations for what the caller
42/// sees as one request.
43///
44/// The recommended construction is `UsageEventId::from_attempt()` which
45/// includes `(request_id, attempt_index, provider_id)` to produce a
46/// unique key per observation.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct UsageEventId {
49    /// The idempotency key string.
50    ///
51    /// Format (from_attempt): `"{request_id}:{attempt_index}:{provider_id}"`
52    /// Format (from_raw): caller-defined
53    pub idempotency_key: String,
54}
55
56impl UsageEventId {
57    /// Construct an idempotency key from request components.
58    ///
59    /// This is the **recommended** construction method. It ensures the
60    /// idempotency key includes all components needed to distinguish:
61    /// - Different requests (request_id)
62    /// - Retry attempts (attempt_index)
63    /// - Fallback providers (provider_id)
64    ///
65    /// Returns `Err(UsageEventIdError::InvalidAttemptIndex)` if `attempt_index < 0`.
66    ///
67    /// # Example
68    ///
69    /// ```rust
70    /// # use latch_billing::identity::UsageEventId;
71    /// let id = UsageEventId::from_attempt("req-123", 0, "openai").unwrap();
72    /// assert_eq!(id.idempotency_key, "req-123:0:openai");
73    /// ```
74    pub fn from_attempt(
75        request_id: &str,
76        attempt_index: i32,
77        provider_id: &str,
78    ) -> Result<Self, UsageEventIdError> {
79        if attempt_index < 0 {
80            return Err(UsageEventIdError::InvalidAttemptIndex(attempt_index));
81        }
82        Ok(Self {
83            idempotency_key: format!("{request_id}:{attempt_index}:{provider_id}"),
84        })
85    }
86
87    /// Construct an idempotency key from a raw string.
88    ///
89    /// Use this for non-provider scenarios (e.g., client-side estimation,
90    /// batch processing). The caller is responsible for ensuring uniqueness.
91    pub fn from_raw(key: impl Into<String>) -> Self {
92        Self {
93            idempotency_key: key.into(),
94        }
95    }
96}
97
98/// Builder for more precise idempotency semantics (e.g., step-level billing).
99pub struct UsageEventIdBuilder {
100    request_id: String,
101    attempt_index: i32,
102    provider_id: String,
103    step_id: Option<String>,
104    phase: Option<String>,
105}
106
107impl UsageEventIdBuilder {
108    /// Create a new builder with required fields.
109    pub fn new(request_id: &str, attempt_index: i32, provider_id: &str) -> Self {
110        Self {
111            request_id: request_id.to_string(),
112            attempt_index,
113            provider_id: provider_id.to_string(),
114            step_id: None,
115            phase: None,
116        }
117    }
118
119    /// Add step ID for step-level billing.
120    pub fn step_id(mut self, id: impl Into<String>) -> Self {
121        self.step_id = Some(id.into());
122        self
123    }
124
125    /// Add phase for multi-phase attempts.
126    pub fn phase(mut self, p: impl Into<String>) -> Self {
127        self.phase = Some(p.into());
128        self
129    }
130
131    /// Build the UsageEventId.
132    pub fn build(self) -> Result<UsageEventId, UsageEventIdError> {
133        if self.attempt_index < 0 {
134            return Err(UsageEventIdError::InvalidAttemptIndex(self.attempt_index));
135        }
136
137        let mut key = format!(
138            "{}:{}:{}",
139            self.request_id, self.attempt_index, self.provider_id
140        );
141
142        if let Some(ref s) = self.step_id {
143            key.push(':');
144            key.push_str(s);
145        }
146
147        if let Some(ref p) = self.phase {
148            key.push(':');
149            key.push_str(p);
150        }
151
152        Ok(UsageEventId { idempotency_key: key })
153    }
154}
155
156/// Error type for UsageEventId operations.
157#[derive(Debug, Clone)]
158pub enum UsageEventIdError {
159    InvalidAttemptIndex(i32),
160}
161
162/// Correlation IDs for tracing requests across systems.
163///
164/// These are informational - they help with debugging and audit trails
165/// but are not used for idempotency.
166#[derive(Debug, Clone, Default, Serialize, Deserialize)]
167pub struct CorrelationIds {
168    /// The request ID (from the gateway or client).
169    pub request_id: Option<String>,
170
171    /// Distributed tracing ID (e.g., OpenTelemetry trace ID).
172    pub trace_id: Option<String>,
173
174    /// Session ID (for conversational use cases).
175    pub session_id: Option<String>,
176
177    /// Turn ID (within a session).
178    pub turn_id: Option<String>,
179
180    /// Run ID (for agent/ workflow use cases).
181    pub run_id: Option<String>,
182
183    /// Step ID (within a run).
184    pub step_id: Option<String>,
185
186    /// Attempt index (0 = first attempt, 1 = first retry, etc.).
187    /// Mirrors the field in `UsageEventId::from_attempt()`.
188    pub attempt_index: Option<i32>,
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn from_attempt_constructs_correct_key() {
197        let id = UsageEventId::from_attempt("req-123", 0, "openai").unwrap();
198        assert_eq!(id.idempotency_key, "req-123:0:openai");
199    }
200
201    #[test]
202    fn from_attempt_with_retry() {
203        let id = UsageEventId::from_attempt("req-123", 1, "anthropic").unwrap();
204        assert_eq!(id.idempotency_key, "req-123:1:anthropic");
205    }
206
207    #[test]
208    fn from_attempt_rejects_negative_attempt() {
209        let result = UsageEventId::from_attempt("req-123", -1, "openai");
210        assert!(matches!(result, Err(UsageEventIdError::InvalidAttemptIndex(-1))));
211    }
212
213    #[test]
214    fn from_raw_uses_caller_key() {
215        let id = UsageEventId::from_raw("custom-key-123");
216        assert_eq!(id.idempotency_key, "custom-key-123");
217    }
218
219    #[test]
220    fn billing_subject_default_all_none() {
221        let sub = BillingSubject::default();
222        assert_eq!(sub.tenant_id, None);
223        assert_eq!(sub.api_key_id, None);
224    }
225
226    #[test]
227    fn usage_event_id_builder_basic() {
228        let id = UsageEventIdBuilder::new("req-123", 0, "openai")
229            .build()
230            .unwrap();
231        assert_eq!(id.idempotency_key, "req-123:0:openai");
232    }
233
234    #[test]
235    fn usage_event_id_builder_with_step() {
236        let id = UsageEventIdBuilder::new("req-123", 0, "openai")
237            .step_id("step-1")
238            .build()
239            .unwrap();
240        assert_eq!(id.idempotency_key, "req-123:0:openai:step-1");
241    }
242
243    #[test]
244    fn usage_event_id_builder_with_phase() {
245        let id = UsageEventIdBuilder::new("req-123", 0, "openai")
246            .phase("init")
247            .build()
248            .unwrap();
249        assert_eq!(id.idempotency_key, "req-123:0:openai:init");
250    }
251}