1use std::collections::{BTreeMap, HashMap, HashSet};
10
11use serde::de::DeserializeOwned;
12use serde::{Deserialize, Deserializer, Serialize};
13use serde_json::Value;
14
15use crate::contract::inference::{ContextWindowPolicy, ReasoningEffort};
16use crate::error::StateError;
17
18pub trait PluginConfigKey: 'static + Send + Sync {
34 const KEY: &'static str;
36
37 type Config: Default
39 + Clone
40 + Serialize
41 + DeserializeOwned
42 + schemars::JsonSchema
43 + Send
44 + Sync
45 + 'static;
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
60#[serde(deny_unknown_fields)]
61pub struct AgentSpec {
62 pub id: String,
64 pub model_id: String,
66 pub system_prompt: String,
68 #[serde(default = "default_max_rounds")]
70 pub max_rounds: usize,
71 #[serde(default = "default_max_continuation_retries")]
73 pub max_continuation_retries: usize,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub context_policy: Option<ContextWindowPolicy>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub reasoning_effort: Option<ReasoningEffort>,
81 #[serde(default)]
83 pub plugin_ids: Vec<String>,
84 #[serde(
88 default,
89 skip_serializing_if = "HashSet::is_empty",
90 alias = "active_plugins"
91 )]
92 pub active_hook_filter: HashSet<String>,
93 #[serde(default)]
95 pub allowed_tools: Option<Vec<String>>,
96 #[serde(default)]
98 pub excluded_tools: Option<Vec<String>>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub endpoint: Option<RemoteEndpoint>,
103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
106 pub delegates: Vec<String>,
107 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
109 pub sections: HashMap<String, Value>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub registry: Option<String>,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
118pub struct RemoteAuth {
119 #[serde(rename = "type")]
120 pub auth_type: String,
121 #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
122 pub params: BTreeMap<String, Value>,
123}
124
125impl RemoteAuth {
126 #[must_use]
127 pub fn bearer(token: impl Into<String>) -> Self {
128 let mut params = BTreeMap::new();
129 params.insert("token".into(), Value::String(token.into()));
130 Self {
131 auth_type: "bearer".into(),
132 params,
133 }
134 }
135
136 #[must_use]
137 pub fn param_str(&self, key: &str) -> Option<&str> {
138 self.params.get(key).and_then(Value::as_str)
139 }
140}
141
142#[derive(Debug, Clone, Serialize, PartialEq, schemars::JsonSchema)]
144pub struct RemoteEndpoint {
145 #[serde(default = "default_remote_backend")]
146 pub backend: String,
147 pub base_url: String,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub auth: Option<RemoteAuth>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub target: Option<String>,
153 #[serde(default = "default_timeout")]
154 pub timeout_ms: u64,
155 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
156 pub options: BTreeMap<String, Value>,
157}
158
159impl Default for RemoteEndpoint {
160 fn default() -> Self {
161 Self {
162 backend: default_remote_backend(),
163 base_url: String::new(),
164 auth: None,
165 target: None,
166 timeout_ms: default_timeout(),
167 options: BTreeMap::new(),
168 }
169 }
170}
171
172fn default_remote_backend() -> String {
173 "a2a".to_string()
174}
175
176fn default_timeout() -> u64 {
177 300_000
178}
179
180#[derive(Debug, Deserialize)]
181struct RawRemoteEndpoint {
182 #[serde(default)]
183 backend: Option<String>,
184 base_url: String,
185 #[serde(default)]
186 auth: Option<RemoteAuth>,
187 #[serde(default)]
188 target: Option<String>,
189 #[serde(default)]
190 timeout_ms: Option<u64>,
191 #[serde(default)]
192 options: BTreeMap<String, Value>,
193 #[serde(default)]
194 bearer_token: Option<String>,
195 #[serde(default)]
196 agent_id: Option<String>,
197 #[serde(default)]
198 poll_interval_ms: Option<u64>,
199}
200
201impl<'de> Deserialize<'de> for RemoteEndpoint {
202 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
203 where
204 D: Deserializer<'de>,
205 {
206 let raw = RawRemoteEndpoint::deserialize(deserializer)?;
207 let has_legacy_fields =
208 raw.bearer_token.is_some() || raw.agent_id.is_some() || raw.poll_interval_ms.is_some();
209 let has_canonical_fields = raw.backend.is_some()
210 || raw.auth.is_some()
211 || raw.target.is_some()
212 || !raw.options.is_empty();
213
214 if has_legacy_fields && has_canonical_fields {
215 return Err(serde::de::Error::custom(
216 "cannot mix legacy A2A endpoint fields with canonical remote endpoint fields",
217 ));
218 }
219
220 if has_legacy_fields {
221 let mut options = BTreeMap::new();
222 if let Some(poll_interval_ms) = raw.poll_interval_ms {
223 options.insert("poll_interval_ms".into(), Value::from(poll_interval_ms));
224 }
225 return Ok(Self {
226 backend: default_remote_backend(),
227 base_url: raw.base_url,
228 auth: raw.bearer_token.map(RemoteAuth::bearer),
229 target: raw.agent_id,
230 timeout_ms: raw.timeout_ms.unwrap_or_else(default_timeout),
231 options,
232 });
233 }
234
235 let backend = raw.backend.unwrap_or_else(default_remote_backend);
236 if backend.trim().is_empty() {
237 return Err(serde::de::Error::custom(
238 "remote endpoint backend must not be empty",
239 ));
240 }
241
242 Ok(Self {
243 backend,
244 base_url: raw.base_url,
245 auth: raw.auth,
246 target: raw.target,
247 timeout_ms: raw.timeout_ms.unwrap_or_else(default_timeout),
248 options: raw.options,
249 })
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
259#[serde(deny_unknown_fields)]
260pub struct ModelBindingSpec {
261 pub id: String,
263 pub provider_id: String,
265 pub upstream_model: String,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
275pub struct ProviderSpec {
276 pub id: String,
278 pub adapter: String,
280 #[serde(
286 default,
287 deserialize_with = "deserialize_optional_non_empty",
288 skip_serializing_if = "Option::is_none"
289 )]
290 pub api_key: Option<crate::RedactedString>,
291 #[serde(
294 default,
295 deserialize_with = "deserialize_optional_non_empty",
296 skip_serializing_if = "Option::is_none"
297 )]
298 pub base_url: Option<String>,
299 #[serde(default = "default_provider_timeout_secs")]
301 pub timeout_secs: u64,
302 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
310 pub adapter_options: BTreeMap<String, Value>,
311}
312
313fn deserialize_optional_non_empty<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
317where
318 D: Deserializer<'de>,
319 T: From<String>,
320{
321 Ok(Option::<String>::deserialize(deserializer)?
322 .filter(|value| !value.is_empty())
323 .map(T::from))
324}
325
326fn default_provider_timeout_secs() -> u64 {
327 300
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
336#[serde(rename_all = "lowercase")]
337pub enum McpTransportKind {
338 Stdio,
340 Http,
342}
343
344#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
346pub struct McpRestartPolicy {
347 #[serde(default)]
349 pub enabled: bool,
350 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub max_attempts: Option<u32>,
353 #[serde(default = "default_mcp_restart_delay_ms")]
355 pub delay_ms: u64,
356 #[serde(default = "default_mcp_restart_backoff_multiplier")]
358 pub backoff_multiplier: f64,
359 #[serde(default = "default_mcp_restart_max_delay_ms")]
361 pub max_delay_ms: u64,
362}
363
364impl Default for McpRestartPolicy {
365 fn default() -> Self {
366 Self {
367 enabled: false,
368 max_attempts: None,
369 delay_ms: default_mcp_restart_delay_ms(),
370 backoff_multiplier: default_mcp_restart_backoff_multiplier(),
371 max_delay_ms: default_mcp_restart_max_delay_ms(),
372 }
373 }
374}
375
376const fn default_mcp_restart_delay_ms() -> u64 {
377 1000
378}
379
380const fn default_mcp_restart_backoff_multiplier() -> f64 {
381 2.0
382}
383
384const fn default_mcp_restart_max_delay_ms() -> u64 {
385 30_000
386}
387
388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
390pub struct McpServerSpec {
391 pub id: String,
393 pub transport: McpTransportKind,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub command: Option<String>,
398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
400 pub args: Vec<String>,
401 #[serde(default, skip_serializing_if = "Option::is_none")]
403 pub url: Option<String>,
404 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
406 pub config: serde_json::Map<String, Value>,
407 #[serde(default = "default_mcp_timeout_secs")]
409 pub timeout_secs: u64,
410 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
412 pub env: BTreeMap<String, String>,
413 #[serde(default)]
415 pub restart_policy: McpRestartPolicy,
416}
417
418fn default_mcp_timeout_secs() -> u64 {
419 30
420}
421
422impl Default for McpServerSpec {
423 fn default() -> Self {
424 Self {
425 id: String::new(),
426 transport: McpTransportKind::Stdio,
427 command: None,
428 args: Vec::new(),
429 url: None,
430 config: serde_json::Map::new(),
431 timeout_secs: default_mcp_timeout_secs(),
432 env: BTreeMap::new(),
433 restart_policy: McpRestartPolicy::default(),
434 }
435 }
436}
437
438impl Default for ProviderSpec {
439 fn default() -> Self {
440 Self {
441 id: String::new(),
442 adapter: String::new(),
443 api_key: None,
444 base_url: None,
445 timeout_secs: default_provider_timeout_secs(),
446 adapter_options: BTreeMap::new(),
447 }
448 }
449}
450
451impl Default for AgentSpec {
452 fn default() -> Self {
453 Self {
454 id: String::new(),
455 model_id: String::new(),
456 system_prompt: String::new(),
457 max_rounds: default_max_rounds(),
458 max_continuation_retries: default_max_continuation_retries(),
459 context_policy: None,
460 reasoning_effort: None,
461 plugin_ids: Vec::new(),
462 active_hook_filter: HashSet::new(),
463 allowed_tools: None,
464 excluded_tools: None,
465 endpoint: None,
466 delegates: Vec::new(),
467 sections: HashMap::new(),
468 registry: None,
469 }
470 }
471}
472
473fn default_max_rounds() -> usize {
474 16
475}
476
477fn default_max_continuation_retries() -> usize {
478 2
479}
480
481impl AgentSpec {
482 pub fn new(id: impl Into<String>) -> Self {
499 Self {
500 id: id.into(),
501 ..Default::default()
502 }
503 }
504
505 pub fn config<K: PluginConfigKey>(&self) -> Result<K::Config, StateError> {
511 match self.sections.get(K::KEY) {
512 Some(value) => {
513 serde_json::from_value(value.clone()).map_err(|e| StateError::KeyDecode {
514 key: K::KEY.into(),
515 message: e.to_string(),
516 })
517 }
518 None => Ok(K::Config::default()),
519 }
520 }
521
522 pub fn set_config<K: PluginConfigKey>(&mut self, config: K::Config) -> Result<(), StateError> {
524 let value = serde_json::to_value(config).map_err(|e| StateError::KeyEncode {
525 key: K::KEY.into(),
526 message: e.to_string(),
527 })?;
528 self.sections.insert(K::KEY.to_string(), value);
529 Ok(())
530 }
531
532 #[must_use]
535 pub fn with_model_id(mut self, model_id: impl Into<String>) -> Self {
536 self.model_id = model_id.into();
537 self
538 }
539
540 #[must_use]
541 pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
542 self.system_prompt = prompt.into();
543 self
544 }
545
546 #[must_use]
547 pub fn with_max_rounds(mut self, n: usize) -> Self {
548 self.max_rounds = n;
549 self
550 }
551
552 #[must_use]
553 pub fn with_reasoning_effort(mut self, effort: ReasoningEffort) -> Self {
554 self.reasoning_effort = Some(effort);
555 self
556 }
557
558 #[must_use]
559 pub fn with_hook_filter(mut self, plugin_id: impl Into<String>) -> Self {
560 self.active_hook_filter.insert(plugin_id.into());
561 self
562 }
563
564 pub fn with_config<K: PluginConfigKey>(
566 mut self,
567 config: K::Config,
568 ) -> Result<Self, StateError> {
569 self.set_config::<K>(config)?;
570 Ok(self)
571 }
572
573 #[must_use]
574 pub fn with_delegate(mut self, agent_id: impl Into<String>) -> Self {
575 self.delegates.push(agent_id.into());
576 self
577 }
578
579 #[must_use]
580 pub fn with_endpoint(mut self, endpoint: RemoteEndpoint) -> Self {
581 self.endpoint = Some(endpoint);
582 self
583 }
584
585 #[must_use]
587 pub fn with_section(mut self, key: impl Into<String>, value: Value) -> Self {
588 self.sections.insert(key.into(), value);
589 self
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use serde_json::json;
597
598 #[test]
599 fn agent_spec_serde_roundtrip() {
600 let spec = AgentSpec {
601 id: "coder".into(),
602 model_id: "claude-opus".into(),
603 system_prompt: "You are a coding assistant.".into(),
604 max_rounds: 8,
605 plugin_ids: vec!["permission".into(), "logging".into()],
606 allowed_tools: Some(vec!["read_file".into(), "write_file".into()]),
607 excluded_tools: Some(vec!["delete_file".into()]),
608 sections: {
609 let mut m = HashMap::new();
610 m.insert("permission".into(), json!({"mode": "strict"}));
611 m
612 },
613 ..Default::default()
614 };
615
616 let json_str = serde_json::to_string(&spec).unwrap();
617 let parsed: AgentSpec = serde_json::from_str(&json_str).unwrap();
618
619 assert_eq!(parsed.id, "coder");
620 assert_eq!(parsed.model_id, "claude-opus");
621 assert_eq!(parsed.system_prompt, "You are a coding assistant.");
622 assert_eq!(parsed.max_rounds, 8);
623 assert_eq!(parsed.plugin_ids, vec!["permission", "logging"]);
624 assert_eq!(
625 parsed.allowed_tools,
626 Some(vec!["read_file".into(), "write_file".into()])
627 );
628 assert_eq!(parsed.excluded_tools, Some(vec!["delete_file".into()]));
629 assert_eq!(parsed.sections["permission"]["mode"], "strict");
630 }
631
632 #[test]
633 fn agent_spec_defaults() {
634 let json_str = r#"{"id":"min","model_id":"m","system_prompt":"sp"}"#;
635 let spec: AgentSpec = serde_json::from_str(json_str).unwrap();
636
637 assert_eq!(spec.model_id, "m");
638 assert_eq!(spec.max_rounds, 16);
639 assert_eq!(spec.max_continuation_retries, 2);
640 assert!(spec.context_policy.is_none());
641 assert!(spec.plugin_ids.is_empty());
642 assert!(spec.active_hook_filter.is_empty());
643 assert!(spec.allowed_tools.is_none());
644 assert!(spec.excluded_tools.is_none());
645 assert!(spec.sections.is_empty());
646 }
647
648 #[test]
649 fn model_binding_spec_uses_canonical_names() {
650 let canonical = ModelBindingSpec {
651 id: "default".into(),
652 provider_id: "openai".into(),
653 upstream_model: "gpt-4o-mini".into(),
654 };
655
656 let encoded = serde_json::to_value(&canonical).unwrap();
657 assert_eq!(encoded["provider_id"], "openai");
658 assert_eq!(encoded["upstream_model"], "gpt-4o-mini");
659 assert!(encoded.get("provider").is_none());
660 assert!(encoded.get("model").is_none());
661 }
662
663 #[test]
664 fn provider_model_legacy_fields_are_rejected() {
665 let agent =
666 serde_json::from_str::<AgentSpec>(r#"{"id":"min","model":"m","system_prompt":"sp"}"#);
667 assert!(agent.is_err());
668
669 let model = serde_json::from_value::<ModelBindingSpec>(json!({
670 "id": "default",
671 "provider": "openai",
672 "model": "gpt-4o-mini"
673 }));
674 assert!(model.is_err());
675 }
676
677 struct ModelNameKey;
680 impl PluginConfigKey for ModelNameKey {
681 const KEY: &'static str = "model_name";
682 type Config = ModelNameConfig;
683 }
684
685 #[derive(
686 Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
687 )]
688 struct ModelNameConfig {
689 pub name: String,
690 }
691
692 struct PermKey;
693 impl PluginConfigKey for PermKey {
694 const KEY: &'static str = "permission";
695 type Config = PermConfig;
696 }
697
698 #[derive(
699 Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
700 )]
701 struct PermConfig {
702 pub mode: String,
703 }
704
705 #[test]
706 fn typed_config_roundtrip() {
707 let spec = AgentSpec::new("test")
708 .with_config::<ModelNameKey>(ModelNameConfig {
709 name: "opus".into(),
710 })
711 .unwrap()
712 .with_config::<PermKey>(PermConfig {
713 mode: "strict".into(),
714 })
715 .unwrap();
716
717 let model: ModelNameConfig = spec.config::<ModelNameKey>().unwrap();
718 assert_eq!(model.name, "opus");
719
720 let perm: PermConfig = spec.config::<PermKey>().unwrap();
721 assert_eq!(perm.mode, "strict");
722 }
723
724 #[test]
725 fn missing_config_returns_default() {
726 let spec = AgentSpec::new("test");
727 let model: ModelNameConfig = spec.config::<ModelNameKey>().unwrap();
728 assert_eq!(model, ModelNameConfig::default());
729 }
730
731 #[test]
732 fn config_serializes_to_json() {
733 let spec = AgentSpec::new("coder")
734 .with_model_id("sonnet")
735 .with_config::<ModelNameKey>(ModelNameConfig {
736 name: "custom".into(),
737 })
738 .unwrap();
739
740 let json = serde_json::to_string(&spec).unwrap();
741 let parsed: AgentSpec = serde_json::from_str(&json).unwrap();
742
743 assert_eq!(parsed.id, "coder");
744 assert_eq!(parsed.model_id, "sonnet");
745
746 let model: ModelNameConfig = parsed.config::<ModelNameKey>().unwrap();
747 assert_eq!(model.name, "custom");
748 }
749
750 #[test]
751 fn multiple_configs_independent() {
752 let mut spec = AgentSpec::new("test");
753 spec.set_config::<ModelNameKey>(ModelNameConfig { name: "a".into() })
754 .unwrap();
755 spec.set_config::<PermKey>(PermConfig { mode: "b".into() })
756 .unwrap();
757
758 spec.set_config::<ModelNameKey>(ModelNameConfig {
760 name: "updated".into(),
761 })
762 .unwrap();
763
764 let model: ModelNameConfig = spec.config::<ModelNameKey>().unwrap();
765 assert_eq!(model.name, "updated");
766
767 let perm: PermConfig = spec.config::<PermKey>().unwrap();
768 assert_eq!(perm.mode, "b");
769 }
770
771 #[test]
772 fn with_section_raw_json_still_works() {
773 let spec =
774 AgentSpec::new("test").with_section("custom", serde_json::json!({"key": "value"}));
775 assert_eq!(spec.sections["custom"]["key"], "value");
776 }
777
778 #[test]
779 fn remote_endpoint_canonical_roundtrip_uses_single_shape() {
780 let mut options = BTreeMap::new();
781 options.insert("poll_interval_ms".into(), json!(1000));
782 let endpoint = RemoteEndpoint {
783 backend: "a2a".into(),
784 base_url: "https://remote.example.com/v1/a2a".into(),
785 auth: Some(RemoteAuth::bearer("tok_123")),
786 target: Some("worker".into()),
787 timeout_ms: 60_000,
788 options,
789 };
790
791 let encoded = serde_json::to_value(&endpoint).unwrap();
792 assert_eq!(encoded["backend"], "a2a");
793 assert_eq!(encoded["auth"]["type"], "bearer");
794 assert_eq!(encoded["auth"]["token"], "tok_123");
795 assert_eq!(encoded["target"], "worker");
796 assert_eq!(encoded["options"]["poll_interval_ms"], 1000);
797 assert!(encoded.get("bearer_token").is_none());
798 assert!(encoded.get("agent_id").is_none());
799 assert!(encoded.get("poll_interval_ms").is_none());
800
801 let parsed: RemoteEndpoint = serde_json::from_value(encoded).unwrap();
802 assert_eq!(parsed, endpoint);
803 }
804
805 #[test]
806 fn remote_endpoint_legacy_a2a_input_normalizes_to_canonical_shape() {
807 let endpoint: RemoteEndpoint = serde_json::from_value(json!({
808 "base_url": "https://remote.example.com/v1/a2a",
809 "bearer_token": "tok_legacy",
810 "agent_id": "worker",
811 "poll_interval_ms": 750,
812 "timeout_ms": 60_000
813 }))
814 .unwrap();
815
816 assert_eq!(endpoint.backend, "a2a");
817 assert_eq!(
818 endpoint
819 .auth
820 .as_ref()
821 .and_then(|auth| auth.param_str("token")),
822 Some("tok_legacy")
823 );
824 assert_eq!(endpoint.target.as_deref(), Some("worker"));
825 assert_eq!(endpoint.options.get("poll_interval_ms"), Some(&json!(750)));
826 assert_eq!(endpoint.timeout_ms, 60_000);
827 }
828
829 #[test]
830 fn remote_endpoint_rejects_mixed_legacy_and_canonical_fields() {
831 let err = serde_json::from_value::<RemoteEndpoint>(json!({
832 "backend": "a2a",
833 "base_url": "https://remote.example.com/v1/a2a",
834 "auth": { "type": "bearer", "token": "tok_new" },
835 "bearer_token": "tok_old"
836 }))
837 .unwrap_err();
838
839 assert!(
840 err.to_string()
841 .contains("cannot mix legacy A2A endpoint fields")
842 );
843 }
844
845 #[test]
846 fn builder() {
847 let spec = AgentSpec::new("reviewer")
848 .with_model_id("claude-opus")
849 .with_hook_filter("permission")
850 .with_config::<PermKey>(PermConfig {
851 mode: "strict".into(),
852 })
853 .unwrap();
854
855 assert_eq!(spec.id, "reviewer");
856 assert_eq!(spec.model_id, "claude-opus");
857 assert!(spec.active_hook_filter.contains("permission"));
858 }
859
860 #[test]
863 fn provider_spec_debug_does_not_leak_api_key() {
864 let spec = ProviderSpec {
865 id: "openai".into(),
866 adapter: "openai".into(),
867 api_key: Some("sk-super-secret-12345".into()),
868 ..ProviderSpec::default()
869 };
870 let debug = format!("{spec:?}");
871 assert!(
872 !debug.contains("sk-super-secret-12345"),
873 "ProviderSpec Debug must not contain the api_key value, got: {debug}"
874 );
875 }
876
877 #[test]
878 fn provider_spec_empty_string_api_key_deserializes_as_none() {
879 let json_str = r#"{"id":"x","adapter":"openai","api_key":""}"#;
880 let spec: ProviderSpec = serde_json::from_str(json_str).unwrap();
881 assert!(
882 spec.api_key.is_none(),
883 "empty-string api_key should deserialize as None"
884 );
885 }
886
887 #[test]
888 fn provider_spec_empty_string_base_url_deserializes_as_none() {
889 let json_str = r#"{"id":"x","adapter":"openai","base_url":""}"#;
890 let spec: ProviderSpec = serde_json::from_str(json_str).unwrap();
891 assert!(
892 spec.base_url.is_none(),
893 "empty-string base_url should deserialize as None"
894 );
895 }
896
897 #[test]
898 fn provider_spec_adapter_options_round_trip() {
899 let mut opts = BTreeMap::new();
900 opts.insert("headers".into(), json!({"OpenAI-Organization": "org-xyz"}));
901 let spec = ProviderSpec {
902 id: "openai".into(),
903 adapter: "openai".into(),
904 adapter_options: opts,
905 ..ProviderSpec::default()
906 };
907 let encoded = serde_json::to_string(&spec).unwrap();
908 let parsed: ProviderSpec = serde_json::from_str(&encoded).unwrap();
909 assert_eq!(
910 parsed
911 .adapter_options
912 .get("headers")
913 .and_then(|value| value.get("OpenAI-Organization"))
914 .and_then(Value::as_str),
915 Some("org-xyz")
916 );
917 }
918
919 #[test]
920 fn provider_spec_adapter_options_skipped_when_empty() {
921 let spec = ProviderSpec {
922 id: "openai".into(),
923 adapter: "openai".into(),
924 ..ProviderSpec::default()
925 };
926 let encoded = serde_json::to_string(&spec).unwrap();
927 assert!(
928 !encoded.contains("adapter_options"),
929 "expected adapter_options to be elided when empty, got: {encoded}"
930 );
931 }
932}