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.supported_models.is_empty() && endpoint.model_mapping.is_empty()
784            })
785            .unwrap_or(true)
786}
787
788fn merge_persisted_provider_spec_v4(
789    existing: Option<&crate::config::ProviderConfigV4>,
790    provider: &SanitizedPersistedProviderSpec,
791) -> crate::config::ProviderConfigV4 {
792    let spec = &provider.spec;
793    let mut out = crate::config::ProviderConfigV4 {
794        alias: spec.alias.clone(),
795        enabled: spec.enabled,
796        base_url: None,
797        auth: existing
798            .map(|provider| provider.auth.clone())
799            .unwrap_or_default(),
800        inline_auth: crate::config::UpstreamAuth {
801            auth_token: existing.and_then(|provider| provider.inline_auth.auth_token.clone()),
802            auth_token_env: spec.auth_token_env.clone(),
803            api_key: existing.and_then(|provider| provider.inline_auth.api_key.clone()),
804            api_key_env: spec.api_key_env.clone(),
805        },
806        tags: if provider.tags_provided {
807            spec.tags.clone()
808        } else {
809            existing
810                .map(|provider| provider.tags.clone())
811                .unwrap_or_default()
812        },
813        supported_models: existing
814            .map(|provider| provider.supported_models.clone())
815            .unwrap_or_default(),
816        model_mapping: existing
817            .map(|provider| provider.model_mapping.clone())
818            .unwrap_or_default(),
819        limits: if provider.limits_provided {
820            spec.limits.clone()
821        } else {
822            existing
823                .map(|provider| provider.limits.clone())
824                .unwrap_or_default()
825        },
826        endpoints: std::collections::BTreeMap::new(),
827    };
828
829    if spec.endpoints.len() == 1
830        && spec.endpoints[0].name == "default"
831        && spec.endpoints[0].priority == 0
832        && spec.endpoints[0].tags.is_empty()
833        && v4_default_endpoint_can_be_inlined(existing, provider, &spec.endpoints[0])
834    {
835        out.base_url = Some(spec.endpoints[0].base_url.clone());
836    } else {
837        out.endpoints = spec
838            .endpoints
839            .iter()
840            .map(|endpoint| {
841                let existing_endpoint =
842                    existing.and_then(|provider| provider.endpoints.get(endpoint.name.as_str()));
843                (
844                    endpoint.name.clone(),
845                    crate::config::ProviderEndpointV4 {
846                        base_url: endpoint.base_url.clone(),
847                        enabled: endpoint.enabled,
848                        priority: endpoint.priority,
849                        tags: if provider
850                            .endpoint_tags_provided
851                            .get(endpoint.name.as_str())
852                            .copied()
853                            .unwrap_or(false)
854                        {
855                            endpoint.tags.clone()
856                        } else {
857                            existing_endpoint
858                                .map(|endpoint| endpoint.tags.clone())
859                                .unwrap_or_default()
860                        },
861                        supported_models: existing_endpoint
862                            .map(|endpoint| endpoint.supported_models.clone())
863                            .unwrap_or_default(),
864                        model_mapping: existing_endpoint
865                            .map(|endpoint| endpoint.model_mapping.clone())
866                            .unwrap_or_default(),
867                        limits: if provider
868                            .endpoint_limits_provided
869                            .get(endpoint.name.as_str())
870                            .copied()
871                            .unwrap_or(false)
872                        {
873                            endpoint.limits.clone()
874                        } else {
875                            existing_endpoint
876                                .map(|endpoint| endpoint.limits.clone())
877                                .unwrap_or_default()
878                        },
879                    },
880                )
881            })
882            .collect();
883    }
884
885    out
886}
887
888pub(super) fn runtime_service_manager_for_document<'a>(
889    runtime: &'a crate::config::ProxyConfig,
890    service_name: &str,
891) -> &'a crate::config::ServiceConfigManager {
892    if service_name == "claude" {
893        &runtime.claude
894    } else {
895        &runtime.codex
896    }
897}
898
899fn ensure_v4_entry_route_mut(
900    view: &mut crate::config::ServiceViewV4,
901) -> &mut crate::config::RoutingNodeV4 {
902    let routing = view
903        .routing
904        .get_or_insert_with(crate::config::RoutingConfigV4::default);
905    if !routing.routes.contains_key(routing.entry.as_str()) {
906        routing.routes.insert(
907            routing.entry.clone(),
908            crate::config::RoutingNodeV4::default(),
909        );
910    }
911    routing
912        .routes
913        .get_mut(routing.entry.as_str())
914        .expect("entry route inserted above")
915}
916
917fn append_new_provider_to_explicit_v4_order(
918    view: &mut crate::config::ServiceViewV4,
919    provider_name: &str,
920) {
921    let routing = ensure_v4_entry_route_mut(view);
922    if routing.children.iter().any(|name| name == provider_name) {
923        return;
924    }
925    routing.children.push(provider_name.to_string());
926}
927
928fn validate_station_members_for_view(
929    service_name: &str,
930    station_name: &str,
931    view: &crate::config::ServiceViewV2,
932    members: &[crate::config::GroupMemberRefV2],
933) -> Result<(), (StatusCode, String)> {
934    for member in members {
935        let provider = view
936            .providers
937            .get(member.provider.as_str())
938            .ok_or_else(|| {
939                (
940                    StatusCode::BAD_REQUEST,
941                    format!(
942                        "[{service_name}] station '{}' references missing provider '{}'",
943                        station_name, member.provider
944                    ),
945                )
946            })?;
947
948        for endpoint_name in &member.endpoint_names {
949            if !provider.endpoints.contains_key(endpoint_name.as_str()) {
950                return Err((
951                    StatusCode::BAD_REQUEST,
952                    format!(
953                        "[{service_name}] station '{}' references missing endpoint '{}.{}'",
954                        station_name, member.provider, endpoint_name
955                    ),
956                ));
957            }
958        }
959    }
960    Ok(())
961}
962
963pub(super) async fn list_persisted_station_specs(
964    proxy: ProxyService,
965) -> Result<Json<crate::config::PersistedStationsCatalog>, (StatusCode, String)> {
966    match load_persisted_proxy_settings_document().await? {
967        PersistedProxySettingsDocument::V2(cfg) => {
968            Ok(Json(crate::config::build_persisted_station_catalog(
969                service_view_v2(&cfg, proxy.service_name),
970            )))
971        }
972        PersistedProxySettingsDocument::V4(_) => Err((
973            StatusCode::BAD_REQUEST,
974            "route graph configs do not expose station specs; use the routing and provider specs APIs"
975                .to_string(),
976        )),
977    }
978}
979
980pub(super) async fn list_persisted_provider_specs(
981    proxy: ProxyService,
982) -> Result<Json<crate::config::PersistedProvidersCatalog>, (StatusCode, String)> {
983    proxy
984        .persisted_provider_specs()
985        .await
986        .map(Json)
987        .map_err(super::ProxyControlError::into_http_error)
988}
989
990pub(super) async fn list_persisted_routing_spec(
991    proxy: ProxyService,
992) -> Result<Json<crate::config::PersistedRoutingSpec>, (StatusCode, String)> {
993    proxy
994        .persisted_routing_spec()
995        .await
996        .map(Json)
997        .map_err(super::ProxyControlError::into_http_error)
998}
999
1000pub(super) async fn upsert_persisted_routing_spec(
1001    proxy: ProxyService,
1002    Json(payload): Json<PersistedRoutingUpsertRequest>,
1003) -> Result<Json<crate::config::PersistedRoutingSpec>, (StatusCode, String)> {
1004    proxy
1005        .upsert_persisted_routing_spec(payload)
1006        .await
1007        .map(Json)
1008        .map_err(super::ProxyControlError::into_http_error)
1009}
1010
1011pub(super) async fn upsert_persisted_routing_spec_for_proxy(
1012    proxy: &ProxyService,
1013    payload: PersistedRoutingUpsertRequest,
1014) -> Result<crate::config::PersistedRoutingSpec, (StatusCode, String)> {
1015    let mut document = match load_persisted_proxy_settings_document().await? {
1016        PersistedProxySettingsDocument::V4(document) => document,
1017        PersistedProxySettingsDocument::V2(_) => {
1018            return Err((
1019                StatusCode::BAD_REQUEST,
1020                "routing API requires a version = 5 route graph config".to_string(),
1021            ));
1022        }
1023    };
1024
1025    let view = service_view_v4_mut(&mut document, proxy.service_name);
1026    let routing = sanitize_routing_spec_request(view, payload)?;
1027    validate_v4_routing_spec_for_view(proxy.service_name, view, &routing)?;
1028    view.routing = Some(routing);
1029    crate::config::compile_v4_to_runtime(&document)
1030        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1031    save_persisted_proxy_settings_document_and_reload(
1032        proxy,
1033        PersistedProxySettingsDocument::V4(document),
1034    )
1035    .await?;
1036
1037    if let PersistedProxySettingsDocument::V4(cfg) =
1038        load_persisted_proxy_settings_document().await?
1039    {
1040        return Ok(persisted_routing_spec_from_v4(service_view_v4(
1041            &cfg,
1042            proxy.service_name,
1043        )));
1044    }
1045
1046    unreachable!("saved routing document should reload as v4");
1047}
1048
1049pub(super) async fn upsert_persisted_profile(
1050    proxy: ProxyService,
1051    Path(profile_name): Path<String>,
1052    Json(payload): Json<PersistedProfileUpsertRequest>,
1053) -> Result<Json<ProfilesResponse>, (StatusCode, String)> {
1054    let profile_name = sanitize_profile_name(profile_name.as_str())?;
1055    let has_station_binding = profile_request_has_station_binding(&payload);
1056
1057    if let PersistedProxySettingsDocument::V4(mut document) =
1058        load_persisted_proxy_settings_document().await?
1059    {
1060        if has_station_binding {
1061            return Err((
1062                StatusCode::BAD_REQUEST,
1063                "route graph profiles do not support station bindings; edit routing instead"
1064                    .to_string(),
1065            ));
1066        }
1067        let profile = sanitize_profile_request(payload);
1068        let view = service_view_v4_mut(&mut document, proxy.service_name);
1069        view.profiles.insert(profile_name.clone(), profile);
1070        let runtime = crate::config::compile_v4_to_runtime(&document)
1071            .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1072        let mgr = runtime_service_manager_for_document(&runtime, proxy.service_name);
1073        let resolved = crate::config::resolve_service_profile(mgr, profile_name.as_str())
1074            .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1075        crate::config::validate_profile_station_compatibility(
1076            proxy.service_name,
1077            mgr,
1078            profile_name.as_str(),
1079            &resolved,
1080        )
1081        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1082        save_persisted_proxy_settings_document_and_reload(
1083            &proxy,
1084            PersistedProxySettingsDocument::V4(document),
1085        )
1086        .await?;
1087        return Ok(Json(make_profiles_response(&proxy).await));
1088    }
1089
1090    let profile = sanitize_profile_request(payload);
1091    let cfg_snapshot = proxy.config.snapshot().await;
1092    let mut cfg = cfg_snapshot.as_ref().clone();
1093    let mgr = runtime_service_manager_mut(&mut cfg, proxy.service_name);
1094
1095    mgr.profiles.insert(profile_name.clone(), profile);
1096    let resolved = crate::config::resolve_service_profile(mgr, profile_name.as_str())
1097        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1098    crate::config::validate_profile_station_compatibility(
1099        proxy.service_name,
1100        mgr,
1101        profile_name.as_str(),
1102        &resolved,
1103    )
1104    .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1105
1106    Ok(Json(
1107        save_runtime_profile_settings_and_reload(&proxy, cfg).await?,
1108    ))
1109}
1110
1111pub(super) async fn delete_persisted_profile(
1112    proxy: ProxyService,
1113    Path(profile_name): Path<String>,
1114) -> Result<Json<ProfilesResponse>, (StatusCode, String)> {
1115    let profile_name = sanitize_profile_name(profile_name.as_str())?;
1116
1117    if let PersistedProxySettingsDocument::V4(mut document) =
1118        load_persisted_proxy_settings_document().await?
1119    {
1120        let view = service_view_v4_mut(&mut document, proxy.service_name);
1121        let referencing_profiles = view
1122            .profiles
1123            .iter()
1124            .filter_map(|(name, profile)| {
1125                (profile.extends.as_deref() == Some(profile_name.as_str())).then_some(name.clone())
1126            })
1127            .collect::<Vec<_>>();
1128        if !referencing_profiles.is_empty() {
1129            return Err((
1130                StatusCode::CONFLICT,
1131                format!(
1132                    "profile '{}' is extended by profiles: {}",
1133                    profile_name,
1134                    referencing_profiles.join(", ")
1135                ),
1136            ));
1137        }
1138        if view.profiles.remove(profile_name.as_str()).is_none() {
1139            return Err((
1140                StatusCode::NOT_FOUND,
1141                format!("profile '{}' not found", profile_name),
1142            ));
1143        }
1144        if view.default_profile.as_deref() == Some(profile_name.as_str()) {
1145            view.default_profile = None;
1146        }
1147        save_persisted_proxy_settings_document_and_reload(
1148            &proxy,
1149            PersistedProxySettingsDocument::V4(document),
1150        )
1151        .await?;
1152        if proxy
1153            .state
1154            .get_runtime_default_profile_override(proxy.service_name)
1155            .await
1156            .as_deref()
1157            == Some(profile_name.as_str())
1158        {
1159            proxy
1160                .state
1161                .clear_runtime_default_profile_override(proxy.service_name)
1162                .await;
1163        }
1164        return Ok(Json(make_profiles_response(&proxy).await));
1165    }
1166
1167    let cfg_snapshot = proxy.config.snapshot().await;
1168    let mut cfg = cfg_snapshot.as_ref().clone();
1169    let mgr = runtime_service_manager_mut(&mut cfg, proxy.service_name);
1170
1171    let referencing_profiles = mgr
1172        .profiles
1173        .iter()
1174        .filter_map(|(name, profile)| {
1175            (profile.extends.as_deref() == Some(profile_name.as_str())).then_some(name.clone())
1176        })
1177        .collect::<Vec<_>>();
1178    if !referencing_profiles.is_empty() {
1179        return Err((
1180            StatusCode::CONFLICT,
1181            format!(
1182                "profile '{}' is extended by profiles: {}",
1183                profile_name,
1184                referencing_profiles.join(", ")
1185            ),
1186        ));
1187    }
1188
1189    if mgr.profiles.remove(profile_name.as_str()).is_none() {
1190        return Err((
1191            StatusCode::NOT_FOUND,
1192            format!("profile '{}' not found", profile_name),
1193        ));
1194    }
1195    if mgr.default_profile.as_deref() == Some(profile_name.as_str()) {
1196        mgr.default_profile = None;
1197    }
1198
1199    save_runtime_profile_settings_and_reload(&proxy, cfg).await?;
1200    if proxy
1201        .state
1202        .get_runtime_default_profile_override(proxy.service_name)
1203        .await
1204        .as_deref()
1205        == Some(profile_name.as_str())
1206    {
1207        proxy
1208            .state
1209            .clear_runtime_default_profile_override(proxy.service_name)
1210            .await;
1211    }
1212
1213    Ok(Json(make_profiles_response(&proxy).await))
1214}
1215
1216pub(super) async fn update_persisted_station(
1217    proxy: ProxyService,
1218    Path(station_name): Path<String>,
1219    Json(payload): Json<PersistedStationUpdateRequest>,
1220) -> Result<StatusCode, (StatusCode, String)> {
1221    let station_name = sanitize_station_name(station_name.as_str())?;
1222    if payload.enabled.is_none() && payload.level.is_none() {
1223        return Err((
1224            StatusCode::BAD_REQUEST,
1225            "at least one persisted station field must be provided".to_string(),
1226        ));
1227    }
1228
1229    if matches!(
1230        load_persisted_proxy_settings_document().await?,
1231        PersistedProxySettingsDocument::V4(_)
1232    ) {
1233        return Err((
1234            StatusCode::BAD_REQUEST,
1235            "route graph configs do not support station settings writes; edit providers and routing instead"
1236                .to_string(),
1237        ));
1238    }
1239
1240    let cfg_snapshot = proxy.config.snapshot().await;
1241    let mut cfg = cfg_snapshot.as_ref().clone();
1242    let mgr = runtime_service_manager_mut(&mut cfg, proxy.service_name);
1243    let Some(station) = mgr.station_mut(station_name.as_str()) else {
1244        return Err((
1245            StatusCode::NOT_FOUND,
1246            format!("station '{}' not found", station_name),
1247        ));
1248    };
1249    if let Some(enabled) = payload.enabled {
1250        station.enabled = enabled;
1251    }
1252    if let Some(level) = payload.level {
1253        station.level = level.clamp(1, 10);
1254    }
1255
1256    save_runtime_proxy_settings_and_reload(&proxy, cfg).await?;
1257    Ok(StatusCode::NO_CONTENT)
1258}
1259
1260pub(super) async fn set_persisted_active_station(
1261    proxy: ProxyService,
1262    Json(payload): Json<PersistedStationActiveRequest>,
1263) -> Result<StatusCode, (StatusCode, String)> {
1264    let station_name = payload.station_name();
1265
1266    if matches!(
1267        load_persisted_proxy_settings_document().await?,
1268        PersistedProxySettingsDocument::V4(_)
1269    ) {
1270        return Err((
1271            StatusCode::BAD_REQUEST,
1272            "route graph configs do not support station active writes; edit routing instead"
1273                .to_string(),
1274        ));
1275    }
1276
1277    let cfg_snapshot = proxy.config.snapshot().await;
1278    let mut cfg = cfg_snapshot.as_ref().clone();
1279    let mgr = runtime_service_manager_mut(&mut cfg, proxy.service_name);
1280    if let Some(station_name) = station_name.as_deref()
1281        && !mgr.contains_station(station_name)
1282    {
1283        return Err((
1284            StatusCode::NOT_FOUND,
1285            format!("station '{}' not found", station_name),
1286        ));
1287    }
1288    mgr.active = station_name;
1289
1290    save_runtime_proxy_settings_and_reload(&proxy, cfg).await?;
1291    Ok(StatusCode::NO_CONTENT)
1292}
1293
1294pub(super) async fn upsert_persisted_station_spec(
1295    proxy: ProxyService,
1296    Path(station_name): Path<String>,
1297    Json(payload): Json<PersistedStationSpecUpsertRequest>,
1298) -> Result<StatusCode, (StatusCode, String)> {
1299    let station_name = sanitize_station_name(station_name.as_str())?;
1300    let mut station = sanitize_station_spec_request(payload)?;
1301    station.name = station_name.clone();
1302
1303    if matches!(
1304        load_persisted_proxy_settings_document().await?,
1305        PersistedProxySettingsDocument::V4(_)
1306    ) {
1307        return Err((
1308            StatusCode::BAD_REQUEST,
1309            "route graph configs do not support station spec editing; edit providers and routing instead"
1310                .to_string(),
1311        ));
1312    }
1313
1314    let mut cfg = load_persisted_proxy_settings_v2().await?;
1315    let view = service_view_v2_mut(&mut cfg, proxy.service_name);
1316    validate_station_members_for_view(
1317        proxy.service_name,
1318        station_name.as_str(),
1319        view,
1320        &station.members,
1321    )?;
1322    view.groups.insert(
1323        station_name.clone(),
1324        crate::config::GroupConfigV2 {
1325            alias: station.alias.clone(),
1326            enabled: station.enabled,
1327            level: station.level.clamp(1, 10),
1328            members: station.members.clone(),
1329        },
1330    );
1331
1332    crate::config::compile_v2_to_runtime(&cfg)
1333        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1334    save_persisted_proxy_settings_v2_and_reload(&proxy, cfg).await?;
1335    Ok(StatusCode::NO_CONTENT)
1336}
1337
1338pub(super) async fn delete_persisted_station_spec(
1339    proxy: ProxyService,
1340    Path(station_name): Path<String>,
1341) -> Result<StatusCode, (StatusCode, String)> {
1342    let station_name = sanitize_station_name(station_name.as_str())?;
1343    if matches!(
1344        load_persisted_proxy_settings_document().await?,
1345        PersistedProxySettingsDocument::V4(_)
1346    ) {
1347        return Err((
1348            StatusCode::BAD_REQUEST,
1349            "route graph configs do not support station spec editing; edit providers and routing instead"
1350                .to_string(),
1351        ));
1352    }
1353    let mut cfg = load_persisted_proxy_settings_v2().await?;
1354    let view = service_view_v2_mut(&mut cfg, proxy.service_name);
1355
1356    let referencing_profiles = view
1357        .profiles
1358        .iter()
1359        .filter_map(|(profile_name, profile)| {
1360            (profile.station.as_deref() == Some(station_name.as_str()))
1361                .then_some(profile_name.clone())
1362        })
1363        .collect::<Vec<_>>();
1364    if !referencing_profiles.is_empty() {
1365        return Err((
1366            StatusCode::CONFLICT,
1367            format!(
1368                "station '{}' is referenced by profiles: {}",
1369                station_name,
1370                referencing_profiles.join(", ")
1371            ),
1372        ));
1373    }
1374
1375    if view.groups.remove(station_name.as_str()).is_none() {
1376        return Err((
1377            StatusCode::NOT_FOUND,
1378            format!("station '{}' not found", station_name),
1379        ));
1380    }
1381    if view.active_group.as_deref() == Some(station_name.as_str()) {
1382        view.active_group = None;
1383    }
1384
1385    crate::config::compile_v2_to_runtime(&cfg)
1386        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1387    save_persisted_proxy_settings_v2_and_reload(&proxy, cfg).await?;
1388    Ok(StatusCode::NO_CONTENT)
1389}
1390
1391pub(super) async fn upsert_persisted_provider_spec(
1392    proxy: ProxyService,
1393    Path(provider_name): Path<String>,
1394    Json(payload): Json<PersistedProviderSpecUpsertRequest>,
1395) -> Result<StatusCode, (StatusCode, String)> {
1396    upsert_persisted_provider_spec_for_proxy(&proxy, provider_name, payload).await
1397}
1398
1399pub(super) async fn upsert_persisted_provider_spec_for_proxy(
1400    proxy: &ProxyService,
1401    provider_name: String,
1402    payload: PersistedProviderSpecUpsertRequest,
1403) -> Result<StatusCode, (StatusCode, String)> {
1404    let provider_name = sanitize_provider_name(provider_name.as_str())?;
1405    let mut provider = sanitize_provider_spec_request(payload)?;
1406    provider.spec.name = provider_name.clone();
1407
1408    if let PersistedProxySettingsDocument::V4(mut document) =
1409        load_persisted_proxy_settings_document().await?
1410    {
1411        let view = service_view_v4_mut(&mut document, proxy.service_name);
1412        let existing_provider = view.providers.get(provider_name.as_str()).cloned();
1413        let is_new_provider = existing_provider.is_none();
1414        view.providers.insert(
1415            provider_name.clone(),
1416            merge_persisted_provider_spec_v4(existing_provider.as_ref(), &provider),
1417        );
1418        if is_new_provider {
1419            append_new_provider_to_explicit_v4_order(view, provider_name.as_str());
1420        }
1421        if !provider.spec.enabled {
1422            let entry = ensure_v4_entry_route_mut(view);
1423            if entry.target.as_deref() == Some(provider_name.as_str()) {
1424                entry.strategy = crate::config::RoutingPolicyV4::OrderedFailover;
1425                entry.target = None;
1426            }
1427        }
1428        crate::config::compile_v4_to_runtime(&document)
1429            .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1430        save_persisted_proxy_settings_document_and_reload(
1431            proxy,
1432            PersistedProxySettingsDocument::V4(document),
1433        )
1434        .await?;
1435        return Ok(StatusCode::NO_CONTENT);
1436    }
1437
1438    if provider.limits_provided
1439        || provider
1440            .endpoint_limits_provided
1441            .values()
1442            .any(|provided| *provided)
1443    {
1444        return Err((
1445            StatusCode::BAD_REQUEST,
1446            "provider concurrency limits require a route graph config".to_string(),
1447        ));
1448    }
1449
1450    let mut cfg = load_persisted_proxy_settings_v2().await?;
1451    let view = service_view_v2_mut(&mut cfg, proxy.service_name);
1452    let existing_provider = view.providers.get(provider_name.as_str()).cloned();
1453    view.providers.insert(
1454        provider_name,
1455        merge_persisted_provider_spec(existing_provider.as_ref(), &provider),
1456    );
1457
1458    crate::config::compile_v2_to_runtime(&cfg)
1459        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1460    save_persisted_proxy_settings_v2_and_reload(proxy, cfg).await?;
1461    Ok(StatusCode::NO_CONTENT)
1462}
1463
1464pub(super) async fn delete_persisted_provider_spec(
1465    proxy: ProxyService,
1466    Path(provider_name): Path<String>,
1467) -> Result<StatusCode, (StatusCode, String)> {
1468    let provider_name = sanitize_provider_name(provider_name.as_str())?;
1469
1470    if let PersistedProxySettingsDocument::V4(mut document) =
1471        load_persisted_proxy_settings_document().await?
1472    {
1473        let view = service_view_v4_mut(&mut document, proxy.service_name);
1474        let Some(_) = view.providers.remove(provider_name.as_str()) else {
1475            return Err((
1476                StatusCode::NOT_FOUND,
1477                format!("provider '{}' not found", provider_name),
1478            ));
1479        };
1480        if let Some(routing) = view.routing.as_mut() {
1481            for node in routing.routes.values_mut() {
1482                node.children.retain(|name| name != &provider_name);
1483                if node.target.as_deref() == Some(provider_name.as_str()) {
1484                    node.target = None;
1485                    if matches!(node.strategy, crate::config::RoutingPolicyV4::ManualSticky) {
1486                        node.strategy = crate::config::RoutingPolicyV4::OrderedFailover;
1487                    }
1488                }
1489            }
1490        }
1491        save_persisted_proxy_settings_document_and_reload(
1492            &proxy,
1493            PersistedProxySettingsDocument::V4(document),
1494        )
1495        .await?;
1496        return Ok(StatusCode::NO_CONTENT);
1497    }
1498
1499    let mut cfg = load_persisted_proxy_settings_v2().await?;
1500    let view = service_view_v2_mut(&mut cfg, proxy.service_name);
1501
1502    let referencing_stations = view
1503        .groups
1504        .iter()
1505        .filter_map(|(station_name, station)| {
1506            station
1507                .members
1508                .iter()
1509                .any(|member| member.provider == provider_name)
1510                .then_some(station_name.clone())
1511        })
1512        .collect::<Vec<_>>();
1513    if !referencing_stations.is_empty() {
1514        return Err((
1515            StatusCode::CONFLICT,
1516            format!(
1517                "provider '{}' is referenced by stations: {}",
1518                provider_name,
1519                referencing_stations.join(", ")
1520            ),
1521        ));
1522    }
1523
1524    if view.providers.remove(provider_name.as_str()).is_none() {
1525        return Err((
1526            StatusCode::NOT_FOUND,
1527            format!("provider '{}' not found", provider_name),
1528        ));
1529    }
1530
1531    crate::config::compile_v2_to_runtime(&cfg)
1532        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
1533    save_persisted_proxy_settings_v2_and_reload(&proxy, cfg).await?;
1534    Ok(StatusCode::NO_CONTENT)
1535}
1536
1537pub(super) async fn set_persisted_default_profile(
1538    proxy: ProxyService,
1539    Json(payload): Json<PersistedDefaultProfileRequest>,
1540) -> Result<Json<ProfilesResponse>, (StatusCode, String)> {
1541    proxy
1542        .set_persisted_default_profile(payload.profile_name)
1543        .await
1544        .map(Json)
1545        .map_err(super::ProxyControlError::into_http_error)
1546}