1use super::*;
2
3#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
4pub struct ServiceControlProfile {
5 #[serde(default, skip_serializing_if = "Option::is_none")]
6 pub extends: Option<String>,
7 #[serde(default, skip_serializing_if = "Option::is_none")]
10 pub station: Option<String>,
11 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub model: Option<String>,
13 #[serde(default, skip_serializing_if = "Option::is_none")]
14 pub reasoning_effort: Option<String>,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub service_tier: Option<String>,
17}
18
19fn merge_service_control_profile(
20 mut base: ServiceControlProfile,
21 overlay: &ServiceControlProfile,
22) -> ServiceControlProfile {
23 base.extends = overlay.extends.clone();
24 if overlay.station.is_some() {
25 base.station = overlay.station.clone();
26 }
27 if overlay.model.is_some() {
28 base.model = overlay.model.clone();
29 }
30 if overlay.reasoning_effort.is_some() {
31 base.reasoning_effort = overlay.reasoning_effort.clone();
32 }
33 if overlay.service_tier.is_some() {
34 base.service_tier = overlay.service_tier.clone();
35 }
36 base
37}
38
39pub fn resolve_service_profile_from_catalog(
40 profiles: &BTreeMap<String, ServiceControlProfile>,
41 profile_name: &str,
42) -> Result<ServiceControlProfile> {
43 fn resolve_inner(
44 profiles: &BTreeMap<String, ServiceControlProfile>,
45 profile_name: &str,
46 stack: &mut Vec<String>,
47 cache: &mut BTreeMap<String, ServiceControlProfile>,
48 ) -> Result<ServiceControlProfile> {
49 if let Some(profile) = cache.get(profile_name) {
50 return Ok(profile.clone());
51 }
52
53 if let Some(pos) = stack.iter().position(|name| name == profile_name) {
54 let mut cycle = stack[pos..].to_vec();
55 cycle.push(profile_name.to_string());
56 anyhow::bail!("profile inheritance cycle: {}", cycle.join(" -> "));
57 }
58
59 let profile = profiles
60 .get(profile_name)
61 .with_context(|| format!("profile '{}' not found", profile_name))?;
62
63 stack.push(profile_name.to_string());
64 let resolved = if let Some(parent_name) = profile
65 .extends
66 .as_deref()
67 .map(str::trim)
68 .filter(|name| !name.is_empty())
69 {
70 let parent = resolve_inner(profiles, parent_name, stack, cache)?;
71 merge_service_control_profile(parent, profile)
72 } else {
73 profile.clone()
74 };
75 stack.pop();
76
77 cache.insert(profile_name.to_string(), resolved.clone());
78 Ok(resolved)
79 }
80
81 let mut stack = Vec::new();
82 let mut cache = BTreeMap::new();
83 resolve_inner(profiles, profile_name, &mut stack, &mut cache)
84}
85
86pub fn resolve_service_profile(
87 mgr: &ServiceConfigManager,
88 profile_name: &str,
89) -> Result<ServiceControlProfile> {
90 resolve_service_profile_from_catalog(&mgr.profiles, profile_name)
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94enum ExplicitCapabilitySupport {
95 Unknown,
96 Supported,
97 Unsupported,
98}
99
100fn parse_boolish_capability_tag(value: &str) -> Option<bool> {
101 match value.trim().to_ascii_lowercase().as_str() {
102 "1" | "true" | "yes" | "y" | "on" | "supported" => Some(true),
103 "0" | "false" | "no" | "n" | "off" | "unsupported" => Some(false),
104 _ => None,
105 }
106}
107
108fn explicit_capability_support_for_upstreams(
109 upstreams: &[UpstreamConfig],
110 tag_keys: &[&str],
111) -> ExplicitCapabilitySupport {
112 let mut saw_supported = false;
113 let mut saw_explicit_unsupported = false;
114 let mut saw_unknown = false;
115
116 for upstream in upstreams {
117 match tag_keys
118 .iter()
119 .find_map(|key| upstream.tags.get(*key))
120 .and_then(|value| parse_boolish_capability_tag(value))
121 {
122 Some(true) => saw_supported = true,
123 Some(false) => saw_explicit_unsupported = true,
124 None => saw_unknown = true,
125 }
126 }
127
128 if saw_supported {
129 ExplicitCapabilitySupport::Supported
130 } else if saw_explicit_unsupported && !saw_unknown {
131 ExplicitCapabilitySupport::Unsupported
132 } else {
133 ExplicitCapabilitySupport::Unknown
134 }
135}
136
137pub fn validate_profile_station_compatibility(
138 service_name: &str,
139 mgr: &ServiceConfigManager,
140 profile_name: &str,
141 profile: &ServiceControlProfile,
142) -> Result<()> {
143 let Some(station) = profile
144 .station
145 .as_deref()
146 .map(str::trim)
147 .filter(|station| !station.is_empty())
148 else {
149 return Ok(());
150 };
151
152 let Some(config) = mgr.station(station) else {
153 anyhow::bail!(
154 "[{service_name}] profile '{}' references missing station '{}'",
155 profile_name,
156 station
157 );
158 };
159
160 if let Some(model) = profile
161 .model
162 .as_deref()
163 .map(str::trim)
164 .filter(|model| !model.is_empty())
165 {
166 let supported = config.upstreams.is_empty()
167 || config.upstreams.iter().any(|upstream| {
168 crate::model_routing::is_model_supported(
169 &upstream.supported_models,
170 &upstream.model_mapping,
171 model,
172 )
173 });
174 if !supported {
175 anyhow::bail!(
176 "[{service_name}] profile '{}' model '{}' is not supported by station '{}'",
177 profile_name,
178 model,
179 station
180 );
181 }
182 }
183
184 if let Some(service_tier) = profile
185 .service_tier
186 .as_deref()
187 .map(str::trim)
188 .filter(|service_tier| !service_tier.is_empty())
189 && explicit_capability_support_for_upstreams(
190 &config.upstreams,
191 &[
192 "supports_service_tier",
193 "supports_service_tiers",
194 "supports_fast_mode",
195 "supports_fast",
196 ],
197 ) == ExplicitCapabilitySupport::Unsupported
198 {
199 anyhow::bail!(
200 "[{service_name}] profile '{}' requires service_tier '{}' but station '{}' explicitly disables fast/service-tier support",
201 profile_name,
202 service_tier,
203 station
204 );
205 }
206
207 if let Some(reasoning_effort) = profile
208 .reasoning_effort
209 .as_deref()
210 .map(str::trim)
211 .filter(|reasoning_effort| !reasoning_effort.is_empty())
212 && explicit_capability_support_for_upstreams(
213 &config.upstreams,
214 &["supports_reasoning_effort", "supports_reasoning"],
215 ) == ExplicitCapabilitySupport::Unsupported
216 {
217 anyhow::bail!(
218 "[{service_name}] profile '{}' requires reasoning_effort '{}' but station '{}' explicitly disables reasoning support",
219 profile_name,
220 reasoning_effort,
221 station
222 );
223 }
224
225 Ok(())
226}
227
228pub(crate) fn validate_service_profiles(
229 service_name: &str,
230 mgr: &ServiceConfigManager,
231) -> Result<()> {
232 if let Some(default_profile) = mgr.default_profile.as_deref()
233 && !mgr.profiles.contains_key(default_profile)
234 {
235 anyhow::bail!(
236 "[{service_name}] default_profile '{}' does not exist in profiles",
237 default_profile
238 );
239 }
240
241 for profile_name in mgr.profiles.keys() {
242 let resolved = resolve_service_profile(mgr, profile_name)?;
243 validate_profile_station_compatibility(service_name, mgr, profile_name, &resolved)?;
244 }
245
246 Ok(())
247}