Skip to main content

codex_helper_core/
config_v4.rs

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}