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