1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6use typed_builder::TypedBuilder;
7
8use super::{
9 DEFAULT_MODEL, capabilities::CapabilitiesConfig, mcp::McpServer, models::GeminiConfig,
10};
11use crate::{
12 hooks::HookEntry, policies::PolicyRule, tools::ToolDefinition, triggers::TriggerEntry,
13};
14
15#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17pub struct SystemInstructionSection {
18 pub content: String,
20 #[serde(default = "default_section_title")]
22 pub title: String,
23}
24
25fn default_section_title() -> String {
26 "user_system_instructions".to_owned()
27}
28
29fn default_model_name() -> String {
30 DEFAULT_MODEL.to_owned()
31}
32
33#[non_exhaustive]
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(untagged)]
40pub enum SystemInstructions {
41 Custom(String),
43 Templated {
45 #[serde(default)]
47 identity: Option<String>,
48 #[serde(default)]
50 sections: Vec<SystemInstructionSection>,
51 },
52}
53
54impl SystemInstructions {
55 #[must_use]
57 pub fn custom(text: impl Into<String>) -> Self {
58 Self::Custom(text.into())
59 }
60}
61
62impl From<&str> for SystemInstructions {
63 fn from(s: &str) -> Self {
64 Self::custom(s)
65 }
66}
67
68impl From<String> for SystemInstructions {
69 fn from(s: String) -> Self {
70 Self::custom(s)
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(transparent)]
79pub struct JsonSchema(serde_json::Value);
80
81impl JsonSchema {
82 #[must_use]
83 pub const fn new(value: serde_json::Value) -> Self {
85 Self(value)
86 }
87
88 #[must_use]
89 pub const fn as_value(&self) -> &serde_json::Value {
91 &self.0
92 }
93
94 pub fn validate(&self) -> Result<(), &'static str> {
104 if self.0.is_object() {
105 Ok(())
106 } else {
107 Err("JSON Schema must be a JSON object at the top level")
108 }
109 }
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
150#[builder(field_defaults(default))]
151pub struct AgentConfig {
152 #[serde(default = "default_model_name")]
154 #[builder(default = DEFAULT_MODEL.to_owned(), setter(into))]
155 pub model: String,
156 #[serde(default)]
158 #[builder(setter(into, strip_option))]
159 pub api_key: Option<String>,
160 #[builder(setter(into, strip_option))]
162 pub system_instructions: Option<SystemInstructions>,
163 #[serde(default)]
164 #[builder(setter(strip_option))]
166 pub capabilities: Option<CapabilitiesConfig>,
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<PathBuf>>| v.into_iter().map(Into::into).collect()))]
173 pub workspaces: Vec<PathBuf>,
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<ToolDefinition>>| v.into_iter().map(Into::into).collect()))]
177 pub tools: Vec<ToolDefinition>,
178 #[serde(default = "default_policies")]
179 #[builder(default = default_policies(), setter(transform = |v: impl IntoIterator<Item = impl Into<PolicyRule>>| v.into_iter().map(Into::into).collect()))]
181 pub policies: Vec<PolicyRule>,
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<TriggerEntry>>| v.into_iter().map(Into::into).collect()))]
185 pub triggers: Vec<TriggerEntry>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<HookEntry>>| v.into_iter().map(Into::into).collect()))]
189 pub hooks: Vec<HookEntry>,
190 #[serde(
191 default,
192 skip_serializing_if = "Vec::is_empty",
193 rename = "skills_paths"
194 )]
195 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<PathBuf>>| v.into_iter().map(Into::into).collect()))]
199 pub skills: Vec<PathBuf>,
200
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 #[builder(setter(transform = |v: impl IntoIterator<Item = impl Into<McpServer>>| v.into_iter().map(Into::into).collect()))]
204 pub mcp_servers: Vec<McpServer>,
205 #[serde(default)]
207 #[builder(setter(into, strip_option))]
208 pub conversation_id: Option<String>,
209 #[serde(default)]
211 #[builder(setter(into, strip_option))]
212 pub save_dir: Option<PathBuf>,
213 #[serde(default)]
215 #[builder(setter(into, strip_option))]
216 pub app_data_dir: Option<PathBuf>,
217 #[serde(default)]
219 #[builder(setter(strip_option))]
220 pub response_schema: Option<JsonSchema>,
221 #[serde(default, rename = "gemini_config")]
228 #[builder(setter(strip_option))]
229 pub gemini: Option<GeminiConfig>,
230 #[serde(default)]
234 #[builder(setter(into, strip_option))]
235 pub max_quota_retries: Option<u32>,
236}
237
238impl Default for AgentConfig {
239 fn default() -> Self {
240 Self::builder().build()
241 }
242}
243
244impl AgentConfig {
245 #[must_use]
253 pub fn effective_api_key(&self) -> Option<String> {
254 self.gemini
255 .as_ref()
256 .and_then(|g| g.models.default.api_key.clone())
257 .or_else(|| self.gemini.as_ref().and_then(|g| g.api_key.clone()))
258 .or_else(|| self.api_key.clone())
259 .or_else(|| std::env::var("GEMINI_API_KEY").ok())
261 }
262
263 #[must_use]
267 pub fn custom_tool_names(&self) -> Vec<String> {
268 self.tools.iter().map(|t| t.name.clone()).collect()
269 }
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, Default)]
282pub struct LocalAgentConfig {
283 #[serde(flatten)]
285 pub agent: AgentConfig,
286}
287
288impl LocalAgentConfig {
289 #[must_use]
291 pub const fn new(agent: AgentConfig) -> Self {
292 Self { agent }
293 }
294}
295
296impl From<AgentConfig> for LocalAgentConfig {
297 fn from(agent: AgentConfig) -> Self {
298 Self::new(agent)
299 }
300}
301
302fn default_policies() -> Vec<PolicyRule> {
305 vec![
306 PolicyRule::Deny("run_command".to_string()),
307 PolicyRule::AllowAll,
308 ]
309}
310
311#[cfg(test)]
312mod tests {
313 use pyo3::types::PyAnyMethods;
314
315 use super::{
316 super::{
317 DEFAULT_IMAGE_GENERATION_MODEL,
318 capabilities::BuiltinTools,
319 models::{
320 GenerationConfig, ModelConfig, ModelEntry, ThinkingLevel, default_image_model_entry,
321 },
322 },
323 *,
324 };
325
326 #[derive(schemars::JsonSchema)]
327 struct CustomToolParams {}
328
329 #[test]
330 fn test_roundtrip_serialization() {
331 let config = AgentConfig {
332 system_instructions: Some(SystemInstructions::Custom("Be helpful".to_string())),
333 capabilities: Some(CapabilitiesConfig {
334 enable_subagents: true,
335 enabled_tools: Some(vec![BuiltinTools::ListDir]),
336 compaction_threshold: Some(4000),
337 ..CapabilitiesConfig::default()
338 }),
339 workspaces: vec![PathBuf::from("/tmp")],
340 ..AgentConfig::default()
341 };
342
343 let json = serde_json::to_string(&config).unwrap();
344 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
345 assert_eq!(parsed.workspaces.len(), 1);
346 assert_eq!(
347 parsed.capabilities.unwrap().enabled_tools.unwrap()[0],
348 BuiltinTools::ListDir
349 );
350 }
351
352 #[test]
353 fn agent_config_builder_with_gemini() {
354 let gemini = GeminiConfig {
355 api_key: Some("test-key".to_string()),
356 base_url: None,
357 models: ModelConfig::default(),
358 };
359 let config = AgentConfig::builder().gemini(gemini).build();
360 let gemini_cfg = config.gemini.expect("gemini should be Some");
361 assert_eq!(gemini_cfg.api_key.as_deref(), Some("test-key"));
362 assert_eq!(gemini_cfg.models.default.name, DEFAULT_MODEL);
363 }
364
365 #[test]
366 fn agent_config_builder_gemini_with_thinking_level() {
367 let gemini = GeminiConfig {
368 api_key: None,
369 base_url: None,
370 models: ModelConfig {
371 default: ModelEntry {
372 name: "gemini-3.5-flash".to_string(),
373 api_key: None,
374 generation: GenerationConfig {
375 thinking_level: Some(ThinkingLevel::High),
376 },
377 },
378 image_generation: default_image_model_entry(),
379 },
380 };
381 let config = AgentConfig::builder().gemini(gemini).build();
382 let gemini_cfg = config.gemini.expect("gemini should be Some");
383 assert_eq!(
384 gemini_cfg.models.default.generation.thinking_level,
385 Some(ThinkingLevel::High)
386 );
387 assert_eq!(gemini_cfg.models.default.name, "gemini-3.5-flash");
388 }
389
390 #[test]
391 fn agent_config_gemini_none_by_default() {
392 let config = AgentConfig::default();
393 assert!(config.gemini.is_none());
394 }
395
396 #[test]
397 fn agent_config_gemini_serde_roundtrip() {
398 let config = AgentConfig {
399 gemini: Some(GeminiConfig {
400 api_key: Some("roundtrip-key".to_string()),
401 base_url: None,
402 models: ModelConfig {
403 default: ModelEntry {
404 name: "gemini-3.5-flash".to_string(),
405 api_key: Some("model-key".to_string()),
406 generation: GenerationConfig {
407 thinking_level: Some(ThinkingLevel::Medium),
408 },
409 },
410 image_generation: default_image_model_entry(),
411 },
412 }),
413 ..AgentConfig::default()
414 };
415 let json = serde_json::to_string(&config).unwrap();
416 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
417 let gemini_cfg = parsed.gemini.expect("gemini should survive roundtrip");
418 assert_eq!(gemini_cfg.api_key.as_deref(), Some("roundtrip-key"));
419 assert_eq!(gemini_cfg.models.default.name, "gemini-3.5-flash");
420 assert_eq!(
421 gemini_cfg.models.default.api_key.as_deref(),
422 Some("model-key")
423 );
424 assert_eq!(
425 gemini_cfg.models.default.generation.thinking_level,
426 Some(ThinkingLevel::Medium)
427 );
428 }
429
430 #[test]
431 fn system_instructions_custom_serde() {
432 let instr = SystemInstructions::Custom("Be a helpful assistant".to_string());
433 let json = serde_json::to_string(&instr).unwrap();
434 let parsed: SystemInstructions = serde_json::from_str(&json).unwrap();
435 match parsed {
436 SystemInstructions::Custom(text) => assert_eq!(text, "Be a helpful assistant"),
437 SystemInstructions::Templated { .. } => {
438 panic!("Expected Custom, got Templated")
439 }
440 }
441 }
442
443 #[test]
444 fn system_instructions_templated_serde() {
445 let instr = SystemInstructions::Templated {
446 identity: Some("a security analyst".to_string()),
447 sections: vec![SystemInstructionSection {
448 content: "Always check permissions".to_string(),
449 title: "security".to_string(),
450 }],
451 };
452 let json = serde_json::to_string(&instr).unwrap();
453 let parsed: SystemInstructions = serde_json::from_str(&json).unwrap();
454 match parsed {
455 SystemInstructions::Templated { identity, sections } => {
456 assert_eq!(identity.as_deref(), Some("a security analyst"));
457 assert_eq!(sections.len(), 1);
458 assert_eq!(sections[0].content, "Always check permissions");
459 }
460 SystemInstructions::Custom(_) => {
461 panic!("Expected Templated, got Custom")
462 }
463 }
464 }
465
466 #[test]
467 fn agent_config_fully_populated_serde() {
468 let config = AgentConfig {
469 system_instructions: Some(SystemInstructions::Templated {
470 identity: Some("test-identity".to_string()),
471 sections: vec![],
472 }),
473 capabilities: Some(CapabilitiesConfig {
474 enable_subagents: true,
475 disabled_tools: Some(vec![BuiltinTools::RunCommand]),
476 compaction_threshold: Some(1000),
477 ..CapabilitiesConfig::default()
478 }),
479 workspaces: vec![PathBuf::from("/a"), PathBuf::from("/b")],
480 tools: vec![crate::tools::ToolDefinition {
481 name: "custom_tool".to_owned(),
482 description: "A custom tool".to_owned(),
483 parameter_schema: serde_json::to_value(schemars::schema_for!(CustomToolParams))
484 .unwrap(),
485 }],
486 policies: vec![PolicyRule::DenyAll],
487 triggers: vec![TriggerEntry {
488 name: "poll".to_owned(),
489 config: crate::triggers::TriggerConfig::every_secs(30),
490 message_template: "time to poll".to_owned(),
491 }],
492 hooks: vec![HookEntry {
493 name: "pre_gate".to_owned(),
494 point: crate::hooks::HookPoint::PreTurn,
495 callback_id: "cb_pre".to_owned(),
496 }],
497 skills: vec![PathBuf::from("/skills/foo")],
498 ..AgentConfig::default()
499 };
500 let json = serde_json::to_string(&config).unwrap();
501 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
502 assert_eq!(parsed.workspaces.len(), 2);
503 assert_eq!(parsed.tools.len(), 1);
504 assert_eq!(parsed.policies.len(), 1);
505 assert_eq!(parsed.triggers.len(), 1);
506 assert_eq!(parsed.hooks.len(), 1);
507 assert_eq!(parsed.skills.len(), 1);
508 }
509
510 #[test]
511 fn agent_config_empty_defaults_serde() {
512 let json = r#"{"system_instructions":null}"#;
513 let parsed: AgentConfig = serde_json::from_str(json).unwrap();
514 assert!(parsed.system_instructions.is_none());
515 assert!(parsed.capabilities.is_none());
516 assert!(parsed.workspaces.is_empty());
517 assert!(parsed.tools.is_empty());
518 assert_eq!(
519 parsed.policies,
520 vec![
521 PolicyRule::Deny("run_command".to_string()),
522 PolicyRule::AllowAll,
523 ]
524 );
525 assert!(parsed.triggers.is_empty());
526 assert!(parsed.hooks.is_empty());
527 assert!(parsed.skills.is_empty());
528
529 assert!(parsed.gemini.is_none());
530 }
531
532 #[test]
533 fn agent_config_all_optional_fields_roundtrip() {
534 let config = AgentConfig {
535 workspaces: vec![PathBuf::from("/ws")],
536 skills: vec![PathBuf::from("/skills/test")],
537
538 conversation_id: Some("conv-123".to_string()),
539 save_dir: Some(PathBuf::from("/save")),
540 app_data_dir: Some(PathBuf::from("/app")),
541 response_schema: Some(JsonSchema::new(serde_json::json!({"type": "object"}))),
542 ..AgentConfig::default()
543 };
544 let json = serde_json::to_string(&config).unwrap();
545 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
546 assert_eq!(parsed.workspaces.len(), 1);
547 assert_eq!(parsed.conversation_id.as_deref(), Some("conv-123"));
548 assert_eq!(parsed.save_dir.as_ref().unwrap(), &PathBuf::from("/save"));
549 assert!(parsed.response_schema.is_some());
550 }
551
552 #[test]
553 fn agent_config_custom_tools_and_builtin_tools_coexist() {
554 let custom_tool = crate::tools::ToolDefinition {
558 name: "my_custom_tool".to_owned(),
559 description: "Does something custom".to_owned(),
560 parameter_schema: serde_json::json!({"type": "object", "properties": {}}),
561 };
562 let config = AgentConfig {
563 tools: vec![custom_tool],
564 capabilities: Some(CapabilitiesConfig {
565 enabled_tools: Some(vec![BuiltinTools::ViewFile, BuiltinTools::RunCommand]),
566 ..CapabilitiesConfig::default()
567 }),
568 ..AgentConfig::default()
569 };
570
571 let json = serde_json::to_string(&config).unwrap();
573 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
574
575 assert_eq!(parsed.tools.len(), 1);
577 assert_eq!(parsed.tools[0].name, "my_custom_tool");
578
579 let caps = parsed.capabilities.as_ref().unwrap();
581 let enabled = caps.enabled_tools.as_ref().unwrap();
582 assert_eq!(enabled.len(), 2);
583 assert!(enabled.contains(&BuiltinTools::ViewFile));
584 assert!(enabled.contains(&BuiltinTools::RunCommand));
585
586 assert!(caps.validate().is_ok());
588 }
589
590 #[test]
591 fn agent_config_custom_tools_only_no_builtins() {
592 let config = AgentConfig {
594 tools: vec![crate::tools::ToolDefinition {
595 name: "fetch_data".to_owned(),
596 description: "Fetches data".to_owned(),
597 parameter_schema: serde_json::json!({"type": "object"}),
598 }],
599 capabilities: Some(CapabilitiesConfig::custom_tools_only()),
600 ..AgentConfig::default()
601 };
602
603 let caps = config.capabilities.as_ref().unwrap();
604 assert!(caps.enabled_tools.as_ref().unwrap().is_empty());
605 assert!(caps.validate().is_ok());
606 assert_eq!(config.tools.len(), 1);
607 }
608
609 #[test]
612 fn local_agent_config_default() {
613 let config = LocalAgentConfig::default();
614 assert_eq!(config.agent.model, DEFAULT_MODEL);
615 }
616
617 #[test]
618 fn local_agent_config_from_agent_config() {
619 let agent_cfg = AgentConfig {
620 model: "gemini-3.5-flash".to_string(),
621 ..AgentConfig::default()
622 };
623 let local: LocalAgentConfig = agent_cfg.into();
624 assert_eq!(local.agent.model, "gemini-3.5-flash");
625 }
626
627 #[test]
628 fn local_agent_config_serde_roundtrip() {
629 let config = LocalAgentConfig::new(AgentConfig::default());
630 let json = serde_json::to_string(&config).unwrap();
631 let parsed: LocalAgentConfig = serde_json::from_str(&json).unwrap();
632 assert_eq!(parsed.agent.model, DEFAULT_MODEL);
633 }
634
635 #[test]
641 fn skills_serializes_as_skills_paths() {
642 let config = AgentConfig::builder()
643 .skills(vec![PathBuf::from("/skill/a.md")])
644 .build();
645 let json = serde_json::to_string(&config).unwrap();
646 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
647 assert!(
648 v.get("skills_paths").is_some(),
649 "Expected JSON key 'skills_paths', got: {json}"
650 );
651 assert!(
652 v.get("skills").is_none(),
653 "Should not have 'skills' key in JSON"
654 );
655 }
656
657 #[test]
658 fn skills_paths_deserializes_to_skills_field() {
659 let json = r#"{"skills_paths": ["/skill/a.md"]}"#;
660 let config: AgentConfig = serde_json::from_str(json).unwrap();
661 assert_eq!(config.skills.len(), 1);
662 assert_eq!(config.skills[0], PathBuf::from("/skill/a.md"));
663 }
664
665 #[test]
666 fn gemini_serializes_as_gemini_config() {
667 let config = AgentConfig::builder()
668 .gemini(super::super::GeminiConfig::default())
669 .build();
670 let json = serde_json::to_string(&config).unwrap();
671 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
672 assert!(
673 v.get("gemini_config").is_some(),
674 "Expected JSON key 'gemini_config', got: {json}"
675 );
676 assert!(
677 v.get("gemini").is_none(),
678 "Should not have 'gemini' key in JSON"
679 );
680 }
681
682 #[test]
683 fn gemini_config_deserializes_to_gemini_field() {
684 let json = r#"{"gemini_config": {"api_key": "test-key"}}"#;
685 let config: AgentConfig = serde_json::from_str(json).unwrap();
686 assert_eq!(
687 config.gemini.as_ref().unwrap().api_key.as_deref(),
688 Some("test-key")
689 );
690 }
691
692 #[test]
695 fn empty_vecs_omitted_from_json() {
696 let config = AgentConfig::default();
697 let json = serde_json::to_string(&config).unwrap();
698 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
699 for key in &[
701 "workspaces",
702 "tools",
703 "triggers",
704 "hooks",
705 "skills_paths",
706 "mcp_servers",
707 ] {
708 assert!(
709 v.get(key).is_none(),
710 "Empty vec field '{key}' should be omitted from JSON, got: {json}"
711 );
712 }
713 assert!(
715 v.get("policies").is_some(),
716 "policies should always be serialized"
717 );
718 }
719
720 #[test]
721 fn populated_vecs_included_in_json() {
722 let config = AgentConfig::builder()
723 .skills(vec![PathBuf::from("/skill.md")])
724 .workspaces(vec![PathBuf::from("/ws")])
725 .build();
726 let json = serde_json::to_string(&config).unwrap();
727 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
728 assert!(
729 v.get("skills_paths").is_some(),
730 "Non-empty skills should be present"
731 );
732 assert!(
733 v.get("workspaces").is_some(),
734 "Non-empty workspaces should be present"
735 );
736 }
737
738 #[test]
741 fn default_policies_deny_run_command_allow_rest() {
742 let config = AgentConfig::default();
743 assert_eq!(config.policies.len(), 2);
744 assert_eq!(
745 config.policies[0],
746 PolicyRule::Deny("run_command".to_string())
747 );
748 assert_eq!(config.policies[1], PolicyRule::AllowAll);
749 }
750
751 fn py_str_attr(module: &str, attr: &str) -> String {
760 pyo3::Python::initialize();
761 pyo3::Python::attach(|py| {
762 crate::runtime::venv::configure_python_sys_path(py)
763 .unwrap_or_else(|e| panic!("Failed to configure python sys.path: {e}"));
764 let m = py
765 .import(module)
766 .unwrap_or_else(|e| panic!("Failed to import {module}: {e}"));
767 m.getattr(attr)
768 .unwrap_or_else(|e| panic!("Failed to get {module}.{attr}: {e}"))
769 .extract::<String>()
770 .unwrap_or_else(|e| panic!("Failed to extract {module}.{attr} as String: {e}"))
771 })
772 }
773
774 #[test]
775 fn default_model_matches_python_sdk() {
776 let py_val = py_str_attr("google.antigravity.types", "DEFAULT_MODEL");
777 assert_eq!(
778 DEFAULT_MODEL, py_val,
779 "Rust DEFAULT_MODEL ({DEFAULT_MODEL}) != Python SDK ({py_val})"
780 );
781 }
782
783 #[test]
784 fn default_image_model_matches_python_sdk() {
785 let py_val = py_str_attr("google.antigravity.types", "DEFAULT_IMAGE_GENERATION_MODEL");
786 assert_eq!(
787 DEFAULT_IMAGE_GENERATION_MODEL, py_val,
788 "Rust DEFAULT_IMAGE_GENERATION_MODEL ({DEFAULT_IMAGE_GENERATION_MODEL}) != Python SDK ({py_val})"
789 );
790 }
791
792 #[test]
795 fn effective_api_key_prefers_per_model_key() {
796 let config = AgentConfig::builder()
797 .api_key("top-level-key")
798 .gemini(super::super::GeminiConfig {
799 api_key: Some("shared-key".into()),
800 base_url: None,
801 models: super::super::ModelConfig {
802 default: super::super::ModelEntry {
803 name: "gemini-3.5-flash".into(),
804 api_key: Some("per-model-key".into()),
805 generation: super::super::GenerationConfig::default(),
806 },
807 image_generation: super::super::ModelEntry {
808 name: "imagen-4.0-generate-preview-06-03".into(),
809 api_key: None,
810 generation: super::super::GenerationConfig::default(),
811 },
812 },
813 })
814 .build();
815 assert_eq!(config.effective_api_key().as_deref(), Some("per-model-key"));
816 }
817
818 #[test]
819 fn effective_api_key_falls_back_to_gemini_shared_key() {
820 let config = AgentConfig::builder()
821 .gemini(super::super::GeminiConfig {
822 api_key: Some("shared-key".into()),
823 ..Default::default()
824 })
825 .build();
826 assert_eq!(config.effective_api_key().as_deref(), Some("shared-key"));
827 }
828
829 #[test]
830 fn effective_api_key_falls_back_to_top_level() {
831 let config = AgentConfig::builder().api_key("top-level-key").build();
832 assert_eq!(config.effective_api_key().as_deref(), Some("top-level-key"));
833 }
834
835 #[test]
836 fn effective_api_key_none_without_any_key() {
837 let config = AgentConfig::builder().build();
843 let result = config.effective_api_key();
844 match std::env::var("GEMINI_API_KEY").ok() {
846 Some(env_key) => assert_eq!(result.as_deref(), Some(env_key.as_str())),
847 None => assert!(result.is_none()),
848 }
849 }
850}