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(¤t_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, ¤t);
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, ¤t);
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, ¤t);
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, ¤t);
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}