1use super::*;
2use crate::routing_ir::{RouteCandidate, compile_v4_route_plan_template_for_compat_runtime};
3use std::collections::BTreeSet;
4
5const ROUTING_STATION_NAME: &str = "routing";
6
7#[derive(Debug, Clone)]
8pub struct ConfigV4MigrationReport {
9 pub config: ProxyConfigV4,
10 pub warnings: Vec<String>,
11}
12
13fn merge_auth(block: &UpstreamAuth, inline: &UpstreamAuth) -> UpstreamAuth {
14 UpstreamAuth {
15 auth_token: inline
16 .auth_token
17 .clone()
18 .or_else(|| block.auth_token.clone()),
19 auth_token_env: inline
20 .auth_token_env
21 .clone()
22 .or_else(|| block.auth_token_env.clone()),
23 api_key: inline.api_key.clone().or_else(|| block.api_key.clone()),
24 api_key_env: inline
25 .api_key_env
26 .clone()
27 .or_else(|| block.api_key_env.clone()),
28 }
29}
30
31fn remove_import_metadata_tags(tags: &mut BTreeMap<String, String>) {
32 tags.remove("provider_id");
33 tags.remove("requires_openai_auth");
34 if tags
35 .get("source")
36 .is_some_and(|value| value == "codex-config")
37 {
38 tags.remove("source");
39 }
40}
41
42fn compact_service_view_v4_for_write(view: &mut ServiceViewV4) {
43 for provider in view.providers.values_mut() {
44 remove_import_metadata_tags(&mut provider.tags);
45 for endpoint in provider.endpoints.values_mut() {
46 remove_import_metadata_tags(&mut endpoint.tags);
47 }
48 }
49 if let Some(routing) = view.routing.as_mut() {
50 routing.normalize_authoring();
51 }
52}
53
54pub fn collect_route_graph_affinity_migration_warnings(
55 service_name: &str,
56 view: &ServiceViewV4,
57 warnings: &mut Vec<String>,
58) {
59 let Some(routing) = view.routing.as_ref() else {
60 return;
61 };
62
63 if routing.affinity_policy == RoutingAffinityPolicyV5::PreferredGroup
64 && route_graph_has_fallback_choices(routing)
65 {
66 warnings.push(format!(
67 "[{service_name}] route graph affinity now defaults to fallback-sticky for safer session/account continuity; this migrated config keeps its explicit preferred-group policy. Set \"fallback-sticky\" if you want the new default."
68 ));
69 }
70}
71
72fn route_graph_has_fallback_choices(routing: &RoutingConfigV4) -> bool {
73 routing.order.len() > 1
74 || routing.chain.len() > 1
75 || routing.routes.values().any(|node| {
76 node.children.len() > 1 || (node.target.is_some() && !node.children.is_empty())
77 })
78}
79
80pub fn compact_v4_config_for_write(cfg: &mut ProxyConfigV4) {
81 compact_service_view_v4_for_write(&mut cfg.codex);
82 compact_service_view_v4_for_write(&mut cfg.claude);
83}
84
85fn provider_v4_to_v2(
86 service_name: &str,
87 provider_name: &str,
88 provider: &ProviderConfigV4,
89) -> Result<ProviderConfigV2> {
90 validate_provider_concurrency_limits(service_name, provider_name, None, &provider.limits)?;
91 let mut endpoints = BTreeMap::new();
92 if let Some(base_url) = provider
93 .base_url
94 .as_deref()
95 .map(str::trim)
96 .filter(|value| !value.is_empty())
97 {
98 if provider.endpoints.contains_key("default") {
99 anyhow::bail!(
100 "[{service_name}] provider '{provider_name}' cannot define both base_url and endpoints.default"
101 );
102 }
103 endpoints.insert(
104 "default".to_string(),
105 ProviderEndpointV2 {
106 base_url: base_url.to_string(),
107 enabled: true,
108 priority: default_provider_endpoint_priority(),
109 tags: BTreeMap::from([("endpoint_id".to_string(), "default".to_string())]),
110 supported_models: BTreeMap::new(),
111 model_mapping: BTreeMap::new(),
112 },
113 );
114 }
115
116 for (endpoint_name, endpoint) in &provider.endpoints {
117 validate_provider_concurrency_limits(
118 service_name,
119 provider_name,
120 Some(endpoint_name),
121 &endpoint.limits,
122 )?;
123 if endpoint.base_url.trim().is_empty() {
124 anyhow::bail!(
125 "[{service_name}] provider '{provider_name}' endpoint '{endpoint_name}' has an empty base_url"
126 );
127 }
128 endpoints.insert(
129 endpoint_name.clone(),
130 ProviderEndpointV2 {
131 base_url: endpoint.base_url.trim().to_string(),
132 enabled: endpoint.enabled,
133 priority: endpoint.priority,
134 tags: {
135 let mut tags = endpoint.tags.clone();
136 tags.insert("endpoint_id".to_string(), endpoint_name.clone());
137 tags
138 },
139 supported_models: endpoint.supported_models.clone(),
140 model_mapping: endpoint.model_mapping.clone(),
141 },
142 );
143 }
144
145 if endpoints.is_empty() {
146 anyhow::bail!("[{service_name}] provider '{provider_name}' has no base_url or endpoints");
147 }
148
149 let mut tags = provider.tags.clone();
150 tags.insert("provider_id".to_string(), provider_name.to_string());
151
152 Ok(ProviderConfigV2 {
153 alias: provider.alias.clone(),
154 enabled: provider.enabled,
155 auth: merge_auth(&provider.auth, &provider.inline_auth),
156 tags,
157 supported_models: provider.supported_models.clone(),
158 model_mapping: provider.model_mapping.clone(),
159 endpoints,
160 })
161}
162
163fn btree_string_map_to_hash_map(values: &BTreeMap<String, String>) -> HashMap<String, String> {
164 values
165 .iter()
166 .map(|(key, value)| (key.clone(), value.clone()))
167 .collect()
168}
169
170fn btree_bool_map_to_hash_map(values: &BTreeMap<String, bool>) -> HashMap<String, bool> {
171 values
172 .iter()
173 .map(|(key, value)| (key.clone(), *value))
174 .collect()
175}
176
177fn validate_runtime_provider_v4_shape(
178 service_name: &str,
179 provider_name: &str,
180 provider: &ProviderConfigV4,
181) -> Result<()> {
182 validate_provider_concurrency_limits(service_name, provider_name, None, &provider.limits)?;
183 let mut has_endpoint = false;
184 if let Some(_base_url) = provider
185 .base_url
186 .as_deref()
187 .map(str::trim)
188 .filter(|value| !value.is_empty())
189 {
190 if provider.endpoints.contains_key("default") {
191 anyhow::bail!(
192 "[{service_name}] provider '{provider_name}' cannot define both base_url and endpoints.default"
193 );
194 }
195 has_endpoint = true;
196 }
197
198 for (endpoint_name, endpoint) in &provider.endpoints {
199 validate_provider_concurrency_limits(
200 service_name,
201 provider_name,
202 Some(endpoint_name),
203 &endpoint.limits,
204 )?;
205 if endpoint.base_url.trim().is_empty() {
206 anyhow::bail!(
207 "[{service_name}] provider '{provider_name}' endpoint '{endpoint_name}' has an empty base_url"
208 );
209 }
210 has_endpoint = true;
211 }
212
213 if !has_endpoint {
214 anyhow::bail!("[{service_name}] provider '{provider_name}' has no base_url or endpoints");
215 }
216
217 Ok(())
218}
219
220fn validate_provider_concurrency_limits(
221 service_name: &str,
222 provider_name: &str,
223 endpoint_name: Option<&str>,
224 limits: &ProviderConcurrencyLimits,
225) -> Result<()> {
226 if limits.max_concurrent_requests == Some(0) {
227 if let Some(endpoint_name) = endpoint_name {
228 anyhow::bail!(
229 "[{service_name}] provider '{provider_name}' endpoint '{endpoint_name}' limits.max_concurrent_requests must be greater than 0"
230 );
231 }
232 anyhow::bail!(
233 "[{service_name}] provider '{provider_name}' limits.max_concurrent_requests must be greater than 0"
234 );
235 }
236 Ok(())
237}
238
239fn validate_service_view_v4_runtime_shape(service_name: &str, view: &ServiceViewV4) -> Result<()> {
240 for (provider_name, provider) in &view.providers {
241 validate_runtime_provider_v4_shape(service_name, provider_name, provider)?;
242 }
243 Ok(())
244}
245
246fn route_candidate_to_compat_upstream(candidate: &RouteCandidate) -> UpstreamConfig {
247 let mut tags = btree_string_map_to_hash_map(&candidate.tags);
248 tags.insert("endpoint_id".to_string(), candidate.endpoint_id.clone());
249
250 UpstreamConfig {
251 base_url: candidate.base_url.clone(),
252 auth: candidate.auth.clone(),
253 tags,
254 supported_models: btree_bool_map_to_hash_map(&candidate.supported_models),
255 model_mapping: btree_string_map_to_hash_map(&candidate.model_mapping),
256 }
257}
258
259fn provider_order_from_routing(
260 service_name: &str,
261 view: &ServiceViewV4,
262 routing: &RoutingConfigV4,
263) -> Result<Vec<String>> {
264 if view.providers.is_empty() && routing.routes.is_empty() {
265 return Ok(Vec::new());
266 }
267
268 if routing.routes.is_empty() {
269 return Ok(view.providers.keys().cloned().collect());
270 }
271
272 for route_name in routing.routes.keys() {
273 if view.providers.contains_key(route_name.as_str()) {
274 anyhow::bail!(
275 "[{service_name}] route node '{route_name}' conflicts with a provider of the same name"
276 );
277 }
278 }
279
280 let mut stack = Vec::new();
281 let order = expand_route_node(
282 service_name,
283 view,
284 routing,
285 routing.entry.as_str(),
286 &mut stack,
287 )?;
288 ensure_unique_route_order(service_name, &order)?;
289 Ok(order)
290}
291
292fn ensure_unique_route_order(service_name: &str, order: &[String]) -> Result<()> {
293 let mut seen = BTreeSet::new();
294 for provider_name in order {
295 if !seen.insert(provider_name.as_str()) {
296 anyhow::bail!(
297 "[{service_name}] routing graph expands provider '{provider_name}' more than once; duplicate leaves are ambiguous"
298 );
299 }
300 }
301 Ok(())
302}
303
304fn expand_route_ref(
305 service_name: &str,
306 view: &ServiceViewV4,
307 routing: &RoutingConfigV4,
308 child_name: &str,
309 stack: &mut Vec<String>,
310) -> Result<Vec<String>> {
311 if view.providers.contains_key(child_name) {
312 return Ok(vec![child_name.to_string()]);
313 }
314
315 expand_route_node(service_name, view, routing, child_name, stack)
316}
317
318fn expand_route_node(
319 service_name: &str,
320 view: &ServiceViewV4,
321 routing: &RoutingConfigV4,
322 route_name: &str,
323 stack: &mut Vec<String>,
324) -> Result<Vec<String>> {
325 if stack.iter().any(|name| name == route_name) {
326 let mut cycle = stack.clone();
327 cycle.push(route_name.to_string());
328 anyhow::bail!(
329 "[{service_name}] routing graph has a cycle: {}",
330 cycle.join(" -> ")
331 );
332 }
333
334 let Some(node) = routing.routes.get(route_name) else {
335 anyhow::bail!(
336 "[{service_name}] routing entry references missing route node '{route_name}'"
337 );
338 };
339
340 stack.push(route_name.to_string());
341 let result = match node.strategy {
342 RoutingPolicyV4::OrderedFailover => expand_ordered_route_children(
343 service_name,
344 view,
345 routing,
346 route_name,
347 &node.children,
348 stack,
349 ),
350 RoutingPolicyV4::ManualSticky => {
351 let target = node
352 .target
353 .as_deref()
354 .or_else(|| node.children.first().map(String::as_str))
355 .with_context(|| {
356 format!("[{service_name}] manual-sticky route '{route_name}' requires target")
357 })?;
358 if let Some(provider) = view.providers.get(target)
359 && !provider.enabled
360 {
361 anyhow::bail!(
362 "[{service_name}] manual-sticky route '{route_name}' targets disabled provider '{target}'"
363 );
364 }
365 expand_route_ref(service_name, view, routing, target, stack)
366 }
367 RoutingPolicyV4::TagPreferred => {
368 expand_tag_preferred_route(service_name, view, routing, route_name, node, stack)
369 }
370 RoutingPolicyV4::Conditional => {
371 expand_conditional_route_compat(service_name, view, routing, route_name, node, stack)
372 }
373 };
374 stack.pop();
375 result
376}
377
378fn expand_ordered_route_children(
379 service_name: &str,
380 view: &ServiceViewV4,
381 routing: &RoutingConfigV4,
382 route_name: &str,
383 children: &[String],
384 stack: &mut Vec<String>,
385) -> Result<Vec<String>> {
386 if children.is_empty() {
387 anyhow::bail!(
388 "[{service_name}] ordered-failover route '{route_name}' requires at least one child"
389 );
390 }
391
392 let mut order = Vec::new();
393 for child_name in children {
394 order.extend(expand_route_ref(
395 service_name,
396 view,
397 routing,
398 child_name.as_str(),
399 stack,
400 )?);
401 }
402 Ok(order)
403}
404
405fn child_route_matches_any_filter(
406 view: &ServiceViewV4,
407 provider_names: &[String],
408 filters: &[BTreeMap<String, String>],
409) -> bool {
410 provider_names.iter().any(|provider_name| {
411 view.providers
412 .get(provider_name.as_str())
413 .is_some_and(|provider| provider_matches_any_filter(&provider.tags, filters))
414 })
415}
416
417fn expand_tag_preferred_route(
418 service_name: &str,
419 view: &ServiceViewV4,
420 routing: &RoutingConfigV4,
421 route_name: &str,
422 node: &RoutingNodeV4,
423 stack: &mut Vec<String>,
424) -> Result<Vec<String>> {
425 if node.children.is_empty() {
426 anyhow::bail!(
427 "[{service_name}] tag-preferred route '{route_name}' requires at least one child"
428 );
429 }
430 if node.prefer_tags.is_empty() {
431 anyhow::bail!("[{service_name}] tag-preferred route '{route_name}' requires prefer_tags");
432 }
433
434 let mut preferred = Vec::new();
435 let mut fallback = Vec::new();
436 for child_name in &node.children {
437 let child_order =
438 expand_route_ref(service_name, view, routing, child_name.as_str(), stack)?;
439 if child_route_matches_any_filter(view, &child_order, &node.prefer_tags) {
440 preferred.extend(child_order);
441 } else {
442 fallback.extend(child_order);
443 }
444 }
445
446 if matches!(node.on_exhausted, RoutingExhaustedActionV4::Stop) {
447 if preferred.is_empty() {
448 anyhow::bail!(
449 "[{service_name}] tag-preferred route '{route_name}' with on_exhausted = 'stop' matched no providers"
450 );
451 }
452 return Ok(preferred);
453 }
454
455 preferred.extend(fallback);
456 Ok(preferred)
457}
458
459fn expand_conditional_route_compat(
460 service_name: &str,
461 view: &ServiceViewV4,
462 routing: &RoutingConfigV4,
463 route_name: &str,
464 node: &RoutingNodeV4,
465 stack: &mut Vec<String>,
466) -> Result<Vec<String>> {
467 let condition = node.when.as_ref().with_context(|| {
468 format!("[{service_name}] conditional route '{route_name}' requires when")
469 })?;
470 if condition.is_empty() {
471 anyhow::bail!(
472 "[{service_name}] conditional route '{route_name}' requires at least one condition field"
473 );
474 }
475
476 let then = node
477 .then
478 .as_deref()
479 .map(str::trim)
480 .filter(|value| !value.is_empty())
481 .with_context(|| {
482 format!("[{service_name}] conditional route '{route_name}' requires then")
483 })?;
484 let default_route = node
485 .default_route
486 .as_deref()
487 .map(str::trim)
488 .filter(|value| !value.is_empty())
489 .with_context(|| {
490 format!("[{service_name}] conditional route '{route_name}' requires default")
491 })?;
492
493 let mut order = Vec::new();
494 order.extend(expand_route_ref(service_name, view, routing, then, stack)?);
495 order.extend(expand_route_ref(
496 service_name,
497 view,
498 routing,
499 default_route,
500 stack,
501 )?);
502 dedupe_preserving_order(&mut order);
503 Ok(order)
504}
505
506fn dedupe_preserving_order(values: &mut Vec<String>) {
507 let mut seen = BTreeSet::new();
508 values.retain(|value| seen.insert(value.clone()));
509}
510
511fn provider_matches_any_filter(
512 tags: &BTreeMap<String, String>,
513 filters: &[BTreeMap<String, String>],
514) -> bool {
515 filters.iter().any(|filter| {
516 !filter.is_empty()
517 && filter
518 .iter()
519 .all(|(key, value)| tags.get(key) == Some(value))
520 })
521}
522
523fn default_routing_for_view(view: &ServiceViewV4) -> RoutingConfigV4 {
524 if view.providers.is_empty() {
525 RoutingConfigV4::default()
526 } else {
527 RoutingConfigV4::ordered_failover(view.providers.keys().cloned().collect())
528 }
529}
530
531pub fn effective_v4_routing(view: &ServiceViewV4) -> RoutingConfigV4 {
532 let mut routing = view
533 .routing
534 .clone()
535 .unwrap_or_else(|| default_routing_for_view(view));
536 routing.normalize_authoring();
537 routing
538}
539
540pub fn resolved_v4_provider_order(service_name: &str, view: &ServiceViewV4) -> Result<Vec<String>> {
541 let routing = effective_v4_routing(view);
542 provider_order_from_routing(service_name, view, &routing)
543}
544
545fn compile_service_view_v4(service_name: &str, view: &ServiceViewV4) -> Result<ServiceViewV2> {
546 let providers = view
547 .providers
548 .iter()
549 .map(|(provider_name, provider)| {
550 provider_v4_to_v2(service_name, provider_name, provider)
551 .map(|provider| (provider_name.clone(), provider))
552 })
553 .collect::<Result<BTreeMap<_, _>>>()?;
554
555 let route_order = resolved_v4_provider_order(service_name, view)?;
556 let groups = if route_order.is_empty() {
557 BTreeMap::new()
558 } else {
559 BTreeMap::from([(
560 ROUTING_STATION_NAME.to_string(),
561 GroupConfigV2 {
562 alias: Some("active routing".to_string()),
563 enabled: true,
564 level: default_service_config_level(),
565 members: route_order
566 .into_iter()
567 .map(|provider| GroupMemberRefV2 {
568 provider,
569 endpoint_names: Vec::new(),
570 preferred: false,
571 })
572 .collect(),
573 },
574 )])
575 };
576
577 Ok(ServiceViewV2 {
578 active_group: if groups.is_empty() {
579 None
580 } else {
581 Some(ROUTING_STATION_NAME.to_string())
582 },
583 default_profile: view.default_profile.clone(),
584 profiles: view.profiles.clone(),
585 providers,
586 groups,
587 })
588}
589
590fn compile_service_view_v4_runtime(
591 service_name: &str,
592 view: &ServiceViewV4,
593) -> Result<ServiceConfigManager> {
594 validate_service_view_v4_runtime_shape(service_name, view)?;
595 let template = compile_v4_route_plan_template_for_compat_runtime(service_name, view)?;
596 let mut configs = HashMap::new();
597 if !template.expanded_provider_order.is_empty() {
598 configs.insert(
599 ROUTING_STATION_NAME.to_string(),
600 ServiceConfig {
601 name: ROUTING_STATION_NAME.to_string(),
602 alias: Some("active routing".to_string()),
603 enabled: true,
604 level: default_service_config_level(),
605 upstreams: template
606 .candidates
607 .iter()
608 .map(route_candidate_to_compat_upstream)
609 .collect(),
610 },
611 );
612 }
613
614 let mgr = ServiceConfigManager {
615 active: if configs.is_empty() {
616 None
617 } else {
618 Some(ROUTING_STATION_NAME.to_string())
619 },
620 default_profile: view.default_profile.clone(),
621 profiles: view.profiles.clone(),
622 configs,
623 };
624 validate_service_profiles(service_name, &mgr)?;
625 Ok(mgr)
626}
627
628pub fn compile_v4_to_v2(v4: &ProxyConfigV4) -> Result<ProxyConfigV2> {
629 if !is_supported_route_graph_config_version(v4.version) {
630 anyhow::bail!("unsupported route graph config version: {}", v4.version);
631 }
632 v4.fleet.validate()?;
633
634 Ok(ProxyConfigV2 {
635 version: 2,
636 codex: compile_service_view_v4("codex", &v4.codex)?,
637 claude: compile_service_view_v4("claude", &v4.claude)?,
638 retry: v4.retry.clone(),
639 notify: v4.notify.clone(),
640 default_service: v4.default_service,
641 relay_targets: v4.relay_targets.clone(),
642 fleet: v4.fleet.clone(),
643 ui: v4.ui.clone(),
644 })
645}
646
647pub fn compile_v4_to_runtime(v4: &ProxyConfigV4) -> Result<ProxyConfig> {
648 if !is_supported_route_graph_config_version(v4.version) {
649 anyhow::bail!("unsupported route graph config version: {}", v4.version);
650 }
651 v4.fleet.validate()?;
652
653 Ok(ProxyConfig {
654 version: Some(v4.version),
655 codex: compile_service_view_v4_runtime("codex", &v4.codex)?,
656 claude: compile_service_view_v4_runtime("claude", &v4.claude)?,
657 retry: v4.retry.clone(),
658 notify: v4.notify.clone(),
659 default_service: v4.default_service,
660 relay_targets: v4.relay_targets.clone(),
661 fleet: v4.fleet.clone(),
662 ui: v4.ui.clone(),
663 })
664}
665
666fn endpoint_v2_to_v4(endpoint: &ProviderEndpointV2) -> ProviderEndpointV4 {
667 ProviderEndpointV4 {
668 base_url: endpoint.base_url.clone(),
669 continuity_domain: None,
670 enabled: endpoint.enabled,
671 priority: endpoint.priority,
672 tags: endpoint.tags.clone(),
673 supported_models: endpoint.supported_models.clone(),
674 model_mapping: endpoint.model_mapping.clone(),
675 limits: ProviderConcurrencyLimits::default(),
676 }
677}
678
679fn endpoint_can_be_inlined(endpoint_name: &str, endpoint: &ProviderEndpointV2) -> bool {
680 endpoint_name == "default"
681 && endpoint.enabled
682 && endpoint.priority == default_provider_endpoint_priority()
683 && endpoint.tags.is_empty()
684 && endpoint.supported_models.is_empty()
685 && endpoint.model_mapping.is_empty()
686}
687
688fn provider_v2_to_v4(provider: &ProviderConfigV2) -> ProviderConfigV4 {
689 let mut out = ProviderConfigV4 {
690 alias: provider.alias.clone(),
691 enabled: provider.enabled,
692 base_url: None,
693 continuity_domain: None,
694 auth: UpstreamAuth::default(),
695 inline_auth: provider.auth.clone(),
696 tags: provider.tags.clone(),
697 supported_models: provider.supported_models.clone(),
698 model_mapping: provider.model_mapping.clone(),
699 limits: ProviderConcurrencyLimits::default(),
700 endpoints: BTreeMap::new(),
701 };
702
703 if provider.endpoints.len() == 1
704 && let Some((endpoint_name, endpoint)) = provider.endpoints.iter().next()
705 && endpoint_can_be_inlined(endpoint_name, endpoint)
706 {
707 out.base_url = Some(endpoint.base_url.clone());
708 return out;
709 }
710
711 out.endpoints = provider
712 .endpoints
713 .iter()
714 .map(|(name, endpoint)| (name.clone(), endpoint_v2_to_v4(endpoint)))
715 .collect();
716 out
717}
718
719fn member_order_for_group(group: &GroupConfigV2) -> Vec<String> {
720 let mut members = group.members.iter().enumerate().collect::<Vec<_>>();
721 members.sort_by_key(|(idx, member)| (!member.preferred, *idx));
722 members
723 .into_iter()
724 .map(|(_, member)| member.provider.clone())
725 .collect()
726}
727
728fn ordered_selected_v2_group_names(view: &ServiceViewV2) -> Vec<String> {
729 let active = view.active_group.as_deref();
730 let mut groups = view.groups.iter().collect::<Vec<_>>();
731 groups.retain(|(name, group)| group.enabled || active == Some(name.as_str()));
732
733 if groups.is_empty() {
734 if let Some(active_name) = active
735 && view.groups.contains_key(active_name)
736 {
737 return vec![active_name.to_string()];
738 }
739
740 return view.groups.keys().min().cloned().into_iter().collect();
741 }
742
743 groups.sort_by(|(left_name, left), (right_name, right)| {
744 left.level
745 .cmp(&right.level)
746 .then_with(|| {
747 let left_is_active = active == Some(left_name.as_str());
748 let right_is_active = active == Some(right_name.as_str());
749 right_is_active.cmp(&left_is_active)
750 })
751 .then_with(|| left_name.cmp(right_name))
752 });
753 groups
754 .into_iter()
755 .map(|(group_name, _)| group_name.clone())
756 .collect()
757}
758
759fn routing_order_from_v2_groups(view: &ServiceViewV2) -> Vec<String> {
760 if view.groups.is_empty() {
761 return view.providers.keys().cloned().collect();
762 }
763
764 let mut seen = BTreeMap::<String, ()>::new();
765 let mut order = Vec::new();
766 for group_name in ordered_selected_v2_group_names(view) {
767 let Some(group) = view.groups.get(group_name.as_str()) else {
768 continue;
769 };
770 for provider in member_order_for_group(group) {
771 if seen.insert(provider.clone(), ()).is_none() {
772 order.push(provider);
773 }
774 }
775 }
776 order
777}
778
779fn enabled_endpoint_name_set(provider: &ProviderConfigV2) -> BTreeSet<String> {
780 provider
781 .endpoints
782 .iter()
783 .filter(|(_, endpoint)| endpoint.enabled)
784 .map(|(endpoint_name, _)| endpoint_name.clone())
785 .collect()
786}
787
788fn selected_enabled_endpoint_name_set(
789 provider: &ProviderConfigV2,
790 member: &GroupMemberRefV2,
791) -> BTreeSet<String> {
792 if member.endpoint_names.is_empty() {
793 return enabled_endpoint_name_set(provider);
794 }
795
796 member
797 .endpoint_names
798 .iter()
799 .filter(|endpoint_name| {
800 provider
801 .endpoints
802 .get(endpoint_name.as_str())
803 .is_some_and(|endpoint| endpoint.enabled)
804 })
805 .cloned()
806 .collect()
807}
808
809fn endpoint_scope_is_full_provider(provider: &ProviderConfigV2, member: &GroupMemberRefV2) -> bool {
810 selected_enabled_endpoint_name_set(provider, member) == enabled_endpoint_name_set(provider)
811}
812
813fn collect_service_v2_to_v4_warnings(
814 service_name: &str,
815 view: &ServiceViewV2,
816 warnings: &mut Vec<String>,
817) {
818 if !view.groups.is_empty() {
819 let selected_group_names = ordered_selected_v2_group_names(view);
820 if view.groups.len() > 1 {
821 let selected = if selected_group_names.is_empty() {
822 "<none>".to_string()
823 } else {
824 selected_group_names.join(", ")
825 };
826 warnings.push(format!(
827 "[{service_name}] v2 has {} stations/groups; v4 migration flattens the effective route into a single route graph entry (selected groups: {selected}). Station aliases, levels, and enabled flags are not preserved as station metadata.",
828 view.groups.len()
829 ));
830 }
831
832 let active = view.active_group.as_deref();
833 let omitted_disabled = view
834 .groups
835 .iter()
836 .filter(|(group_name, group)| !group.enabled && active != Some(group_name.as_str()))
837 .map(|(group_name, _)| group_name.clone())
838 .collect::<Vec<_>>();
839 if !omitted_disabled.is_empty() {
840 warnings.push(format!(
841 "[{service_name}] disabled inactive v2 stations/groups are omitted from the route graph: {}.",
842 omitted_disabled.join(", ")
843 ));
844 }
845
846 let included_disabled_active = selected_group_names
847 .iter()
848 .filter(|group_name| {
849 view.groups
850 .get(group_name.as_str())
851 .is_some_and(|group| !group.enabled && active == Some(group_name.as_str()))
852 })
853 .cloned()
854 .collect::<Vec<_>>();
855 if !included_disabled_active.is_empty() {
856 warnings.push(format!(
857 "[{service_name}] disabled active v2 stations/groups remain routeable in the route graph to match current runtime fallback behavior: {}.",
858 included_disabled_active.join(", ")
859 ));
860 }
861
862 let mut provider_occurrences = BTreeMap::<String, usize>::new();
863 for group_name in &selected_group_names {
864 let Some(group) = view.groups.get(group_name.as_str()) else {
865 continue;
866 };
867 for member in &group.members {
868 *provider_occurrences
869 .entry(member.provider.clone())
870 .or_insert(0) += 1;
871
872 let Some(provider) = view.providers.get(member.provider.as_str()) else {
873 continue;
874 };
875 if !endpoint_scope_is_full_provider(provider, member) {
876 let selected = selected_enabled_endpoint_name_set(provider, member)
877 .into_iter()
878 .collect::<Vec<_>>()
879 .join(", ");
880 let available = enabled_endpoint_name_set(provider)
881 .into_iter()
882 .collect::<Vec<_>>()
883 .join(", ");
884 warnings.push(format!(
885 "[{service_name}] v2 group '{group_name}' scopes provider '{}' to endpoint(s) [{}], but route graph leaves are provider-level; provider '{}' keeps all enabled endpoint(s) [{}].",
886 member.provider, selected, member.provider, available
887 ));
888 }
889 }
890 }
891
892 let repeated = provider_occurrences
893 .into_iter()
894 .filter(|(_, count)| *count > 1)
895 .map(|(provider, count)| format!("{provider} x{count}"))
896 .collect::<Vec<_>>();
897 if !repeated.is_empty() {
898 warnings.push(format!(
899 "[{service_name}] providers referenced multiple times in selected v2 groups are de-duplicated in the route graph: {}.",
900 repeated.join(", ")
901 ));
902 }
903 }
904
905 let migrated_view = migrate_service_v2_to_v4(view);
906 collect_route_graph_affinity_migration_warnings(service_name, &migrated_view, warnings);
907
908 let cleared_profiles = view
909 .profiles
910 .iter()
911 .filter(|(_, profile)| {
912 profile
913 .station
914 .as_deref()
915 .map(str::trim)
916 .is_some_and(|station| !station.is_empty())
917 })
918 .map(|(profile_name, profile)| {
919 format!(
920 "{} -> {}",
921 profile_name,
922 profile.station.as_deref().unwrap_or_default()
923 )
924 })
925 .collect::<Vec<_>>();
926 if !cleared_profiles.is_empty() {
927 warnings.push(format!(
928 "[{service_name}] profile station bindings are cleared because route graph routing owns active provider selection: {}.",
929 cleared_profiles.join(", ")
930 ));
931 }
932}
933
934fn migrate_service_v2_to_v4(view: &ServiceViewV2) -> ServiceViewV4 {
935 let mut profiles = view.profiles.clone();
936 for profile in profiles.values_mut() {
937 if profile
938 .station
939 .as_deref()
940 .map(str::trim)
941 .is_some_and(|station| !station.is_empty())
942 {
943 profile.station = None;
944 }
945 }
946
947 let providers = view
948 .providers
949 .iter()
950 .map(|(name, provider)| (name.clone(), provider_v2_to_v4(provider)))
951 .collect::<BTreeMap<_, _>>();
952
953 let order = routing_order_from_v2_groups(view);
954 let routing = if providers.is_empty() {
955 None
956 } else {
957 Some(RoutingConfigV4::ordered_failover(order))
958 };
959
960 ServiceViewV4 {
961 default_profile: view.default_profile.clone(),
962 compaction: CodexCompactionConfig::default(),
963 profiles,
964 providers,
965 routing,
966 }
967}
968
969pub fn migrate_v2_to_v4(v2: &ProxyConfigV2) -> Result<ProxyConfigV4> {
970 Ok(migrate_v2_to_v4_with_report(v2)?.config)
971}
972
973pub fn migrate_v2_to_v4_with_report(v2: &ProxyConfigV2) -> Result<ConfigV4MigrationReport> {
974 let compact = compact_v2_config(v2)?;
975 let mut warnings = Vec::new();
976 collect_service_v2_to_v4_warnings("codex", &compact.codex, &mut warnings);
977 collect_service_v2_to_v4_warnings("claude", &compact.claude, &mut warnings);
978
979 let config = ProxyConfigV4 {
980 version: CURRENT_ROUTE_GRAPH_CONFIG_VERSION,
981 codex: migrate_service_v2_to_v4(&compact.codex),
982 claude: migrate_service_v2_to_v4(&compact.claude),
983 retry: compact.retry,
984 notify: compact.notify,
985 default_service: compact.default_service,
986 relay_targets: compact.relay_targets,
987 fleet: compact.fleet,
988 ui: compact.ui,
989 };
990
991 Ok(ConfigV4MigrationReport { config, warnings })
992}
993
994pub fn migrate_legacy_to_v4(old: &ProxyConfig) -> Result<ProxyConfigV4> {
995 Ok(migrate_legacy_to_v4_with_report(old)?.config)
996}
997
998pub fn migrate_legacy_to_v4_with_report(old: &ProxyConfig) -> Result<ConfigV4MigrationReport> {
999 migrate_v2_to_v4_with_report(&migrate_legacy_to_v2(old))
1000}
1001
1002pub mod legacy {
1003 use super::*;
1004
1005 fn default_legacy_proxy_config_version() -> u32 {
1006 3
1007 }
1008
1009 #[derive(Debug, Clone, Serialize, Deserialize)]
1010 pub struct ProxyConfigV3Legacy {
1011 #[serde(default = "default_legacy_proxy_config_version")]
1012 pub version: u32,
1013 #[serde(default)]
1014 pub codex: ServiceViewV3Legacy,
1015 #[serde(default)]
1016 pub claude: ServiceViewV3Legacy,
1017 #[serde(default)]
1018 pub retry: RetryConfig,
1019 #[serde(default)]
1020 pub notify: NotifyConfig,
1021 #[serde(default)]
1022 pub default_service: Option<ServiceKind>,
1023 #[serde(default)]
1024 pub ui: UiConfig,
1025 }
1026
1027 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
1028 pub struct ServiceViewV3Legacy {
1029 #[serde(default, skip_serializing_if = "Option::is_none")]
1030 pub default_profile: Option<String>,
1031 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1032 pub profiles: BTreeMap<String, ServiceControlProfile>,
1033 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1034 pub providers: BTreeMap<String, ProviderConfigV4>,
1035 #[serde(default, skip_serializing_if = "Option::is_none")]
1036 pub routing: Option<RoutingConfigV3Legacy>,
1037 }
1038
1039 #[derive(Debug, Clone, Serialize, Deserialize)]
1040 pub struct RoutingConfigV3Legacy {
1041 #[serde(default = "default_legacy_routing_policy")]
1042 pub policy: RoutingPolicyV3Legacy,
1043 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1044 pub order: Vec<String>,
1045 #[serde(default, skip_serializing_if = "Option::is_none")]
1046 pub target: Option<String>,
1047 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1048 pub prefer_tags: Vec<BTreeMap<String, String>>,
1049 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1050 pub chain: Vec<String>,
1051 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1052 pub pools: BTreeMap<String, RoutingPoolV4>,
1053 #[serde(default = "default_legacy_on_exhausted")]
1054 pub on_exhausted: RoutingExhaustedActionV3Legacy,
1055 }
1056
1057 impl Default for RoutingConfigV3Legacy {
1058 fn default() -> Self {
1059 Self {
1060 policy: default_legacy_routing_policy(),
1061 order: Vec::new(),
1062 target: None,
1063 prefer_tags: Vec::new(),
1064 chain: Vec::new(),
1065 pools: BTreeMap::new(),
1066 on_exhausted: default_legacy_on_exhausted(),
1067 }
1068 }
1069 }
1070
1071 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1072 #[serde(rename_all = "kebab-case")]
1073 pub enum RoutingPolicyV3Legacy {
1074 ManualSticky,
1075 OrderedFailover,
1076 TagPreferred,
1077 PoolFallback,
1078 }
1079
1080 fn default_legacy_routing_policy() -> RoutingPolicyV3Legacy {
1081 RoutingPolicyV3Legacy::OrderedFailover
1082 }
1083
1084 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1085 #[serde(rename_all = "kebab-case")]
1086 pub enum RoutingExhaustedActionV3Legacy {
1087 Continue,
1088 Stop,
1089 }
1090
1091 fn default_legacy_on_exhausted() -> RoutingExhaustedActionV3Legacy {
1092 RoutingExhaustedActionV3Legacy::Continue
1093 }
1094
1095 fn safe_route_name(
1096 candidate: &str,
1097 used: &mut BTreeSet<String>,
1098 fallback_suffix: &str,
1099 ) -> String {
1100 let base = if candidate.trim().is_empty() {
1101 "route".to_string()
1102 } else {
1103 candidate.trim().to_string()
1104 };
1105 let mut name = base.clone();
1106 if used.insert(name.clone()) {
1107 return name;
1108 }
1109 name = format!("{base}_{fallback_suffix}");
1110 let mut idx = 2usize;
1111 while !used.insert(name.clone()) {
1112 name = format!("{base}_{fallback_suffix}_{idx}");
1113 idx += 1;
1114 }
1115 name
1116 }
1117
1118 fn route_from_legacy_routing(
1119 service_name: &str,
1120 view: &ServiceViewV3Legacy,
1121 routing: &RoutingConfigV3Legacy,
1122 warnings: &mut Vec<String>,
1123 ) -> Result<RoutingConfigV4> {
1124 let default_children = || view.providers.keys().cloned().collect::<Vec<_>>();
1125 let mut routes = BTreeMap::new();
1126 let entry = "main".to_string();
1127
1128 let mut root = RoutingNodeV4 {
1129 on_exhausted: match routing.on_exhausted {
1130 RoutingExhaustedActionV3Legacy::Continue => RoutingExhaustedActionV4::Continue,
1131 RoutingExhaustedActionV3Legacy::Stop => RoutingExhaustedActionV4::Stop,
1132 },
1133 ..RoutingNodeV4::default()
1134 };
1135
1136 match routing.policy {
1137 RoutingPolicyV3Legacy::ManualSticky => {
1138 root.strategy = RoutingPolicyV4::ManualSticky;
1139 root.target = routing
1140 .target
1141 .clone()
1142 .or_else(|| routing.order.first().cloned())
1143 .or_else(|| view.providers.keys().next().cloned());
1144 root.children = if routing.order.is_empty() {
1145 root.target
1146 .as_ref()
1147 .map(|target| vec![target.clone()])
1148 .unwrap_or_else(default_children)
1149 } else {
1150 routing.order.clone()
1151 };
1152 }
1153 RoutingPolicyV3Legacy::OrderedFailover => {
1154 root.strategy = RoutingPolicyV4::OrderedFailover;
1155 root.children = if routing.order.is_empty() {
1156 default_children()
1157 } else {
1158 routing.order.clone()
1159 };
1160 }
1161 RoutingPolicyV3Legacy::TagPreferred => {
1162 root.strategy = RoutingPolicyV4::TagPreferred;
1163 root.children = if routing.order.is_empty() {
1164 default_children()
1165 } else {
1166 routing.order.clone()
1167 };
1168 root.prefer_tags = routing.prefer_tags.clone();
1169 }
1170 RoutingPolicyV3Legacy::PoolFallback => {
1171 root.strategy = RoutingPolicyV4::OrderedFailover;
1172 let chain = if routing.chain.is_empty() {
1173 routing.pools.keys().cloned().collect::<Vec<_>>()
1174 } else {
1175 routing.chain.clone()
1176 };
1177 if chain.is_empty() {
1178 anyhow::bail!(
1179 "[{service_name}] legacy pool-fallback routing requires at least one pool"
1180 );
1181 }
1182 let mut used = view.providers.keys().cloned().collect::<BTreeSet<_>>();
1183 used.insert(entry.clone());
1184 let mut root_children = Vec::new();
1185 for (idx, pool_name) in chain.iter().enumerate() {
1186 let Some(pool) = routing.pools.get(pool_name.as_str()) else {
1187 anyhow::bail!(
1188 "[{service_name}] legacy routing references missing pool '{pool_name}'"
1189 );
1190 };
1191 if pool.providers.is_empty() {
1192 anyhow::bail!(
1193 "[{service_name}] legacy pool '{pool_name}' must define at least one provider"
1194 );
1195 }
1196 let route_name = safe_route_name(
1197 pool_name,
1198 &mut used,
1199 if idx == 0 { "pool" } else { "branch" },
1200 );
1201 if route_name != *pool_name {
1202 warnings.push(format!(
1203 "[{service_name}] legacy pool '{pool_name}' is renamed to route node '{route_name}' in v4"
1204 ));
1205 }
1206 routes.insert(
1207 route_name.clone(),
1208 RoutingNodeV4 {
1209 strategy: RoutingPolicyV4::OrderedFailover,
1210 children: pool.providers.clone(),
1211 target: None,
1212 prefer_tags: Vec::new(),
1213 on_exhausted: RoutingExhaustedActionV4::Continue,
1214 metadata: BTreeMap::new(),
1215 when: None,
1216 then: None,
1217 default_route: None,
1218 },
1219 );
1220 root_children.push(route_name);
1221 }
1222 if matches!(routing.on_exhausted, RoutingExhaustedActionV3Legacy::Stop) {
1223 root_children.truncate(1);
1224 }
1225 root.children = root_children;
1226 }
1227 }
1228
1229 if root.children.is_empty() {
1230 root.children = default_children();
1231 }
1232 routes.insert(entry.clone(), root);
1233 let mut routing = RoutingConfigV4 {
1234 entry,
1235 routes,
1236 ..RoutingConfigV4::default()
1237 };
1238 routing.normalize_authoring();
1239 Ok(routing)
1240 }
1241
1242 fn migrate_service_view(
1243 service_name: &str,
1244 view: &ServiceViewV3Legacy,
1245 warnings: &mut Vec<String>,
1246 ) -> Result<ServiceViewV4> {
1247 let mut profiles = view.profiles.clone();
1248 for profile in profiles.values_mut() {
1249 if profile
1250 .station
1251 .as_deref()
1252 .map(str::trim)
1253 .is_some_and(|station| !station.is_empty())
1254 {
1255 profile.station = None;
1256 }
1257 }
1258
1259 let routing = if let Some(routing) = view.routing.as_ref() {
1260 Some(route_from_legacy_routing(
1261 service_name,
1262 view,
1263 routing,
1264 warnings,
1265 )?)
1266 } else if view.providers.is_empty() {
1267 None
1268 } else {
1269 Some(RoutingConfigV4::ordered_failover(
1270 view.providers.keys().cloned().collect(),
1271 ))
1272 };
1273
1274 Ok(ServiceViewV4 {
1275 default_profile: view.default_profile.clone(),
1276 compaction: CodexCompactionConfig::default(),
1277 profiles,
1278 providers: view.providers.clone(),
1279 routing,
1280 })
1281 }
1282
1283 pub fn migrate_v3_legacy_to_v4(
1284 legacy: &ProxyConfigV3Legacy,
1285 ) -> Result<ConfigV4MigrationReport> {
1286 let mut warnings = Vec::new();
1287 let mut config = ProxyConfigV4 {
1288 version: CURRENT_ROUTE_GRAPH_CONFIG_VERSION,
1289 codex: migrate_service_view("codex", &legacy.codex, &mut warnings)?,
1290 claude: migrate_service_view("claude", &legacy.claude, &mut warnings)?,
1291 retry: legacy.retry.clone(),
1292 notify: legacy.notify.clone(),
1293 default_service: legacy.default_service,
1294 relay_targets: BTreeMap::new(),
1295 fleet: FleetRegistryConfig::default(),
1296 ui: legacy.ui.clone(),
1297 };
1298 config.normalize_routing_authoring();
1299 Ok(ConfigV4MigrationReport { config, warnings })
1300 }
1301}