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
633 Ok(ProxyConfigV2 {
634 version: 2,
635 codex: compile_service_view_v4("codex", &v4.codex)?,
636 claude: compile_service_view_v4("claude", &v4.claude)?,
637 retry: v4.retry.clone(),
638 notify: v4.notify.clone(),
639 default_service: v4.default_service,
640 relay_targets: v4.relay_targets.clone(),
641 ui: v4.ui.clone(),
642 })
643}
644
645pub fn compile_v4_to_runtime(v4: &ProxyConfigV4) -> Result<ProxyConfig> {
646 if !is_supported_route_graph_config_version(v4.version) {
647 anyhow::bail!("unsupported route graph config version: {}", v4.version);
648 }
649
650 Ok(ProxyConfig {
651 version: Some(v4.version),
652 codex: compile_service_view_v4_runtime("codex", &v4.codex)?,
653 claude: compile_service_view_v4_runtime("claude", &v4.claude)?,
654 retry: v4.retry.clone(),
655 notify: v4.notify.clone(),
656 default_service: v4.default_service,
657 relay_targets: v4.relay_targets.clone(),
658 ui: v4.ui.clone(),
659 })
660}
661
662fn endpoint_v2_to_v4(endpoint: &ProviderEndpointV2) -> ProviderEndpointV4 {
663 ProviderEndpointV4 {
664 base_url: endpoint.base_url.clone(),
665 continuity_domain: None,
666 enabled: endpoint.enabled,
667 priority: endpoint.priority,
668 tags: endpoint.tags.clone(),
669 supported_models: endpoint.supported_models.clone(),
670 model_mapping: endpoint.model_mapping.clone(),
671 limits: ProviderConcurrencyLimits::default(),
672 }
673}
674
675fn endpoint_can_be_inlined(endpoint_name: &str, endpoint: &ProviderEndpointV2) -> bool {
676 endpoint_name == "default"
677 && endpoint.enabled
678 && endpoint.priority == default_provider_endpoint_priority()
679 && endpoint.tags.is_empty()
680 && endpoint.supported_models.is_empty()
681 && endpoint.model_mapping.is_empty()
682}
683
684fn provider_v2_to_v4(provider: &ProviderConfigV2) -> ProviderConfigV4 {
685 let mut out = ProviderConfigV4 {
686 alias: provider.alias.clone(),
687 enabled: provider.enabled,
688 base_url: None,
689 continuity_domain: None,
690 auth: UpstreamAuth::default(),
691 inline_auth: provider.auth.clone(),
692 tags: provider.tags.clone(),
693 supported_models: provider.supported_models.clone(),
694 model_mapping: provider.model_mapping.clone(),
695 limits: ProviderConcurrencyLimits::default(),
696 endpoints: BTreeMap::new(),
697 };
698
699 if provider.endpoints.len() == 1
700 && let Some((endpoint_name, endpoint)) = provider.endpoints.iter().next()
701 && endpoint_can_be_inlined(endpoint_name, endpoint)
702 {
703 out.base_url = Some(endpoint.base_url.clone());
704 return out;
705 }
706
707 out.endpoints = provider
708 .endpoints
709 .iter()
710 .map(|(name, endpoint)| (name.clone(), endpoint_v2_to_v4(endpoint)))
711 .collect();
712 out
713}
714
715fn member_order_for_group(group: &GroupConfigV2) -> Vec<String> {
716 let mut members = group.members.iter().enumerate().collect::<Vec<_>>();
717 members.sort_by_key(|(idx, member)| (!member.preferred, *idx));
718 members
719 .into_iter()
720 .map(|(_, member)| member.provider.clone())
721 .collect()
722}
723
724fn ordered_selected_v2_group_names(view: &ServiceViewV2) -> Vec<String> {
725 let active = view.active_group.as_deref();
726 let mut groups = view.groups.iter().collect::<Vec<_>>();
727 groups.retain(|(name, group)| group.enabled || active == Some(name.as_str()));
728
729 if groups.is_empty() {
730 if let Some(active_name) = active
731 && view.groups.contains_key(active_name)
732 {
733 return vec![active_name.to_string()];
734 }
735
736 return view.groups.keys().min().cloned().into_iter().collect();
737 }
738
739 groups.sort_by(|(left_name, left), (right_name, right)| {
740 left.level
741 .cmp(&right.level)
742 .then_with(|| {
743 let left_is_active = active == Some(left_name.as_str());
744 let right_is_active = active == Some(right_name.as_str());
745 right_is_active.cmp(&left_is_active)
746 })
747 .then_with(|| left_name.cmp(right_name))
748 });
749 groups
750 .into_iter()
751 .map(|(group_name, _)| group_name.clone())
752 .collect()
753}
754
755fn routing_order_from_v2_groups(view: &ServiceViewV2) -> Vec<String> {
756 if view.groups.is_empty() {
757 return view.providers.keys().cloned().collect();
758 }
759
760 let mut seen = BTreeMap::<String, ()>::new();
761 let mut order = Vec::new();
762 for group_name in ordered_selected_v2_group_names(view) {
763 let Some(group) = view.groups.get(group_name.as_str()) else {
764 continue;
765 };
766 for provider in member_order_for_group(group) {
767 if seen.insert(provider.clone(), ()).is_none() {
768 order.push(provider);
769 }
770 }
771 }
772 order
773}
774
775fn enabled_endpoint_name_set(provider: &ProviderConfigV2) -> BTreeSet<String> {
776 provider
777 .endpoints
778 .iter()
779 .filter(|(_, endpoint)| endpoint.enabled)
780 .map(|(endpoint_name, _)| endpoint_name.clone())
781 .collect()
782}
783
784fn selected_enabled_endpoint_name_set(
785 provider: &ProviderConfigV2,
786 member: &GroupMemberRefV2,
787) -> BTreeSet<String> {
788 if member.endpoint_names.is_empty() {
789 return enabled_endpoint_name_set(provider);
790 }
791
792 member
793 .endpoint_names
794 .iter()
795 .filter(|endpoint_name| {
796 provider
797 .endpoints
798 .get(endpoint_name.as_str())
799 .is_some_and(|endpoint| endpoint.enabled)
800 })
801 .cloned()
802 .collect()
803}
804
805fn endpoint_scope_is_full_provider(provider: &ProviderConfigV2, member: &GroupMemberRefV2) -> bool {
806 selected_enabled_endpoint_name_set(provider, member) == enabled_endpoint_name_set(provider)
807}
808
809fn collect_service_v2_to_v4_warnings(
810 service_name: &str,
811 view: &ServiceViewV2,
812 warnings: &mut Vec<String>,
813) {
814 if !view.groups.is_empty() {
815 let selected_group_names = ordered_selected_v2_group_names(view);
816 if view.groups.len() > 1 {
817 let selected = if selected_group_names.is_empty() {
818 "<none>".to_string()
819 } else {
820 selected_group_names.join(", ")
821 };
822 warnings.push(format!(
823 "[{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.",
824 view.groups.len()
825 ));
826 }
827
828 let active = view.active_group.as_deref();
829 let omitted_disabled = view
830 .groups
831 .iter()
832 .filter(|(group_name, group)| !group.enabled && active != Some(group_name.as_str()))
833 .map(|(group_name, _)| group_name.clone())
834 .collect::<Vec<_>>();
835 if !omitted_disabled.is_empty() {
836 warnings.push(format!(
837 "[{service_name}] disabled inactive v2 stations/groups are omitted from the route graph: {}.",
838 omitted_disabled.join(", ")
839 ));
840 }
841
842 let included_disabled_active = selected_group_names
843 .iter()
844 .filter(|group_name| {
845 view.groups
846 .get(group_name.as_str())
847 .is_some_and(|group| !group.enabled && active == Some(group_name.as_str()))
848 })
849 .cloned()
850 .collect::<Vec<_>>();
851 if !included_disabled_active.is_empty() {
852 warnings.push(format!(
853 "[{service_name}] disabled active v2 stations/groups remain routeable in the route graph to match current runtime fallback behavior: {}.",
854 included_disabled_active.join(", ")
855 ));
856 }
857
858 let mut provider_occurrences = BTreeMap::<String, usize>::new();
859 for group_name in &selected_group_names {
860 let Some(group) = view.groups.get(group_name.as_str()) else {
861 continue;
862 };
863 for member in &group.members {
864 *provider_occurrences
865 .entry(member.provider.clone())
866 .or_insert(0) += 1;
867
868 let Some(provider) = view.providers.get(member.provider.as_str()) else {
869 continue;
870 };
871 if !endpoint_scope_is_full_provider(provider, member) {
872 let selected = selected_enabled_endpoint_name_set(provider, member)
873 .into_iter()
874 .collect::<Vec<_>>()
875 .join(", ");
876 let available = enabled_endpoint_name_set(provider)
877 .into_iter()
878 .collect::<Vec<_>>()
879 .join(", ");
880 warnings.push(format!(
881 "[{service_name}] v2 group '{group_name}' scopes provider '{}' to endpoint(s) [{}], but route graph leaves are provider-level; provider '{}' keeps all enabled endpoint(s) [{}].",
882 member.provider, selected, member.provider, available
883 ));
884 }
885 }
886 }
887
888 let repeated = provider_occurrences
889 .into_iter()
890 .filter(|(_, count)| *count > 1)
891 .map(|(provider, count)| format!("{provider} x{count}"))
892 .collect::<Vec<_>>();
893 if !repeated.is_empty() {
894 warnings.push(format!(
895 "[{service_name}] providers referenced multiple times in selected v2 groups are de-duplicated in the route graph: {}.",
896 repeated.join(", ")
897 ));
898 }
899 }
900
901 let migrated_view = migrate_service_v2_to_v4(view);
902 collect_route_graph_affinity_migration_warnings(service_name, &migrated_view, warnings);
903
904 let cleared_profiles = view
905 .profiles
906 .iter()
907 .filter(|(_, profile)| {
908 profile
909 .station
910 .as_deref()
911 .map(str::trim)
912 .is_some_and(|station| !station.is_empty())
913 })
914 .map(|(profile_name, profile)| {
915 format!(
916 "{} -> {}",
917 profile_name,
918 profile.station.as_deref().unwrap_or_default()
919 )
920 })
921 .collect::<Vec<_>>();
922 if !cleared_profiles.is_empty() {
923 warnings.push(format!(
924 "[{service_name}] profile station bindings are cleared because route graph routing owns active provider selection: {}.",
925 cleared_profiles.join(", ")
926 ));
927 }
928}
929
930fn migrate_service_v2_to_v4(view: &ServiceViewV2) -> ServiceViewV4 {
931 let mut profiles = view.profiles.clone();
932 for profile in profiles.values_mut() {
933 if profile
934 .station
935 .as_deref()
936 .map(str::trim)
937 .is_some_and(|station| !station.is_empty())
938 {
939 profile.station = None;
940 }
941 }
942
943 let providers = view
944 .providers
945 .iter()
946 .map(|(name, provider)| (name.clone(), provider_v2_to_v4(provider)))
947 .collect::<BTreeMap<_, _>>();
948
949 let order = routing_order_from_v2_groups(view);
950 let routing = if providers.is_empty() {
951 None
952 } else {
953 Some(RoutingConfigV4::ordered_failover(order))
954 };
955
956 ServiceViewV4 {
957 default_profile: view.default_profile.clone(),
958 profiles,
959 providers,
960 routing,
961 }
962}
963
964pub fn migrate_v2_to_v4(v2: &ProxyConfigV2) -> Result<ProxyConfigV4> {
965 Ok(migrate_v2_to_v4_with_report(v2)?.config)
966}
967
968pub fn migrate_v2_to_v4_with_report(v2: &ProxyConfigV2) -> Result<ConfigV4MigrationReport> {
969 let compact = compact_v2_config(v2)?;
970 let mut warnings = Vec::new();
971 collect_service_v2_to_v4_warnings("codex", &compact.codex, &mut warnings);
972 collect_service_v2_to_v4_warnings("claude", &compact.claude, &mut warnings);
973
974 let config = ProxyConfigV4 {
975 version: CURRENT_ROUTE_GRAPH_CONFIG_VERSION,
976 codex: migrate_service_v2_to_v4(&compact.codex),
977 claude: migrate_service_v2_to_v4(&compact.claude),
978 retry: compact.retry,
979 notify: compact.notify,
980 default_service: compact.default_service,
981 relay_targets: compact.relay_targets,
982 ui: compact.ui,
983 };
984
985 Ok(ConfigV4MigrationReport { config, warnings })
986}
987
988pub fn migrate_legacy_to_v4(old: &ProxyConfig) -> Result<ProxyConfigV4> {
989 Ok(migrate_legacy_to_v4_with_report(old)?.config)
990}
991
992pub fn migrate_legacy_to_v4_with_report(old: &ProxyConfig) -> Result<ConfigV4MigrationReport> {
993 migrate_v2_to_v4_with_report(&migrate_legacy_to_v2(old))
994}
995
996pub mod legacy {
997 use super::*;
998
999 fn default_legacy_proxy_config_version() -> u32 {
1000 3
1001 }
1002
1003 #[derive(Debug, Clone, Serialize, Deserialize)]
1004 pub struct ProxyConfigV3Legacy {
1005 #[serde(default = "default_legacy_proxy_config_version")]
1006 pub version: u32,
1007 #[serde(default)]
1008 pub codex: ServiceViewV3Legacy,
1009 #[serde(default)]
1010 pub claude: ServiceViewV3Legacy,
1011 #[serde(default)]
1012 pub retry: RetryConfig,
1013 #[serde(default)]
1014 pub notify: NotifyConfig,
1015 #[serde(default)]
1016 pub default_service: Option<ServiceKind>,
1017 #[serde(default)]
1018 pub ui: UiConfig,
1019 }
1020
1021 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
1022 pub struct ServiceViewV3Legacy {
1023 #[serde(default, skip_serializing_if = "Option::is_none")]
1024 pub default_profile: Option<String>,
1025 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1026 pub profiles: BTreeMap<String, ServiceControlProfile>,
1027 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1028 pub providers: BTreeMap<String, ProviderConfigV4>,
1029 #[serde(default, skip_serializing_if = "Option::is_none")]
1030 pub routing: Option<RoutingConfigV3Legacy>,
1031 }
1032
1033 #[derive(Debug, Clone, Serialize, Deserialize)]
1034 pub struct RoutingConfigV3Legacy {
1035 #[serde(default = "default_legacy_routing_policy")]
1036 pub policy: RoutingPolicyV3Legacy,
1037 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1038 pub order: Vec<String>,
1039 #[serde(default, skip_serializing_if = "Option::is_none")]
1040 pub target: Option<String>,
1041 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1042 pub prefer_tags: Vec<BTreeMap<String, String>>,
1043 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1044 pub chain: Vec<String>,
1045 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1046 pub pools: BTreeMap<String, RoutingPoolV4>,
1047 #[serde(default = "default_legacy_on_exhausted")]
1048 pub on_exhausted: RoutingExhaustedActionV3Legacy,
1049 }
1050
1051 impl Default for RoutingConfigV3Legacy {
1052 fn default() -> Self {
1053 Self {
1054 policy: default_legacy_routing_policy(),
1055 order: Vec::new(),
1056 target: None,
1057 prefer_tags: Vec::new(),
1058 chain: Vec::new(),
1059 pools: BTreeMap::new(),
1060 on_exhausted: default_legacy_on_exhausted(),
1061 }
1062 }
1063 }
1064
1065 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1066 #[serde(rename_all = "kebab-case")]
1067 pub enum RoutingPolicyV3Legacy {
1068 ManualSticky,
1069 OrderedFailover,
1070 TagPreferred,
1071 PoolFallback,
1072 }
1073
1074 fn default_legacy_routing_policy() -> RoutingPolicyV3Legacy {
1075 RoutingPolicyV3Legacy::OrderedFailover
1076 }
1077
1078 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1079 #[serde(rename_all = "kebab-case")]
1080 pub enum RoutingExhaustedActionV3Legacy {
1081 Continue,
1082 Stop,
1083 }
1084
1085 fn default_legacy_on_exhausted() -> RoutingExhaustedActionV3Legacy {
1086 RoutingExhaustedActionV3Legacy::Continue
1087 }
1088
1089 fn safe_route_name(
1090 candidate: &str,
1091 used: &mut BTreeSet<String>,
1092 fallback_suffix: &str,
1093 ) -> String {
1094 let base = if candidate.trim().is_empty() {
1095 "route".to_string()
1096 } else {
1097 candidate.trim().to_string()
1098 };
1099 let mut name = base.clone();
1100 if used.insert(name.clone()) {
1101 return name;
1102 }
1103 name = format!("{base}_{fallback_suffix}");
1104 let mut idx = 2usize;
1105 while !used.insert(name.clone()) {
1106 name = format!("{base}_{fallback_suffix}_{idx}");
1107 idx += 1;
1108 }
1109 name
1110 }
1111
1112 fn route_from_legacy_routing(
1113 service_name: &str,
1114 view: &ServiceViewV3Legacy,
1115 routing: &RoutingConfigV3Legacy,
1116 warnings: &mut Vec<String>,
1117 ) -> Result<RoutingConfigV4> {
1118 let default_children = || view.providers.keys().cloned().collect::<Vec<_>>();
1119 let mut routes = BTreeMap::new();
1120 let entry = "main".to_string();
1121
1122 let mut root = RoutingNodeV4 {
1123 on_exhausted: match routing.on_exhausted {
1124 RoutingExhaustedActionV3Legacy::Continue => RoutingExhaustedActionV4::Continue,
1125 RoutingExhaustedActionV3Legacy::Stop => RoutingExhaustedActionV4::Stop,
1126 },
1127 ..RoutingNodeV4::default()
1128 };
1129
1130 match routing.policy {
1131 RoutingPolicyV3Legacy::ManualSticky => {
1132 root.strategy = RoutingPolicyV4::ManualSticky;
1133 root.target = routing
1134 .target
1135 .clone()
1136 .or_else(|| routing.order.first().cloned())
1137 .or_else(|| view.providers.keys().next().cloned());
1138 root.children = if routing.order.is_empty() {
1139 root.target
1140 .as_ref()
1141 .map(|target| vec![target.clone()])
1142 .unwrap_or_else(default_children)
1143 } else {
1144 routing.order.clone()
1145 };
1146 }
1147 RoutingPolicyV3Legacy::OrderedFailover => {
1148 root.strategy = RoutingPolicyV4::OrderedFailover;
1149 root.children = if routing.order.is_empty() {
1150 default_children()
1151 } else {
1152 routing.order.clone()
1153 };
1154 }
1155 RoutingPolicyV3Legacy::TagPreferred => {
1156 root.strategy = RoutingPolicyV4::TagPreferred;
1157 root.children = if routing.order.is_empty() {
1158 default_children()
1159 } else {
1160 routing.order.clone()
1161 };
1162 root.prefer_tags = routing.prefer_tags.clone();
1163 }
1164 RoutingPolicyV3Legacy::PoolFallback => {
1165 root.strategy = RoutingPolicyV4::OrderedFailover;
1166 let chain = if routing.chain.is_empty() {
1167 routing.pools.keys().cloned().collect::<Vec<_>>()
1168 } else {
1169 routing.chain.clone()
1170 };
1171 if chain.is_empty() {
1172 anyhow::bail!(
1173 "[{service_name}] legacy pool-fallback routing requires at least one pool"
1174 );
1175 }
1176 let mut used = view.providers.keys().cloned().collect::<BTreeSet<_>>();
1177 used.insert(entry.clone());
1178 let mut root_children = Vec::new();
1179 for (idx, pool_name) in chain.iter().enumerate() {
1180 let Some(pool) = routing.pools.get(pool_name.as_str()) else {
1181 anyhow::bail!(
1182 "[{service_name}] legacy routing references missing pool '{pool_name}'"
1183 );
1184 };
1185 if pool.providers.is_empty() {
1186 anyhow::bail!(
1187 "[{service_name}] legacy pool '{pool_name}' must define at least one provider"
1188 );
1189 }
1190 let route_name = safe_route_name(
1191 pool_name,
1192 &mut used,
1193 if idx == 0 { "pool" } else { "branch" },
1194 );
1195 if route_name != *pool_name {
1196 warnings.push(format!(
1197 "[{service_name}] legacy pool '{pool_name}' is renamed to route node '{route_name}' in v4"
1198 ));
1199 }
1200 routes.insert(
1201 route_name.clone(),
1202 RoutingNodeV4 {
1203 strategy: RoutingPolicyV4::OrderedFailover,
1204 children: pool.providers.clone(),
1205 target: None,
1206 prefer_tags: Vec::new(),
1207 on_exhausted: RoutingExhaustedActionV4::Continue,
1208 metadata: BTreeMap::new(),
1209 when: None,
1210 then: None,
1211 default_route: None,
1212 },
1213 );
1214 root_children.push(route_name);
1215 }
1216 if matches!(routing.on_exhausted, RoutingExhaustedActionV3Legacy::Stop) {
1217 root_children.truncate(1);
1218 }
1219 root.children = root_children;
1220 }
1221 }
1222
1223 if root.children.is_empty() {
1224 root.children = default_children();
1225 }
1226 routes.insert(entry.clone(), root);
1227 let mut routing = RoutingConfigV4 {
1228 entry,
1229 routes,
1230 ..RoutingConfigV4::default()
1231 };
1232 routing.normalize_authoring();
1233 Ok(routing)
1234 }
1235
1236 fn migrate_service_view(
1237 service_name: &str,
1238 view: &ServiceViewV3Legacy,
1239 warnings: &mut Vec<String>,
1240 ) -> Result<ServiceViewV4> {
1241 let mut profiles = view.profiles.clone();
1242 for profile in profiles.values_mut() {
1243 if profile
1244 .station
1245 .as_deref()
1246 .map(str::trim)
1247 .is_some_and(|station| !station.is_empty())
1248 {
1249 profile.station = None;
1250 }
1251 }
1252
1253 let routing = if let Some(routing) = view.routing.as_ref() {
1254 Some(route_from_legacy_routing(
1255 service_name,
1256 view,
1257 routing,
1258 warnings,
1259 )?)
1260 } else if view.providers.is_empty() {
1261 None
1262 } else {
1263 Some(RoutingConfigV4::ordered_failover(
1264 view.providers.keys().cloned().collect(),
1265 ))
1266 };
1267
1268 Ok(ServiceViewV4 {
1269 default_profile: view.default_profile.clone(),
1270 profiles,
1271 providers: view.providers.clone(),
1272 routing,
1273 })
1274 }
1275
1276 pub fn migrate_v3_legacy_to_v4(
1277 legacy: &ProxyConfigV3Legacy,
1278 ) -> Result<ConfigV4MigrationReport> {
1279 let mut warnings = Vec::new();
1280 let mut config = ProxyConfigV4 {
1281 version: CURRENT_ROUTE_GRAPH_CONFIG_VERSION,
1282 codex: migrate_service_view("codex", &legacy.codex, &mut warnings)?,
1283 claude: migrate_service_view("claude", &legacy.claude, &mut warnings)?,
1284 retry: legacy.retry.clone(),
1285 notify: legacy.notify.clone(),
1286 default_service: legacy.default_service,
1287 relay_targets: BTreeMap::new(),
1288 ui: legacy.ui.clone(),
1289 };
1290 config.normalize_routing_authoring();
1291 Ok(ConfigV4MigrationReport { config, warnings })
1292 }
1293}