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