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