1use serde::de::DeserializeOwned;
2use serde_json::Value;
3use std::collections::HashSet;
4
5use crate::agent_spec_patch::AgentSpecPatch;
6use crate::config_record::{ConfigRecord, ConfigRecordError, ConfigRecordMerge};
7use crate::contract::lifecycle::StopConditionSpec;
8use crate::registry_spec::{
9 A2A_BACKEND_KIND, AWAKEN_BACKEND_KIND, AgentBackendSpec, AgentSpec, Modality, ModelPoolSpec,
10 ModelSpec, PoolMemberRole, ProviderSpec,
11};
12use crate::skill_allowed_tools::{
13 is_skill_allowed_tool_pattern, parse_skill_allowed_tools, validate_skill_allowed_tool_pattern,
14};
15use crate::skill_spec::SkillSpec;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum UnknownFieldPolicy {
20 Reject,
21 Ignore,
22}
23
24pub const AGENT_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
26pub const AGENT_SPEC_PATCH_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
27pub const PROVIDER_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
31pub const MODEL_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
32pub const MODEL_POOL_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
33pub const SKILL_SPEC_UNKNOWN_FIELD_POLICY: UnknownFieldPolicy = UnknownFieldPolicy::Reject;
34
35const PROVIDER_SPEC_FIELDS: &[&str] = &[
36 "id",
37 "adapter",
38 "api_key",
39 "base_url",
40 "timeout_secs",
41 "adapter_options",
42];
43const MODEL_SPEC_FIELDS: &[&str] = &[
44 "id",
45 "provider_id",
46 "upstream_model",
47 "context_window",
48 "max_output_tokens",
49 "modalities",
50 "knowledge_cutoff",
51 "input_token_price_per_million_usd",
52 "output_token_price_per_million_usd",
53];
54const MODEL_POOL_SPEC_FIELDS: &[&str] = &["id", "members", "routing", "switch"];
55const SKILL_SPEC_FIELDS: &[&str] = &[
56 "id",
57 "name",
58 "description",
59 "instructions_md",
60 "allowed_tools",
61 "when_to_use",
62 "arguments",
63 "argument_hint",
64 "user_invocable",
65 "model_invocable",
66 "model_override",
67 "context",
68 "paths",
69];
70
71const MAX_STOP_TIMEOUT_SECONDS: u64 = 24 * 60 * 60;
72const MAX_STOP_TOKEN_BUDGET_TOTAL: usize = 100_000_000;
73const MAX_CONTENT_MATCH_PATTERN_CHARS: usize = 1024;
74const MAX_LOOP_DETECTION_WINDOW: usize = 64;
75
76#[derive(Debug, thiserror::Error)]
77pub enum ConfigValidationError {
78 #[error("invalid agent spec: {0}")]
79 AgentSpec(#[source] serde_json::Error),
80 #[error("invalid agent spec patch: {0}")]
81 AgentSpecPatch(#[source] serde_json::Error),
82 #[error("invalid provider spec: {0}")]
83 ProviderSpec(#[source] serde_json::Error),
84 #[error("invalid model spec: {0}")]
85 ModelSpec(#[source] serde_json::Error),
86 #[error("invalid model pool spec: {0}")]
87 ModelPoolSpec(#[source] serde_json::Error),
88 #[error("invalid skill spec: {0}")]
89 SkillSpec(#[source] serde_json::Error),
90 #[error("invalid {surface}: unknown field '{field}'")]
91 UnknownField {
92 surface: &'static str,
93 field: String,
94 },
95 #[error("invalid {surface}: field '{field}' cannot be empty")]
96 EmptyField {
97 surface: &'static str,
98 field: &'static str,
99 },
100 #[error("invalid config record: {0}")]
101 ConfigRecord(#[from] ConfigRecordError),
102 #[error("duplicate model id '{id}'")]
103 DuplicateModelId { id: String },
104 #[error("invalid {surface}: {message}")]
105 Invalid {
106 surface: &'static str,
107 message: String,
108 },
109}
110
111pub fn validate_agent_spec(value: Value) -> Result<AgentSpec, ConfigValidationError> {
115 let spec: AgentSpec =
116 serde_json::from_value(value).map_err(ConfigValidationError::AgentSpec)?;
117 validate_backend_spec("agent spec", &spec.backend)?;
118 validate_stop_conditions("agent spec", &spec.stop_conditions)?;
119 Ok(spec)
120}
121
122pub fn validate_agent_spec_patch(value: Value) -> Result<AgentSpecPatch, ConfigValidationError> {
126 let patch: AgentSpecPatch =
127 serde_json::from_value(value).map_err(ConfigValidationError::AgentSpecPatch)?;
128 if patch.backend.is_some() && patch.endpoint.is_some() {
129 return Err(ConfigValidationError::Invalid {
130 surface: "agent spec patch",
131 message: "backend and endpoint cannot be patched in the same request".into(),
132 });
133 }
134 if let Some(backend) = &patch.backend {
135 validate_backend_spec("agent spec patch", backend)?;
136 }
137 if let Some(stop_conditions) = &patch.stop_conditions {
138 validate_stop_conditions("agent spec patch", stop_conditions)?;
139 }
140 Ok(patch)
141}
142
143fn validate_backend_spec(
144 surface: &'static str,
145 backend: &AgentBackendSpec,
146) -> Result<(), ConfigValidationError> {
147 backend
148 .validate()
149 .map_err(|error| ConfigValidationError::Invalid {
150 surface,
151 message: error.to_string(),
152 })?;
153 if !matches!(
154 backend.kind.as_str(),
155 AWAKEN_BACKEND_KIND | A2A_BACKEND_KIND
156 ) {
157 return Err(ConfigValidationError::Invalid {
158 surface,
159 message: format!("unsupported backend kind '{}'", backend.kind),
160 });
161 }
162 Ok(())
163}
164
165fn validate_stop_conditions(
166 surface: &'static str,
167 stop_conditions: &[StopConditionSpec],
168) -> Result<(), ConfigValidationError> {
169 for condition in stop_conditions {
170 match condition {
171 StopConditionSpec::Timeout { seconds } if *seconds > MAX_STOP_TIMEOUT_SECONDS => {
172 return Err(ConfigValidationError::Invalid {
173 surface,
174 message: format!(
175 "timeout.seconds must be <= {MAX_STOP_TIMEOUT_SECONDS}, got {seconds}"
176 ),
177 });
178 }
179 StopConditionSpec::TokenBudget { max_total }
180 if *max_total > MAX_STOP_TOKEN_BUDGET_TOTAL =>
181 {
182 return Err(ConfigValidationError::Invalid {
183 surface,
184 message: format!(
185 "token_budget.max_total must be <= {MAX_STOP_TOKEN_BUDGET_TOTAL}, got {max_total}"
186 ),
187 });
188 }
189 StopConditionSpec::ContentMatch { pattern } => {
190 reject_max_chars(
191 surface,
192 "content_match.pattern",
193 pattern,
194 MAX_CONTENT_MATCH_PATTERN_CHARS,
195 )?;
196 if !pattern.is_empty() {
197 regex::Regex::new(pattern).map_err(|error| ConfigValidationError::Invalid {
198 surface,
199 message: format!("content_match.pattern must be valid regex: {error}"),
200 })?;
201 }
202 }
203 StopConditionSpec::LoopDetection { window } if *window > MAX_LOOP_DETECTION_WINDOW => {
204 return Err(ConfigValidationError::Invalid {
205 surface,
206 message: format!(
207 "loop_detection.window must be <= {MAX_LOOP_DETECTION_WINDOW}, got {window}"
208 ),
209 });
210 }
211 _ => {}
212 }
213 }
214 Ok(())
215}
216
217pub fn validate_provider_spec(value: Value) -> Result<ProviderSpec, ConfigValidationError> {
225 reject_unknown_fields(&value, "provider spec", PROVIDER_SPEC_FIELDS)?;
226 validate_provider_adapter_options(&value)?;
227 let spec: ProviderSpec =
228 serde_json::from_value(value).map_err(ConfigValidationError::ProviderSpec)?;
229 reject_empty("provider spec", "id", &spec.id)?;
230 reject_empty("provider spec", "adapter", &spec.adapter)?;
231 Ok(spec)
232}
233
234fn validate_provider_adapter_options(value: &Value) -> Result<(), ConfigValidationError> {
235 let Some(options) = value
236 .get("adapter_options")
237 .and_then(|value| value.as_object())
238 else {
239 return Ok(());
240 };
241
242 if let Some(schema) = options.get("model_discovery_schema") {
243 let Some(schema) = schema.as_str() else {
244 return Err(ConfigValidationError::Invalid {
245 surface: "provider spec",
246 message: "'adapter_options.model_discovery_schema' must be a string".into(),
247 });
248 };
249 let normalized = schema.to_ascii_lowercase();
250 if !matches!(
251 normalized.as_str(),
252 "openai" | "openai-compatible" | "openrouter" | "gemini" | "google"
253 ) {
254 return Err(ConfigValidationError::Invalid {
255 surface: "provider spec",
256 message: format!(
257 "'adapter_options.model_discovery_schema' must be one of openai, \
258 openai-compatible, openrouter, gemini, google; got '{schema}'"
259 ),
260 });
261 }
262 }
263
264 if let Some(auth) = options.get("model_discovery_auth") {
265 let Some(auth) = auth.as_str() else {
266 return Err(ConfigValidationError::Invalid {
267 surface: "provider spec",
268 message: "'adapter_options.model_discovery_auth' must be a string".into(),
269 });
270 };
271 let normalized = auth.to_ascii_lowercase();
272 if !matches!(
273 normalized.as_str(),
274 "bearer"
275 | "authorization-bearer"
276 | "x-goog-api-key"
277 | "google-api-key"
278 | "gemini-api-key"
279 | "none"
280 | "no-auth"
281 | "disabled"
282 ) {
283 return Err(ConfigValidationError::Invalid {
284 surface: "provider spec",
285 message: format!(
286 "'adapter_options.model_discovery_auth' must be one of bearer, \
287 authorization-bearer, x-goog-api-key, google-api-key, gemini-api-key, \
288 none, no-auth, disabled; got '{auth}'"
289 ),
290 });
291 }
292 }
293
294 Ok(())
295}
296
297pub fn validate_model_spec(value: Value) -> Result<ModelSpec, ConfigValidationError> {
304 reject_unknown_fields(&value, "model spec", MODEL_SPEC_FIELDS)?;
305 let spec: ModelSpec =
306 serde_json::from_value(value).map_err(ConfigValidationError::ModelSpec)?;
307 validate_model_spec_struct(&spec)?;
308 Ok(spec)
309}
310
311pub fn validate_model_spec_struct(spec: &ModelSpec) -> Result<(), ConfigValidationError> {
318 reject_empty("model spec", "id", &spec.id)?;
319 reject_empty("model spec", "provider_id", &spec.provider_id)?;
320 reject_empty("model spec", "upstream_model", &spec.upstream_model)?;
321 if let Some(cutoff) = spec.knowledge_cutoff.as_deref() {
322 reject_empty("model spec", "knowledge_cutoff", cutoff)?;
323 validate_knowledge_cutoff_format(cutoff)?;
324 }
325 reject_zero_capability("context_window", spec.context_window)?;
326 reject_zero_capability("max_output_tokens", spec.max_output_tokens)?;
327 if let (Some(ctx), Some(out)) = (spec.context_window, spec.max_output_tokens)
328 && out > ctx
329 {
330 return Err(ConfigValidationError::Invalid {
331 surface: "model spec",
332 message: format!("max_output_tokens ({out}) must not exceed context_window ({ctx})"),
333 });
334 }
335 reject_invalid_price(
336 "input_token_price_per_million_usd",
337 spec.input_token_price_per_million_usd,
338 )?;
339 reject_invalid_price(
340 "output_token_price_per_million_usd",
341 spec.output_token_price_per_million_usd,
342 )?;
343 reject_duplicate_modalities("input", &spec.modalities.input)?;
344 reject_duplicate_modalities("output", &spec.modalities.output)?;
345 Ok(())
346}
347
348fn reject_invalid_price(field: &str, value: Option<f64>) -> Result<(), ConfigValidationError> {
349 if let Some(price) = value
350 && (!price.is_finite() || price < 0.0)
351 {
352 return Err(ConfigValidationError::Invalid {
353 surface: "model spec",
354 message: format!("'{field}' must be a finite non-negative number, got {price}"),
355 });
356 }
357 Ok(())
358}
359
360fn validate_knowledge_cutoff_format(value: &str) -> Result<(), ConfigValidationError> {
361 let bytes = value.as_bytes();
362 let valid_shape = match bytes.len() {
363 7 => {
364 bytes[4] == b'-'
365 && bytes[..4].iter().all(|b| b.is_ascii_digit())
366 && bytes[5..].iter().all(|b| b.is_ascii_digit())
367 }
368 10 => {
369 bytes[4] == b'-'
370 && bytes[7] == b'-'
371 && bytes[..4].iter().all(|b| b.is_ascii_digit())
372 && bytes[5..7].iter().all(|b| b.is_ascii_digit())
373 && bytes[8..].iter().all(|b| b.is_ascii_digit())
374 }
375 _ => false,
376 };
377 if !valid_shape {
378 return Err(ConfigValidationError::Invalid {
379 surface: "model spec",
380 message: format!(
381 "'knowledge_cutoff' must be ISO date 'YYYY-MM' or 'YYYY-MM-DD', got '{value}'"
382 ),
383 });
384 }
385 let year: i32 = value[..4].parse().unwrap_or(0);
386 let month: u32 = value[5..7].parse().unwrap_or(0);
387 if !(1..=12).contains(&month) {
388 return Err(ConfigValidationError::Invalid {
389 surface: "model spec",
390 message: format!("'knowledge_cutoff' month must be 01-12, got '{value}'"),
391 });
392 }
393 if bytes.len() == 10 {
394 let day: u32 = value[8..10].parse().unwrap_or(0);
395 let max_day = days_in_month(year, month);
398 if day < 1 || day > max_day {
399 return Err(ConfigValidationError::Invalid {
400 surface: "model spec",
401 message: format!(
402 "'knowledge_cutoff' day must be 01-{max_day:02} for {year:04}-{month:02}, got '{value}'"
403 ),
404 });
405 }
406 }
407 Ok(())
408}
409
410fn days_in_month(year: i32, month: u32) -> u32 {
412 match month {
413 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
414 4 | 6 | 9 | 11 => 30,
415 2 => {
416 let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
417 if leap { 29 } else { 28 }
418 }
419 _ => 31,
420 }
421}
422
423fn reject_duplicate_modalities(
424 field: &str,
425 items: &[Modality],
426) -> Result<(), ConfigValidationError> {
427 let mut seen = HashSet::new();
428 for m in items {
429 if !seen.insert(*m) {
430 return Err(ConfigValidationError::Invalid {
431 surface: "model spec",
432 message: format!("'modalities.{field}' contains duplicate '{m:?}'"),
433 });
434 }
435 }
436 Ok(())
437}
438
439fn reject_zero_capability(
440 field: &'static str,
441 value: Option<u32>,
442) -> Result<(), ConfigValidationError> {
443 match value {
444 Some(0) => Err(ConfigValidationError::Invalid {
445 surface: "model spec",
446 message: format!("field '{field}' must be greater than zero"),
447 }),
448 _ => Ok(()),
449 }
450}
451
452pub fn validate_skill_spec(value: Value) -> Result<SkillSpec, ConfigValidationError> {
454 reject_unknown_fields(&value, "skill spec", SKILL_SPEC_FIELDS)?;
455 let spec: SkillSpec =
456 serde_json::from_value(value).map_err(ConfigValidationError::SkillSpec)?;
457 validate_skill_id("skill spec", &spec.id)?;
458 reject_empty("skill spec", "name", &spec.name)?;
459 reject_empty("skill spec", "description", &spec.description)?;
460 reject_empty("skill spec", "instructions_md", &spec.instructions_md)?;
461 reject_max_chars("skill spec", "name", &spec.name, 128)?;
462 reject_max_chars("skill spec", "description", &spec.description, 1024)?;
463 if let Some(value) = &spec.when_to_use {
464 reject_empty("skill spec", "when_to_use", value)?;
465 }
466 if let Some(value) = &spec.argument_hint {
467 reject_empty("skill spec", "argument_hint", value)?;
468 }
469 if let Some(value) = &spec.model_override {
470 reject_empty("skill spec", "model_override", value)?;
471 }
472 let mut argument_names = HashSet::new();
473 for argument in &spec.arguments {
474 reject_empty("skill spec", "arguments.name", &argument.name)?;
475 let argument_name = argument.name.trim();
476 if argument_name != argument.name {
477 return Err(ConfigValidationError::Invalid {
478 surface: "skill spec",
479 message: format!(
480 "argument name '{}' must not contain surrounding whitespace",
481 argument.name
482 ),
483 });
484 }
485 if !argument_names.insert(argument_name.to_string()) {
486 return Err(ConfigValidationError::Invalid {
487 surface: "skill spec",
488 message: format!("duplicate argument name '{}'", argument.name),
489 });
490 }
491 if let Some(description) = &argument.description {
492 reject_empty("skill spec", "arguments.description", description)?;
493 }
494 }
495 for tool in &spec.allowed_tools {
496 validate_allowed_tool_token(tool)?;
497 }
498 if !spec.paths.is_empty() {
499 return Err(ConfigValidationError::Invalid {
500 surface: "skill spec",
501 message: "paths are not supported for DB-managed skills until resources are persisted"
502 .into(),
503 });
504 }
505 Ok(spec)
506}
507
508pub fn validate_model_pool_spec(value: Value) -> Result<ModelPoolSpec, ConfigValidationError> {
514 reject_unknown_fields(&value, "model pool spec", MODEL_POOL_SPEC_FIELDS)?;
515 let spec: ModelPoolSpec =
516 serde_json::from_value(value).map_err(ConfigValidationError::ModelPoolSpec)?;
517 validate_model_pool_spec_struct(&spec)?;
518 Ok(spec)
519}
520
521pub fn validate_model_pool_spec_struct(spec: &ModelPoolSpec) -> Result<(), ConfigValidationError> {
527 reject_empty("model pool spec", "id", &spec.id)?;
528 if spec.members.is_empty() {
529 return Err(ConfigValidationError::Invalid {
530 surface: "model pool spec",
531 message: "must declare at least one member".into(),
532 });
533 }
534 let mut seen = HashSet::new();
535 let mut has_home_member = false;
536 for member in &spec.members {
537 reject_empty("model pool spec", "members.model_id", &member.model_id)?;
538 if member.weight == Some(0) {
539 return Err(ConfigValidationError::Invalid {
540 surface: "model pool spec",
541 message: format!(
542 "member '{}' weight must be greater than zero",
543 member.model_id
544 ),
545 });
546 }
547 if !seen.insert(member.model_id.as_str()) {
548 return Err(ConfigValidationError::Invalid {
549 surface: "model pool spec",
550 message: format!("duplicate member model_id '{}'", member.model_id),
551 });
552 }
553 if member.role == PoolMemberRole::Member {
554 has_home_member = true;
555 }
556 }
557 if !has_home_member {
558 return Err(ConfigValidationError::Invalid {
559 surface: "model pool spec",
560 message: "at least one member must be home-eligible (role 'member'); \
561 a pool of only 'failover_only' members has no home target"
562 .into(),
563 });
564 }
565 Ok(())
566}
567
568pub fn validate_unique_model_ids(specs: &[ModelSpec]) -> Result<(), ConfigValidationError> {
574 let mut seen = HashSet::new();
575 for spec in specs {
576 if !seen.insert(spec.id.as_str()) {
577 return Err(ConfigValidationError::DuplicateModelId {
578 id: spec.id.clone(),
579 });
580 }
581 }
582 Ok(())
583}
584
585pub fn validate_config_record<T>(value: Value) -> Result<ConfigRecord<T>, ConfigValidationError>
588where
589 T: DeserializeOwned + ConfigRecordMerge,
590{
591 crate::config_record::validate_config_record(value).map_err(ConfigValidationError::ConfigRecord)
592}
593
594fn reject_unknown_fields(
595 value: &Value,
596 surface: &'static str,
597 allowed: &[&str],
598) -> Result<(), ConfigValidationError> {
599 let Some(object) = value.as_object() else {
600 return Ok(());
601 };
602 if let Some(field) = object
603 .keys()
604 .find(|field| !allowed.contains(&field.as_str()))
605 {
606 return Err(ConfigValidationError::UnknownField {
607 surface,
608 field: field.clone(),
609 });
610 }
611 Ok(())
612}
613
614fn reject_empty(
615 surface: &'static str,
616 field: &'static str,
617 value: &str,
618) -> Result<(), ConfigValidationError> {
619 if value.trim().is_empty() {
620 Err(ConfigValidationError::EmptyField { surface, field })
621 } else {
622 Ok(())
623 }
624}
625
626fn reject_max_chars(
627 surface: &'static str,
628 field: &'static str,
629 value: &str,
630 max_chars: usize,
631) -> Result<(), ConfigValidationError> {
632 if value.chars().count() > max_chars {
633 Err(ConfigValidationError::Invalid {
634 surface,
635 message: format!("field '{field}' must be <= {max_chars} characters"),
636 })
637 } else {
638 Ok(())
639 }
640}
641
642fn validate_skill_id(surface: &'static str, value: &str) -> Result<(), ConfigValidationError> {
643 let id = value.trim();
644 reject_empty(surface, "id", id)?;
645 if id != value {
646 return Err(ConfigValidationError::Invalid {
647 surface,
648 message: "field 'id' must not contain leading or trailing whitespace".into(),
649 });
650 }
651 let len = id.chars().count();
652 if len > 64 {
653 return Err(ConfigValidationError::Invalid {
654 surface,
655 message: "field 'id' must be <= 64 characters".into(),
656 });
657 }
658 if id != id.to_lowercase() {
659 return Err(ConfigValidationError::Invalid {
660 surface,
661 message: "field 'id' must be lowercase".into(),
662 });
663 }
664 if id.starts_with('-') || id.ends_with('-') || id.contains("--") {
665 return Err(ConfigValidationError::Invalid {
666 surface,
667 message: "field 'id' must not start/end with '-' or contain consecutive '-'".into(),
668 });
669 }
670 if !id.chars().all(|c| c.is_alphanumeric() || c == '-') {
671 return Err(ConfigValidationError::Invalid {
672 surface,
673 message: "field 'id' contains invalid characters".into(),
674 });
675 }
676 Ok(())
677}
678
679fn validate_allowed_tool_token(value: &str) -> Result<(), ConfigValidationError> {
680 let token = value.trim();
681 if token.is_empty() {
682 return Err(ConfigValidationError::Invalid {
683 surface: "skill spec",
684 message: "allowed_tools entries must be non-empty".into(),
685 });
686 }
687 if token != value {
688 return Err(ConfigValidationError::Invalid {
689 surface: "skill spec",
690 message: format!(
691 "allowed_tools entry '{token}' must not contain surrounding whitespace"
692 ),
693 });
694 }
695 let parsed =
696 parse_skill_allowed_tools(token).map_err(|error| ConfigValidationError::Invalid {
697 surface: "skill spec",
698 message: format!("invalid allowed_tools entry '{token}': {error}"),
699 })?;
700 if parsed.len() != 1 || parsed[0].raw != token {
701 return Err(ConfigValidationError::Invalid {
702 surface: "skill spec",
703 message: format!("allowed_tools entry '{token}' must contain exactly one token"),
704 });
705 }
706 if parsed[0].scope.is_some() {
707 return Err(ConfigValidationError::Invalid {
708 surface: "skill spec",
709 message: format!(
710 "scoped allowed_tools entry '{token}' is not supported for DB-managed skills"
711 ),
712 });
713 }
714 if is_skill_allowed_tool_pattern(&parsed[0].tool_id) {
715 validate_skill_allowed_tool_pattern(&parsed[0].tool_id).map_err(|error| {
716 ConfigValidationError::Invalid {
717 surface: "skill spec",
718 message: error.to_string(),
719 }
720 })?;
721 }
722 Ok(())
723}
724
725#[cfg(test)]
726mod tests;