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}
231
232impl Default for AgentConfig {
233 fn default() -> Self {
234 Self::builder().build()
235 }
236}
237
238impl AgentConfig {
239 #[must_use]
247 pub fn effective_api_key(&self) -> Option<String> {
248 self.gemini
249 .as_ref()
250 .and_then(|g| g.models.default.api_key.clone())
251 .or_else(|| self.gemini.as_ref().and_then(|g| g.api_key.clone()))
252 .or_else(|| self.api_key.clone())
253 .or_else(|| std::env::var("GEMINI_API_KEY").ok())
254 }
255
256 #[must_use]
260 pub fn custom_tool_names(&self) -> Vec<String> {
261 self.tools.iter().map(|t| t.name.clone()).collect()
262 }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, Default)]
275pub struct LocalAgentConfig {
276 #[serde(flatten)]
278 pub agent: AgentConfig,
279}
280
281impl LocalAgentConfig {
282 #[must_use]
284 pub const fn new(agent: AgentConfig) -> Self {
285 Self { agent }
286 }
287}
288
289impl From<AgentConfig> for LocalAgentConfig {
290 fn from(agent: AgentConfig) -> Self {
291 Self::new(agent)
292 }
293}
294
295fn default_policies() -> Vec<PolicyRule> {
298 vec![
299 PolicyRule::Deny("run_command".to_string()),
300 PolicyRule::AllowAll,
301 ]
302}
303
304#[cfg(test)]
305mod tests {
306 use pyo3::types::PyAnyMethods;
307
308 use super::{
309 super::{
310 DEFAULT_IMAGE_GENERATION_MODEL,
311 capabilities::BuiltinTools,
312 models::{
313 GenerationConfig, ModelConfig, ModelEntry, ThinkingLevel, default_image_model_entry,
314 },
315 },
316 *,
317 };
318
319 #[derive(schemars::JsonSchema)]
320 struct CustomToolParams {}
321
322 #[test]
323 fn test_roundtrip_serialization() {
324 let config = AgentConfig {
325 system_instructions: Some(SystemInstructions::Custom("Be helpful".to_string())),
326 capabilities: Some(CapabilitiesConfig {
327 enable_subagents: true,
328 enabled_tools: Some(vec![BuiltinTools::ListDir]),
329 compaction_threshold: Some(4000),
330 ..CapabilitiesConfig::default()
331 }),
332 workspaces: vec![PathBuf::from("/tmp")],
333 ..AgentConfig::default()
334 };
335
336 let json = serde_json::to_string(&config).unwrap();
337 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
338 assert_eq!(parsed.workspaces.len(), 1);
339 assert_eq!(
340 parsed.capabilities.unwrap().enabled_tools.unwrap()[0],
341 BuiltinTools::ListDir
342 );
343 }
344
345 #[test]
346 fn agent_config_builder_with_gemini() {
347 let gemini = GeminiConfig {
348 api_key: Some("test-key".to_string()),
349 models: ModelConfig::default(),
350 };
351 let config = AgentConfig::builder().gemini(gemini).build();
352 let gemini_cfg = config.gemini.expect("gemini should be Some");
353 assert_eq!(gemini_cfg.api_key.as_deref(), Some("test-key"));
354 assert_eq!(gemini_cfg.models.default.name, DEFAULT_MODEL);
355 }
356
357 #[test]
358 fn agent_config_builder_gemini_with_thinking_level() {
359 let gemini = GeminiConfig {
360 api_key: None,
361 models: ModelConfig {
362 default: ModelEntry {
363 name: "gemini-3.5-flash".to_string(),
364 api_key: None,
365 generation: GenerationConfig {
366 thinking_level: Some(ThinkingLevel::High),
367 },
368 },
369 image_generation: default_image_model_entry(),
370 },
371 };
372 let config = AgentConfig::builder().gemini(gemini).build();
373 let gemini_cfg = config.gemini.expect("gemini should be Some");
374 assert_eq!(
375 gemini_cfg.models.default.generation.thinking_level,
376 Some(ThinkingLevel::High)
377 );
378 assert_eq!(gemini_cfg.models.default.name, "gemini-3.5-flash");
379 }
380
381 #[test]
382 fn agent_config_gemini_none_by_default() {
383 let config = AgentConfig::default();
384 assert!(config.gemini.is_none());
385 }
386
387 #[test]
388 fn agent_config_gemini_serde_roundtrip() {
389 let config = AgentConfig {
390 gemini: Some(GeminiConfig {
391 api_key: Some("roundtrip-key".to_string()),
392 models: ModelConfig {
393 default: ModelEntry {
394 name: "gemini-3.5-flash".to_string(),
395 api_key: Some("model-key".to_string()),
396 generation: GenerationConfig {
397 thinking_level: Some(ThinkingLevel::Medium),
398 },
399 },
400 image_generation: default_image_model_entry(),
401 },
402 }),
403 ..AgentConfig::default()
404 };
405 let json = serde_json::to_string(&config).unwrap();
406 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
407 let gemini_cfg = parsed.gemini.expect("gemini should survive roundtrip");
408 assert_eq!(gemini_cfg.api_key.as_deref(), Some("roundtrip-key"));
409 assert_eq!(gemini_cfg.models.default.name, "gemini-3.5-flash");
410 assert_eq!(
411 gemini_cfg.models.default.api_key.as_deref(),
412 Some("model-key")
413 );
414 assert_eq!(
415 gemini_cfg.models.default.generation.thinking_level,
416 Some(ThinkingLevel::Medium)
417 );
418 }
419
420 #[test]
421 fn system_instructions_custom_serde() {
422 let instr = SystemInstructions::Custom("Be a helpful assistant".to_string());
423 let json = serde_json::to_string(&instr).unwrap();
424 let parsed: SystemInstructions = serde_json::from_str(&json).unwrap();
425 match parsed {
426 SystemInstructions::Custom(text) => assert_eq!(text, "Be a helpful assistant"),
427 SystemInstructions::Templated { .. } => {
428 panic!("Expected Custom, got Templated")
429 }
430 }
431 }
432
433 #[test]
434 fn system_instructions_templated_serde() {
435 let instr = SystemInstructions::Templated {
436 identity: Some("a security analyst".to_string()),
437 sections: vec![SystemInstructionSection {
438 content: "Always check permissions".to_string(),
439 title: "security".to_string(),
440 }],
441 };
442 let json = serde_json::to_string(&instr).unwrap();
443 let parsed: SystemInstructions = serde_json::from_str(&json).unwrap();
444 match parsed {
445 SystemInstructions::Templated { identity, sections } => {
446 assert_eq!(identity.as_deref(), Some("a security analyst"));
447 assert_eq!(sections.len(), 1);
448 assert_eq!(sections[0].content, "Always check permissions");
449 }
450 SystemInstructions::Custom(_) => {
451 panic!("Expected Templated, got Custom")
452 }
453 }
454 }
455
456 #[test]
457 fn agent_config_fully_populated_serde() {
458 let config = AgentConfig {
459 system_instructions: Some(SystemInstructions::Templated {
460 identity: Some("test-identity".to_string()),
461 sections: vec![],
462 }),
463 capabilities: Some(CapabilitiesConfig {
464 enable_subagents: true,
465 disabled_tools: Some(vec![BuiltinTools::RunCommand]),
466 compaction_threshold: Some(1000),
467 ..CapabilitiesConfig::default()
468 }),
469 workspaces: vec![PathBuf::from("/a"), PathBuf::from("/b")],
470 tools: vec![crate::tools::ToolDefinition {
471 name: "custom_tool".to_owned(),
472 description: "A custom tool".to_owned(),
473 parameter_schema: serde_json::to_value(schemars::schema_for!(CustomToolParams))
474 .unwrap(),
475 }],
476 policies: vec![PolicyRule::DenyAll],
477 triggers: vec![TriggerEntry {
478 name: "poll".to_owned(),
479 config: crate::triggers::TriggerConfig::every_secs(30),
480 message_template: "time to poll".to_owned(),
481 }],
482 hooks: vec![HookEntry {
483 name: "pre_gate".to_owned(),
484 point: crate::hooks::HookPoint::PreTurn,
485 callback_id: "cb_pre".to_owned(),
486 }],
487 skills: vec![PathBuf::from("/skills/foo")],
488 ..AgentConfig::default()
489 };
490 let json = serde_json::to_string(&config).unwrap();
491 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
492 assert_eq!(parsed.workspaces.len(), 2);
493 assert_eq!(parsed.tools.len(), 1);
494 assert_eq!(parsed.policies.len(), 1);
495 assert_eq!(parsed.triggers.len(), 1);
496 assert_eq!(parsed.hooks.len(), 1);
497 assert_eq!(parsed.skills.len(), 1);
498 }
499
500 #[test]
501 fn agent_config_empty_defaults_serde() {
502 let json = r#"{"system_instructions":null}"#;
503 let parsed: AgentConfig = serde_json::from_str(json).unwrap();
504 assert!(parsed.system_instructions.is_none());
505 assert!(parsed.capabilities.is_none());
506 assert!(parsed.workspaces.is_empty());
507 assert!(parsed.tools.is_empty());
508 assert_eq!(
509 parsed.policies,
510 vec![
511 PolicyRule::Deny("run_command".to_string()),
512 PolicyRule::AllowAll,
513 ]
514 );
515 assert!(parsed.triggers.is_empty());
516 assert!(parsed.hooks.is_empty());
517 assert!(parsed.skills.is_empty());
518
519 assert!(parsed.gemini.is_none());
520 }
521
522 #[test]
523 fn thinking_level_all_variants_python_str() {
524 assert_eq!(ThinkingLevel::Minimal.as_str(), "minimal");
525 assert_eq!(ThinkingLevel::Low.as_str(), "low");
526 assert_eq!(ThinkingLevel::Medium.as_str(), "medium");
527 assert_eq!(ThinkingLevel::High.as_str(), "high");
528 }
529
530 #[test]
531 fn thinking_level_all_variants_serde() {
532 for (variant, expected) in [
533 (ThinkingLevel::Minimal, "\"minimal\""),
534 (ThinkingLevel::Low, "\"low\""),
535 (ThinkingLevel::Medium, "\"medium\""),
536 (ThinkingLevel::High, "\"high\""),
537 ] {
538 let json = serde_json::to_string(&variant).unwrap();
539 assert_eq!(json, expected);
540 let parsed: ThinkingLevel = serde_json::from_str(&json).unwrap();
541 assert_eq!(parsed, variant);
542 }
543 }
544
545 #[test]
546 fn system_instructions_custom_variant() {
547 let instr = SystemInstructions::Custom("Be helpful".to_string());
548 let json = serde_json::to_string(&instr).unwrap();
549 let parsed: SystemInstructions = serde_json::from_str(&json).unwrap();
550 match parsed {
551 SystemInstructions::Custom(text) => assert_eq!(text, "Be helpful"),
552 SystemInstructions::Templated { .. } => {
553 panic!("Expected Custom, got Templated")
554 }
555 }
556 }
557
558 #[test]
559 fn agent_config_all_optional_fields_roundtrip() {
560 let config = AgentConfig {
561 workspaces: vec![PathBuf::from("/ws")],
562 skills: vec![PathBuf::from("/skills/test")],
563
564 conversation_id: Some("conv-123".to_string()),
565 save_dir: Some(PathBuf::from("/save")),
566 app_data_dir: Some(PathBuf::from("/app")),
567 response_schema: Some(JsonSchema::new(serde_json::json!({"type": "object"}))),
568 ..AgentConfig::default()
569 };
570 let json = serde_json::to_string(&config).unwrap();
571 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
572 assert_eq!(parsed.workspaces.len(), 1);
573 assert_eq!(parsed.conversation_id.as_deref(), Some("conv-123"));
574 assert_eq!(parsed.save_dir.as_ref().unwrap(), &PathBuf::from("/save"));
575 assert!(parsed.response_schema.is_some());
576 }
577
578 #[test]
579
580 fn agent_config_custom_tools_and_builtin_tools_coexist() {
581 let custom_tool = crate::tools::ToolDefinition {
585 name: "my_custom_tool".to_owned(),
586 description: "Does something custom".to_owned(),
587 parameter_schema: serde_json::json!({"type": "object", "properties": {}}),
588 };
589 let config = AgentConfig {
590 tools: vec![custom_tool],
591 capabilities: Some(CapabilitiesConfig {
592 enabled_tools: Some(vec![BuiltinTools::ViewFile, BuiltinTools::RunCommand]),
593 ..CapabilitiesConfig::default()
594 }),
595 ..AgentConfig::default()
596 };
597
598 let json = serde_json::to_string(&config).unwrap();
600 let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
601
602 assert_eq!(parsed.tools.len(), 1);
604 assert_eq!(parsed.tools[0].name, "my_custom_tool");
605
606 let caps = parsed.capabilities.as_ref().unwrap();
608 let enabled = caps.enabled_tools.as_ref().unwrap();
609 assert_eq!(enabled.len(), 2);
610 assert!(enabled.contains(&BuiltinTools::ViewFile));
611 assert!(enabled.contains(&BuiltinTools::RunCommand));
612
613 assert!(caps.validate().is_ok());
615 }
616
617 #[test]
618 fn agent_config_custom_tools_only_no_builtins() {
619 let config = AgentConfig {
621 tools: vec![crate::tools::ToolDefinition {
622 name: "fetch_data".to_owned(),
623 description: "Fetches data".to_owned(),
624 parameter_schema: serde_json::json!({"type": "object"}),
625 }],
626 capabilities: Some(CapabilitiesConfig::custom_tools_only()),
627 ..AgentConfig::default()
628 };
629
630 let caps = config.capabilities.as_ref().unwrap();
631 assert!(caps.enabled_tools.as_ref().unwrap().is_empty());
632 assert!(caps.validate().is_ok());
633 assert_eq!(config.tools.len(), 1);
634 }
635
636 #[test]
639 fn local_agent_config_default() {
640 let config = LocalAgentConfig::default();
641 assert_eq!(config.agent.model, DEFAULT_MODEL);
642 }
643
644 #[test]
645 fn local_agent_config_from_agent_config() {
646 let agent_cfg = AgentConfig {
647 model: "gemini-3.5-flash".to_string(),
648 ..AgentConfig::default()
649 };
650 let local: LocalAgentConfig = agent_cfg.into();
651 assert_eq!(local.agent.model, "gemini-3.5-flash");
652 }
653
654 #[test]
655 fn local_agent_config_serde_roundtrip() {
656 let config = LocalAgentConfig::new(AgentConfig::default());
657 let json = serde_json::to_string(&config).unwrap();
658 let parsed: LocalAgentConfig = serde_json::from_str(&json).unwrap();
659 assert_eq!(parsed.agent.model, DEFAULT_MODEL);
660 }
661
662 #[test]
668 fn skills_serializes_as_skills_paths() {
669 let config = AgentConfig::builder()
670 .skills(vec![PathBuf::from("/skill/a.md")])
671 .build();
672 let json = serde_json::to_string(&config).unwrap();
673 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
674 assert!(
675 v.get("skills_paths").is_some(),
676 "Expected JSON key 'skills_paths', got: {json}"
677 );
678 assert!(
679 v.get("skills").is_none(),
680 "Should not have 'skills' key in JSON"
681 );
682 }
683
684 #[test]
685 fn skills_paths_deserializes_to_skills_field() {
686 let json = r#"{"skills_paths": ["/skill/a.md"]}"#;
687 let config: AgentConfig = serde_json::from_str(json).unwrap();
688 assert_eq!(config.skills.len(), 1);
689 assert_eq!(config.skills[0], PathBuf::from("/skill/a.md"));
690 }
691
692 #[test]
693 fn gemini_serializes_as_gemini_config() {
694 let config = AgentConfig::builder()
695 .gemini(super::super::GeminiConfig::default())
696 .build();
697 let json = serde_json::to_string(&config).unwrap();
698 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
699 assert!(
700 v.get("gemini_config").is_some(),
701 "Expected JSON key 'gemini_config', got: {json}"
702 );
703 assert!(
704 v.get("gemini").is_none(),
705 "Should not have 'gemini' key in JSON"
706 );
707 }
708
709 #[test]
710 fn gemini_config_deserializes_to_gemini_field() {
711 let json = r#"{"gemini_config": {"api_key": "test-key"}}"#;
712 let config: AgentConfig = serde_json::from_str(json).unwrap();
713 assert_eq!(
714 config.gemini.as_ref().unwrap().api_key.as_deref(),
715 Some("test-key")
716 );
717 }
718
719 #[test]
722 fn empty_vecs_omitted_from_json() {
723 let config = AgentConfig::default();
724 let json = serde_json::to_string(&config).unwrap();
725 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
726 for key in &[
728 "workspaces",
729 "tools",
730 "triggers",
731 "hooks",
732 "skills_paths",
733 "mcp_servers",
734 ] {
735 assert!(
736 v.get(key).is_none(),
737 "Empty vec field '{key}' should be omitted from JSON, got: {json}"
738 );
739 }
740 assert!(
742 v.get("policies").is_some(),
743 "policies should always be serialized"
744 );
745 }
746
747 #[test]
748 fn populated_vecs_included_in_json() {
749 let config = AgentConfig::builder()
750 .skills(vec![PathBuf::from("/skill.md")])
751 .workspaces(vec![PathBuf::from("/ws")])
752 .build();
753 let json = serde_json::to_string(&config).unwrap();
754 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
755 assert!(
756 v.get("skills_paths").is_some(),
757 "Non-empty skills should be present"
758 );
759 assert!(
760 v.get("workspaces").is_some(),
761 "Non-empty workspaces should be present"
762 );
763 }
764
765 #[test]
768 fn default_policies_deny_run_command_allow_rest() {
769 let config = AgentConfig::default();
770 assert_eq!(config.policies.len(), 2);
771 assert_eq!(
772 config.policies[0],
773 PolicyRule::Deny("run_command".to_string())
774 );
775 assert_eq!(config.policies[1], PolicyRule::AllowAll);
776 }
777
778 fn py_str_attr(module: &str, attr: &str) -> String {
787 pyo3::prepare_freethreaded_python();
788 pyo3::Python::with_gil(|py| {
789 crate::runtime::venv::configure_python_sys_path(py)
790 .unwrap_or_else(|e| panic!("Failed to configure python sys.path: {e}"));
791 let m = py
792 .import_bound(module)
793 .unwrap_or_else(|e| panic!("Failed to import {module}: {e}"));
794 m.getattr(attr)
795 .unwrap_or_else(|e| panic!("Failed to get {module}.{attr}: {e}"))
796 .extract::<String>()
797 .unwrap_or_else(|e| panic!("Failed to extract {module}.{attr} as String: {e}"))
798 })
799 }
800
801 #[test]
802 fn default_model_matches_python_sdk() {
803 let py_val = py_str_attr("google.antigravity.types", "DEFAULT_MODEL");
804 assert_eq!(
805 DEFAULT_MODEL, py_val,
806 "Rust DEFAULT_MODEL ({DEFAULT_MODEL}) != Python SDK ({py_val})"
807 );
808 }
809
810 #[test]
811 fn default_image_model_matches_python_sdk() {
812 let py_val = py_str_attr("google.antigravity.types", "DEFAULT_IMAGE_GENERATION_MODEL");
813 assert_eq!(
814 DEFAULT_IMAGE_GENERATION_MODEL, py_val,
815 "Rust DEFAULT_IMAGE_GENERATION_MODEL ({DEFAULT_IMAGE_GENERATION_MODEL}) != Python SDK ({py_val})"
816 );
817 }
818
819 #[test]
822 fn effective_api_key_prefers_per_model_key() {
823 let config = AgentConfig::builder()
824 .api_key("top-level-key")
825 .gemini(super::super::GeminiConfig {
826 api_key: Some("shared-key".into()),
827 models: super::super::ModelConfig {
828 default: super::super::ModelEntry {
829 name: "gemini-3.5-flash".into(),
830 api_key: Some("per-model-key".into()),
831 generation: super::super::GenerationConfig::default(),
832 },
833 image_generation: super::super::ModelEntry {
834 name: "imagen-4.0-generate-preview-06-03".into(),
835 api_key: None,
836 generation: super::super::GenerationConfig::default(),
837 },
838 },
839 })
840 .build();
841 assert_eq!(config.effective_api_key().as_deref(), Some("per-model-key"));
842 }
843
844 #[test]
845 fn effective_api_key_falls_back_to_gemini_shared_key() {
846 let config = AgentConfig::builder()
847 .gemini(super::super::GeminiConfig {
848 api_key: Some("shared-key".into()),
849 ..Default::default()
850 })
851 .build();
852 assert_eq!(config.effective_api_key().as_deref(), Some("shared-key"));
853 }
854
855 #[test]
856 fn effective_api_key_falls_back_to_top_level() {
857 let config = AgentConfig::builder().api_key("top-level-key").build();
858 assert_eq!(config.effective_api_key().as_deref(), Some("top-level-key"));
859 }
860
861 #[test]
862 fn effective_api_key_none_without_any_key() {
863 let saved = std::env::var("GEMINI_API_KEY").ok();
865 unsafe { std::env::remove_var("GEMINI_API_KEY") };
866 let config = AgentConfig::builder().build();
867 let result = config.effective_api_key();
868 if let Some(v) = saved {
870 unsafe { std::env::set_var("GEMINI_API_KEY", v) };
871 }
872 assert!(result.is_none());
873 }
874}