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