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