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