use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ProviderEndpointKey {
pub service_name: String,
pub provider_id: String,
pub endpoint_id: String,
}
impl ProviderEndpointKey {
pub fn new(
service_name: impl Into<String>,
provider_id: impl Into<String>,
endpoint_id: impl Into<String>,
) -> Self {
Self {
service_name: service_name.into(),
provider_id: provider_id.into(),
endpoint_id: endpoint_id.into(),
}
}
pub fn stable_key(&self) -> String {
format!(
"{}/{}/{}",
self.service_name, self.provider_id, self.endpoint_id
)
}
}
impl fmt::Display for ProviderEndpointKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.stable_key().as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct LegacyUpstreamKey {
pub service_name: String,
pub station_name: String,
pub upstream_index: usize,
}
impl LegacyUpstreamKey {
pub fn new(
service_name: impl Into<String>,
station_name: impl Into<String>,
upstream_index: usize,
) -> Self {
Self {
service_name: service_name.into(),
station_name: station_name.into(),
upstream_index,
}
}
pub fn stable_key(&self) -> String {
format!(
"{}/{}/{}",
self.service_name, self.station_name, self.upstream_index
)
}
}
impl fmt::Display for LegacyUpstreamKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.stable_key().as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeUpstreamIdentity {
pub provider_endpoint: ProviderEndpointKey,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compatibility: Option<LegacyUpstreamKey>,
pub base_url: String,
}
impl RuntimeUpstreamIdentity {
pub fn new(
provider_endpoint: ProviderEndpointKey,
compatibility: Option<LegacyUpstreamKey>,
base_url: impl Into<String>,
) -> Self {
Self {
provider_endpoint,
compatibility,
base_url: base_url.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeUpstreamCompatibilityChange {
pub provider_endpoint: ProviderEndpointKey,
pub previous_compatibility: Option<LegacyUpstreamKey>,
pub current_compatibility: Option<LegacyUpstreamKey>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RuntimeUpstreamIdentityMigrationPlan {
pub retained: Vec<RuntimeUpstreamIdentity>,
pub added: Vec<RuntimeUpstreamIdentity>,
pub removed: Vec<RuntimeUpstreamIdentity>,
pub compatibility_changed: Vec<RuntimeUpstreamCompatibilityChange>,
}
pub fn plan_runtime_upstream_identity_migration(
previous: &[RuntimeUpstreamIdentity],
current: &[RuntimeUpstreamIdentity],
) -> RuntimeUpstreamIdentityMigrationPlan {
let previous_by_endpoint = identities_by_provider_endpoint(previous);
let current_by_endpoint = identities_by_provider_endpoint(current);
let mut plan = RuntimeUpstreamIdentityMigrationPlan::default();
for current_identity in current_by_endpoint.values() {
match previous_by_endpoint.get(¤t_identity.provider_endpoint) {
Some(previous_identity) if previous_identity.base_url == current_identity.base_url => {
plan.retained.push(current_identity.clone());
if previous_identity.compatibility != current_identity.compatibility {
plan.compatibility_changed
.push(RuntimeUpstreamCompatibilityChange {
provider_endpoint: current_identity.provider_endpoint.clone(),
previous_compatibility: previous_identity.compatibility.clone(),
current_compatibility: current_identity.compatibility.clone(),
});
}
}
_ => plan.added.push(current_identity.clone()),
}
}
for previous_identity in previous_by_endpoint.values() {
match current_by_endpoint.get(&previous_identity.provider_endpoint) {
Some(current_identity) if current_identity.base_url == previous_identity.base_url => {}
_ => plan.removed.push(previous_identity.clone()),
}
}
plan
}
fn identities_by_provider_endpoint(
identities: &[RuntimeUpstreamIdentity],
) -> BTreeMap<ProviderEndpointKey, RuntimeUpstreamIdentity> {
identities
.iter()
.map(|identity| (identity.provider_endpoint.clone(), identity.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn provider_endpoint_key_uses_stable_service_provider_endpoint_shape() {
let key = ProviderEndpointKey::new("codex", "openai", "default");
assert_eq!(key.stable_key(), "codex/openai/default");
assert_eq!(key.to_string(), "codex/openai/default");
}
#[test]
fn legacy_upstream_key_uses_stable_service_station_index_shape() {
let key = LegacyUpstreamKey::new("codex", "routing", 2);
assert_eq!(key.stable_key(), "codex/routing/2");
assert_eq!(key.to_string(), "codex/routing/2");
}
#[test]
fn runtime_identity_serializes_both_target_and_compatibility_keys() {
let identity = RuntimeUpstreamIdentity::new(
ProviderEndpointKey::new("codex", "openai", "default"),
Some(LegacyUpstreamKey::new("codex", "routing", 0)),
"https://api.openai.com/v1",
);
let value = serde_json::to_value(identity).expect("serialize identity");
assert_eq!(
value["provider_endpoint"]["provider_id"].as_str(),
Some("openai")
);
assert_eq!(
value["compatibility"]["station_name"].as_str(),
Some("routing")
);
assert_eq!(value["compatibility"]["upstream_index"].as_u64(), Some(0));
assert_eq!(
value["base_url"].as_str(),
Some("https://api.openai.com/v1")
);
}
#[test]
fn migration_plan_retains_provider_endpoint_state_across_legacy_index_changes() {
let previous = vec![RuntimeUpstreamIdentity::new(
ProviderEndpointKey::new("codex", "input", "default"),
Some(LegacyUpstreamKey::new("codex", "routing", 1)),
"https://api.example/v1",
)];
let current = vec![RuntimeUpstreamIdentity::new(
ProviderEndpointKey::new("codex", "input", "default"),
Some(LegacyUpstreamKey::new("codex", "routing", 0)),
"https://api.example/v1",
)];
let plan = plan_runtime_upstream_identity_migration(&previous, ¤t);
assert_eq!(plan.retained, current);
assert!(plan.added.is_empty());
assert!(plan.removed.is_empty());
assert_eq!(plan.compatibility_changed.len(), 1);
assert_eq!(
plan.compatibility_changed[0].provider_endpoint.stable_key(),
"codex/input/default"
);
assert_eq!(
plan.compatibility_changed[0]
.previous_compatibility
.as_ref()
.map(LegacyUpstreamKey::stable_key)
.as_deref(),
Some("codex/routing/1")
);
assert_eq!(
plan.compatibility_changed[0]
.current_compatibility
.as_ref()
.map(LegacyUpstreamKey::stable_key)
.as_deref(),
Some("codex/routing/0")
);
}
#[test]
fn migration_plan_replaces_provider_endpoint_state_when_base_url_changes() {
let previous = vec![RuntimeUpstreamIdentity::new(
ProviderEndpointKey::new("codex", "input", "default"),
Some(LegacyUpstreamKey::new("codex", "routing", 0)),
"https://old.example/v1",
)];
let current = vec![RuntimeUpstreamIdentity::new(
ProviderEndpointKey::new("codex", "input", "default"),
Some(LegacyUpstreamKey::new("codex", "routing", 0)),
"https://new.example/v1",
)];
let plan = plan_runtime_upstream_identity_migration(&previous, ¤t);
assert!(plan.retained.is_empty());
assert_eq!(plan.added, current);
assert_eq!(plan.removed, previous);
assert!(plan.compatibility_changed.is_empty());
}
#[test]
fn migration_plan_classifies_added_and_removed_provider_endpoints() {
let previous = vec![RuntimeUpstreamIdentity::new(
ProviderEndpointKey::new("codex", "old", "default"),
Some(LegacyUpstreamKey::new("codex", "routing", 0)),
"https://old.example/v1",
)];
let current = vec![RuntimeUpstreamIdentity::new(
ProviderEndpointKey::new("codex", "new", "default"),
Some(LegacyUpstreamKey::new("codex", "routing", 0)),
"https://new.example/v1",
)];
let plan = plan_runtime_upstream_identity_migration(&previous, ¤t);
assert!(plan.retained.is_empty());
assert_eq!(plan.added, current);
assert_eq!(plan.removed, previous);
assert!(plan.compatibility_changed.is_empty());
}
}