Skip to main content

codex_helper_core/
config_v4.rs

1use super::*;
2use crate::routing_ir::{RouteCandidate, compile_v4_route_plan_template_for_compat_runtime};
3use std::collections::BTreeSet;
4
5const ROUTING_STATION_NAME: &str = "routing";
6
7#[derive(Debug, Clone)]
8pub struct ConfigV4MigrationReport {
9    pub config: ProxyConfigV4,
10    pub warnings: Vec<String>,
11}
12
13fn merge_auth(block: &UpstreamAuth, inline: &UpstreamAuth) -> UpstreamAuth {
14    UpstreamAuth {
15        auth_token: inline
16            .auth_token
17            .clone()
18            .or_else(|| block.auth_token.clone()),
19        auth_token_env: inline
20            .auth_token_env
21            .clone()
22            .or_else(|| block.auth_token_env.clone()),
23        api_key: inline.api_key.clone().or_else(|| block.api_key.clone()),
24        api_key_env: inline
25            .api_key_env
26            .clone()
27            .or_else(|| block.api_key_env.clone()),
28    }
29}
30
31fn remove_import_metadata_tags(tags: &mut BTreeMap<String, String>) {
32    tags.remove("provider_id");
33    tags.remove("requires_openai_auth");
34    if tags
35        .get("source")
36        .is_some_and(|value| value == "codex-config")
37    {
38        tags.remove("source");
39    }
40}
41
42fn compact_service_view_v4_for_write(view: &mut ServiceViewV4) {
43    for provider in view.providers.values_mut() {
44        remove_import_metadata_tags(&mut provider.tags);
45        for endpoint in provider.endpoints.values_mut() {
46            remove_import_metadata_tags(&mut endpoint.tags);
47        }
48    }
49    if let Some(routing) = view.routing.as_mut() {
50        if routing.routes.is_empty() {
51            routing.sync_graph_from_compat();
52        }
53        routing.sync_compat_from_graph();
54    }
55}
56
57pub fn collect_route_graph_affinity_migration_warnings(
58    service_name: &str,
59    view: &ServiceViewV4,
60    warnings: &mut Vec<String>,
61) {
62    let Some(routing) = view.routing.as_ref() else {
63        return;
64    };
65
66    if routing.affinity_policy == RoutingAffinityPolicyV5::PreferredGroup
67        && route_graph_has_fallback_choices(routing)
68    {
69        warnings.push(format!(
70            "[{service_name}] route graph affinity now defaults to fallback-sticky for safer session/account continuity; this migrated config keeps its explicit preferred-group policy. Set \"fallback-sticky\" if you want the new default."
71        ));
72    }
73}
74
75fn route_graph_has_fallback_choices(routing: &RoutingConfigV4) -> bool {
76    routing.order.len() > 1
77        || routing.chain.len() > 1
78        || routing.routes.values().any(|node| {
79            node.children.len() > 1 || (node.target.is_some() && !node.children.is_empty())
80        })
81}
82
83pub fn compact_v4_config_for_write(cfg: &mut ProxyConfigV4) {
84    compact_service_view_v4_for_write(&mut cfg.codex);
85    compact_service_view_v4_for_write(&mut cfg.claude);
86}
87
88fn provider_v4_to_v2(
89    service_name: &str,
90    provider_name: &str,
91    provider: &ProviderConfigV4,
92) -> Result<ProviderConfigV2> {
93    validate_provider_concurrency_limits(service_name, provider_name, None, &provider.limits)?;
94    let mut endpoints = BTreeMap::new();
95    if let Some(base_url) = provider
96        .base_url
97        .as_deref()
98        .map(str::trim)
99        .filter(|value| !value.is_empty())
100    {
101        if provider.endpoints.contains_key("default") {
102            anyhow::bail!(
103                "[{service_name}] provider '{provider_name}' cannot define both base_url and endpoints.default"
104            );
105        }
106        endpoints.insert(
107            "default".to_string(),
108            ProviderEndpointV2 {
109                base_url: base_url.to_string(),
110                enabled: true,
111                priority: default_provider_endpoint_priority(),
112                tags: BTreeMap::from([("endpoint_id".to_string(), "default".to_string())]),
113                supported_models: BTreeMap::new(),
114                model_mapping: BTreeMap::new(),
115            },
116        );
117    }
118
119    for (endpoint_name, endpoint) in &provider.endpoints {
120        validate_provider_concurrency_limits(
121            service_name,
122            provider_name,
123            Some(endpoint_name),
124            &endpoint.limits,
125        )?;
126        if endpoint.base_url.trim().is_empty() {
127            anyhow::bail!(
128                "[{service_name}] provider '{provider_name}' endpoint '{endpoint_name}' has an empty base_url"
129            );
130        }
131        endpoints.insert(
132            endpoint_name.clone(),
133            ProviderEndpointV2 {
134                base_url: endpoint.base_url.trim().to_string(),
135                enabled: endpoint.enabled,
136                priority: endpoint.priority,
137                tags: {
138                    let mut tags = endpoint.tags.clone();
139                    tags.insert("endpoint_id".to_string(), endpoint_name.clone());
140                    tags
141                },
142                supported_models: endpoint.supported_models.clone(),
143                model_mapping: endpoint.model_mapping.clone(),
144            },
145        );
146    }
147
148    if endpoints.is_empty() {
149        anyhow::bail!("[{service_name}] provider '{provider_name}' has no base_url or endpoints");
150    }
151
152    let mut tags = provider.tags.clone();
153    tags.insert("provider_id".to_string(), provider_name.to_string());
154
155    Ok(ProviderConfigV2 {
156        alias: provider.alias.clone(),
157        enabled: provider.enabled,
158        auth: merge_auth(&provider.auth, &provider.inline_auth),
159        tags,
160        supported_models: provider.supported_models.clone(),
161        model_mapping: provider.model_mapping.clone(),
162        endpoints,
163    })
164}
165
166fn btree_string_map_to_hash_map(values: &BTreeMap<String, String>) -> HashMap<String, String> {
167    values
168        .iter()
169        .map(|(key, value)| (key.clone(), value.clone()))
170        .collect()
171}
172
173fn btree_bool_map_to_hash_map(values: &BTreeMap<String, bool>) -> HashMap<String, bool> {
174    values
175        .iter()
176        .map(|(key, value)| (key.clone(), *value))
177        .collect()
178}
179
180fn validate_runtime_provider_v4_shape(
181    service_name: &str,
182    provider_name: &str,
183    provider: &ProviderConfigV4,
184) -> Result<()> {
185    validate_provider_concurrency_limits(service_name, provider_name, None, &provider.limits)?;
186    let mut has_endpoint = false;
187    if let Some(_base_url) = provider
188        .base_url
189        .as_deref()
190        .map(str::trim)
191        .filter(|value| !value.is_empty())
192    {
193        if provider.endpoints.contains_key("default") {
194            anyhow::bail!(
195                "[{service_name}] provider '{provider_name}' cannot define both base_url and endpoints.default"
196            );
197        }
198        has_endpoint = true;
199    }
200
201    for (endpoint_name, endpoint) in &provider.endpoints {
202        validate_provider_concurrency_limits(
203            service_name,
204            provider_name,
205            Some(endpoint_name),
206            &endpoint.limits,
207        )?;
208        if endpoint.base_url.trim().is_empty() {
209            anyhow::bail!(
210                "[{service_name}] provider '{provider_name}' endpoint '{endpoint_name}' has an empty base_url"
211            );
212        }
213        has_endpoint = true;
214    }
215
216    if !has_endpoint {
217        anyhow::bail!("[{service_name}] provider '{provider_name}' has no base_url or endpoints");
218    }
219
220    Ok(())
221}
222
223fn validate_provider_concurrency_limits(
224    service_name: &str,
225    provider_name: &str,
226    endpoint_name: Option<&str>,
227    limits: &ProviderConcurrencyLimits,
228) -> Result<()> {
229    if limits.max_concurrent_requests == Some(0) {
230        if let Some(endpoint_name) = endpoint_name {
231            anyhow::bail!(
232                "[{service_name}] provider '{provider_name}' endpoint '{endpoint_name}' limits.max_concurrent_requests must be greater than 0"
233            );
234        }
235        anyhow::bail!(
236            "[{service_name}] provider '{provider_name}' limits.max_concurrent_requests must be greater than 0"
237        );
238    }
239    Ok(())
240}
241
242fn validate_service_view_v4_runtime_shape(service_name: &str, view: &ServiceViewV4) -> Result<()> {
243    for (provider_name, provider) in &view.providers {
244        validate_runtime_provider_v4_shape(service_name, provider_name, provider)?;
245    }
246    Ok(())
247}
248
249fn route_candidate_to_compat_upstream(candidate: &RouteCandidate) -> UpstreamConfig {
250    let mut tags = btree_string_map_to_hash_map(&candidate.tags);
251    tags.insert("endpoint_id".to_string(), candidate.endpoint_id.clone());
252
253    UpstreamConfig {
254        base_url: candidate.base_url.clone(),
255        auth: candidate.auth.clone(),
256        tags,
257        supported_models: btree_bool_map_to_hash_map(&candidate.supported_models),
258        model_mapping: btree_string_map_to_hash_map(&candidate.model_mapping),
259    }
260}
261
262fn provider_order_from_routing(
263    service_name: &str,
264    view: &ServiceViewV4,
265    routing: &RoutingConfigV4,
266) -> Result<Vec<String>> {
267    if view.providers.is_empty() && routing.routes.is_empty() {
268        return Ok(Vec::new());
269    }
270
271    if routing.routes.is_empty() {
272        return Ok(view.providers.keys().cloned().collect());
273    }
274
275    for route_name in routing.routes.keys() {
276        if view.providers.contains_key(route_name.as_str()) {
277            anyhow::bail!(
278                "[{service_name}] route node '{route_name}' conflicts with a provider of the same name"
279            );
280        }
281    }
282
283    let mut stack = Vec::new();
284    let order = expand_route_node(
285        service_name,
286        view,
287        routing,
288        routing.entry.as_str(),
289        &mut stack,
290    )?;
291    ensure_unique_route_order(service_name, &order)?;
292    Ok(order)
293}
294
295fn ensure_unique_route_order(service_name: &str, order: &[String]) -> Result<()> {
296    let mut seen = BTreeSet::new();
297    for provider_name in order {
298        if !seen.insert(provider_name.as_str()) {
299            anyhow::bail!(
300                "[{service_name}] routing graph expands provider '{provider_name}' more than once; duplicate leaves are ambiguous"
301            );
302        }
303    }
304    Ok(())
305}
306
307fn expand_route_ref(
308    service_name: &str,
309    view: &ServiceViewV4,
310    routing: &RoutingConfigV4,
311    child_name: &str,
312    stack: &mut Vec<String>,
313) -> Result<Vec<String>> {
314    if view.providers.contains_key(child_name) {
315        return Ok(vec![child_name.to_string()]);
316    }
317
318    expand_route_node(service_name, view, routing, child_name, stack)
319}
320
321fn expand_route_node(
322    service_name: &str,
323    view: &ServiceViewV4,
324    routing: &RoutingConfigV4,
325    route_name: &str,
326    stack: &mut Vec<String>,
327) -> Result<Vec<String>> {
328    if stack.iter().any(|name| name == route_name) {
329        let mut cycle = stack.clone();
330        cycle.push(route_name.to_string());
331        anyhow::bail!(
332            "[{service_name}] routing graph has a cycle: {}",
333            cycle.join(" -> ")
334        );
335    }
336
337    let Some(node) = routing.routes.get(route_name) else {
338        anyhow::bail!(
339            "[{service_name}] routing entry references missing route node '{route_name}'"
340        );
341    };
342
343    stack.push(route_name.to_string());
344    let result = match node.strategy {
345        RoutingPolicyV4::OrderedFailover => expand_ordered_route_children(
346            service_name,
347            view,
348            routing,
349            route_name,
350            &node.children,
351            stack,
352        ),
353        RoutingPolicyV4::ManualSticky => {
354            let target = node
355                .target
356                .as_deref()
357                .or_else(|| node.children.first().map(String::as_str))
358                .with_context(|| {
359                    format!("[{service_name}] manual-sticky route '{route_name}' requires target")
360                })?;
361            if let Some(provider) = view.providers.get(target)
362                && !provider.enabled
363            {
364                anyhow::bail!(
365                    "[{service_name}] manual-sticky route '{route_name}' targets disabled provider '{target}'"
366                );
367            }
368            expand_route_ref(service_name, view, routing, target, stack)
369        }
370        RoutingPolicyV4::TagPreferred => {
371            expand_tag_preferred_route(service_name, view, routing, route_name, node, stack)
372        }
373        RoutingPolicyV4::Conditional => {
374            expand_conditional_route_compat(service_name, view, routing, route_name, node, stack)
375        }
376    };
377    stack.pop();
378    result
379}
380
381fn expand_ordered_route_children(
382    service_name: &str,
383    view: &ServiceViewV4,
384    routing: &RoutingConfigV4,
385    route_name: &str,
386    children: &[String],
387    stack: &mut Vec<String>,
388) -> Result<Vec<String>> {
389    if children.is_empty() {
390        anyhow::bail!(
391            "[{service_name}] ordered-failover route '{route_name}' requires at least one child"
392        );
393    }
394
395    let mut order = Vec::new();
396    for child_name in children {
397        order.extend(expand_route_ref(
398            service_name,
399            view,
400            routing,
401            child_name.as_str(),
402            stack,
403        )?);
404    }
405    Ok(order)
406}
407
408fn child_route_matches_any_filter(
409    view: &ServiceViewV4,
410    provider_names: &[String],
411    filters: &[BTreeMap<String, String>],
412) -> bool {
413    provider_names.iter().any(|provider_name| {
414        view.providers
415            .get(provider_name.as_str())
416            .is_some_and(|provider| provider_matches_any_filter(&provider.tags, filters))
417    })
418}
419
420fn expand_tag_preferred_route(
421    service_name: &str,
422    view: &ServiceViewV4,
423    routing: &RoutingConfigV4,
424    route_name: &str,
425    node: &RoutingNodeV4,
426    stack: &mut Vec<String>,
427) -> Result<Vec<String>> {
428    if node.children.is_empty() {
429        anyhow::bail!(
430            "[{service_name}] tag-preferred route '{route_name}' requires at least one child"
431        );
432    }
433    if node.prefer_tags.is_empty() {
434        anyhow::bail!("[{service_name}] tag-preferred route '{route_name}' requires prefer_tags");
435    }
436
437    let mut preferred = Vec::new();
438    let mut fallback = Vec::new();
439    for child_name in &node.children {
440        let child_order =
441            expand_route_ref(service_name, view, routing, child_name.as_str(), stack)?;
442        if child_route_matches_any_filter(view, &child_order, &node.prefer_tags) {
443            preferred.extend(child_order);
444        } else {
445            fallback.extend(child_order);
446        }
447    }
448
449    if matches!(node.on_exhausted, RoutingExhaustedActionV4::Stop) {
450        if preferred.is_empty() {
451            anyhow::bail!(
452                "[{service_name}] tag-preferred route '{route_name}' with on_exhausted = 'stop' matched no providers"
453            );
454        }
455        return Ok(preferred);
456    }
457
458    preferred.extend(fallback);
459    Ok(preferred)
460}
461
462fn expand_conditional_route_compat(
463    service_name: &str,
464    view: &ServiceViewV4,
465    routing: &RoutingConfigV4,
466    route_name: &str,
467    node: &RoutingNodeV4,
468    stack: &mut Vec<String>,
469) -> Result<Vec<String>> {
470    let condition = node.when.as_ref().with_context(|| {
471        format!("[{service_name}] conditional route '{route_name}' requires when")
472    })?;
473    if condition.is_empty() {
474        anyhow::bail!(
475            "[{service_name}] conditional route '{route_name}' requires at least one condition field"
476        );
477    }
478
479    let then = node
480        .then
481        .as_deref()
482        .map(str::trim)
483        .filter(|value| !value.is_empty())
484        .with_context(|| {
485            format!("[{service_name}] conditional route '{route_name}' requires then")
486        })?;
487    let default_route = node
488        .default_route
489        .as_deref()
490        .map(str::trim)
491        .filter(|value| !value.is_empty())
492        .with_context(|| {
493            format!("[{service_name}] conditional route '{route_name}' requires default")
494        })?;
495
496    let mut order = Vec::new();
497    order.extend(expand_route_ref(service_name, view, routing, then, stack)?);
498    order.extend(expand_route_ref(
499        service_name,
500        view,
501        routing,
502        default_route,
503        stack,
504    )?);
505    dedupe_preserving_order(&mut order);
506    Ok(order)
507}
508
509fn dedupe_preserving_order(values: &mut Vec<String>) {
510    let mut seen = BTreeSet::new();
511    values.retain(|value| seen.insert(value.clone()));
512}
513
514fn provider_matches_any_filter(
515    tags: &BTreeMap<String, String>,
516    filters: &[BTreeMap<String, String>],
517) -> bool {
518    filters.iter().any(|filter| {
519        !filter.is_empty()
520            && filter
521                .iter()
522                .all(|(key, value)| tags.get(key) == Some(value))
523    })
524}
525
526fn default_routing_for_view(view: &ServiceViewV4) -> RoutingConfigV4 {
527    if view.providers.is_empty() {
528        RoutingConfigV4::default()
529    } else {
530        RoutingConfigV4::ordered_failover(view.providers.keys().cloned().collect())
531    }
532}
533
534pub fn effective_v4_routing(view: &ServiceViewV4) -> RoutingConfigV4 {
535    let mut routing = view
536        .routing
537        .clone()
538        .unwrap_or_else(|| default_routing_for_view(view));
539    if routing.routes.is_empty() {
540        routing.sync_graph_from_compat();
541    }
542    routing.sync_compat_from_graph();
543    routing
544}
545
546pub fn resolved_v4_provider_order(service_name: &str, view: &ServiceViewV4) -> Result<Vec<String>> {
547    let routing = effective_v4_routing(view);
548    provider_order_from_routing(service_name, view, &routing)
549}
550
551fn compile_service_view_v4(service_name: &str, view: &ServiceViewV4) -> Result<ServiceViewV2> {
552    let providers = view
553        .providers
554        .iter()
555        .map(|(provider_name, provider)| {
556            provider_v4_to_v2(service_name, provider_name, provider)
557                .map(|provider| (provider_name.clone(), provider))
558        })
559        .collect::<Result<BTreeMap<_, _>>>()?;
560
561    let route_order = resolved_v4_provider_order(service_name, view)?;
562    let groups = if route_order.is_empty() {
563        BTreeMap::new()
564    } else {
565        BTreeMap::from([(
566            ROUTING_STATION_NAME.to_string(),
567            GroupConfigV2 {
568                alias: Some("active routing".to_string()),
569                enabled: true,
570                level: default_service_config_level(),
571                members: route_order
572                    .into_iter()
573                    .map(|provider| GroupMemberRefV2 {
574                        provider,
575                        endpoint_names: Vec::new(),
576                        preferred: false,
577                    })
578                    .collect(),
579            },
580        )])
581    };
582
583    Ok(ServiceViewV2 {
584        active_group: if groups.is_empty() {
585            None
586        } else {
587            Some(ROUTING_STATION_NAME.to_string())
588        },
589        default_profile: view.default_profile.clone(),
590        profiles: view.profiles.clone(),
591        providers,
592        groups,
593    })
594}
595
596fn compile_service_view_v4_runtime(
597    service_name: &str,
598    view: &ServiceViewV4,
599) -> Result<ServiceConfigManager> {
600    validate_service_view_v4_runtime_shape(service_name, view)?;
601    let template = compile_v4_route_plan_template_for_compat_runtime(service_name, view)?;
602    let mut configs = HashMap::new();
603    if !template.expanded_provider_order.is_empty() {
604        configs.insert(
605            ROUTING_STATION_NAME.to_string(),
606            ServiceConfig {
607                name: ROUTING_STATION_NAME.to_string(),
608                alias: Some("active routing".to_string()),
609                enabled: true,
610                level: default_service_config_level(),
611                upstreams: template
612                    .candidates
613                    .iter()
614                    .map(route_candidate_to_compat_upstream)
615                    .collect(),
616            },
617        );
618    }
619
620    let mgr = ServiceConfigManager {
621        active: if configs.is_empty() {
622            None
623        } else {
624            Some(ROUTING_STATION_NAME.to_string())
625        },
626        default_profile: view.default_profile.clone(),
627        profiles: view.profiles.clone(),
628        configs,
629    };
630    validate_service_profiles(service_name, &mgr)?;
631    Ok(mgr)
632}
633
634pub fn compile_v4_to_v2(v4: &ProxyConfigV4) -> Result<ProxyConfigV2> {
635    if !is_supported_route_graph_config_version(v4.version) {
636        anyhow::bail!("unsupported route graph config version: {}", v4.version);
637    }
638
639    Ok(ProxyConfigV2 {
640        version: 2,
641        codex: compile_service_view_v4("codex", &v4.codex)?,
642        claude: compile_service_view_v4("claude", &v4.claude)?,
643        retry: v4.retry.clone(),
644        notify: v4.notify.clone(),
645        default_service: v4.default_service,
646        ui: v4.ui.clone(),
647    })
648}
649
650pub fn compile_v4_to_runtime(v4: &ProxyConfigV4) -> Result<ProxyConfig> {
651    if !is_supported_route_graph_config_version(v4.version) {
652        anyhow::bail!("unsupported route graph config version: {}", v4.version);
653    }
654
655    Ok(ProxyConfig {
656        version: Some(v4.version),
657        codex: compile_service_view_v4_runtime("codex", &v4.codex)?,
658        claude: compile_service_view_v4_runtime("claude", &v4.claude)?,
659        retry: v4.retry.clone(),
660        notify: v4.notify.clone(),
661        default_service: v4.default_service,
662        ui: v4.ui.clone(),
663    })
664}
665
666fn endpoint_v2_to_v4(endpoint: &ProviderEndpointV2) -> ProviderEndpointV4 {
667    ProviderEndpointV4 {
668        base_url: endpoint.base_url.clone(),
669        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        profiles,
963        providers,
964        routing,
965    }
966}
967
968pub fn migrate_v2_to_v4(v2: &ProxyConfigV2) -> Result<ProxyConfigV4> {
969    Ok(migrate_v2_to_v4_with_report(v2)?.config)
970}
971
972pub fn migrate_v2_to_v4_with_report(v2: &ProxyConfigV2) -> Result<ConfigV4MigrationReport> {
973    let compact = compact_v2_config(v2)?;
974    let mut warnings = Vec::new();
975    collect_service_v2_to_v4_warnings("codex", &compact.codex, &mut warnings);
976    collect_service_v2_to_v4_warnings("claude", &compact.claude, &mut warnings);
977
978    let config = ProxyConfigV4 {
979        version: CURRENT_ROUTE_GRAPH_CONFIG_VERSION,
980        codex: migrate_service_v2_to_v4(&compact.codex),
981        claude: migrate_service_v2_to_v4(&compact.claude),
982        retry: compact.retry,
983        notify: compact.notify,
984        default_service: compact.default_service,
985        ui: compact.ui,
986    };
987
988    Ok(ConfigV4MigrationReport { config, warnings })
989}
990
991pub fn migrate_legacy_to_v4(old: &ProxyConfig) -> Result<ProxyConfigV4> {
992    Ok(migrate_legacy_to_v4_with_report(old)?.config)
993}
994
995pub fn migrate_legacy_to_v4_with_report(old: &ProxyConfig) -> Result<ConfigV4MigrationReport> {
996    migrate_v2_to_v4_with_report(&migrate_legacy_to_v2(old))
997}
998
999pub mod legacy {
1000    use super::*;
1001
1002    fn default_legacy_proxy_config_version() -> u32 {
1003        3
1004    }
1005
1006    #[derive(Debug, Clone, Serialize, Deserialize)]
1007    pub struct ProxyConfigV3Legacy {
1008        #[serde(default = "default_legacy_proxy_config_version")]
1009        pub version: u32,
1010        #[serde(default)]
1011        pub codex: ServiceViewV3Legacy,
1012        #[serde(default)]
1013        pub claude: ServiceViewV3Legacy,
1014        #[serde(default)]
1015        pub retry: RetryConfig,
1016        #[serde(default)]
1017        pub notify: NotifyConfig,
1018        #[serde(default)]
1019        pub default_service: Option<ServiceKind>,
1020        #[serde(default)]
1021        pub ui: UiConfig,
1022    }
1023
1024    #[derive(Debug, Clone, Serialize, Deserialize, Default)]
1025    pub struct ServiceViewV3Legacy {
1026        #[serde(default, skip_serializing_if = "Option::is_none")]
1027        pub default_profile: Option<String>,
1028        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1029        pub profiles: BTreeMap<String, ServiceControlProfile>,
1030        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1031        pub providers: BTreeMap<String, ProviderConfigV4>,
1032        #[serde(default, skip_serializing_if = "Option::is_none")]
1033        pub routing: Option<RoutingConfigV3Legacy>,
1034    }
1035
1036    #[derive(Debug, Clone, Serialize, Deserialize)]
1037    pub struct RoutingConfigV3Legacy {
1038        #[serde(default = "default_legacy_routing_policy")]
1039        pub policy: RoutingPolicyV3Legacy,
1040        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1041        pub order: Vec<String>,
1042        #[serde(default, skip_serializing_if = "Option::is_none")]
1043        pub target: Option<String>,
1044        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1045        pub prefer_tags: Vec<BTreeMap<String, String>>,
1046        #[serde(default, skip_serializing_if = "Vec::is_empty")]
1047        pub chain: Vec<String>,
1048        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1049        pub pools: BTreeMap<String, RoutingPoolV4>,
1050        #[serde(default = "default_legacy_on_exhausted")]
1051        pub on_exhausted: RoutingExhaustedActionV3Legacy,
1052    }
1053
1054    impl Default for RoutingConfigV3Legacy {
1055        fn default() -> Self {
1056            Self {
1057                policy: default_legacy_routing_policy(),
1058                order: Vec::new(),
1059                target: None,
1060                prefer_tags: Vec::new(),
1061                chain: Vec::new(),
1062                pools: BTreeMap::new(),
1063                on_exhausted: default_legacy_on_exhausted(),
1064            }
1065        }
1066    }
1067
1068    #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1069    #[serde(rename_all = "kebab-case")]
1070    pub enum RoutingPolicyV3Legacy {
1071        ManualSticky,
1072        OrderedFailover,
1073        TagPreferred,
1074        PoolFallback,
1075    }
1076
1077    fn default_legacy_routing_policy() -> RoutingPolicyV3Legacy {
1078        RoutingPolicyV3Legacy::OrderedFailover
1079    }
1080
1081    #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1082    #[serde(rename_all = "kebab-case")]
1083    pub enum RoutingExhaustedActionV3Legacy {
1084        Continue,
1085        Stop,
1086    }
1087
1088    fn default_legacy_on_exhausted() -> RoutingExhaustedActionV3Legacy {
1089        RoutingExhaustedActionV3Legacy::Continue
1090    }
1091
1092    fn safe_route_name(
1093        candidate: &str,
1094        used: &mut BTreeSet<String>,
1095        fallback_suffix: &str,
1096    ) -> String {
1097        let base = if candidate.trim().is_empty() {
1098            "route".to_string()
1099        } else {
1100            candidate.trim().to_string()
1101        };
1102        let mut name = base.clone();
1103        if used.insert(name.clone()) {
1104            return name;
1105        }
1106        name = format!("{base}_{fallback_suffix}");
1107        let mut idx = 2usize;
1108        while !used.insert(name.clone()) {
1109            name = format!("{base}_{fallback_suffix}_{idx}");
1110            idx += 1;
1111        }
1112        name
1113    }
1114
1115    fn route_from_legacy_routing(
1116        service_name: &str,
1117        view: &ServiceViewV3Legacy,
1118        routing: &RoutingConfigV3Legacy,
1119        warnings: &mut Vec<String>,
1120    ) -> Result<RoutingConfigV4> {
1121        let default_children = || view.providers.keys().cloned().collect::<Vec<_>>();
1122        let mut routes = BTreeMap::new();
1123        let entry = "main".to_string();
1124
1125        let mut root = RoutingNodeV4 {
1126            on_exhausted: match routing.on_exhausted {
1127                RoutingExhaustedActionV3Legacy::Continue => RoutingExhaustedActionV4::Continue,
1128                RoutingExhaustedActionV3Legacy::Stop => RoutingExhaustedActionV4::Stop,
1129            },
1130            ..RoutingNodeV4::default()
1131        };
1132
1133        match routing.policy {
1134            RoutingPolicyV3Legacy::ManualSticky => {
1135                root.strategy = RoutingPolicyV4::ManualSticky;
1136                root.target = routing
1137                    .target
1138                    .clone()
1139                    .or_else(|| routing.order.first().cloned())
1140                    .or_else(|| view.providers.keys().next().cloned());
1141                root.children = if routing.order.is_empty() {
1142                    root.target
1143                        .as_ref()
1144                        .map(|target| vec![target.clone()])
1145                        .unwrap_or_else(default_children)
1146                } else {
1147                    routing.order.clone()
1148                };
1149            }
1150            RoutingPolicyV3Legacy::OrderedFailover => {
1151                root.strategy = RoutingPolicyV4::OrderedFailover;
1152                root.children = if routing.order.is_empty() {
1153                    default_children()
1154                } else {
1155                    routing.order.clone()
1156                };
1157            }
1158            RoutingPolicyV3Legacy::TagPreferred => {
1159                root.strategy = RoutingPolicyV4::TagPreferred;
1160                root.children = if routing.order.is_empty() {
1161                    default_children()
1162                } else {
1163                    routing.order.clone()
1164                };
1165                root.prefer_tags = routing.prefer_tags.clone();
1166            }
1167            RoutingPolicyV3Legacy::PoolFallback => {
1168                root.strategy = RoutingPolicyV4::OrderedFailover;
1169                let chain = if routing.chain.is_empty() {
1170                    routing.pools.keys().cloned().collect::<Vec<_>>()
1171                } else {
1172                    routing.chain.clone()
1173                };
1174                if chain.is_empty() {
1175                    anyhow::bail!(
1176                        "[{service_name}] legacy pool-fallback routing requires at least one pool"
1177                    );
1178                }
1179                let mut used = view.providers.keys().cloned().collect::<BTreeSet<_>>();
1180                used.insert(entry.clone());
1181                let mut root_children = Vec::new();
1182                for (idx, pool_name) in chain.iter().enumerate() {
1183                    let Some(pool) = routing.pools.get(pool_name.as_str()) else {
1184                        anyhow::bail!(
1185                            "[{service_name}] legacy routing references missing pool '{pool_name}'"
1186                        );
1187                    };
1188                    if pool.providers.is_empty() {
1189                        anyhow::bail!(
1190                            "[{service_name}] legacy pool '{pool_name}' must define at least one provider"
1191                        );
1192                    }
1193                    let route_name = safe_route_name(
1194                        pool_name,
1195                        &mut used,
1196                        if idx == 0 { "pool" } else { "branch" },
1197                    );
1198                    if route_name != *pool_name {
1199                        warnings.push(format!(
1200                            "[{service_name}] legacy pool '{pool_name}' is renamed to route node '{route_name}' in v4"
1201                        ));
1202                    }
1203                    routes.insert(
1204                        route_name.clone(),
1205                        RoutingNodeV4 {
1206                            strategy: RoutingPolicyV4::OrderedFailover,
1207                            children: pool.providers.clone(),
1208                            target: None,
1209                            prefer_tags: Vec::new(),
1210                            on_exhausted: RoutingExhaustedActionV4::Continue,
1211                            metadata: BTreeMap::new(),
1212                            when: None,
1213                            then: None,
1214                            default_route: None,
1215                        },
1216                    );
1217                    root_children.push(route_name);
1218                }
1219                if matches!(routing.on_exhausted, RoutingExhaustedActionV3Legacy::Stop) {
1220                    root_children.truncate(1);
1221                }
1222                root.children = root_children;
1223            }
1224        }
1225
1226        if root.children.is_empty() {
1227            root.children = default_children();
1228        }
1229        routes.insert(entry.clone(), root);
1230        let mut routing = RoutingConfigV4 {
1231            entry,
1232            routes,
1233            ..RoutingConfigV4::default()
1234        };
1235        routing.sync_compat_from_graph();
1236        Ok(routing)
1237    }
1238
1239    fn migrate_service_view(
1240        service_name: &str,
1241        view: &ServiceViewV3Legacy,
1242        warnings: &mut Vec<String>,
1243    ) -> Result<ServiceViewV4> {
1244        let mut profiles = view.profiles.clone();
1245        for profile in profiles.values_mut() {
1246            if profile
1247                .station
1248                .as_deref()
1249                .map(str::trim)
1250                .is_some_and(|station| !station.is_empty())
1251            {
1252                profile.station = None;
1253            }
1254        }
1255
1256        let routing = if let Some(routing) = view.routing.as_ref() {
1257            Some(route_from_legacy_routing(
1258                service_name,
1259                view,
1260                routing,
1261                warnings,
1262            )?)
1263        } else if view.providers.is_empty() {
1264            None
1265        } else {
1266            Some(RoutingConfigV4::ordered_failover(
1267                view.providers.keys().cloned().collect(),
1268            ))
1269        };
1270
1271        Ok(ServiceViewV4 {
1272            default_profile: view.default_profile.clone(),
1273            profiles,
1274            providers: view.providers.clone(),
1275            routing,
1276        })
1277    }
1278
1279    pub fn migrate_v3_legacy_to_v4(
1280        legacy: &ProxyConfigV3Legacy,
1281    ) -> Result<ConfigV4MigrationReport> {
1282        let mut warnings = Vec::new();
1283        let mut config = ProxyConfigV4 {
1284            version: CURRENT_ROUTE_GRAPH_CONFIG_VERSION,
1285            codex: migrate_service_view("codex", &legacy.codex, &mut warnings)?,
1286            claude: migrate_service_view("claude", &legacy.claude, &mut warnings)?,
1287            retry: legacy.retry.clone(),
1288            notify: legacy.notify.clone(),
1289            default_service: legacy.default_service,
1290            ui: legacy.ui.clone(),
1291        };
1292        if let Some(routing) = config.codex.routing.as_mut() {
1293            routing.sync_compat_from_graph();
1294        }
1295        if let Some(routing) = config.claude.routing.as_mut() {
1296            routing.sync_compat_from_graph();
1297        }
1298        Ok(ConfigV4MigrationReport { config, warnings })
1299    }
1300}