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