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)]
41pub struct LegacyUpstreamKey {
42    pub service_name: String,
43    pub station_name: String,
44    pub upstream_index: usize,
45}
46
47impl LegacyUpstreamKey {
48    pub fn new(
49        service_name: impl Into<String>,
50        station_name: impl Into<String>,
51        upstream_index: usize,
52    ) -> Self {
53        Self {
54            service_name: service_name.into(),
55            station_name: station_name.into(),
56            upstream_index,
57        }
58    }
59
60    pub fn stable_key(&self) -> String {
61        format!(
62            "{}/{}/{}",
63            self.service_name, self.station_name, self.upstream_index
64        )
65    }
66}
67
68impl fmt::Display for LegacyUpstreamKey {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        f.write_str(self.stable_key().as_str())
71    }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75pub struct RuntimeUpstreamIdentity {
76    pub provider_endpoint: ProviderEndpointKey,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub compatibility: Option<LegacyUpstreamKey>,
79    pub base_url: String,
80}
81
82impl RuntimeUpstreamIdentity {
83    pub fn new(
84        provider_endpoint: ProviderEndpointKey,
85        compatibility: Option<LegacyUpstreamKey>,
86        base_url: impl Into<String>,
87    ) -> Self {
88        Self {
89            provider_endpoint,
90            compatibility,
91            base_url: base_url.into(),
92        }
93    }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct RuntimeUpstreamCompatibilityChange {
98    pub provider_endpoint: ProviderEndpointKey,
99    pub previous_compatibility: Option<LegacyUpstreamKey>,
100    pub current_compatibility: Option<LegacyUpstreamKey>,
101}
102
103#[derive(Debug, Clone, Default, PartialEq, Eq)]
104pub struct RuntimeUpstreamIdentityMigrationPlan {
105    pub retained: Vec<RuntimeUpstreamIdentity>,
106    pub added: Vec<RuntimeUpstreamIdentity>,
107    pub removed: Vec<RuntimeUpstreamIdentity>,
108    pub compatibility_changed: Vec<RuntimeUpstreamCompatibilityChange>,
109}
110
111pub fn plan_runtime_upstream_identity_migration(
112    previous: &[RuntimeUpstreamIdentity],
113    current: &[RuntimeUpstreamIdentity],
114) -> RuntimeUpstreamIdentityMigrationPlan {
115    let previous_by_endpoint = identities_by_provider_endpoint(previous);
116    let current_by_endpoint = identities_by_provider_endpoint(current);
117    let mut plan = RuntimeUpstreamIdentityMigrationPlan::default();
118
119    for current_identity in current_by_endpoint.values() {
120        match previous_by_endpoint.get(&current_identity.provider_endpoint) {
121            Some(previous_identity) if previous_identity.base_url == current_identity.base_url => {
122                plan.retained.push(current_identity.clone());
123                if previous_identity.compatibility != current_identity.compatibility {
124                    plan.compatibility_changed
125                        .push(RuntimeUpstreamCompatibilityChange {
126                            provider_endpoint: current_identity.provider_endpoint.clone(),
127                            previous_compatibility: previous_identity.compatibility.clone(),
128                            current_compatibility: current_identity.compatibility.clone(),
129                        });
130                }
131            }
132            _ => plan.added.push(current_identity.clone()),
133        }
134    }
135
136    for previous_identity in previous_by_endpoint.values() {
137        match current_by_endpoint.get(&previous_identity.provider_endpoint) {
138            Some(current_identity) if current_identity.base_url == previous_identity.base_url => {}
139            _ => plan.removed.push(previous_identity.clone()),
140        }
141    }
142
143    plan
144}
145
146fn identities_by_provider_endpoint(
147    identities: &[RuntimeUpstreamIdentity],
148) -> BTreeMap<ProviderEndpointKey, RuntimeUpstreamIdentity> {
149    identities
150        .iter()
151        .map(|identity| (identity.provider_endpoint.clone(), identity.clone()))
152        .collect()
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn provider_endpoint_key_uses_stable_service_provider_endpoint_shape() {
161        let key = ProviderEndpointKey::new("codex", "openai", "default");
162
163        assert_eq!(key.stable_key(), "codex/openai/default");
164        assert_eq!(key.to_string(), "codex/openai/default");
165    }
166
167    #[test]
168    fn legacy_upstream_key_uses_stable_service_station_index_shape() {
169        let key = LegacyUpstreamKey::new("codex", "routing", 2);
170
171        assert_eq!(key.stable_key(), "codex/routing/2");
172        assert_eq!(key.to_string(), "codex/routing/2");
173    }
174
175    #[test]
176    fn runtime_identity_serializes_both_target_and_compatibility_keys() {
177        let identity = RuntimeUpstreamIdentity::new(
178            ProviderEndpointKey::new("codex", "openai", "default"),
179            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
180            "https://api.openai.com/v1",
181        );
182
183        let value = serde_json::to_value(identity).expect("serialize identity");
184
185        assert_eq!(
186            value["provider_endpoint"]["provider_id"].as_str(),
187            Some("openai")
188        );
189        assert_eq!(
190            value["compatibility"]["station_name"].as_str(),
191            Some("routing")
192        );
193        assert_eq!(value["compatibility"]["upstream_index"].as_u64(), Some(0));
194        assert_eq!(
195            value["base_url"].as_str(),
196            Some("https://api.openai.com/v1")
197        );
198    }
199
200    #[test]
201    fn migration_plan_retains_provider_endpoint_state_across_legacy_index_changes() {
202        let previous = vec![RuntimeUpstreamIdentity::new(
203            ProviderEndpointKey::new("codex", "input", "default"),
204            Some(LegacyUpstreamKey::new("codex", "routing", 1)),
205            "https://api.example/v1",
206        )];
207        let current = vec![RuntimeUpstreamIdentity::new(
208            ProviderEndpointKey::new("codex", "input", "default"),
209            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
210            "https://api.example/v1",
211        )];
212
213        let plan = plan_runtime_upstream_identity_migration(&previous, &current);
214
215        assert_eq!(plan.retained, current);
216        assert!(plan.added.is_empty());
217        assert!(plan.removed.is_empty());
218        assert_eq!(plan.compatibility_changed.len(), 1);
219        assert_eq!(
220            plan.compatibility_changed[0].provider_endpoint.stable_key(),
221            "codex/input/default"
222        );
223        assert_eq!(
224            plan.compatibility_changed[0]
225                .previous_compatibility
226                .as_ref()
227                .map(LegacyUpstreamKey::stable_key)
228                .as_deref(),
229            Some("codex/routing/1")
230        );
231        assert_eq!(
232            plan.compatibility_changed[0]
233                .current_compatibility
234                .as_ref()
235                .map(LegacyUpstreamKey::stable_key)
236                .as_deref(),
237            Some("codex/routing/0")
238        );
239    }
240
241    #[test]
242    fn migration_plan_replaces_provider_endpoint_state_when_base_url_changes() {
243        let previous = vec![RuntimeUpstreamIdentity::new(
244            ProviderEndpointKey::new("codex", "input", "default"),
245            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
246            "https://old.example/v1",
247        )];
248        let current = vec![RuntimeUpstreamIdentity::new(
249            ProviderEndpointKey::new("codex", "input", "default"),
250            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
251            "https://new.example/v1",
252        )];
253
254        let plan = plan_runtime_upstream_identity_migration(&previous, &current);
255
256        assert!(plan.retained.is_empty());
257        assert_eq!(plan.added, current);
258        assert_eq!(plan.removed, previous);
259        assert!(plan.compatibility_changed.is_empty());
260    }
261
262    #[test]
263    fn migration_plan_classifies_added_and_removed_provider_endpoints() {
264        let previous = vec![RuntimeUpstreamIdentity::new(
265            ProviderEndpointKey::new("codex", "old", "default"),
266            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
267            "https://old.example/v1",
268        )];
269        let current = vec![RuntimeUpstreamIdentity::new(
270            ProviderEndpointKey::new("codex", "new", "default"),
271            Some(LegacyUpstreamKey::new("codex", "routing", 0)),
272            "https://new.example/v1",
273        )];
274
275        let plan = plan_runtime_upstream_identity_migration(&previous, &current);
276
277        assert!(plan.retained.is_empty());
278        assert_eq!(plan.added, current);
279        assert_eq!(plan.removed, previous);
280        assert!(plan.compatibility_changed.is_empty());
281    }
282}