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