Skip to main content

codex_helper_core/
runtime_identity.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
7pub struct ProviderEndpointKey {
8    pub service_name: String,
9    pub provider_id: String,
10    pub endpoint_id: String,
11}
12
13impl ProviderEndpointKey {
14    pub fn new(
15        service_name: impl Into<String>,
16        provider_id: impl Into<String>,
17        endpoint_id: impl Into<String>,
18    ) -> Self {
19        Self {
20            service_name: service_name.into(),
21            provider_id: provider_id.into(),
22            endpoint_id: endpoint_id.into(),
23        }
24    }
25
26    pub fn stable_key(&self) -> String {
27        format!(
28            "{}/{}/{}",
29            self.service_name, self.provider_id, self.endpoint_id
30        )
31    }
32}
33
34impl fmt::Display for ProviderEndpointKey {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        f.write_str(self.stable_key().as_str())
37    }
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
41#[serde(tag = "kind", rename_all = "snake_case")]
42pub enum ContinuityDomainKey {
43    ProviderEndpoint {
44        provider_endpoint: ProviderEndpointKey,
45    },
46    Explicit {
47        service_name: String,
48        domain: String,
49    },
50}
51
52impl ContinuityDomainKey {
53    pub fn provider_endpoint(provider_endpoint: ProviderEndpointKey) -> Self {
54        Self::ProviderEndpoint { provider_endpoint }
55    }
56
57    pub fn explicit(service_name: impl Into<String>, domain: impl Into<String>) -> Option<Self> {
58        let domain = domain.into();
59        let domain = domain.trim();
60        if domain.is_empty() {
61            return None;
62        }
63        Some(Self::Explicit {
64            service_name: service_name.into(),
65            domain: domain.to_string(),
66        })
67    }
68
69    pub fn stable_key(&self) -> String {
70        match self {
71            Self::ProviderEndpoint { provider_endpoint } => {
72                format!("provider_endpoint:{}", provider_endpoint.stable_key())
73            }
74            Self::Explicit {
75                service_name,
76                domain,
77            } => {
78                format!("explicit:{service_name}/{domain}")
79            }
80        }
81    }
82
83    pub fn is_explicit(&self) -> bool {
84        matches!(self, Self::Explicit { .. })
85    }
86}
87
88impl fmt::Display for ContinuityDomainKey {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.write_str(self.stable_key().as_str())
91    }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
95pub struct LegacyUpstreamKey {
96    pub service_name: String,
97    pub station_name: String,
98    pub upstream_index: usize,
99}
100
101impl LegacyUpstreamKey {
102    pub fn new(
103        service_name: impl Into<String>,
104        station_name: impl Into<String>,
105        upstream_index: usize,
106    ) -> Self {
107        Self {
108            service_name: service_name.into(),
109            station_name: station_name.into(),
110            upstream_index,
111        }
112    }
113
114    pub fn stable_key(&self) -> String {
115        format!(
116            "{}/{}/{}",
117            self.service_name, self.station_name, self.upstream_index
118        )
119    }
120}
121
122impl fmt::Display for LegacyUpstreamKey {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.write_str(self.stable_key().as_str())
125    }
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
129pub struct RuntimeUpstreamIdentity {
130    pub provider_endpoint: ProviderEndpointKey,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub compatibility: Option<LegacyUpstreamKey>,
133    pub base_url: String,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub continuity_domain: Option<String>,
136}
137
138impl RuntimeUpstreamIdentity {
139    pub fn new(
140        provider_endpoint: ProviderEndpointKey,
141        compatibility: Option<LegacyUpstreamKey>,
142        base_url: impl Into<String>,
143    ) -> Self {
144        Self {
145            provider_endpoint,
146            compatibility,
147            base_url: base_url.into(),
148            continuity_domain: None,
149        }
150    }
151
152    pub fn new_with_continuity_domain(
153        provider_endpoint: ProviderEndpointKey,
154        compatibility: Option<LegacyUpstreamKey>,
155        base_url: impl Into<String>,
156        continuity_domain: Option<String>,
157    ) -> Self {
158        Self {
159            provider_endpoint,
160            compatibility,
161            base_url: base_url.into(),
162            continuity_domain: continuity_domain
163                .map(|value| value.trim().to_string())
164                .filter(|value| !value.is_empty()),
165        }
166    }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct RuntimeUpstreamCompatibilityChange {
171    pub provider_endpoint: ProviderEndpointKey,
172    pub previous_compatibility: Option<LegacyUpstreamKey>,
173    pub current_compatibility: Option<LegacyUpstreamKey>,
174}
175
176#[derive(Debug, Clone, Default, PartialEq, Eq)]
177pub struct RuntimeUpstreamIdentityMigrationPlan {
178    pub retained: Vec<RuntimeUpstreamIdentity>,
179    pub added: Vec<RuntimeUpstreamIdentity>,
180    pub removed: Vec<RuntimeUpstreamIdentity>,
181    pub compatibility_changed: Vec<RuntimeUpstreamCompatibilityChange>,
182}
183
184pub fn plan_runtime_upstream_identity_migration(
185    previous: &[RuntimeUpstreamIdentity],
186    current: &[RuntimeUpstreamIdentity],
187) -> RuntimeUpstreamIdentityMigrationPlan {
188    let previous_by_endpoint = identities_by_provider_endpoint(previous);
189    let current_by_endpoint = identities_by_provider_endpoint(current);
190    let mut plan = RuntimeUpstreamIdentityMigrationPlan::default();
191
192    for current_identity in current_by_endpoint.values() {
193        match previous_by_endpoint.get(&current_identity.provider_endpoint) {
194            Some(previous_identity)
195                if previous_identity.base_url == current_identity.base_url
196                    && previous_identity.continuity_domain
197                        == current_identity.continuity_domain =>
198            {
199                plan.retained.push(current_identity.clone());
200                if previous_identity.compatibility != current_identity.compatibility {
201                    plan.compatibility_changed
202                        .push(RuntimeUpstreamCompatibilityChange {
203                            provider_endpoint: current_identity.provider_endpoint.clone(),
204                            previous_compatibility: previous_identity.compatibility.clone(),
205                            current_compatibility: current_identity.compatibility.clone(),
206                        });
207                }
208            }
209            _ => plan.added.push(current_identity.clone()),
210        }
211    }
212
213    for previous_identity in previous_by_endpoint.values() {
214        match current_by_endpoint.get(&previous_identity.provider_endpoint) {
215            Some(current_identity)
216                if current_identity.base_url == previous_identity.base_url
217                    && current_identity.continuity_domain
218                        == previous_identity.continuity_domain => {}
219            _ => plan.removed.push(previous_identity.clone()),
220        }
221    }
222
223    plan
224}
225
226fn identities_by_provider_endpoint(
227    identities: &[RuntimeUpstreamIdentity],
228) -> BTreeMap<ProviderEndpointKey, RuntimeUpstreamIdentity> {
229    identities
230        .iter()
231        .map(|identity| (identity.provider_endpoint.clone(), identity.clone()))
232        .collect()
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn provider_endpoint_key_uses_stable_service_provider_endpoint_shape() {
241        let key = ProviderEndpointKey::new("codex", "openai", "default");
242
243        assert_eq!(key.stable_key(), "codex/openai/default");
244        assert_eq!(key.to_string(), "codex/openai/default");
245    }
246
247    #[test]
248    fn legacy_upstream_key_uses_stable_service_station_index_shape() {
249        let key = LegacyUpstreamKey::new("codex", "routing", 2);
250
251        assert_eq!(key.stable_key(), "codex/routing/2");
252        assert_eq!(key.to_string(), "codex/routing/2");
253    }
254
255    #[test]
256    fn continuity_domain_key_distinguishes_default_endpoint_from_explicit_domain() {
257        let endpoint = ProviderEndpointKey::new("codex", "relay-a", "default");
258        let default_domain = ContinuityDomainKey::provider_endpoint(endpoint.clone());
259        let explicit_domain =
260            ContinuityDomainKey::explicit("codex", "shared-relay").expect("explicit domain");
261
262        assert_eq!(
263            default_domain.stable_key(),
264            "provider_endpoint:codex/relay-a/default"
265        );
266        assert_eq!(explicit_domain.stable_key(), "explicit:codex/shared-relay");
267        assert!(!default_domain.is_explicit());
268        assert!(explicit_domain.is_explicit());
269        assert_ne!(
270            default_domain,
271            ContinuityDomainKey::provider_endpoint(ProviderEndpointKey::new(
272                "codex", "relay-b", "default"
273            ))
274        );
275    }
276
277    #[test]
278    fn runtime_identity_serializes_both_target_and_compatibility_keys() {
279        let identity = RuntimeUpstreamIdentity::new_with_continuity_domain(
280            ProviderEndpointKey::new("codex", "openai", "default"),
281            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
282            "https://api.openai.com/v1",
283            Some("official-openai:acct-1".to_string()),
284        );
285
286        let value = serde_json::to_value(identity).expect("serialize identity");
287
288        assert_eq!(
289            value["provider_endpoint"]["provider_id"].as_str(),
290            Some("openai")
291        );
292        assert_eq!(
293            value["compatibility"]["station_name"].as_str(),
294            Some("routing")
295        );
296        assert_eq!(value["compatibility"]["upstream_index"].as_u64(), Some(0));
297        assert_eq!(
298            value["base_url"].as_str(),
299            Some("https://api.openai.com/v1")
300        );
301        assert_eq!(
302            value["continuity_domain"].as_str(),
303            Some("official-openai:acct-1")
304        );
305    }
306
307    #[test]
308    fn migration_plan_retains_provider_endpoint_state_across_legacy_index_changes() {
309        let previous = vec![RuntimeUpstreamIdentity::new(
310            ProviderEndpointKey::new("codex", "input", "default"),
311            Some(LegacyUpstreamKey::new("codex", "routing", 1)),
312            "https://api.example/v1",
313        )];
314        let current = vec![RuntimeUpstreamIdentity::new(
315            ProviderEndpointKey::new("codex", "input", "default"),
316            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
317            "https://api.example/v1",
318        )];
319
320        let plan = plan_runtime_upstream_identity_migration(&previous, &current);
321
322        assert_eq!(plan.retained, current);
323        assert!(plan.added.is_empty());
324        assert!(plan.removed.is_empty());
325        assert_eq!(plan.compatibility_changed.len(), 1);
326        assert_eq!(
327            plan.compatibility_changed[0].provider_endpoint.stable_key(),
328            "codex/input/default"
329        );
330        assert_eq!(
331            plan.compatibility_changed[0]
332                .previous_compatibility
333                .as_ref()
334                .map(LegacyUpstreamKey::stable_key)
335                .as_deref(),
336            Some("codex/routing/1")
337        );
338        assert_eq!(
339            plan.compatibility_changed[0]
340                .current_compatibility
341                .as_ref()
342                .map(LegacyUpstreamKey::stable_key)
343                .as_deref(),
344            Some("codex/routing/0")
345        );
346    }
347
348    #[test]
349    fn migration_plan_replaces_provider_endpoint_state_when_base_url_changes() {
350        let previous = vec![RuntimeUpstreamIdentity::new(
351            ProviderEndpointKey::new("codex", "input", "default"),
352            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
353            "https://old.example/v1",
354        )];
355        let current = vec![RuntimeUpstreamIdentity::new(
356            ProviderEndpointKey::new("codex", "input", "default"),
357            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
358            "https://new.example/v1",
359        )];
360
361        let plan = plan_runtime_upstream_identity_migration(&previous, &current);
362
363        assert!(plan.retained.is_empty());
364        assert_eq!(plan.added, current);
365        assert_eq!(plan.removed, previous);
366        assert!(plan.compatibility_changed.is_empty());
367    }
368
369    #[test]
370    fn migration_plan_replaces_provider_endpoint_state_when_continuity_domain_changes() {
371        let previous = vec![RuntimeUpstreamIdentity::new_with_continuity_domain(
372            ProviderEndpointKey::new("codex", "input", "default"),
373            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
374            "https://api.example/v1",
375            Some("old-domain".to_string()),
376        )];
377        let current = vec![RuntimeUpstreamIdentity::new_with_continuity_domain(
378            ProviderEndpointKey::new("codex", "input", "default"),
379            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
380            "https://api.example/v1",
381            Some("new-domain".to_string()),
382        )];
383
384        let plan = plan_runtime_upstream_identity_migration(&previous, &current);
385
386        assert!(plan.retained.is_empty());
387        assert_eq!(plan.added, current);
388        assert_eq!(plan.removed, previous);
389        assert!(plan.compatibility_changed.is_empty());
390    }
391
392    #[test]
393    fn migration_plan_classifies_added_and_removed_provider_endpoints() {
394        let previous = vec![RuntimeUpstreamIdentity::new(
395            ProviderEndpointKey::new("codex", "old", "default"),
396            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
397            "https://old.example/v1",
398        )];
399        let current = vec![RuntimeUpstreamIdentity::new(
400            ProviderEndpointKey::new("codex", "new", "default"),
401            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
402            "https://new.example/v1",
403        )];
404
405        let plan = plan_runtime_upstream_identity_migration(&previous, &current);
406
407        assert!(plan.retained.is_empty());
408        assert_eq!(plan.added, current);
409        assert_eq!(plan.removed, previous);
410        assert!(plan.compatibility_changed.is_empty());
411    }
412}