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 if routing.routes.is_empty() {
51 routing.sync_graph_from_compat();
52 }
53 routing.sync_compat_from_graph();
54 }
55}
56
57pub fn collect_route_graph_affinity_migration_warnings(
58 service_name: &str,
59 view: &ServiceViewV4,
60 warnings: &mut Vec<String>,
61) {
62 let Some(routing) = view.routing.as_ref() else {
63 return;
64 };
65
66 if routing.affinity_policy == RoutingAffinityPolicyV5::PreferredGroup
67 && route_graph_has_fallback_choices(routing)
68 {
69 warnings.push(format!(
70 "[{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."
71 ));
72 }
73}
74
75fn route_graph_has_fallback_choices(routing: &RoutingConfigV4) -> bool {
76 routing.order.len() > 1
77 || routing.chain.len() > 1
78 || routing.routes.values().any(|node| {
79 node.children.len() > 1 || (node.target.is_some() && !node.children.is_empty())
80 })
81}
82
83pub fn compact_v4_config_for_write(cfg: &mut ProxyConfigV4) {
84 compact_service_view_v4_for_write(&mut cfg.codex);
85 compact_service_view_v4_for_write(&mut cfg.claude);
86}
87
88fn provider_v4_to_v2(
89 service_name: &str,
90 provider_name: &str,
91 provider: &ProviderConfigV4,
92) -> Result<ProviderConfigV2> {
93 validate_provider_concurrency_limits(service_name, provider_name, None, &provider.limits)?;
94 let mut endpoints = BTreeMap::new();
95 if let Some(base_url) = provider
96 .base_url
97 .as_deref()
98 .map(str::trim)
99 .filter(|value| !value.is_empty())
100 {
101 if provider.endpoints.contains_key("default") {
102 anyhow::bail!(
103 "[{service_name}] provider '{provider_name}' cannot define both base_url and endpoints.default"
104 );
105 }
106 endpoints.insert(
107 "default".to_string(),
108 ProviderEndpointV2 {
109 base_url: base_url.to_string(),
110 enabled: true,
111 priority: default_provider_endpoint_priority(),
112 tags: BTreeMap::from([("endpoint_id".to_string(), "default".to_string())]),
113 supported_models: BTreeMap::new(),
114 model_mapping: BTreeMap::new(),
115 },
116 );
117 }
118
119 for (endpoint_name, endpoint) in &provider.endpoints {
120 validate_provider_concurrency_limits(
121 service_name,
122 provider_name,
123 Some(endpoint_name),
124 &endpoint.limits,
125 )?;
126 if endpoint.base_url.trim().is_empty() {
127 anyhow::bail!(
128 "[{service_name}] provider '{provider_name}' endpoint '{endpoint_name}' has an empty base_url"
129 );
130 }
131 endpoints.insert(
132 endpoint_name.clone(),
133 ProviderEndpointV2 {
134 base_url: endpoint.base_url.trim().to_string(),
135 enabled: endpoint.enabled,
136 priority: endpoint.priority,
137 tags: {
138 let mut tags = endpoint.tags.clone();
139 tags.insert("endpoint_id".to_string(), endpoint_name.clone());
140 tags
141 },
142 supported_models: endpoint.supported_models.clone(),
143 model_mapping: endpoint.model_mapping.clone(),
144 },
145 );
146 }
147
148 if endpoints.is_empty() {
149 anyhow::bail!("[{service_name}] provider '{provider_name}' has no base_url or endpoints");
150 }
151
152 let mut tags = provider.tags.clone();
153 tags.insert("provider_id".to_string(), provider_name.to_string());
154
155 Ok(ProviderConfigV2 {
156 alias: provider.alias.clone(),
157 enabled: provider.enabled,
158 auth: merge_auth(&provider.auth, &provider.inline_auth),
159 tags,
160 supported_models: provider.supported_models.clone(),
161 model_mapping: provider.model_mapping.clone(),
162 endpoints,
163 })
164}
165
166fn btree_string_map_to_hash_map(values: &BTreeMap<String, String>) -> HashMap<String, String> {
167 values
168 .iter()
169 .map(|(key, value)| (key.clone(), value.clone()))
170 .collect()
171}
172
173fn btree_bool_map_to_hash_map(values: &BTreeMap<String, bool>) -> HashMap<String, bool> {
174 values
175 .iter()
176 .map(|(key, value)| (key.clone(), *value))
177 .collect()
178}
179
180fn validate_runtime_provider_v4_shape(
181 service_name: &str,
182 provider_name: &str,
183 provider: &ProviderConfigV4,
184) -> Result<()> {
185 validate_provider_concurrency_limits(service_name, provider_name, None, &provider.limits)?;
186 let mut has_endpoint = false;
187 if let Some(_base_url) = provider
188 .base_url
189 .as_deref()
190 .map(str::trim)
191 .filter(|value| !value.is_empty())
192 {
193 if provider.endpoints.contains_key("default") {
194 anyhow::bail!(
195 "[{service_name}] provider '{provider_name}' cannot define both base_url and endpoints.default"
196 );
197 }
198 has_endpoint = true;
199 }
200
201 for (endpoint_name, endpoint) in &provider.endpoints {
202 validate_provider_concurrency_limits(
203 service_name,
204 provider_name,
205 Some(endpoint_name),
206 &endpoint.limits,
207 )?;
208 if endpoint.base_url.trim().is_empty() {
209 anyhow::bail!(
210 "[{service_name}] provider '{provider_name}' endpoint '{endpoint_name}' has an empty base_url"
211 );
212 }
213 has_endpoint = true;
214 }
215
216 if !has_endpoint {
217 anyhow::bail!("[{service_name}] provider '{provider_name}' has no base_url or endpoints");
218 }
219
220 Ok(())
221}
222
223fn validate_provider_concurrency_limits(
224 service_name: &str,
225 provider_name: &str,
226 endpoint_name: Option<&str>,
227 limits: &ProviderConcurrencyLimits,
228) -> Result<()> {
229 if limits.max_concurrent_requests == Some(0) {
230 if let Some(endpoint_name) = endpoint_name {
231 anyhow::bail!(
232 "[{service_name}] provider '{provider_name}' endpoint '{endpoint_name}' limits.max_concurrent_requests must be greater than 0"
233 );
234 }
235 anyhow::bail!(
236 "[{service_name}] provider '{provider_name}' limits.max_concurrent_requests must be greater than 0"
237 );
238 }
239 Ok(())
240}
241
242fn validate_service_view_v4_runtime_shape(service_name: &str, view: &ServiceViewV4) -> Result<()> {
243 for (provider_name, provider) in &view.providers {
244 validate_runtime_provider_v4_shape(service_name, provider_name, provider)?;
245 }
246 Ok(())
247}
248
249fn route_candidate_to_compat_upstream(candidate: &RouteCandidate) -> UpstreamConfig {
250 let mut tags = btree_string_map_to_hash_map(&candidate.tags);
251 tags.insert("endpoint_id".to_string(), candidate.endpoint_id.clone());
252
253 UpstreamConfig {
254 base_url: candidate.base_url.clone(),
255 auth: candidate.auth.clone(),
256 tags,
257 supported_models: btree_bool_map_to_hash_map(&candidate.supported_models),
258 model_mapping: btree_string_map_to_hash_map(&candidate.model_mapping),
259 }
260}
261
262fn provider_order_from_routing(
263 service_name: &str,
264 view: &ServiceViewV4,
265 routing: &RoutingConfigV4,
266) -> Result<Vec<String>> {
267 if view.providers.is_empty() && routing.routes.is_empty() {
268 return Ok(Vec::new());
269 }
270
271 if routing.routes.is_empty() {
272 return Ok(view.providers.keys().cloned().collect());
273 }
274
275 for route_name in routing.routes.keys() {
276 if view.providers.contains_key(route_name.as_str()) {
277 anyhow::bail!(
278 "[{service_name}] route node '{route_name}' conflicts with a provider of the same name"
279 );
280 }
281 }
282
283 let mut stack = Vec::new();
284 let order = expand_route_node(
285 service_name,
286 view,
287 routing,
288 routing.entry.as_str(),
289 &mut stack,
290 )?;
291 ensure_unique_route_order(service_name, &order)?;
292 Ok(order)
293}
294
295fn ensure_unique_route_order(service_name: &str, order: &[String]) -> Result<()> {
296 let mut seen = BTreeSet::new();
297 for provider_name in order {
298 if !seen.insert(provider_name.as_str()) {
299 anyhow::bail!(
300 "[{service_name}] routing graph expands provider '{provider_name}' more than once; duplicate leaves are ambiguous"
301 );
302 }
303 }
304 Ok(())
305}
306
307fn expand_route_ref(
308 service_name: &str,
309 view: &ServiceViewV4,
310 routing: &RoutingConfigV4,
311 child_name: &str,
312 stack: &mut Vec<String>,
313) -> Result<Vec<String>> {
314 if view.providers.contains_key(child_name) {
315 return Ok(vec![child_name.to_string()]);
316 }
317
318 expand_route_node(service_name, view, routing, child_name, stack)
319}
320
321fn expand_route_node(
322 service_name: &str,
323 view: &ServiceViewV4,
324 routing: &RoutingConfigV4,
325 route_name: &str,
326 stack: &mut Vec<String>,
327) -> Result<Vec<String>> {
328 if stack.iter().any(|name| name == route_name) {
329 let mut cycle = stack.clone();
330 cycle.push(route_name.to_string());
331 anyhow::bail!(
332 "[{service_name}] routing graph has a cycle: {}",
333 cycle.join(" -> ")
334 );
335 }
336
337 let Some(node) = routing.routes.get(route_name) else {
338 anyhow::bail!(
339 "[{service_name}] routing entry references missing route node '{route_name}'"
340 );
341 };
342
343 stack.push(route_name.to_string());
344 let result = match node.strategy {
345 RoutingPolicyV4::OrderedFailover => expand_ordered_route_children(
346 service_name,
347 view,
348 routing,
349 route_name,
350 &node.children,
351 stack,
352 ),
353 RoutingPolicyV4::ManualSticky => {
354 let target = node
355 .target
356 .as_deref()
357 .or_else(|| node.children.first().map(String::as_str))
358 .with_context(|| {
359 format!("[{service_name}] manual-sticky route '{route_name}' requires target")
360 })?;
361 if let Some(provider) = view.providers.get(target)
362 && !provider.enabled
363 {
364 anyhow::bail!(
365 "[{service_name}] manual-sticky route '{route_name}' targets disabled provider '{target}'"
366 );
367 }
368 expand_route_ref(service_name, view, routing, target, stack)
369 }
370 RoutingPolicyV4::TagPreferred => {
371 expand_tag_preferred_route(service_name, view, routing, route_name, node, stack)
372 }
373 RoutingPolicyV4::Conditional => {
374 expand_conditional_route_compat(service_name, view, routing, route_name, node, stack)
375 }
376 };
377 stack.pop();
378 result
379}
380
381fn expand_ordered_route_children(
382 service_name: &str,
383 view: &ServiceViewV4,
384 routing: &RoutingConfigV4,
385 route_name: &str,
386 children: &[String],
387 stack: &mut Vec<String>,
388) -> Result<Vec<String>> {
389 if children.is_empty() {
390 anyhow::bail!(
391 "[{service_name}] ordered-failover route '{route_name}' requires at least one child"
392 );
393 }
394
395 let mut order = Vec::new();
396 for child_name in children {
397 order.extend(expand_route_ref(
398 service_name,
399 view,
400 routing,
401 child_name.as_str(),
402 stack,
403 )?);
404 }
405 Ok(order)
406}
407
408fn child_route_matches_any_filter(
409 view: &ServiceViewV4,
410 provider_names: &[String],
411 filters: &[BTreeMap<String, String>],
412) -> bool {
413 provider_names.iter().any(|provider_name| {
414 view.providers
415 .get(provider_name.as_str())
416 .is_some_and(|provider| provider_matches_any_filter(&provider.tags, filters))
417 })
418}
419
420fn expand_tag_preferred_route(
421 service_name: &str,
422 view: &ServiceViewV4,
423 routing: &RoutingConfigV4,
424 route_name: &str,
425 node: &RoutingNodeV4,
426 stack: &mut Vec<String>,
427) -> Result<Vec<String>> {
428 if node.children.is_empty() {
429 anyhow::bail!(
430 "[{service_name}] tag-preferred route '{route_name}' requires at least one child"
431 );
432 }
433 if node.prefer_tags.is_empty() {
434 anyhow::bail!("[{service_name}] tag-preferred route '{route_name}' requires prefer_tags");
435 }
436
437 let mut preferred = Vec::new();
438 let mut fallback = Vec::new();
439 for child_name in &node.children {
440 let child_order =
441 expand_route_ref(service_name, view, routing, child_name.as_str(), stack)?;
442 if child_route_matches_any_filter(view, &child_order, &node.prefer_tags) {
443 preferred.extend(child_order);
444 } else {
445 fallback.extend(child_order);
446 }
447 }
448
449 if matches!(node.on_exhausted, RoutingExhaustedActionV4::Stop) {
450 if preferred.is_empty() {
451 anyhow::bail!(
452 "[{service_name}] tag-preferred route '{route_name}' with on_exhausted = 'stop' matched no providers"
453 );
454 }
455 return Ok(preferred);
456 }
457
458 preferred.extend(fallback);
459 Ok(preferred)
460}
461
462fn expand_conditional_route_compat(
463 service_name: &str,
464 view: &ServiceViewV4,
465 routing: &RoutingConfigV4,
466 route_name: &str,
467 node: &RoutingNodeV4,
468 stack: &mut Vec<String>,
469) -> Result<Vec<String>> {
470 let condition = node.when.as_ref().with_context(|| {
471 format!("[{service_name}] conditional route '{route_name}' requires when")
472 })?;
473 if condition.is_empty() {
474 anyhow::bail!(
475 "[{service_name}] conditional route '{route_name}' requires at least one condition field"
476 );
477 }
478
479 let then = node
480 .then
481 .as_deref()
482 .map(str::trim)
483 .filter(|value| !value.is_empty())
484 .with_context(|| {
485 format!("[{service_name}] conditional route '{route_name}' requires then")
486 })?;
487 let default_route = node
488 .default_route
489 .as_deref()
490 .map(str::trim)
491 .filter(|value| !value.is_empty())
492 .with_context(|| {
493 format!("[{service_name}] conditional route '{route_name}' requires default")
494 })?;
495
496 let mut order = Vec::new();
497 order.extend(expand_route_ref(service_name, view, routing, then, stack)?);
498 order.extend(expand_route_ref(
499 service_name,
500 view,
501 routing,
502 default_route,
503 stack,
504 )?);
505 dedupe_preserving_order(&mut order);
506 Ok(order)
507}
508
509fn dedupe_preserving_order(values: &mut Vec<String>) {
510 let mut seen = BTreeSet::new();
511 values.retain(|value| seen.insert(value.clone()));
512}
513
514fn provider_matches_any_filter(
515 tags: &BTreeMap<String, String>,
516 filters: &[BTreeMap<String, String>],
517) -> bool {
518 filters.iter().any(|filter| {
519 !filter.is_empty()
520 && filter
521 .iter()
522 .all(|(key, value)| tags.get(key) == Some(value))
523 })
524}
525
526fn default_routing_for_view(view: &ServiceViewV4) -> RoutingConfigV4 {
527 if view.providers.is_empty() {
528 RoutingConfigV4::default()
529 } else {
530 RoutingConfigV4::ordered_failover(view.providers.keys().cloned().collect())
531 }
532}
533
534pub fn effective_v4_routing(view: &ServiceViewV4) -> RoutingConfigV4 {
535 let mut routing = view
536 .routing
537 .clone()
538 .unwrap_or_else(|| default_routing_for_view(view));
539 if routing.routes.is_empty() {
540 routing.sync_graph_from_compat();
541 }
542 routing.sync_compat_from_graph();
543 routing
544}
545
546pub fn resolved_v4_provider_order(service_name: &str, view: &ServiceViewV4) -> Result<Vec<String>> {
547 let routing = effective_v4_routing(view);
548 provider_order_from_routing(service_name, view, &routing)
549}
550
551fn compile_service_view_v4(service_name: &str, view: &ServiceViewV4) -> Result<ServiceViewV2> {
552 let providers = view
553 .providers
554 .iter()
555 .map(|(provider_name, provider)| {
556 provider_v4_to_v2(service_name, provider_name, provider)
557 .map(|provider| (provider_name.clone(), provider))
558 })
559 .collect::<Result<BTreeMap<_, _>>>()?;
560
561 let route_order = resolved_v4_provider_order(service_name, view)?;
562 let groups = if route_order.is_empty() {
563 BTreeMap::new()
564 } else {
565 BTreeMap::from([(
566 ROUTING_STATION_NAME.to_string(),
567 GroupConfigV2 {
568 alias: Some("active routing".to_string()),
569 enabled: true,
570 level: default_service_config_level(),
571 members: route_order
572 .into_iter()
573 .map(|provider| GroupMemberRefV2 {
574 provider,
575 endpoint_names: Vec::new(),
576 preferred: false,
577 })
578 .collect(),
579 },
580 )])
581 };
582
583 Ok(ServiceViewV2 {
584 active_group: if groups.is_empty() {
585 None
586 } else {
587 Some(ROUTING_STATION_NAME.to_string())
588 },
589 default_profile: view.default_profile.clone(),
590 profiles: view.profiles.clone(),
591 providers,
592 groups,
593 })
594}
595
596fn compile_service_view_v4_runtime(
597 service_name: &str,
598 view: &ServiceViewV4,
599) -> Result<ServiceConfigManager> {
600 validate_service_view_v4_runtime_shape(service_name, view)?;
601 let template = compile_v4_route_plan_template_for_compat_runtime(service_name, view)?;
602 let mut configs = HashMap::new();
603 if !template.expanded_provider_order.is_empty() {
604 configs.insert(
605 ROUTING_STATION_NAME.to_string(),
606 ServiceConfig {
607 name: ROUTING_STATION_NAME.to_string(),
608 alias: Some("active routing".to_string()),
609 enabled: true,
610 level: default_service_config_level(),
611 upstreams: template
612 .candidates
613 .iter()
614 .map(route_candidate_to_compat_upstream)
615 .collect(),
616 },
617 );
618 }
619
620 let mgr = ServiceConfigManager {
621 active: if configs.is_empty() {
622 None
623 } else {
624 Some(ROUTING_STATION_NAME.to_string())
625 },
626 default_profile: view.default_profile.clone(),
627 profiles: view.profiles.clone(),
628 configs,
629 };
630 validate_service_profiles(service_name, &mgr)?;
631 Ok(mgr)
632}
633
634pub fn compile_v4_to_v2(v4: &ProxyConfigV4) -> Result<ProxyConfigV2> {
635 if !is_supported_route_graph_config_version(v4.version) {
636 anyhow::bail!("unsupported route graph config version: {}", v4.version);
637 }
638
639 Ok(ProxyConfigV2 {
640 version: 2,
641 codex: compile_service_view_v4("codex", &v4.codex)?,
642 claude: compile_service_view_v4("claude", &v4.claude)?,
643 retry: v4.retry.clone(),
644 notify: v4.notify.clone(),
645 default_service: v4.default_service,
646 ui: v4.ui.clone(),
647 })
648}
649
650pub fn compile_v4_to_runtime(v4: &ProxyConfigV4) -> Result<ProxyConfig> {
651 if !is_supported_route_graph_config_version(v4.version) {
652 anyhow::bail!("unsupported route graph config version: {}", v4.version);
653 }
654
655 Ok(ProxyConfig {
656 version: Some(v4.version),
657 codex: compile_service_view_v4_runtime("codex", &v4.codex)?,
658 claude: compile_service_view_v4_runtime("claude", &v4.claude)?,
659 retry: v4.retry.clone(),
660 notify: v4.notify.clone(),
661 default_service: v4.default_service,
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 profiles,
963 providers,
964 routing,
965 }
966}
967
968pub fn migrate_v2_to_v4(v2: &ProxyConfigV2) -> Result<ProxyConfigV4> {
969 Ok(migrate_v2_to_v4_with_report(v2)?.config)
970}
971
972pub fn migrate_v2_to_v4_with_report(v2: &ProxyConfigV2) -> Result<ConfigV4MigrationReport> {
973 let compact = compact_v2_config(v2)?;
974 let mut warnings = Vec::new();
975 collect_service_v2_to_v4_warnings("codex", &compact.codex, &mut warnings);
976 collect_service_v2_to_v4_warnings("claude", &compact.claude, &mut warnings);
977
978 let config = ProxyConfigV4 {
979 version: CURRENT_ROUTE_GRAPH_CONFIG_VERSION,
980 codex: migrate_service_v2_to_v4(&compact.codex),
981 claude: migrate_service_v2_to_v4(&compact.claude),
982 retry: compact.retry,
983 notify: compact.notify,
984 default_service: compact.default_service,
985 ui: compact.ui,
986 };
987
988 Ok(ConfigV4MigrationReport { config, warnings })
989}
990
991pub fn migrate_legacy_to_v4(old: &ProxyConfig) -> Result<ProxyConfigV4> {
992 Ok(migrate_legacy_to_v4_with_report(old)?.config)
993}
994
995pub fn migrate_legacy_to_v4_with_report(old: &ProxyConfig) -> Result<ConfigV4MigrationReport> {
996 migrate_v2_to_v4_with_report(&migrate_legacy_to_v2(old))
997}
998
999pub mod legacy {
1000 use super::*;
1001
1002 fn default_legacy_proxy_config_version() -> u32 {
1003 3
1004 }
1005
1006 #[derive(Debug, Clone, Serialize, Deserialize)]
1007 pub struct ProxyConfigV3Legacy {
1008 #[serde(default = "default_legacy_proxy_config_version")]
1009 pub version: u32,
1010 #[serde(default)]
1011 pub codex: ServiceViewV3Legacy,
1012 #[serde(default)]
1013 pub claude: ServiceViewV3Legacy,
1014 #[serde(default)]
1015 pub retry: RetryConfig,
1016 #[serde(default)]
1017 pub notify: NotifyConfig,
1018 #[serde(default)]
1019 pub default_service: Option<ServiceKind>,
1020 #[serde(default)]
1021 pub ui: UiConfig,
1022 }
1023
1024 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
1025 pub struct ServiceViewV3Legacy {
1026 #[serde(default, skip_serializing_if = "Option::is_none")]
1027 pub default_profile: Option<String>,
1028 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1029 pub profiles: BTreeMap<String, ServiceControlProfile>,
1030 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1031 pub providers: BTreeMap<String, ProviderConfigV4>,
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1033 pub routing: Option<RoutingConfigV3Legacy>,
1034 }
1035
1036 #[derive(Debug, Clone, Serialize, Deserialize)]
1037 pub struct RoutingConfigV3Legacy {
1038 #[serde(default = "default_legacy_routing_policy")]
1039 pub policy: RoutingPolicyV3Legacy,
1040 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1041 pub order: Vec<String>,
1042 #[serde(default, skip_serializing_if = "Option::is_none")]
1043 pub target: Option<String>,
1044 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1045 pub prefer_tags: Vec<BTreeMap<String, String>>,
1046 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1047 pub chain: Vec<String>,
1048 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1049 pub pools: BTreeMap<String, RoutingPoolV4>,
1050 #[serde(default = "default_legacy_on_exhausted")]
1051 pub on_exhausted: RoutingExhaustedActionV3Legacy,
1052 }
1053
1054 impl Default for RoutingConfigV3Legacy {
1055 fn default() -> Self {
1056 Self {
1057 policy: default_legacy_routing_policy(),
1058 order: Vec::new(),
1059 target: None,
1060 prefer_tags: Vec::new(),
1061 chain: Vec::new(),
1062 pools: BTreeMap::new(),
1063 on_exhausted: default_legacy_on_exhausted(),
1064 }
1065 }
1066 }
1067
1068 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1069 #[serde(rename_all = "kebab-case")]
1070 pub enum RoutingPolicyV3Legacy {
1071 ManualSticky,
1072 OrderedFailover,
1073 TagPreferred,
1074 PoolFallback,
1075 }
1076
1077 fn default_legacy_routing_policy() -> RoutingPolicyV3Legacy {
1078 RoutingPolicyV3Legacy::OrderedFailover
1079 }
1080
1081 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1082 #[serde(rename_all = "kebab-case")]
1083 pub enum RoutingExhaustedActionV3Legacy {
1084 Continue,
1085 Stop,
1086 }
1087
1088 fn default_legacy_on_exhausted() -> RoutingExhaustedActionV3Legacy {
1089 RoutingExhaustedActionV3Legacy::Continue
1090 }
1091
1092 fn safe_route_name(
1093 candidate: &str,
1094 used: &mut BTreeSet<String>,
1095 fallback_suffix: &str,
1096 ) -> String {
1097 let base = if candidate.trim().is_empty() {
1098 "route".to_string()
1099 } else {
1100 candidate.trim().to_string()
1101 };
1102 let mut name = base.clone();
1103 if used.insert(name.clone()) {
1104 return name;
1105 }
1106 name = format!("{base}_{fallback_suffix}");
1107 let mut idx = 2usize;
1108 while !used.insert(name.clone()) {
1109 name = format!("{base}_{fallback_suffix}_{idx}");
1110 idx += 1;
1111 }
1112 name
1113 }
1114
1115 fn route_from_legacy_routing(
1116 service_name: &str,
1117 view: &ServiceViewV3Legacy,
1118 routing: &RoutingConfigV3Legacy,
1119 warnings: &mut Vec<String>,
1120 ) -> Result<RoutingConfigV4> {
1121 let default_children = || view.providers.keys().cloned().collect::<Vec<_>>();
1122 let mut routes = BTreeMap::new();
1123 let entry = "main".to_string();
1124
1125 let mut root = RoutingNodeV4 {
1126 on_exhausted: match routing.on_exhausted {
1127 RoutingExhaustedActionV3Legacy::Continue => RoutingExhaustedActionV4::Continue,
1128 RoutingExhaustedActionV3Legacy::Stop => RoutingExhaustedActionV4::Stop,
1129 },
1130 ..RoutingNodeV4::default()
1131 };
1132
1133 match routing.policy {
1134 RoutingPolicyV3Legacy::ManualSticky => {
1135 root.strategy = RoutingPolicyV4::ManualSticky;
1136 root.target = routing
1137 .target
1138 .clone()
1139 .or_else(|| routing.order.first().cloned())
1140 .or_else(|| view.providers.keys().next().cloned());
1141 root.children = if routing.order.is_empty() {
1142 root.target
1143 .as_ref()
1144 .map(|target| vec![target.clone()])
1145 .unwrap_or_else(default_children)
1146 } else {
1147 routing.order.clone()
1148 };
1149 }
1150 RoutingPolicyV3Legacy::OrderedFailover => {
1151 root.strategy = RoutingPolicyV4::OrderedFailover;
1152 root.children = if routing.order.is_empty() {
1153 default_children()
1154 } else {
1155 routing.order.clone()
1156 };
1157 }
1158 RoutingPolicyV3Legacy::TagPreferred => {
1159 root.strategy = RoutingPolicyV4::TagPreferred;
1160 root.children = if routing.order.is_empty() {
1161 default_children()
1162 } else {
1163 routing.order.clone()
1164 };
1165 root.prefer_tags = routing.prefer_tags.clone();
1166 }
1167 RoutingPolicyV3Legacy::PoolFallback => {
1168 root.strategy = RoutingPolicyV4::OrderedFailover;
1169 let chain = if routing.chain.is_empty() {
1170 routing.pools.keys().cloned().collect::<Vec<_>>()
1171 } else {
1172 routing.chain.clone()
1173 };
1174 if chain.is_empty() {
1175 anyhow::bail!(
1176 "[{service_name}] legacy pool-fallback routing requires at least one pool"
1177 );
1178 }
1179 let mut used = view.providers.keys().cloned().collect::<BTreeSet<_>>();
1180 used.insert(entry.clone());
1181 let mut root_children = Vec::new();
1182 for (idx, pool_name) in chain.iter().enumerate() {
1183 let Some(pool) = routing.pools.get(pool_name.as_str()) else {
1184 anyhow::bail!(
1185 "[{service_name}] legacy routing references missing pool '{pool_name}'"
1186 );
1187 };
1188 if pool.providers.is_empty() {
1189 anyhow::bail!(
1190 "[{service_name}] legacy pool '{pool_name}' must define at least one provider"
1191 );
1192 }
1193 let route_name = safe_route_name(
1194 pool_name,
1195 &mut used,
1196 if idx == 0 { "pool" } else { "branch" },
1197 );
1198 if route_name != *pool_name {
1199 warnings.push(format!(
1200 "[{service_name}] legacy pool '{pool_name}' is renamed to route node '{route_name}' in v4"
1201 ));
1202 }
1203 routes.insert(
1204 route_name.clone(),
1205 RoutingNodeV4 {
1206 strategy: RoutingPolicyV4::OrderedFailover,
1207 children: pool.providers.clone(),
1208 target: None,
1209 prefer_tags: Vec::new(),
1210 on_exhausted: RoutingExhaustedActionV4::Continue,
1211 metadata: BTreeMap::new(),
1212 when: None,
1213 then: None,
1214 default_route: None,
1215 },
1216 );
1217 root_children.push(route_name);
1218 }
1219 if matches!(routing.on_exhausted, RoutingExhaustedActionV3Legacy::Stop) {
1220 root_children.truncate(1);
1221 }
1222 root.children = root_children;
1223 }
1224 }
1225
1226 if root.children.is_empty() {
1227 root.children = default_children();
1228 }
1229 routes.insert(entry.clone(), root);
1230 let mut routing = RoutingConfigV4 {
1231 entry,
1232 routes,
1233 ..RoutingConfigV4::default()
1234 };
1235 routing.sync_compat_from_graph();
1236 Ok(routing)
1237 }
1238
1239 fn migrate_service_view(
1240 service_name: &str,
1241 view: &ServiceViewV3Legacy,
1242 warnings: &mut Vec<String>,
1243 ) -> Result<ServiceViewV4> {
1244 let mut profiles = view.profiles.clone();
1245 for profile in profiles.values_mut() {
1246 if profile
1247 .station
1248 .as_deref()
1249 .map(str::trim)
1250 .is_some_and(|station| !station.is_empty())
1251 {
1252 profile.station = None;
1253 }
1254 }
1255
1256 let routing = if let Some(routing) = view.routing.as_ref() {
1257 Some(route_from_legacy_routing(
1258 service_name,
1259 view,
1260 routing,
1261 warnings,
1262 )?)
1263 } else if view.providers.is_empty() {
1264 None
1265 } else {
1266 Some(RoutingConfigV4::ordered_failover(
1267 view.providers.keys().cloned().collect(),
1268 ))
1269 };
1270
1271 Ok(ServiceViewV4 {
1272 default_profile: view.default_profile.clone(),
1273 profiles,
1274 providers: view.providers.clone(),
1275 routing,
1276 })
1277 }
1278
1279 pub fn migrate_v3_legacy_to_v4(
1280 legacy: &ProxyConfigV3Legacy,
1281 ) -> Result<ConfigV4MigrationReport> {
1282 let mut warnings = Vec::new();
1283 let mut config = ProxyConfigV4 {
1284 version: CURRENT_ROUTE_GRAPH_CONFIG_VERSION,
1285 codex: migrate_service_view("codex", &legacy.codex, &mut warnings)?,
1286 claude: migrate_service_view("claude", &legacy.claude, &mut warnings)?,
1287 retry: legacy.retry.clone(),
1288 notify: legacy.notify.clone(),
1289 default_service: legacy.default_service,
1290 ui: legacy.ui.clone(),
1291 };
1292 if let Some(routing) = config.codex.routing.as_mut() {
1293 routing.sync_compat_from_graph();
1294 }
1295 if let Some(routing) = config.claude.routing.as_mut() {
1296 routing.sync_compat_from_graph();
1297 }
1298 Ok(ConfigV4MigrationReport { config, warnings })
1299 }
1300}