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