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