Skip to main content

codex_helper_core/proxy/
persisted_registry_api.rs

1use axum::Json;
2use axum::extract::Path;
3use axum::http::StatusCode;
4
5use super::ProxyService;
6use super::api_responses::{ProfilesResponse, make_profiles_response};
7use super::control_plane_service::{
8    PersistedProxySettingsDocument, load_persisted_proxy_settings_document,
9    load_persisted_proxy_settings_v2, runtime_service_manager_mut,
10    save_persisted_proxy_settings_document_and_reload, save_persisted_proxy_settings_v2_and_reload,
11    save_runtime_profile_settings_and_reload, save_runtime_proxy_settings_and_reload,
12    service_view_v2, service_view_v2_mut, service_view_v4, service_view_v4_mut,
13};
14
15fn default_persisted_station_enabled() -> bool {
16    true
17}
18
19fn default_persisted_station_level() -> u8 {
20    1
21}
22
23fn default_persisted_routing_on_exhausted() -> crate::config::RoutingExhaustedActionV4 {
24    crate::config::RoutingExhaustedActionV4::Continue
25}
26
27#[derive(serde::Deserialize)]
28pub(super) struct PersistedStationUpdateRequest {
29    #[serde(default)]
30    enabled: Option<bool>,
31    #[serde(default)]
32    level: Option<u8>,
33}
34
35#[derive(serde::Deserialize)]
36pub(super) struct PersistedStationSpecUpsertRequest {
37    #[serde(default)]
38    alias: Option<String>,
39    #[serde(default = "default_persisted_station_enabled")]
40    enabled: bool,
41    #[serde(default = "default_persisted_station_level")]
42    level: u8,
43    #[serde(default)]
44    members: Vec<crate::config::GroupMemberRefV2>,
45}
46
47#[derive(serde::Deserialize)]
48pub(super) struct PersistedProviderEndpointSpecUpsertRequest {
49    name: String,
50    base_url: String,
51    #[serde(default = "default_persisted_station_enabled")]
52    enabled: bool,
53    #[serde(default)]
54    priority: u32,
55    #[serde(default)]
56    tags: Option<std::collections::BTreeMap<String, String>>,
57}
58
59impl From<crate::config::PersistedProviderEndpointSpec>
60    for PersistedProviderEndpointSpecUpsertRequest
61{
62    fn from(endpoint: crate::config::PersistedProviderEndpointSpec) -> Self {
63        Self {
64            name: endpoint.name,
65            base_url: endpoint.base_url,
66            enabled: endpoint.enabled,
67            priority: endpoint.priority,
68            tags: Some(endpoint.tags),
69        }
70    }
71}
72
73#[derive(serde::Deserialize)]
74pub(super) struct PersistedProviderSpecUpsertRequest {
75    #[serde(default)]
76    alias: Option<String>,
77    #[serde(default = "default_persisted_station_enabled")]
78    enabled: bool,
79    #[serde(default)]
80    auth_token_env: Option<String>,
81    #[serde(default)]
82    api_key_env: Option<String>,
83    #[serde(default)]
84    tags: Option<std::collections::BTreeMap<String, String>>,
85    #[serde(default)]
86    endpoints: Vec<PersistedProviderEndpointSpecUpsertRequest>,
87}
88
89impl From<crate::config::PersistedProviderSpec> for PersistedProviderSpecUpsertRequest {
90    fn from(provider: crate::config::PersistedProviderSpec) -> Self {
91        Self {
92            alias: provider.alias,
93            enabled: provider.enabled,
94            auth_token_env: provider.auth_token_env,
95            api_key_env: provider.api_key_env,
96            tags: Some(provider.tags),
97            endpoints: provider.endpoints.into_iter().map(Into::into).collect(),
98        }
99    }
100}
101
102struct SanitizedPersistedProviderSpec {
103    spec: crate::config::PersistedProviderSpec,
104    tags_provided: bool,
105    endpoint_tags_provided: std::collections::BTreeMap<String, bool>,
106}
107
108#[derive(serde::Deserialize)]
109pub(super) struct PersistedProfileUpsertRequest {
110    #[serde(default)]
111    extends: Option<String>,
112    #[serde(default)]
113    station: Option<String>,
114    #[serde(default)]
115    model: Option<String>,
116    #[serde(default)]
117    reasoning_effort: Option<String>,
118    #[serde(default)]
119    service_tier: Option<String>,
120}
121
122#[derive(serde::Deserialize)]
123pub(super) struct PersistedStationActiveRequest {
124    #[serde(default)]
125    station_name: Option<String>,
126}
127
128impl PersistedStationActiveRequest {
129    fn station_name(&self) -> Option<String> {
130        self.station_name
131            .as_deref()
132            .map(str::trim)
133            .filter(|name| !name.is_empty())
134            .map(ToOwned::to_owned)
135    }
136}
137
138#[derive(serde::Deserialize)]
139pub(super) struct PersistedDefaultProfileRequest {
140    profile_name: Option<String>,
141}
142
143#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
144pub struct PersistedRoutingUpsertRequest {
145    #[serde(default)]
146    pub entry: Option<String>,
147    #[serde(default)]
148    pub affinity_policy: Option<crate::config::RoutingAffinityPolicyV5>,
149    #[serde(default)]
150    pub fallback_ttl_ms: Option<u64>,
151    #[serde(default)]
152    pub reprobe_preferred_after_ms: Option<u64>,
153    #[serde(default)]
154    pub routes: Option<std::collections::BTreeMap<String, crate::config::RoutingNodeV4>>,
155    #[serde(default)]
156    pub policy: Option<crate::config::RoutingPolicyV4>,
157    #[serde(default)]
158    pub order: Vec<String>,
159    #[serde(default)]
160    pub target: Option<String>,
161    #[serde(default)]
162    pub prefer_tags: Option<Vec<std::collections::BTreeMap<String, String>>>,
163    #[serde(default)]
164    pub chain: Vec<String>,
165    #[serde(default)]
166    pub pools: std::collections::BTreeMap<String, crate::config::RoutingPoolV4>,
167    #[serde(default = "default_persisted_routing_on_exhausted")]
168    pub on_exhausted: crate::config::RoutingExhaustedActionV4,
169}
170
171fn sanitize_profile_name(profile_name: &str) -> Result<String, (StatusCode, String)> {
172    let profile_name = profile_name.trim();
173    if profile_name.is_empty() {
174        return Err((
175            StatusCode::BAD_REQUEST,
176            "profile name is required".to_string(),
177        ));
178    }
179    Ok(profile_name.to_string())
180}
181
182fn sanitize_station_name(station_name: &str) -> Result<String, (StatusCode, String)> {
183    let station_name = station_name.trim();
184    if station_name.is_empty() {
185        return Err((
186            StatusCode::BAD_REQUEST,
187            "station name is required".to_string(),
188        ));
189    }
190    Ok(station_name.to_string())
191}
192
193fn sanitize_provider_name(provider_name: &str) -> Result<String, (StatusCode, String)> {
194    let provider_name = provider_name.trim();
195    if provider_name.is_empty() {
196        return Err((
197            StatusCode::BAD_REQUEST,
198            "provider name is required".to_string(),
199        ));
200    }
201    Ok(provider_name.to_string())
202}
203
204fn sanitize_profile_request(
205    payload: PersistedProfileUpsertRequest,
206) -> crate::config::ServiceControlProfile {
207    fn normalize(value: Option<String>) -> Option<String> {
208        value
209            .as_deref()
210            .map(str::trim)
211            .filter(|value| !value.is_empty())
212            .map(ToOwned::to_owned)
213    }
214
215    crate::config::ServiceControlProfile {
216        extends: normalize(payload.extends),
217        station: normalize(payload.station),
218        model: normalize(payload.model),
219        reasoning_effort: normalize(payload.reasoning_effort),
220        service_tier: normalize(payload.service_tier),
221    }
222}
223
224fn profile_request_has_station_binding(payload: &PersistedProfileUpsertRequest) -> bool {
225    payload
226        .station
227        .as_deref()
228        .is_some_and(|station| !station.trim().is_empty())
229}
230
231fn normalize_optional_config_string(value: Option<String>) -> Option<String> {
232    value
233        .as_deref()
234        .map(str::trim)
235        .filter(|value| !value.is_empty())
236        .map(ToOwned::to_owned)
237}
238
239fn sanitize_tag_map(
240    tags: std::collections::BTreeMap<String, String>,
241    context: &str,
242) -> Result<std::collections::BTreeMap<String, String>, (StatusCode, String)> {
243    let mut out = std::collections::BTreeMap::new();
244    for (key, value) in tags {
245        let key = key.trim();
246        if key.is_empty() {
247            return Err((
248                StatusCode::BAD_REQUEST,
249                format!("{context} tag key is required"),
250            ));
251        }
252        let value = value.trim();
253        if value.is_empty() {
254            continue;
255        }
256        out.insert(key.to_string(), value.to_string());
257    }
258    Ok(out)
259}
260
261fn sanitize_station_spec_request(
262    payload: PersistedStationSpecUpsertRequest,
263) -> Result<crate::config::PersistedStationSpec, (StatusCode, String)> {
264    let mut members = Vec::new();
265    for member in payload.members {
266        let provider = member.provider.trim();
267        if provider.is_empty() {
268            return Err((
269                StatusCode::BAD_REQUEST,
270                "station member provider is required".to_string(),
271            ));
272        }
273
274        let mut endpoint_names = member
275            .endpoint_names
276            .into_iter()
277            .map(|name| name.trim().to_string())
278            .filter(|name| !name.is_empty())
279            .collect::<Vec<_>>();
280        endpoint_names.dedup();
281
282        members.push(crate::config::GroupMemberRefV2 {
283            provider: provider.to_string(),
284            endpoint_names,
285            preferred: member.preferred,
286        });
287    }
288
289    Ok(crate::config::PersistedStationSpec {
290        name: String::new(),
291        alias: normalize_optional_config_string(payload.alias),
292        enabled: payload.enabled,
293        level: payload.level.clamp(1, 10),
294        members,
295    })
296}
297
298fn sanitize_provider_spec_request(
299    payload: PersistedProviderSpecUpsertRequest,
300) -> Result<SanitizedPersistedProviderSpec, (StatusCode, String)> {
301    let mut endpoints = Vec::new();
302    let mut endpoint_tags_provided = std::collections::BTreeMap::new();
303    let mut seen = std::collections::BTreeSet::new();
304    for endpoint in payload.endpoints {
305        let endpoint_name = endpoint.name.trim();
306        if endpoint_name.is_empty() {
307            return Err((
308                StatusCode::BAD_REQUEST,
309                "provider endpoint name is required".to_string(),
310            ));
311        }
312        let base_url = endpoint.base_url.trim();
313        if base_url.is_empty() {
314            return Err((
315                StatusCode::BAD_REQUEST,
316                format!("provider endpoint '{}' base_url is required", endpoint_name),
317            ));
318        }
319        if !seen.insert(endpoint_name.to_string()) {
320            return Err((
321                StatusCode::BAD_REQUEST,
322                format!("duplicate provider endpoint '{}'", endpoint_name),
323            ));
324        }
325        let tags_provided = endpoint.tags.is_some();
326        let tags = endpoint
327            .tags
328            .map(|tags| sanitize_tag_map(tags, &format!("provider endpoint '{}'", endpoint_name)))
329            .transpose()?
330            .unwrap_or_default();
331
332        endpoint_tags_provided.insert(endpoint_name.to_string(), tags_provided);
333        endpoints.push(crate::config::PersistedProviderEndpointSpec {
334            name: endpoint_name.to_string(),
335            base_url: base_url.to_string(),
336            enabled: endpoint.enabled,
337            priority: endpoint.priority,
338            tags,
339        });
340    }
341
342    let tags_provided = payload.tags.is_some();
343    let tags = payload
344        .tags
345        .map(|tags| sanitize_tag_map(tags, "provider"))
346        .transpose()?
347        .unwrap_or_default();
348
349    Ok(SanitizedPersistedProviderSpec {
350        spec: crate::config::PersistedProviderSpec {
351            name: String::new(),
352            alias: normalize_optional_config_string(payload.alias),
353            enabled: payload.enabled,
354            auth_token_env: normalize_optional_config_string(payload.auth_token_env),
355            api_key_env: normalize_optional_config_string(payload.api_key_env),
356            tags,
357            endpoints,
358        },
359        tags_provided,
360        endpoint_tags_provided,
361    })
362}
363
364fn merge_persisted_provider_spec(
365    existing: Option<&crate::config::ProviderConfigV2>,
366    provider: &SanitizedPersistedProviderSpec,
367) -> crate::config::ProviderConfigV2 {
368    let spec = &provider.spec;
369    let mut auth = existing
370        .map(|provider| provider.auth.clone())
371        .unwrap_or_default();
372    auth.auth_token_env = spec.auth_token_env.clone();
373    auth.api_key_env = spec.api_key_env.clone();
374
375    crate::config::ProviderConfigV2 {
376        alias: spec.alias.clone(),
377        enabled: spec.enabled,
378        auth,
379        tags: if provider.tags_provided {
380            spec.tags.clone()
381        } else {
382            existing
383                .map(|provider| provider.tags.clone())
384                .unwrap_or_default()
385        },
386        supported_models: existing
387            .map(|provider| provider.supported_models.clone())
388            .unwrap_or_default(),
389        model_mapping: existing
390            .map(|provider| provider.model_mapping.clone())
391            .unwrap_or_default(),
392        endpoints: spec
393            .endpoints
394            .iter()
395            .map(|endpoint| {
396                let existing_endpoint =
397                    existing.and_then(|provider| provider.endpoints.get(endpoint.name.as_str()));
398                (
399                    endpoint.name.clone(),
400                    crate::config::ProviderEndpointV2 {
401                        base_url: endpoint.base_url.clone(),
402                        enabled: endpoint.enabled,
403                        priority: endpoint.priority,
404                        tags: if provider
405                            .endpoint_tags_provided
406                            .get(endpoint.name.as_str())
407                            .copied()
408                            .unwrap_or(false)
409                        {
410                            endpoint.tags.clone()
411                        } else {
412                            existing_endpoint
413                                .map(|endpoint| endpoint.tags.clone())
414                                .unwrap_or_default()
415                        },
416                        supported_models: existing_endpoint
417                            .map(|endpoint| endpoint.supported_models.clone())
418                            .unwrap_or_default(),
419                        model_mapping: existing_endpoint
420                            .map(|endpoint| endpoint.model_mapping.clone())
421                            .unwrap_or_default(),
422                    },
423                )
424            })
425            .collect(),
426    }
427}
428
429fn persisted_routing_spec_from_v4(
430    view: &crate::config::ServiceViewV4,
431) -> crate::config::PersistedRoutingSpec {
432    let routing = crate::config::effective_v4_routing(view);
433    let order = crate::config::resolved_v4_provider_order("persisted-routing", view)
434        .unwrap_or_else(|_| view.providers.keys().cloned().collect::<Vec<_>>());
435    let entry_node = routing.entry_node();
436    crate::config::PersistedRoutingSpec {
437        entry: routing.entry.clone(),
438        affinity_policy: routing.affinity_policy,
439        fallback_ttl_ms: routing.fallback_ttl_ms,
440        reprobe_preferred_after_ms: routing.reprobe_preferred_after_ms,
441        routes: routing.routes.clone(),
442        policy: entry_node
443            .map(|node| node.strategy)
444            .unwrap_or(crate::config::RoutingPolicyV4::OrderedFailover),
445        order: order.clone(),
446        target: entry_node.and_then(|node| node.target.clone()),
447        prefer_tags: entry_node
448            .map(|node| node.prefer_tags.clone())
449            .unwrap_or_default(),
450        on_exhausted: entry_node
451            .map(|node| node.on_exhausted)
452            .unwrap_or(crate::config::RoutingExhaustedActionV4::Continue),
453        entry_strategy: entry_node
454            .map(|node| node.strategy)
455            .unwrap_or(crate::config::RoutingPolicyV4::OrderedFailover),
456        expanded_order: order,
457        entry_target: entry_node.and_then(|node| node.target.clone()),
458        providers: view
459            .providers
460            .iter()
461            .map(
462                |(name, provider)| crate::config::PersistedRoutingProviderRef {
463                    name: name.clone(),
464                    alias: provider.alias.clone(),
465                    enabled: provider.enabled,
466                    tags: provider.tags.clone(),
467                },
468            )
469            .collect(),
470    }
471}
472
473fn sanitize_routing_spec_request(
474    view: &crate::config::ServiceViewV4,
475    payload: PersistedRoutingUpsertRequest,
476) -> Result<crate::config::RoutingConfigV4, (StatusCode, String)> {
477    if !payload.chain.is_empty() || !payload.pools.is_empty() {
478        return Err((
479            StatusCode::BAD_REQUEST,
480            "pool routing is no longer part of the v4 authoring model; use nested routes instead"
481                .to_string(),
482        ));
483    }
484
485    let has_graph_update = payload.entry.is_some() || payload.routes.is_some();
486    let has_entry_update = payload.policy.is_some()
487        || !payload.order.is_empty()
488        || payload.target.is_some()
489        || payload.prefer_tags.is_some()
490        || payload.on_exhausted != crate::config::RoutingExhaustedActionV4::Continue;
491
492    let mut routing = crate::config::effective_v4_routing(view);
493    if let Some(entry) = payload.entry {
494        let entry = entry.trim();
495        if entry.is_empty() {
496            return Err((
497                StatusCode::BAD_REQUEST,
498                "routing entry is required".to_string(),
499            ));
500        }
501        routing.entry = entry.to_string();
502    }
503    if let Some(affinity_policy) = payload.affinity_policy {
504        routing.affinity_policy = affinity_policy;
505    }
506    if let Some(fallback_ttl_ms) = payload.fallback_ttl_ms {
507        routing.fallback_ttl_ms = Some(fallback_ttl_ms);
508    }
509    if let Some(reprobe_preferred_after_ms) = payload.reprobe_preferred_after_ms {
510        routing.reprobe_preferred_after_ms = Some(reprobe_preferred_after_ms);
511    }
512    if let Some(routes) = payload.routes {
513        routing.routes = sanitize_route_nodes(routes)?;
514    }
515
516    if has_entry_update || !has_graph_update {
517        let entry_name = routing.entry.clone();
518        if !routing.routes.contains_key(entry_name.as_str()) {
519            routing.routes.insert(
520                entry_name.clone(),
521                crate::config::RoutingNodeV4 {
522                    strategy: crate::config::RoutingPolicyV4::OrderedFailover,
523                    children: Vec::new(),
524                    target: None,
525                    prefer_tags: Vec::new(),
526                    on_exhausted: crate::config::RoutingExhaustedActionV4::Continue,
527                    metadata: std::collections::BTreeMap::new(),
528                    when: None,
529                    then: None,
530                    default_route: None,
531                },
532            );
533        }
534        let node = routing
535            .routes
536            .get_mut(entry_name.as_str())
537            .expect("entry route inserted above");
538
539        if let Some(policy) = payload.policy {
540            node.strategy = policy;
541        }
542
543        let order = sanitize_route_children(payload.order)?;
544        if !order.is_empty() {
545            node.children = order;
546        }
547
548        if let Some(raw_target) = payload.target {
549            let target = raw_target.trim();
550            if target.is_empty() {
551                node.target = None;
552            } else {
553                node.strategy = crate::config::RoutingPolicyV4::ManualSticky;
554                node.target = Some(target.to_string());
555                if node.children.is_empty() {
556                    node.children = vec![target.to_string()];
557                }
558            }
559        }
560
561        if let Some(prefer_tags) = payload.prefer_tags {
562            node.prefer_tags = sanitize_routing_prefer_tags(prefer_tags)?;
563            if !node.prefer_tags.is_empty() {
564                node.strategy = crate::config::RoutingPolicyV4::TagPreferred;
565            }
566        }
567
568        node.on_exhausted = payload.on_exhausted;
569        if !matches!(node.strategy, crate::config::RoutingPolicyV4::ManualSticky) {
570            node.target = None;
571        }
572        if !matches!(node.strategy, crate::config::RoutingPolicyV4::TagPreferred) {
573            node.prefer_tags.clear();
574        }
575        if !matches!(node.strategy, crate::config::RoutingPolicyV4::Conditional) {
576            node.when = None;
577            node.then = None;
578            node.default_route = None;
579        }
580        if node.children.is_empty() {
581            node.children = view.providers.keys().cloned().collect();
582        }
583    }
584
585    routing.sync_compat_from_graph();
586    Ok(routing)
587}
588
589fn sanitize_route_nodes(
590    routes: std::collections::BTreeMap<String, crate::config::RoutingNodeV4>,
591) -> Result<std::collections::BTreeMap<String, crate::config::RoutingNodeV4>, (StatusCode, String)>
592{
593    let mut out = std::collections::BTreeMap::new();
594    for (name, mut node) in routes {
595        let route_name = name.trim();
596        if route_name.is_empty() {
597            return Err((
598                StatusCode::BAD_REQUEST,
599                "routing route name is required".to_string(),
600            ));
601        }
602        node.children = sanitize_route_children(node.children)?;
603        if let Some(target) = node.target.take() {
604            let target = target.trim();
605            if !target.is_empty() {
606                node.target = Some(target.to_string());
607            }
608        }
609        if let Some(target) = node.then.take() {
610            let target = target.trim();
611            if !target.is_empty() {
612                node.then = Some(target.to_string());
613            }
614        }
615        if let Some(target) = node.default_route.take() {
616            let target = target.trim();
617            if !target.is_empty() {
618                node.default_route = Some(target.to_string());
619            }
620        }
621        node.prefer_tags = sanitize_routing_prefer_tags(node.prefer_tags)?;
622        if !matches!(node.strategy, crate::config::RoutingPolicyV4::ManualSticky) {
623            node.target = None;
624        }
625        if !matches!(node.strategy, crate::config::RoutingPolicyV4::TagPreferred) {
626            node.prefer_tags.clear();
627        }
628        if !matches!(node.strategy, crate::config::RoutingPolicyV4::Conditional) {
629            node.when = None;
630            node.then = None;
631            node.default_route = None;
632        }
633        if out.insert(route_name.to_string(), node).is_some() {
634            return Err((
635                StatusCode::BAD_REQUEST,
636                format!("duplicate routing route '{}'", route_name),
637            ));
638        }
639    }
640    Ok(out)
641}
642
643fn sanitize_route_children(children: Vec<String>) -> Result<Vec<String>, (StatusCode, String)> {
644    let mut order = Vec::new();
645    let mut seen = std::collections::BTreeSet::new();
646    for child_name in children {
647        let child_name = child_name.trim();
648        if child_name.is_empty() {
649            return Err((
650                StatusCode::BAD_REQUEST,
651                "routing child name is required".to_string(),
652            ));
653        }
654        if !seen.insert(child_name.to_string()) {
655            return Err((
656                StatusCode::BAD_REQUEST,
657                format!("duplicate routing child '{}'", child_name),
658            ));
659        }
660        order.push(child_name.to_string());
661    }
662    Ok(order)
663}
664
665fn sanitize_routing_prefer_tags(
666    prefer_tags: Vec<std::collections::BTreeMap<String, String>>,
667) -> Result<Vec<std::collections::BTreeMap<String, String>>, (StatusCode, String)> {
668    let mut out = Vec::new();
669    for filter in prefer_tags {
670        let normalized = filter
671            .into_iter()
672            .filter_map(|(key, value)| {
673                let key = key.trim();
674                let value = value.trim();
675                (!key.is_empty() && !value.is_empty()).then(|| (key.to_string(), value.to_string()))
676            })
677            .collect::<std::collections::BTreeMap<_, _>>();
678        if normalized.is_empty() {
679            return Err((
680                StatusCode::BAD_REQUEST,
681                "routing prefer_tags entries must contain at least one key/value pair".to_string(),
682            ));
683        }
684        out.push(normalized);
685    }
686    Ok(out)
687}
688
689fn validate_v4_routing_spec_for_view(
690    service_name: &str,
691    view: &crate::config::ServiceViewV4,
692    routing: &crate::config::RoutingConfigV4,
693) -> Result<(), (StatusCode, String)> {
694    let mut next_view = view.clone();
695    next_view.routing = Some(routing.clone());
696    crate::config::resolved_v4_provider_order(service_name, &next_view)
697        .map(|_| ())
698        .map_err(|err| (StatusCode::BAD_REQUEST, err.to_string()))
699}
700
701fn v4_default_endpoint_can_be_inlined(existing: Option<&crate::config::ProviderConfigV4>) -> bool {
702    existing
703        .and_then(|provider| provider.endpoints.get("default"))
704        .map(|endpoint| {
705            endpoint.tags.is_empty()
706                && endpoint.supported_models.is_empty()
707                && endpoint.model_mapping.is_empty()
708        })
709        .unwrap_or(true)
710}
711
712fn merge_persisted_provider_spec_v4(
713    existing: Option<&crate::config::ProviderConfigV4>,
714    provider: &SanitizedPersistedProviderSpec,
715) -> crate::config::ProviderConfigV4 {
716    let spec = &provider.spec;
717    let mut out = crate::config::ProviderConfigV4 {
718        alias: spec.alias.clone(),
719        enabled: spec.enabled,
720        base_url: None,
721        auth: existing
722            .map(|provider| provider.auth.clone())
723            .unwrap_or_default(),
724        inline_auth: crate::config::UpstreamAuth {
725            auth_token: existing.and_then(|provider| provider.inline_auth.auth_token.clone()),
726            auth_token_env: spec.auth_token_env.clone(),
727            api_key: existing.and_then(|provider| provider.inline_auth.api_key.clone()),
728            api_key_env: spec.api_key_env.clone(),
729        },
730        tags: if provider.tags_provided {
731            spec.tags.clone()
732        } else {
733            existing
734                .map(|provider| provider.tags.clone())
735                .unwrap_or_default()
736        },
737        supported_models: existing
738            .map(|provider| provider.supported_models.clone())
739            .unwrap_or_default(),
740        model_mapping: existing
741            .map(|provider| provider.model_mapping.clone())
742            .unwrap_or_default(),
743        endpoints: std::collections::BTreeMap::new(),
744    };
745
746    if spec.endpoints.len() == 1
747        && spec.endpoints[0].name == "default"
748        && spec.endpoints[0].priority == 0
749        && spec.endpoints[0].tags.is_empty()
750        && v4_default_endpoint_can_be_inlined(existing)
751    {
752        out.base_url = Some(spec.endpoints[0].base_url.clone());
753    } else {
754        out.endpoints = spec
755            .endpoints
756            .iter()
757            .map(|endpoint| {
758                let existing_endpoint =
759                    existing.and_then(|provider| provider.endpoints.get(endpoint.name.as_str()));
760                (
761                    endpoint.name.clone(),
762                    crate::config::ProviderEndpointV4 {
763                        base_url: endpoint.base_url.clone(),
764                        enabled: endpoint.enabled,
765                        priority: endpoint.priority,
766                        tags: if provider
767                            .endpoint_tags_provided
768                            .get(endpoint.name.as_str())
769                            .copied()
770                            .unwrap_or(false)
771                        {
772                            endpoint.tags.clone()
773                        } else {
774                            existing_endpoint
775                                .map(|endpoint| endpoint.tags.clone())
776                                .unwrap_or_default()
777                        },
778                        supported_models: existing_endpoint
779                            .map(|endpoint| endpoint.supported_models.clone())
780                            .unwrap_or_default(),
781                        model_mapping: existing_endpoint
782                            .map(|endpoint| endpoint.model_mapping.clone())
783                            .unwrap_or_default(),
784                    },
785                )
786            })
787            .collect();
788    }
789
790    out
791}
792
793pub(super) fn runtime_service_manager_for_document<'a>(
794    runtime: &'a crate::config::ProxyConfig,
795    service_name: &str,
796) -> &'a crate::config::ServiceConfigManager {
797    if service_name == "claude" {
798        &runtime.claude
799    } else {
800        &runtime.codex
801    }
802}
803
804fn ensure_v4_entry_route_mut(
805    view: &mut crate::config::ServiceViewV4,
806) -> &mut crate::config::RoutingNodeV4 {
807    let routing = view
808        .routing
809        .get_or_insert_with(crate::config::RoutingConfigV4::default);
810    if !routing.routes.contains_key(routing.entry.as_str()) {
811        routing.routes.insert(
812            routing.entry.clone(),
813            crate::config::RoutingNodeV4::default(),
814        );
815    }
816    routing
817        .routes
818        .get_mut(routing.entry.as_str())
819        .expect("entry route inserted above")
820}
821
822fn append_new_provider_to_explicit_v4_order(
823    view: &mut crate::config::ServiceViewV4,
824    provider_name: &str,
825) {
826    let routing = ensure_v4_entry_route_mut(view);
827    if routing.children.iter().any(|name| name == provider_name) {
828        return;
829    }
830    routing.children.push(provider_name.to_string());
831}
832
833fn validate_station_members_for_view(
834    service_name: &str,
835    station_name: &str,
836    view: &crate::config::ServiceViewV2,
837    members: &[crate::config::GroupMemberRefV2],
838) -> Result<(), (StatusCode, String)> {
839    for member in members {
840        let provider = view
841            .providers
842            .get(member.provider.as_str())
843            .ok_or_else(|| {
844                (
845                    StatusCode::BAD_REQUEST,
846                    format!(
847                        "[{service_name}] station '{}' references missing provider '{}'",
848                        station_name, member.provider
849                    ),
850                )
851            })?;
852
853        for endpoint_name in &member.endpoint_names {
854            if !provider.endpoints.contains_key(endpoint_name.as_str()) {
855                return Err((
856                    StatusCode::BAD_REQUEST,
857                    format!(
858                        "[{service_name}] station '{}' references missing endpoint '{}.{}'",
859                        station_name, member.provider, endpoint_name
860                    ),
861                ));
862            }
863        }
864    }
865    Ok(())
866}
867
868pub(super) async fn list_persisted_station_specs(
869    proxy: ProxyService,
870) -> Result<Json<crate::config::PersistedStationsCatalog>, (StatusCode, String)> {
871    match load_persisted_proxy_settings_document().await? {
872        PersistedProxySettingsDocument::V2(cfg) => {
873            Ok(Json(crate::config::build_persisted_station_catalog(
874                service_view_v2(&cfg, proxy.service_name),
875            )))
876        }
877        PersistedProxySettingsDocument::V4(_) => Err((
878            StatusCode::BAD_REQUEST,
879            "route graph configs do not expose station specs; use the routing and provider specs APIs"
880                .to_string(),
881        )),
882    }
883}
884
885pub(super) async fn list_persisted_provider_specs(
886    proxy: ProxyService,
887) -> Result<Json<crate::config::PersistedProvidersCatalog>, (StatusCode, String)> {
888    proxy
889        .persisted_provider_specs()
890        .await
891        .map(Json)
892        .map_err(super::ProxyControlError::into_http_error)
893}
894
895pub(super) async fn list_persisted_routing_spec(
896    proxy: ProxyService,
897) -> Result<Json<crate::config::PersistedRoutingSpec>, (StatusCode, String)> {
898    proxy
899        .persisted_routing_spec()
900        .await
901        .map(Json)
902        .map_err(super::ProxyControlError::into_http_error)
903}
904
905pub(super) async fn upsert_persisted_routing_spec(
906    proxy: ProxyService,
907    Json(payload): Json<PersistedRoutingUpsertRequest>,
908) -> Result<Json<crate::config::PersistedRoutingSpec>, (StatusCode, String)> {
909    proxy
910        .upsert_persisted_routing_spec(payload)
911        .await
912        .map(Json)
913        .map_err(super::ProxyControlError::into_http_error)
914}
915
916pub(super) async fn upsert_persisted_routing_spec_for_proxy(
917    proxy: &ProxyService,
918    payload: PersistedRoutingUpsertRequest,
919) -> Result<crate::config::PersistedRoutingSpec, (StatusCode, String)> {
920    let mut document = match load_persisted_proxy_settings_document().await? {
921        PersistedProxySettingsDocument::V4(document) => document,
922        PersistedProxySettingsDocument::V2(_) => {
923            return Err((
924                StatusCode::BAD_REQUEST,
925                "routing API requires a version = 5 route graph config".to_string(),
926            ));
927        }
928    };
929
930    let view = service_view_v4_mut(&mut document, proxy.service_name);
931    let routing = sanitize_routing_spec_request(view, payload)?;
932    validate_v4_routing_spec_for_view(proxy.service_name, view, &routing)?;
933    view.routing = Some(routing);
934    crate::config::compile_v4_to_runtime(&document)
935        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
936    save_persisted_proxy_settings_document_and_reload(
937        proxy,
938        PersistedProxySettingsDocument::V4(document),
939    )
940    .await?;
941
942    if let PersistedProxySettingsDocument::V4(cfg) =
943        load_persisted_proxy_settings_document().await?
944    {
945        return Ok(persisted_routing_spec_from_v4(service_view_v4(
946            &cfg,
947            proxy.service_name,
948        )));
949    }
950
951    unreachable!("saved routing document should reload as v4");
952}
953
954pub(super) async fn upsert_persisted_profile(
955    proxy: ProxyService,
956    Path(profile_name): Path<String>,
957    Json(payload): Json<PersistedProfileUpsertRequest>,
958) -> Result<Json<ProfilesResponse>, (StatusCode, String)> {
959    let profile_name = sanitize_profile_name(profile_name.as_str())?;
960    let has_station_binding = profile_request_has_station_binding(&payload);
961
962    if let PersistedProxySettingsDocument::V4(mut document) =
963        load_persisted_proxy_settings_document().await?
964    {
965        if has_station_binding {
966            return Err((
967                StatusCode::BAD_REQUEST,
968                "route graph profiles do not support station bindings; edit routing instead"
969                    .to_string(),
970            ));
971        }
972        let profile = sanitize_profile_request(payload);
973        let view = service_view_v4_mut(&mut document, proxy.service_name);
974        view.profiles.insert(profile_name.clone(), profile);
975        let runtime = crate::config::compile_v4_to_runtime(&document)
976            .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
977        let mgr = runtime_service_manager_for_document(&runtime, proxy.service_name);
978        let resolved = crate::config::resolve_service_profile(mgr, profile_name.as_str())
979            .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
980        crate::config::validate_profile_station_compatibility(
981            proxy.service_name,
982            mgr,
983            profile_name.as_str(),
984            &resolved,
985        )
986        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
987        save_persisted_proxy_settings_document_and_reload(
988            &proxy,
989            PersistedProxySettingsDocument::V4(document),
990        )
991        .await?;
992        return Ok(Json(make_profiles_response(&proxy).await));
993    }
994
995    let profile = sanitize_profile_request(payload);
996    let cfg_snapshot = proxy.config.snapshot().await;
997    let mut cfg = cfg_snapshot.as_ref().clone();
998    let mgr = runtime_service_manager_mut(&mut cfg, proxy.service_name);
999
1000    mgr.profiles.insert(profile_name.clone(), profile);
1001    let resolved = crate::config::resolve_service_profile(mgr, profile_name.as_str())
1002        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1003    crate::config::validate_profile_station_compatibility(
1004        proxy.service_name,
1005        mgr,
1006        profile_name.as_str(),
1007        &resolved,
1008    )
1009    .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1010
1011    Ok(Json(
1012        save_runtime_profile_settings_and_reload(&proxy, cfg).await?,
1013    ))
1014}
1015
1016pub(super) async fn delete_persisted_profile(
1017    proxy: ProxyService,
1018    Path(profile_name): Path<String>,
1019) -> Result<Json<ProfilesResponse>, (StatusCode, String)> {
1020    let profile_name = sanitize_profile_name(profile_name.as_str())?;
1021
1022    if let PersistedProxySettingsDocument::V4(mut document) =
1023        load_persisted_proxy_settings_document().await?
1024    {
1025        let view = service_view_v4_mut(&mut document, proxy.service_name);
1026        let referencing_profiles = view
1027            .profiles
1028            .iter()
1029            .filter_map(|(name, profile)| {
1030                (profile.extends.as_deref() == Some(profile_name.as_str())).then_some(name.clone())
1031            })
1032            .collect::<Vec<_>>();
1033        if !referencing_profiles.is_empty() {
1034            return Err((
1035                StatusCode::CONFLICT,
1036                format!(
1037                    "profile '{}' is extended by profiles: {}",
1038                    profile_name,
1039                    referencing_profiles.join(", ")
1040                ),
1041            ));
1042        }
1043        if view.profiles.remove(profile_name.as_str()).is_none() {
1044            return Err((
1045                StatusCode::NOT_FOUND,
1046                format!("profile '{}' not found", profile_name),
1047            ));
1048        }
1049        if view.default_profile.as_deref() == Some(profile_name.as_str()) {
1050            view.default_profile = None;
1051        }
1052        save_persisted_proxy_settings_document_and_reload(
1053            &proxy,
1054            PersistedProxySettingsDocument::V4(document),
1055        )
1056        .await?;
1057        if proxy
1058            .state
1059            .get_runtime_default_profile_override(proxy.service_name)
1060            .await
1061            .as_deref()
1062            == Some(profile_name.as_str())
1063        {
1064            proxy
1065                .state
1066                .clear_runtime_default_profile_override(proxy.service_name)
1067                .await;
1068        }
1069        return Ok(Json(make_profiles_response(&proxy).await));
1070    }
1071
1072    let cfg_snapshot = proxy.config.snapshot().await;
1073    let mut cfg = cfg_snapshot.as_ref().clone();
1074    let mgr = runtime_service_manager_mut(&mut cfg, proxy.service_name);
1075
1076    let referencing_profiles = mgr
1077        .profiles
1078        .iter()
1079        .filter_map(|(name, profile)| {
1080            (profile.extends.as_deref() == Some(profile_name.as_str())).then_some(name.clone())
1081        })
1082        .collect::<Vec<_>>();
1083    if !referencing_profiles.is_empty() {
1084        return Err((
1085            StatusCode::CONFLICT,
1086            format!(
1087                "profile '{}' is extended by profiles: {}",
1088                profile_name,
1089                referencing_profiles.join(", ")
1090            ),
1091        ));
1092    }
1093
1094    if mgr.profiles.remove(profile_name.as_str()).is_none() {
1095        return Err((
1096            StatusCode::NOT_FOUND,
1097            format!("profile '{}' not found", profile_name),
1098        ));
1099    }
1100    if mgr.default_profile.as_deref() == Some(profile_name.as_str()) {
1101        mgr.default_profile = None;
1102    }
1103
1104    save_runtime_profile_settings_and_reload(&proxy, cfg).await?;
1105    if proxy
1106        .state
1107        .get_runtime_default_profile_override(proxy.service_name)
1108        .await
1109        .as_deref()
1110        == Some(profile_name.as_str())
1111    {
1112        proxy
1113            .state
1114            .clear_runtime_default_profile_override(proxy.service_name)
1115            .await;
1116    }
1117
1118    Ok(Json(make_profiles_response(&proxy).await))
1119}
1120
1121pub(super) async fn update_persisted_station(
1122    proxy: ProxyService,
1123    Path(station_name): Path<String>,
1124    Json(payload): Json<PersistedStationUpdateRequest>,
1125) -> Result<StatusCode, (StatusCode, String)> {
1126    let station_name = sanitize_station_name(station_name.as_str())?;
1127    if payload.enabled.is_none() && payload.level.is_none() {
1128        return Err((
1129            StatusCode::BAD_REQUEST,
1130            "at least one persisted station field must be provided".to_string(),
1131        ));
1132    }
1133
1134    if matches!(
1135        load_persisted_proxy_settings_document().await?,
1136        PersistedProxySettingsDocument::V4(_)
1137    ) {
1138        return Err((
1139            StatusCode::BAD_REQUEST,
1140            "route graph configs do not support station settings writes; edit providers and routing instead"
1141                .to_string(),
1142        ));
1143    }
1144
1145    let cfg_snapshot = proxy.config.snapshot().await;
1146    let mut cfg = cfg_snapshot.as_ref().clone();
1147    let mgr = runtime_service_manager_mut(&mut cfg, proxy.service_name);
1148    let Some(station) = mgr.station_mut(station_name.as_str()) else {
1149        return Err((
1150            StatusCode::NOT_FOUND,
1151            format!("station '{}' not found", station_name),
1152        ));
1153    };
1154    if let Some(enabled) = payload.enabled {
1155        station.enabled = enabled;
1156    }
1157    if let Some(level) = payload.level {
1158        station.level = level.clamp(1, 10);
1159    }
1160
1161    save_runtime_proxy_settings_and_reload(&proxy, cfg).await?;
1162    Ok(StatusCode::NO_CONTENT)
1163}
1164
1165pub(super) async fn set_persisted_active_station(
1166    proxy: ProxyService,
1167    Json(payload): Json<PersistedStationActiveRequest>,
1168) -> Result<StatusCode, (StatusCode, String)> {
1169    let station_name = payload.station_name();
1170
1171    if matches!(
1172        load_persisted_proxy_settings_document().await?,
1173        PersistedProxySettingsDocument::V4(_)
1174    ) {
1175        return Err((
1176            StatusCode::BAD_REQUEST,
1177            "route graph configs do not support station active writes; edit routing instead"
1178                .to_string(),
1179        ));
1180    }
1181
1182    let cfg_snapshot = proxy.config.snapshot().await;
1183    let mut cfg = cfg_snapshot.as_ref().clone();
1184    let mgr = runtime_service_manager_mut(&mut cfg, proxy.service_name);
1185    if let Some(station_name) = station_name.as_deref()
1186        && !mgr.contains_station(station_name)
1187    {
1188        return Err((
1189            StatusCode::NOT_FOUND,
1190            format!("station '{}' not found", station_name),
1191        ));
1192    }
1193    mgr.active = station_name;
1194
1195    save_runtime_proxy_settings_and_reload(&proxy, cfg).await?;
1196    Ok(StatusCode::NO_CONTENT)
1197}
1198
1199pub(super) async fn upsert_persisted_station_spec(
1200    proxy: ProxyService,
1201    Path(station_name): Path<String>,
1202    Json(payload): Json<PersistedStationSpecUpsertRequest>,
1203) -> Result<StatusCode, (StatusCode, String)> {
1204    let station_name = sanitize_station_name(station_name.as_str())?;
1205    let mut station = sanitize_station_spec_request(payload)?;
1206    station.name = station_name.clone();
1207
1208    if matches!(
1209        load_persisted_proxy_settings_document().await?,
1210        PersistedProxySettingsDocument::V4(_)
1211    ) {
1212        return Err((
1213            StatusCode::BAD_REQUEST,
1214            "route graph configs do not support station spec editing; edit providers and routing instead"
1215                .to_string(),
1216        ));
1217    }
1218
1219    let mut cfg = load_persisted_proxy_settings_v2().await?;
1220    let view = service_view_v2_mut(&mut cfg, proxy.service_name);
1221    validate_station_members_for_view(
1222        proxy.service_name,
1223        station_name.as_str(),
1224        view,
1225        &station.members,
1226    )?;
1227    view.groups.insert(
1228        station_name.clone(),
1229        crate::config::GroupConfigV2 {
1230            alias: station.alias.clone(),
1231            enabled: station.enabled,
1232            level: station.level.clamp(1, 10),
1233            members: station.members.clone(),
1234        },
1235    );
1236
1237    crate::config::compile_v2_to_runtime(&cfg)
1238        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1239    save_persisted_proxy_settings_v2_and_reload(&proxy, cfg).await?;
1240    Ok(StatusCode::NO_CONTENT)
1241}
1242
1243pub(super) async fn delete_persisted_station_spec(
1244    proxy: ProxyService,
1245    Path(station_name): Path<String>,
1246) -> Result<StatusCode, (StatusCode, String)> {
1247    let station_name = sanitize_station_name(station_name.as_str())?;
1248    if matches!(
1249        load_persisted_proxy_settings_document().await?,
1250        PersistedProxySettingsDocument::V4(_)
1251    ) {
1252        return Err((
1253            StatusCode::BAD_REQUEST,
1254            "route graph configs do not support station spec editing; edit providers and routing instead"
1255                .to_string(),
1256        ));
1257    }
1258    let mut cfg = load_persisted_proxy_settings_v2().await?;
1259    let view = service_view_v2_mut(&mut cfg, proxy.service_name);
1260
1261    let referencing_profiles = view
1262        .profiles
1263        .iter()
1264        .filter_map(|(profile_name, profile)| {
1265            (profile.station.as_deref() == Some(station_name.as_str()))
1266                .then_some(profile_name.clone())
1267        })
1268        .collect::<Vec<_>>();
1269    if !referencing_profiles.is_empty() {
1270        return Err((
1271            StatusCode::CONFLICT,
1272            format!(
1273                "station '{}' is referenced by profiles: {}",
1274                station_name,
1275                referencing_profiles.join(", ")
1276            ),
1277        ));
1278    }
1279
1280    if view.groups.remove(station_name.as_str()).is_none() {
1281        return Err((
1282            StatusCode::NOT_FOUND,
1283            format!("station '{}' not found", station_name),
1284        ));
1285    }
1286    if view.active_group.as_deref() == Some(station_name.as_str()) {
1287        view.active_group = None;
1288    }
1289
1290    crate::config::compile_v2_to_runtime(&cfg)
1291        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1292    save_persisted_proxy_settings_v2_and_reload(&proxy, cfg).await?;
1293    Ok(StatusCode::NO_CONTENT)
1294}
1295
1296pub(super) async fn upsert_persisted_provider_spec(
1297    proxy: ProxyService,
1298    Path(provider_name): Path<String>,
1299    Json(payload): Json<PersistedProviderSpecUpsertRequest>,
1300) -> Result<StatusCode, (StatusCode, String)> {
1301    upsert_persisted_provider_spec_for_proxy(&proxy, provider_name, payload).await
1302}
1303
1304pub(super) async fn upsert_persisted_provider_spec_for_proxy(
1305    proxy: &ProxyService,
1306    provider_name: String,
1307    payload: PersistedProviderSpecUpsertRequest,
1308) -> Result<StatusCode, (StatusCode, String)> {
1309    let provider_name = sanitize_provider_name(provider_name.as_str())?;
1310    let mut provider = sanitize_provider_spec_request(payload)?;
1311    provider.spec.name = provider_name.clone();
1312
1313    if let PersistedProxySettingsDocument::V4(mut document) =
1314        load_persisted_proxy_settings_document().await?
1315    {
1316        let view = service_view_v4_mut(&mut document, proxy.service_name);
1317        let existing_provider = view.providers.get(provider_name.as_str()).cloned();
1318        let is_new_provider = existing_provider.is_none();
1319        view.providers.insert(
1320            provider_name.clone(),
1321            merge_persisted_provider_spec_v4(existing_provider.as_ref(), &provider),
1322        );
1323        if is_new_provider {
1324            append_new_provider_to_explicit_v4_order(view, provider_name.as_str());
1325        }
1326        if !provider.spec.enabled {
1327            let entry = ensure_v4_entry_route_mut(view);
1328            if entry.target.as_deref() == Some(provider_name.as_str()) {
1329                entry.strategy = crate::config::RoutingPolicyV4::OrderedFailover;
1330                entry.target = None;
1331            }
1332        }
1333        crate::config::compile_v4_to_runtime(&document)
1334            .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1335        save_persisted_proxy_settings_document_and_reload(
1336            proxy,
1337            PersistedProxySettingsDocument::V4(document),
1338        )
1339        .await?;
1340        return Ok(StatusCode::NO_CONTENT);
1341    }
1342
1343    let mut cfg = load_persisted_proxy_settings_v2().await?;
1344    let view = service_view_v2_mut(&mut cfg, proxy.service_name);
1345    let existing_provider = view.providers.get(provider_name.as_str()).cloned();
1346    view.providers.insert(
1347        provider_name,
1348        merge_persisted_provider_spec(existing_provider.as_ref(), &provider),
1349    );
1350
1351    crate::config::compile_v2_to_runtime(&cfg)
1352        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1353    save_persisted_proxy_settings_v2_and_reload(proxy, cfg).await?;
1354    Ok(StatusCode::NO_CONTENT)
1355}
1356
1357pub(super) async fn delete_persisted_provider_spec(
1358    proxy: ProxyService,
1359    Path(provider_name): Path<String>,
1360) -> Result<StatusCode, (StatusCode, String)> {
1361    let provider_name = sanitize_provider_name(provider_name.as_str())?;
1362
1363    if let PersistedProxySettingsDocument::V4(mut document) =
1364        load_persisted_proxy_settings_document().await?
1365    {
1366        let view = service_view_v4_mut(&mut document, proxy.service_name);
1367        let Some(_) = view.providers.remove(provider_name.as_str()) else {
1368            return Err((
1369                StatusCode::NOT_FOUND,
1370                format!("provider '{}' not found", provider_name),
1371            ));
1372        };
1373        if let Some(routing) = view.routing.as_mut() {
1374            for node in routing.routes.values_mut() {
1375                node.children.retain(|name| name != &provider_name);
1376                if node.target.as_deref() == Some(provider_name.as_str()) {
1377                    node.target = None;
1378                    if matches!(node.strategy, crate::config::RoutingPolicyV4::ManualSticky) {
1379                        node.strategy = crate::config::RoutingPolicyV4::OrderedFailover;
1380                    }
1381                }
1382            }
1383        }
1384        save_persisted_proxy_settings_document_and_reload(
1385            &proxy,
1386            PersistedProxySettingsDocument::V4(document),
1387        )
1388        .await?;
1389        return Ok(StatusCode::NO_CONTENT);
1390    }
1391
1392    let mut cfg = load_persisted_proxy_settings_v2().await?;
1393    let view = service_view_v2_mut(&mut cfg, proxy.service_name);
1394
1395    let referencing_stations = view
1396        .groups
1397        .iter()
1398        .filter_map(|(station_name, station)| {
1399            station
1400                .members
1401                .iter()
1402                .any(|member| member.provider == provider_name)
1403                .then_some(station_name.clone())
1404        })
1405        .collect::<Vec<_>>();
1406    if !referencing_stations.is_empty() {
1407        return Err((
1408            StatusCode::CONFLICT,
1409            format!(
1410                "provider '{}' is referenced by stations: {}",
1411                provider_name,
1412                referencing_stations.join(", ")
1413            ),
1414        ));
1415    }
1416
1417    if view.providers.remove(provider_name.as_str()).is_none() {
1418        return Err((
1419            StatusCode::NOT_FOUND,
1420            format!("provider '{}' not found", provider_name),
1421        ));
1422    }
1423
1424    crate::config::compile_v2_to_runtime(&cfg)
1425        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1426    save_persisted_proxy_settings_v2_and_reload(&proxy, cfg).await?;
1427    Ok(StatusCode::NO_CONTENT)
1428}
1429
1430pub(super) async fn set_persisted_default_profile(
1431    proxy: ProxyService,
1432    Json(payload): Json<PersistedDefaultProfileRequest>,
1433) -> Result<Json<ProfilesResponse>, (StatusCode, String)> {
1434    proxy
1435        .set_persisted_default_profile(payload.profile_name)
1436        .await
1437        .map(Json)
1438        .map_err(super::ProxyControlError::into_http_error)
1439}