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