1use crate::AgentError;
2use crate::a2a::AgentSkill;
3use crate::browser::{BrowserAgentConfig, BrowsrClientConfig};
4use crate::configuration::DefinitionOverrides;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use std::default::Default;
8
9pub const DEFAULT_EXTERNAL_TOOL_TIMEOUT_SECS: u64 = 120;
11
12#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
14pub struct AvailableSkill {
15 pub id: String,
17 pub name: String,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub description: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
26#[serde(deny_unknown_fields, rename_all = "snake_case")]
27#[derive(Default)]
28pub struct AgentStrategy {
29 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub reasoning_depth: Option<ReasoningDepth>,
32
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub execution_mode: Option<ExecutionMode>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub replanning: Option<ReplanningConfig>,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub external_tool_timeout_secs: Option<u64>,
44}
45
46impl AgentStrategy {
47 pub fn get_reasoning_depth(&self) -> ReasoningDepth {
49 self.reasoning_depth.clone().unwrap_or_default()
50 }
51
52 pub fn get_execution_mode(&self) -> ExecutionMode {
54 self.execution_mode.clone().unwrap_or_default()
55 }
56
57 pub fn get_replanning(&self) -> ReplanningConfig {
59 self.replanning.clone().unwrap_or_default()
60 }
61
62 pub fn get_external_tool_timeout_secs(&self) -> u64 {
64 self.external_tool_timeout_secs
65 .unwrap_or(DEFAULT_EXTERNAL_TOOL_TIMEOUT_SECS)
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
70#[serde(rename_all = "snake_case")]
71pub enum CodeLanguage {
72 #[default]
73 Typescript,
74}
75
76impl std::fmt::Display for CodeLanguage {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 match self {
79 CodeLanguage::Typescript => write!(f, "typescript"),
80 }
81 }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
86pub struct ReflectionConfig {
87 #[serde(default)]
89 pub enabled: bool,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub reflection_agent: Option<String>,
95 #[serde(default)]
97 pub trigger: ReflectionTrigger,
98 #[serde(default)]
100 pub depth: ReflectionDepth,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
105#[serde(rename_all = "snake_case")]
106pub enum ReflectionTrigger {
107 #[default]
109 EndOfExecution,
110 AfterEachStep,
112 AfterFailures,
114 AfterNSteps(usize),
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
120#[serde(rename_all = "snake_case")]
121pub enum ReflectionDepth {
122 #[default]
124 Light,
125 Standard,
127 Deep,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
133pub struct PlanConfig {
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub model_settings: Option<ModelSettings>,
137 #[serde(default = "default_plan_max_iterations")]
139 pub max_iterations: usize,
140}
141
142impl Default for PlanConfig {
143 fn default() -> Self {
144 Self {
145 model_settings: None,
146 max_iterations: default_plan_max_iterations(),
147 }
148 }
149}
150
151fn default_plan_max_iterations() -> usize {
152 10
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
157#[serde(rename_all = "snake_case")]
158pub enum ReasoningDepth {
159 Shallow,
161 #[default]
163 Standard,
164 Deep,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
170#[serde(rename_all = "snake_case", tag = "type")]
171pub enum ExecutionMode {
172 #[default]
174 Tools,
175 Code { language: CodeLanguage },
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
181#[serde(rename_all = "snake_case")]
182pub struct ReplanningConfig {
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub trigger: Option<ReplanningTrigger>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub enabled: Option<bool>,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
193#[serde(rename_all = "snake_case")]
194pub enum ReplanningTrigger {
195 #[default]
197 Never,
198 AfterReflection,
200 AfterNIterations(usize),
202 AfterFailures,
204}
205
206impl ReplanningConfig {
207 pub fn get_trigger(&self) -> ReplanningTrigger {
209 self.trigger.clone().unwrap_or_default()
210 }
211
212 pub fn is_enabled(&self) -> bool {
214 self.enabled.unwrap_or(false)
215 }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
219#[serde(rename_all = "snake_case")]
220pub enum ExecutionKind {
221 #[default]
222 Retriable,
223 Interleaved,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
227#[serde(rename_all = "snake_case")]
228pub enum MemoryKind {
229 #[default]
230 None,
231 ShortTerm,
232 LongTerm,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
242#[serde(rename_all = "snake_case")]
243pub enum ToolDeliveryMode {
244 #[serde(alias = "all_tools")]
246 Full,
247 #[default]
249 #[serde(alias = "tool_search")]
250 Deferred,
251 NamesOnly,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
274#[serde(rename_all = "snake_case")]
275pub enum OpenAiApiFormat {
276 #[default]
278 Auto,
279 Completions,
281 Responses,
283}
284
285impl OpenAiApiFormat {
286 pub fn resolve(&self, model: &str) -> ResolvedOpenAiApiFormat {
289 match self {
290 OpenAiApiFormat::Completions => ResolvedOpenAiApiFormat::Completions,
291 OpenAiApiFormat::Responses => ResolvedOpenAiApiFormat::Responses,
292 OpenAiApiFormat::Auto => {
293 if Self::model_requires_responses_api(model) {
294 ResolvedOpenAiApiFormat::Responses
295 } else {
296 ResolvedOpenAiApiFormat::Completions
297 }
298 }
299 }
300 }
301
302 fn model_requires_responses_api(model: &str) -> bool {
309 let m = model.to_lowercase();
310 m.starts_with("codex")
312 || m.ends_with("-codex")
313 || m.contains("/codex")
314 || m.ends_with("-pro")
316 || m.ends_with("-deep-research")
318 }
319}
320
321#[derive(Debug, Clone, PartialEq)]
323pub enum ResolvedOpenAiApiFormat {
324 Completions,
325 Responses,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
330#[serde(rename_all = "snake_case")]
331pub enum ToolCallFormat {
332 #[default]
335 Xml,
336 JsonL,
339
340 Code,
343 #[serde(rename = "provider")]
344 Provider,
345 None,
346}
347
348#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, Default)]
349pub struct UserMessageOverrides {
350 pub parts: Vec<PartDefinition>,
352 #[serde(default)]
354 pub include_artifacts: bool,
355 #[serde(default = "default_include_step_count")]
357 pub include_step_count: Option<bool>,
358}
359
360fn default_include_step_count() -> Option<bool> {
361 Some(true)
362}
363
364#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema)]
365#[serde(tag = "type", content = "source", rename_all = "snake_case")]
366pub enum PartDefinition {
367 Template(String), SessionKey(String), }
370
371#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
372#[serde(deny_unknown_fields)]
373pub struct LlmDefinition {
374 pub name: String,
376 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub model_settings: Option<ModelSettings>,
379 #[serde(default)]
381 pub tool_format: ToolCallFormat,
382 #[serde(default)]
384 pub tool_delivery_mode: ToolDeliveryMode,
385}
386
387impl LlmDefinition {
388 pub fn ms(&self) -> Result<&ModelSettings, String> {
391 self.model_settings.as_ref().ok_or_else(|| {
392 "No model configured. Please set a default model in Agent Settings → Default Model."
393 .to_string()
394 })
395 }
396
397 pub fn ms_mut(&mut self) -> Result<&mut ModelSettings, String> {
400 self.model_settings.as_mut().ok_or_else(|| {
401 "No model configured. Please set a default model in Agent Settings → Default Model."
402 .to_string()
403 })
404 }
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash, Default)]
410#[serde(rename_all = "snake_case")]
411pub enum RuntimeMode {
412 Cli,
414 #[default]
416 Cloud,
417 Browser,
419}
420
421impl RuntimeMode {
422 pub fn as_template_name(&self) -> &'static str {
431 match self {
432 Self::Cli => "cli",
433 Self::Cloud => "cloud",
434 Self::Browser => "browser",
435 }
436 }
437}
438
439#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
442pub struct CompactionConfig {
443 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub auto_threshold: Option<f32>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub keep_recent_entries: Option<usize>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub prompt_version: Option<String>,
454}
455
456#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
458pub struct StandardDefinition {
459 pub name: String,
461 #[serde(default, skip_serializing_if = "String::is_empty")]
463 pub description: String,
464
465 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub version: Option<String>,
468
469 #[serde(default, skip_serializing_if = "String::is_empty")]
471 pub instructions: String,
472
473 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub model_settings: Option<ModelSettings>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub analysis_model_settings: Option<ModelSettings>,
480
481 #[serde(default, skip_serializing_if = "Option::is_none")]
483 pub history_size: Option<usize>,
484 #[serde(default, skip_serializing_if = "Option::is_none")]
486 pub strategy: Option<AgentStrategy>,
487 #[serde(default, skip_serializing_if = "Option::is_none")]
489 pub icon_url: Option<String>,
490
491 #[serde(default, skip_serializing_if = "Vec::is_empty")]
496 pub commands: Vec<crate::channel_commands::SlashCommand>,
497
498 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub max_iterations: Option<usize>,
500
501 #[serde(default, skip_serializing_if = "Vec::is_empty")]
503 pub skills_description: Vec<AgentSkill>,
504
505 #[serde(default, skip_serializing_if = "Vec::is_empty")]
507 pub available_skills: Vec<AvailableSkill>,
508
509 #[serde(default, skip_serializing_if = "Vec::is_empty")]
515 pub connections: Vec<crate::connections::ConnectionRequirement>,
516
517 #[serde(default, skip_serializing_if = "Vec::is_empty")]
519 pub sub_agents: Vec<String>,
520
521 #[serde(default, skip_serializing_if = "is_default_tool_format")]
523 pub tool_format: ToolCallFormat,
524
525 #[serde(default, skip_serializing_if = "is_default_tool_delivery_mode")]
527 pub tool_delivery_mode: ToolDeliveryMode,
528
529 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub tools: Option<ToolsConfig>,
532
533 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
535 pub partials: std::collections::HashMap<String, String>,
536
537 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub reflection: Option<ReflectionConfig>,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
542 pub enable_todos: Option<bool>,
543
544 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub browser_config: Option<BrowserAgentConfig>,
547
548 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub include_shell: Option<bool>,
551
552 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub context_size: Option<u32>,
555
556 #[serde(
558 skip_serializing_if = "Option::is_none",
559 default = "default_append_default_instructions"
560 )]
561 pub append_default_instructions: Option<bool>,
562 #[serde(
564 skip_serializing_if = "Option::is_none",
565 default = "default_include_scratchpad"
566 )]
567 pub include_scratchpad: Option<bool>,
568
569 #[serde(default, skip_serializing_if = "Vec::is_empty")]
571 pub hooks: Vec<String>,
572
573 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub user_message_overrides: Option<UserMessageOverrides>,
576
577 #[serde(
579 default = "default_compaction_enabled",
580 skip_serializing_if = "is_true"
581 )]
582 pub compaction_enabled: bool,
583
584 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub compaction: Option<CompactionConfig>,
588
589 #[serde(
603 default,
604 deserialize_with = "deserialize_runtime_modes",
605 skip_serializing_if = "Vec::is_empty"
606 )]
607 pub runtime: Vec<RuntimeMode>,
608}
609
610fn deserialize_runtime_modes<'de, D>(deserializer: D) -> Result<Vec<RuntimeMode>, D::Error>
612where
613 D: serde::Deserializer<'de>,
614{
615 use serde::de::{self, Deserialize};
616
617 #[derive(Deserialize)]
618 #[serde(untagged)]
619 enum OneOrMany {
620 One(RuntimeMode),
621 Many(Vec<RuntimeMode>),
622 }
623
624 match Option::<OneOrMany>::deserialize(deserializer)? {
625 None => Ok(Vec::new()),
626 Some(OneOrMany::One(rt)) => Ok(vec![rt]),
627 Some(OneOrMany::Many(v)) => {
628 let mut seen = std::collections::HashSet::new();
630 for rt in &v {
631 let key = format!("{:?}", rt);
632 if !seen.insert(key) {
633 return Err(de::Error::custom(format!(
634 "duplicate runtime entry: {:?}",
635 rt
636 )));
637 }
638 }
639 Ok(v)
640 }
641 }
642}
643fn default_append_default_instructions() -> Option<bool> {
644 Some(true)
645}
646fn default_include_scratchpad() -> Option<bool> {
647 Some(true)
648}
649fn default_compaction_enabled() -> bool {
650 true
651}
652fn is_true(v: &bool) -> bool {
653 *v
654}
655fn is_default_tool_format(v: &ToolCallFormat) -> bool {
656 *v == ToolCallFormat::default()
657}
658fn is_default_tool_delivery_mode(v: &ToolDeliveryMode) -> bool {
659 *v == ToolDeliveryMode::default()
660}
661impl StandardDefinition {
662 pub fn allowed_runtimes(&self) -> Vec<RuntimeMode> {
666 self.runtime.clone()
667 }
668
669 pub fn is_runnable_in(
679 &self,
680 current: &RuntimeMode,
681 runner_provides: Option<&RuntimeMode>,
682 ) -> bool {
683 let allowed = self.allowed_runtimes();
684 if allowed.is_empty() {
685 return true;
686 }
687 if allowed.iter().any(|rt| rt == current) {
688 return true;
689 }
690 match runner_provides {
691 Some(p) => allowed.iter().any(|rt| rt == p),
692 None => false,
693 }
694 }
695
696 pub fn should_use_browser(&self) -> bool {
698 self.browser_config
699 .as_ref()
700 .map(|cfg| cfg.is_enabled())
701 .unwrap_or(false)
702 }
703
704 pub fn browser_settings(&self) -> Option<&BrowserAgentConfig> {
706 self.browser_config.as_ref()
707 }
708
709 pub fn browser_runtime_config(&self) -> Option<BrowsrClientConfig> {
711 self.browser_config.as_ref().map(|cfg| cfg.runtime_config())
712 }
713
714 pub fn should_persist_browser_session(&self) -> bool {
716 self.browser_config
717 .as_ref()
718 .map(|cfg| cfg.should_persist_session())
719 .unwrap_or(false)
720 }
721
722 pub fn is_reflection_enabled(&self) -> bool {
724 self.reflection.as_ref().map(|r| r.enabled).unwrap_or(false)
725 }
726
727 pub fn reflection_config(&self) -> Option<&ReflectionConfig> {
729 self.reflection.as_ref().filter(|r| r.enabled)
730 }
731 pub fn is_todos_enabled(&self) -> bool {
733 self.enable_todos.unwrap_or(false)
734 }
735
736 pub fn should_include_shell(&self) -> bool {
738 self.include_shell.unwrap_or(false)
739 }
740
741 pub fn model_settings(&self) -> Option<&ModelSettings> {
743 self.model_settings.as_ref()
744 }
745
746 pub fn model_settings_mut(&mut self) -> Option<&mut ModelSettings> {
748 self.model_settings.as_mut()
749 }
750
751 pub fn get_effective_context_size(&self) -> u32 {
755 self.context_size.filter(|&s| s > 0).unwrap_or_else(|| {
756 self.model_settings()
757 .map(|ms| ms.effective_context_size())
758 .unwrap_or_else(default_context_size)
759 })
760 }
761
762 pub fn analysis_model_settings_config(&self) -> Option<&ModelSettings> {
764 self.analysis_model_settings
765 .as_ref()
766 .or_else(|| self.model_settings())
767 }
768
769 pub fn include_scratchpad(&self) -> bool {
771 self.include_scratchpad.unwrap_or(true)
772 }
773
774 pub fn apply_overrides(&mut self, overrides: DefinitionOverrides) {
776 if let Some(ref mut ms) = self.model_settings {
778 if let Some(model) = overrides.model {
779 ms.model = model
781 .split_once('/')
782 .map(|(_, m)| m.to_string())
783 .unwrap_or(model);
784 }
785 if let Some(temperature) = overrides.temperature {
786 ms.inner.temperature = Some(temperature);
787 }
788 if let Some(max_tokens) = overrides.max_tokens {
789 ms.inner.max_tokens = Some(max_tokens);
790 }
791 }
792
793 if let Some(max_iterations) = overrides.max_iterations {
795 self.max_iterations = Some(max_iterations);
796 }
797
798 if let Some(instructions) = overrides.instructions {
800 self.instructions = instructions;
801 }
802
803 if let Some(suffix) = overrides.instructions_append {
807 if self.instructions.is_empty() {
808 self.instructions = suffix;
809 } else {
810 self.instructions.push_str("\n\n");
811 self.instructions.push_str(&suffix);
812 }
813 }
814
815 if let Some(runtime) = overrides.runtime {
816 self.runtime = runtime;
817 }
818
819 if let Some(description) = overrides.description {
820 self.description = description;
821 }
822
823 if let Some(name) = overrides.name {
824 self.name = name;
825 }
826
827 if let Some(sub_agents) = overrides.sub_agents {
828 self.sub_agents = sub_agents;
829 }
830
831 if let Some(tools_override) = overrides.tools {
832 self.tools = Some(tools_override);
833 }
834
835 if let Some(use_browser) = overrides.use_browser {
836 let mut config = self.browser_config.clone().unwrap_or_default();
837 config.enabled = use_browser;
838 self.browser_config = Some(config);
839 }
840
841 if let Some(dynamic_tools) = overrides.dynamic_tools {
843 let tools = self.tools.get_or_insert_with(ToolsConfig::default);
844 tools.dynamic.extend(dynamic_tools);
845 }
846 }
847}
848
849pub const VALID_BUILTIN_TOOLS: &[&str] = &[
856 "final",
858 "reflect",
859 "invoke_agent",
861 "get_task",
863 "wait_task",
864 "cancel_task",
865 "list_my_tasks",
866 "browsr_scrape",
868 "browsr_browser",
869 "browsr_crawl",
870 "browser_step",
871 "search",
872 "start_shell",
874 "execute_shell",
875 "stop_shell",
876 "distri_execute_code",
878 "tool_search",
880 "load_skill",
883 "inject_connection_env",
885 "console_log",
887 "artifact_tool",
889 "write_todos",
895];
896
897pub const CORE_TOOLS: &[&str] = &[
900 "final",
901 "invoke_agent",
902 "tool_search",
903 "write_todos",
904 "execute_shell",
905 "start_shell",
906 "load_skill",
907];
908
909pub const DEFAULT_DEFERRED_THRESHOLD: usize = 15;
911
912#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
913#[serde(deny_unknown_fields)]
914pub struct ToolsConfig {
915 #[serde(default, skip_serializing_if = "Vec::is_empty")]
917 pub builtin: Vec<String>,
918
919 #[serde(default, skip_serializing_if = "Vec::is_empty")]
921 pub dynamic: Vec<crate::dynamic_tool::DynamicToolFactory>,
922
923 #[serde(default, skip_serializing_if = "Vec::is_empty")]
925 pub mcp: Vec<McpToolConfig>,
926
927 #[serde(default, skip_serializing_if = "Option::is_none")]
929 pub external: Option<Vec<String>>,
930
931 #[serde(default, skip_serializing_if = "is_default_delivery_mode")]
935 pub delivery_mode: ToolDeliveryMode,
936
937 #[serde(default, skip_serializing_if = "Option::is_none")]
941 pub deferred_threshold: Option<usize>,
942
943 #[serde(default, skip_serializing_if = "Vec::is_empty")]
946 pub always_full_schema: Vec<String>,
947}
948
949fn is_default_delivery_mode(mode: &ToolDeliveryMode) -> bool {
950 *mode == ToolDeliveryMode::Deferred
951}
952
953impl ToolsConfig {
954 pub fn invalid_builtin_tools(&self) -> Vec<String> {
957 self.builtin
958 .iter()
959 .filter(|name| !VALID_BUILTIN_TOOLS.contains(&name.as_str()))
960 .cloned()
961 .collect()
962 }
963
964 pub fn is_core_tool(&self, name: &str) -> bool {
966 CORE_TOOLS.contains(&name) || self.always_full_schema.iter().any(|n| n == name)
967 }
968
969 pub fn effective_threshold(&self) -> usize {
971 self.deferred_threshold
972 .unwrap_or(DEFAULT_DEFERRED_THRESHOLD)
973 }
974
975 pub fn effective_delivery_mode(&self, _total_tools: usize) -> ToolDeliveryMode {
979 self.delivery_mode.clone()
980 }
981}
982
983#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
988#[serde(deny_unknown_fields)]
989pub struct McpToolConfig {
990 pub server: String,
992
993 #[serde(default, skip_serializing_if = "Vec::is_empty")]
996 pub include: Vec<String>,
997
998 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1000 pub exclude: Vec<String>,
1001}
1002
1003#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1004#[serde(deny_unknown_fields)]
1005pub struct McpDefinition {
1006 #[serde(default)]
1008 pub filter: Option<Vec<String>>,
1009 pub name: String,
1011 #[serde(default)]
1013 pub r#type: McpServerType,
1014 #[serde(default)]
1016 pub auth_config: Option<crate::a2a::SecurityScheme>,
1017}
1018
1019#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
1020#[serde(rename_all = "lowercase")]
1021pub enum McpServerType {
1022 #[default]
1023 Tool,
1024 Agent,
1025}
1026
1027#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1028#[serde(deny_unknown_fields, rename_all = "lowercase", tag = "name")]
1029pub enum ModelProvider {
1030 #[serde(rename = "openai")]
1031 OpenAI {},
1032 #[serde(rename = "openai_compat")]
1033 OpenAICompatible {
1034 base_url: String,
1035 api_key: Option<String>,
1036 project_id: Option<String>,
1037 },
1038 #[serde(rename = "azure_openai")]
1039 AzureOpenAI {
1040 base_url: String,
1041 api_key: Option<String>,
1042 deployment: String,
1043 #[serde(default = "ModelProvider::azure_api_version")]
1044 api_version: String,
1045 },
1046 #[serde(rename = "anthropic")]
1047 Anthropic {
1048 #[serde(default = "ModelProvider::anthropic_base_url")]
1049 base_url: Option<String>,
1050 api_key: Option<String>,
1051 },
1052 #[serde(rename = "zai")]
1057 ZAi {
1058 #[serde(default = "ModelProvider::zai_base_url")]
1059 base_url: String,
1060 api_key: Option<String>,
1061 },
1062 #[serde(rename = "gemini")]
1063 Gemini {
1064 #[serde(default = "ModelProvider::gemini_base_url")]
1065 base_url: String,
1066 api_key: Option<String>,
1067 },
1068 #[serde(rename = "azure_ai_foundry")]
1069 AzureAiFoundry {
1070 resource: String,
1074 api_key: Option<String>,
1075 },
1076 #[serde(rename = "aws_bedrock")]
1077 AwsBedrock {
1078 base_url: String,
1079 api_key: Option<String>,
1080 },
1081 #[serde(rename = "google_vertex")]
1082 GoogleVertex {
1083 base_url: String,
1084 api_key: Option<String>,
1085 project_id: Option<String>,
1086 },
1087 #[serde(rename = "alibaba_cloud")]
1088 AlibabaCloud {
1089 #[serde(default = "ModelProvider::alibaba_cloud_base_url")]
1090 base_url: String,
1091 api_key: Option<String>,
1092 },
1093 #[serde(rename = "fal_ai")]
1097 FalAi { api_key: Option<String> },
1098}
1099#[derive(Debug, Clone, Serialize, Deserialize)]
1101pub struct ProviderSecretDefinition {
1102 pub id: String,
1104 pub label: String,
1106 pub keys: Vec<SecretKeyDefinition>,
1108 #[serde(default, skip_serializing_if = "Option::is_none")]
1111 pub category: Option<String>,
1112}
1113
1114#[derive(Debug, Clone, Serialize, Deserialize)]
1116pub struct SecretKeyDefinition {
1117 pub key: String,
1119 pub label: String,
1121 #[serde(default)]
1123 pub placeholder: String,
1124 #[serde(default = "default_required")]
1126 pub required: bool,
1127 #[serde(default = "default_sensitive")]
1130 pub sensitive: bool,
1131 #[serde(default, skip_serializing_if = "Option::is_none")]
1136 pub url_template: Option<String>,
1137}
1138
1139fn default_required() -> bool {
1140 true
1141}
1142
1143fn default_sensitive() -> bool {
1144 true
1145}
1146
1147#[derive(Debug, Clone, Serialize, Deserialize)]
1149pub struct ModelInfo {
1150 pub id: String,
1152 pub name: String,
1154}
1155
1156#[derive(Debug, Clone, Serialize, Deserialize)]
1159struct DefaultProviderEntry {
1160 id: String,
1161 label: String,
1162 keys: Vec<SecretKeyDefinition>,
1163 models: Vec<crate::models::Model>,
1164 #[serde(default, skip_serializing_if = "Option::is_none")]
1166 test: Option<crate::models::ProviderTestConfig>,
1167 #[serde(default, skip_serializing_if = "Option::is_none")]
1169 category: Option<String>,
1170}
1171
1172#[derive(Debug, Clone, Serialize, Deserialize)]
1173struct DefaultModelsFile {
1174 providers: Vec<DefaultProviderEntry>,
1175}
1176
1177fn load_default_providers() -> &'static [DefaultProviderEntry] {
1178 use std::sync::OnceLock;
1179 static PROVIDERS: OnceLock<Vec<DefaultProviderEntry>> = OnceLock::new();
1180 PROVIDERS.get_or_init(|| {
1181 let json = include_str!("default_models.json");
1182 let file: DefaultModelsFile =
1183 serde_json::from_str(json).expect("Failed to parse default_models.json");
1184 file.providers
1185 })
1186}
1187
1188impl From<crate::models::ProviderKeyDefinition> for SecretKeyDefinition {
1189 fn from(k: crate::models::ProviderKeyDefinition) -> Self {
1190 SecretKeyDefinition {
1191 key: k.key,
1192 label: k.label,
1193 placeholder: k.placeholder,
1194 required: k.required,
1195 sensitive: k.sensitive,
1196 url_template: k.url_template,
1197 }
1198 }
1199}
1200
1201impl From<crate::models::ModelProviderDefinition> for DefaultProviderEntry {
1202 fn from(d: crate::models::ModelProviderDefinition) -> Self {
1203 let models = d
1204 .models
1205 .into_iter()
1206 .map(|mut m| {
1207 if m.name.trim().is_empty() {
1210 m.name = m.id.clone();
1211 }
1212 m
1213 })
1214 .collect();
1215 DefaultProviderEntry {
1216 id: d.id,
1217 label: d.label,
1218 keys: d.keys.into_iter().map(SecretKeyDefinition::from).collect(),
1219 models,
1220 test: d.test,
1221 category: d.category,
1222 }
1223 }
1224}
1225
1226pub fn lookup_provider_test_config(provider_id: &str) -> Option<crate::models::ProviderTestConfig> {
1231 merged_providers()
1232 .into_iter()
1233 .find(|p| p.id == provider_id)
1234 .and_then(|p| p.test)
1235}
1236
1237static PROVIDER_EXTENSIONS: std::sync::OnceLock<Vec<DefaultProviderEntry>> =
1242 std::sync::OnceLock::new();
1243
1244pub fn register_provider_extensions(extensions: Vec<crate::models::ModelProviderDefinition>) {
1252 let entries: Vec<DefaultProviderEntry> = extensions
1253 .into_iter()
1254 .map(DefaultProviderEntry::from)
1255 .collect();
1256 let count = entries.len();
1257 if PROVIDER_EXTENSIONS.set(entries).is_err() {
1258 tracing::warn!("provider extensions already registered; ignoring {count} new entries");
1259 } else {
1260 tracing::info!("registered {count} provider extension(s)");
1261 }
1262}
1263
1264fn merge_provider_layers(
1267 builtin: &[DefaultProviderEntry],
1268 extensions: &[DefaultProviderEntry],
1269) -> Vec<DefaultProviderEntry> {
1270 let mut merged: Vec<DefaultProviderEntry> = builtin.to_vec();
1271 for ext in extensions {
1272 match merged.iter_mut().find(|p| p.id == ext.id) {
1273 Some(slot) => *slot = ext.clone(),
1274 None => merged.push(ext.clone()),
1275 }
1276 }
1277 merged
1278}
1279
1280fn merged_providers() -> Vec<DefaultProviderEntry> {
1283 let extensions = PROVIDER_EXTENSIONS.get().map(Vec::as_slice).unwrap_or(&[]);
1284 merge_provider_layers(load_default_providers(), extensions)
1285}
1286
1287struct RegistryModelLookup;
1289
1290impl crate::models::ModelLookup for RegistryModelLookup {
1291 fn model(&self, provider_id: &str, model_id: &str) -> Option<crate::models::Model> {
1292 merged_providers()
1293 .into_iter()
1294 .find(|p| p.id == provider_id)?
1295 .models
1296 .into_iter()
1297 .find(|m| m.id == model_id)
1298 }
1299
1300 fn find_model(&self, model_id: &str) -> Option<crate::models::Model> {
1301 merged_providers()
1302 .into_iter()
1303 .flat_map(|p| p.models)
1304 .find(|m| m.id == model_id)
1305 }
1306}
1307
1308pub fn model_lookup() -> &'static dyn crate::models::ModelLookup {
1314 static LOOKUP: RegistryModelLookup = RegistryModelLookup;
1315 &LOOKUP
1316}
1317
1318#[derive(Debug, Clone, Serialize, Deserialize)]
1320pub struct ProviderModels {
1321 pub provider_id: String,
1323 pub provider_label: String,
1325 pub models: Vec<crate::models::Model>,
1327}
1328
1329#[derive(Debug, Clone, Serialize, Deserialize)]
1331pub struct ProviderModelsStatus {
1332 pub provider_id: String,
1334 pub provider_label: String,
1336 pub configured: bool,
1338 pub models: Vec<crate::models::Model>,
1340}
1341
1342impl Default for ModelProvider {
1343 fn default() -> Self {
1344 ModelProvider::OpenAI {}
1345 }
1346}
1347
1348impl ModelProvider {
1349 pub fn openai_base_url() -> String {
1350 "https://api.openai.com/v1".to_string()
1351 }
1352
1353 pub fn anthropic_base_url() -> Option<String> {
1354 None
1355 }
1356
1357 pub fn gemini_base_url() -> String {
1358 "https://generativelanguage.googleapis.com/v1beta/openai".to_string()
1359 }
1360
1361 pub fn zai_base_url() -> String {
1363 "https://api.z.ai/api/anthropic".to_string()
1364 }
1365
1366 pub fn azure_api_version() -> String {
1367 "2024-06-01".to_string()
1368 }
1369
1370 pub fn alibaba_cloud_base_url() -> String {
1371 "https://dashscope-intl.aliyuncs.com/compatible-mode/v1".to_string()
1372 }
1373
1374 pub fn fal_ai_base_url() -> &'static str {
1377 "https://fal.run"
1378 }
1379
1380 pub fn api_key_slot_mut(&mut self) -> Option<&mut Option<String>> {
1383 match self {
1384 Self::OpenAI {} => None,
1385 Self::OpenAICompatible { api_key, .. }
1386 | Self::AzureOpenAI { api_key, .. }
1387 | Self::Anthropic { api_key, .. }
1388 | Self::ZAi { api_key, .. }
1389 | Self::Gemini { api_key, .. }
1390 | Self::AzureAiFoundry { api_key, .. }
1391 | Self::AwsBedrock { api_key, .. }
1392 | Self::GoogleVertex { api_key, .. }
1393 | Self::AlibabaCloud { api_key, .. }
1394 | Self::FalAi { api_key } => Some(api_key),
1395 }
1396 }
1397
1398 pub fn base_url_slot_mut(&mut self) -> Option<&mut String> {
1403 match self {
1404 Self::AzureAiFoundry { resource, .. } => Some(resource),
1408 Self::AzureOpenAI { base_url, .. }
1409 | Self::AwsBedrock { base_url, .. }
1410 | Self::GoogleVertex { base_url, .. }
1411 | Self::Gemini { base_url, .. }
1412 | Self::OpenAICompatible { base_url, .. }
1413 | Self::AlibabaCloud { base_url, .. } => Some(base_url),
1414 Self::OpenAI {} | Self::Anthropic { .. } | Self::ZAi { .. } | Self::FalAi { .. } => {
1417 None
1418 }
1419 }
1420 }
1421
1422 pub fn provider_type(&self) -> crate::models::ProviderType {
1424 match self {
1425 ModelProvider::OpenAI {} => crate::models::ProviderType::OpenAI,
1426 ModelProvider::OpenAICompatible { .. } => {
1427 crate::models::ProviderType::Custom("openai_compat".to_string())
1428 }
1429 ModelProvider::AzureOpenAI { .. } => crate::models::ProviderType::Azure,
1430 ModelProvider::Anthropic { .. } => crate::models::ProviderType::Anthropic,
1431 ModelProvider::ZAi { .. } => crate::models::ProviderType::Anthropic,
1433 ModelProvider::Gemini { .. } => crate::models::ProviderType::Gemini,
1434 ModelProvider::AzureAiFoundry { .. } => crate::models::ProviderType::AzureAiFoundry,
1435 ModelProvider::AwsBedrock { .. } => crate::models::ProviderType::AwsBedrock,
1436 ModelProvider::GoogleVertex { .. } => crate::models::ProviderType::GoogleVertex,
1437 ModelProvider::AlibabaCloud { .. } => crate::models::ProviderType::AlibabaCloud,
1438 ModelProvider::FalAi { .. } => crate::models::ProviderType::FalAi,
1439 }
1440 }
1441
1442 pub fn provider_id(&self) -> &str {
1444 match self {
1445 ModelProvider::OpenAI {} => "openai",
1446 ModelProvider::OpenAICompatible { .. } => "openai_compat",
1447 ModelProvider::AzureOpenAI { .. } => "azure_openai",
1448 ModelProvider::Anthropic { .. } => "anthropic",
1449 ModelProvider::ZAi { .. } => "zai",
1450 ModelProvider::Gemini { .. } => "gemini",
1451 ModelProvider::AzureAiFoundry { .. } => "azure_ai_foundry",
1452 ModelProvider::AwsBedrock { .. } => "aws_bedrock",
1453 ModelProvider::GoogleVertex { .. } => "google_vertex",
1454 ModelProvider::AlibabaCloud { .. } => "alibaba_cloud",
1455 ModelProvider::FalAi { .. } => "fal_ai",
1456 }
1457 }
1458
1459 pub fn api_key_secret(&self) -> &'static str {
1469 match self {
1470 ModelProvider::OpenAI {} => "OPENAI_API_KEY",
1471 ModelProvider::OpenAICompatible { .. } => "OPENAI_API_KEY",
1475 ModelProvider::AzureOpenAI { .. } => "AZURE_OPENAI_API_KEY",
1476 ModelProvider::Anthropic { .. } => "ANTHROPIC_API_KEY",
1477 ModelProvider::ZAi { .. } => "ZAI_API_KEY",
1478 ModelProvider::Gemini { .. } => "GEMINI_API_KEY",
1479 ModelProvider::AzureAiFoundry { .. } => "AZURE_AI_FOUNDRY_API_KEY",
1480 ModelProvider::AwsBedrock { .. } => "AWS_ACCESS_KEY_ID",
1484 ModelProvider::GoogleVertex { .. } => "GOOGLE_VERTEX_API_KEY",
1485 ModelProvider::AlibabaCloud { .. } => "DASHSCOPE_API_KEY",
1486 ModelProvider::FalAi { .. } => "FAL_KEY",
1487 }
1488 }
1489
1490 pub fn endpoint_secret(&self) -> Option<&'static str> {
1496 match self {
1497 ModelProvider::AzureOpenAI { .. } => Some("AZURE_OPENAI_ENDPOINT"),
1498 ModelProvider::AzureAiFoundry { .. } => Some("AZURE_AI_FOUNDRY_RESOURCE"),
1500 ModelProvider::AwsBedrock { .. } => Some("AWS_BEDROCK_ENDPOINT"),
1501 ModelProvider::GoogleVertex { .. } => Some("GOOGLE_VERTEX_ENDPOINT"),
1502 ModelProvider::OpenAI {}
1503 | ModelProvider::OpenAICompatible { .. }
1504 | ModelProvider::Anthropic { .. }
1505 | ModelProvider::ZAi { .. }
1506 | ModelProvider::Gemini { .. }
1507 | ModelProvider::AlibabaCloud { .. }
1508 | ModelProvider::FalAi { .. } => None,
1509 }
1510 }
1511
1512 pub fn azure_ai_foundry_base_url(resource: &str) -> String {
1520 let r = resource.trim().trim_matches('/');
1521 format!("https://{r}.openai.azure.com/openai/v1")
1522 }
1523
1524 pub fn azure_ai_foundry_resource(&self) -> Option<&str> {
1526 match self {
1527 ModelProvider::AzureAiFoundry { resource, .. } => Some(resource.as_str()),
1528 _ => None,
1529 }
1530 }
1531
1532 pub fn completion_url(&self) -> Option<String> {
1537 match self {
1538 ModelProvider::AzureAiFoundry { resource, .. } if !resource.trim().is_empty() => {
1539 Some(Self::azure_ai_foundry_base_url(resource))
1540 }
1541 _ => None,
1542 }
1543 }
1544
1545 pub fn tts_url(&self) -> Option<String> {
1549 self.completion_url()
1550 }
1551
1552 pub fn stt_url(&self) -> Option<String> {
1554 self.completion_url()
1555 }
1556
1557 pub fn image_url(&self) -> Option<String> {
1565 match self {
1566 ModelProvider::AzureAiFoundry { resource, .. } if !resource.trim().is_empty() => {
1567 let r = resource.trim().trim_matches('/');
1568 Some(format!("https://{r}.services.ai.azure.com/openai/v1"))
1569 }
1570 _ => self.completion_url(),
1571 }
1572 }
1573
1574 pub fn resolved_endpoint(&self) -> (Option<String>, Option<String>) {
1578 match self {
1579 ModelProvider::OpenAI {} => (Some(Self::openai_base_url()), None),
1580 ModelProvider::AzureAiFoundry { api_key, .. } => {
1581 (self.completion_url(), api_key.clone())
1582 }
1583 ModelProvider::Anthropic { base_url, api_key } => (
1584 Some(
1585 base_url
1586 .clone()
1587 .unwrap_or_else(|| "https://api.anthropic.com".to_string()),
1588 ),
1589 api_key.clone(),
1590 ),
1591 ModelProvider::ZAi { base_url, api_key } => (Some(base_url.clone()), api_key.clone()),
1592 ModelProvider::Gemini { base_url, api_key } => {
1593 (Some(base_url.clone()), api_key.clone())
1594 }
1595 ModelProvider::OpenAICompatible {
1596 base_url, api_key, ..
1597 } => (Some(base_url.clone()), api_key.clone()),
1598 ModelProvider::AlibabaCloud { base_url, api_key } => {
1599 (Some(base_url.clone()), api_key.clone())
1600 }
1601 ModelProvider::AzureOpenAI {
1602 base_url, api_key, ..
1603 } => (Some(base_url.clone()), api_key.clone()),
1604 ModelProvider::AwsBedrock { base_url, api_key } => {
1605 (Some(base_url.clone()), api_key.clone())
1606 }
1607 ModelProvider::GoogleVertex {
1608 base_url, api_key, ..
1609 } => (Some(base_url.clone()), api_key.clone()),
1610 ModelProvider::FalAi { api_key } => {
1611 (Some(Self::fal_ai_base_url().to_string()), api_key.clone())
1612 }
1613 }
1614 }
1615
1616 pub fn required_secret_keys(&self) -> Vec<&'static str> {
1621 let api_key_present = match self {
1622 ModelProvider::OpenAI {} => false,
1623 ModelProvider::OpenAICompatible { api_key, .. }
1624 | ModelProvider::AzureOpenAI { api_key, .. }
1625 | ModelProvider::Gemini { api_key, .. }
1626 | ModelProvider::AzureAiFoundry { api_key, .. }
1627 | ModelProvider::AwsBedrock { api_key, .. }
1628 | ModelProvider::GoogleVertex { api_key, .. }
1629 | ModelProvider::AlibabaCloud { api_key, .. }
1630 | ModelProvider::FalAi { api_key } => api_key.is_some(),
1631 ModelProvider::Anthropic { api_key, .. } | ModelProvider::ZAi { api_key, .. } => {
1632 api_key.is_some()
1633 }
1634 };
1635 if api_key_present {
1636 vec![]
1637 } else {
1638 vec![self.api_key_secret()]
1639 }
1640 }
1641
1642 pub fn all_provider_definitions() -> Vec<ProviderSecretDefinition> {
1645 merged_providers()
1646 .into_iter()
1647 .map(|p| ProviderSecretDefinition {
1648 id: p.id,
1649 label: p.label,
1650 keys: p.keys,
1651 category: p.category,
1652 })
1653 .collect()
1654 }
1655
1656 pub fn well_known_models() -> Vec<ProviderModels> {
1659 merged_providers()
1660 .into_iter()
1661 .filter(|p| !p.models.is_empty())
1662 .map(|p| ProviderModels {
1663 provider_id: p.id,
1664 provider_label: p.label,
1665 models: p.models,
1666 })
1667 .collect()
1668 }
1669
1670 pub fn display_name(&self) -> &'static str {
1672 match self {
1673 ModelProvider::OpenAI {} => "OpenAI",
1674 ModelProvider::OpenAICompatible { .. } => "OpenAI Compatible",
1675 ModelProvider::AzureOpenAI { .. } => "Azure",
1676 ModelProvider::Anthropic { .. } => "Anthropic",
1677 ModelProvider::ZAi { .. } => "Z.ai",
1678 ModelProvider::Gemini { .. } => "Google Gemini",
1679 ModelProvider::AzureAiFoundry { .. } => "Azure AI Foundry",
1680 ModelProvider::AwsBedrock { .. } => "AWS Bedrock",
1681 ModelProvider::GoogleVertex { .. } => "Google Vertex AI",
1682 ModelProvider::AlibabaCloud { .. } => "Alibaba Cloud",
1683 ModelProvider::FalAi { .. } => "fal.ai",
1684 }
1685 }
1686
1687 pub fn otel_provider_name(&self) -> &'static str {
1690 match self {
1691 ModelProvider::OpenAI { .. } => "openai",
1692 ModelProvider::OpenAICompatible { .. } => "openai",
1693 ModelProvider::AzureOpenAI { .. } => "azure.ai.openai",
1694 ModelProvider::Anthropic { .. } => "anthropic",
1695 ModelProvider::ZAi { .. } => "zai",
1696 ModelProvider::Gemini { .. } => "google.gemini",
1697 ModelProvider::AzureAiFoundry { .. } => "azure.ai.inference",
1698 ModelProvider::AwsBedrock { .. } => "aws.bedrock",
1699 ModelProvider::GoogleVertex { .. } => "gcp.vertex_ai",
1700 ModelProvider::AlibabaCloud { .. } => "alibaba_cloud",
1701 ModelProvider::FalAi { .. } => "fal.ai",
1702 }
1703 }
1704}
1705
1706#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1710pub struct ModelSettings {
1711 pub model: String,
1712 #[serde(flatten)]
1713 pub inner: ModelSettingsInner,
1714}
1715
1716#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
1720pub struct ModelSettingsInner {
1721 #[serde(default, skip_serializing_if = "Option::is_none")]
1722 pub temperature: Option<f32>,
1723 #[serde(default, skip_serializing_if = "Option::is_none")]
1724 pub max_tokens: Option<u32>,
1725 #[serde(
1729 default,
1730 deserialize_with = "de_context_size",
1731 skip_serializing_if = "Option::is_none"
1732 )]
1733 pub context_size: Option<u32>,
1734 #[serde(default, skip_serializing_if = "Option::is_none")]
1735 pub top_p: Option<f32>,
1736 #[serde(default, skip_serializing_if = "Option::is_none")]
1737 pub frequency_penalty: Option<f32>,
1738 #[serde(default, skip_serializing_if = "Option::is_none")]
1739 pub presence_penalty: Option<f32>,
1740 #[serde(default = "default_model_provider")]
1741 pub provider: ModelProvider,
1742 #[serde(default)]
1744 pub parameters: Option<serde_json::Value>,
1745 #[serde(default)]
1747 pub response_format: Option<serde_json::Value>,
1748 #[serde(default, skip_serializing_if = "is_default_api_format")]
1751 pub api_format: OpenAiApiFormat,
1752}
1753
1754impl ModelSettings {
1755 pub fn new(model: impl Into<String>) -> Self {
1757 Self {
1758 model: model.into(),
1759 inner: ModelSettingsInner::default(),
1760 }
1761 }
1762
1763 pub fn effective_context_size(&self) -> u32 {
1770 self.inner
1771 .context_size
1772 .filter(|&s| s > 0)
1773 .or_else(|| {
1774 crate::model_lookup().context_window(self.inner.provider.provider_id(), &self.model)
1775 })
1776 .unwrap_or_else(default_context_size)
1777 }
1778
1779 pub async fn hydrate_creds(
1797 &mut self,
1798 secret_store: &dyn crate::stores::SecretStore,
1799 ) -> Result<(), String> {
1800 let provider_label = self.inner.provider.provider_id().to_string();
1801 let api_key_secret = self.inner.provider.api_key_secret();
1802 let endpoint_secret = self.inner.provider.endpoint_secret();
1803
1804 if let Some(slot) = self.inner.provider.api_key_slot_mut() {
1806 if slot.is_none() {
1807 match secret_store.get(api_key_secret).await {
1808 Ok(Some(secret)) => *slot = Some(secret.value),
1809 Ok(None) => tracing::warn!(
1810 "{} secret not found for provider '{}'",
1811 api_key_secret,
1812 provider_label
1813 ),
1814 Err(e) => tracing::error!(
1815 "failed to fetch {} for provider '{}': {}",
1816 api_key_secret,
1817 provider_label,
1818 e
1819 ),
1820 }
1821 }
1822 }
1823
1824 if let Some(endpoint_key) = endpoint_secret {
1828 if let Some(slot) = self.inner.provider.base_url_slot_mut() {
1829 if slot.is_empty() {
1830 match secret_store.get(endpoint_key).await {
1831 Ok(Some(secret)) => *slot = secret.value,
1832 Ok(None) => {
1833 return Err(format!(
1834 "{} secret not set for provider '{}'. \
1835 Configure the workspace's '{}' provider \
1836 (POST /v1/providers) before pinning a model \
1837 with this provider prefix.",
1838 endpoint_key, provider_label, provider_label
1839 ));
1840 }
1841 Err(e) => {
1842 return Err(format!(
1843 "failed to fetch {} for provider '{}': {e}",
1844 endpoint_key, provider_label
1845 ));
1846 }
1847 }
1848 }
1849 }
1850 }
1851
1852 Ok(())
1853 }
1854
1855 pub fn from_provider_model_str(s: &str) -> Result<Option<Self>, String> {
1863 let Some((provider_str, model_id)) = s.split_once('/') else {
1864 return Ok(None);
1865 };
1866 if model_id.is_empty() {
1867 return Ok(None);
1868 }
1869 let provider = match provider_str {
1870 "openai" => ModelProvider::OpenAI {},
1871 "anthropic" => ModelProvider::Anthropic {
1872 base_url: None,
1873 api_key: None,
1874 },
1875 "zai" => ModelProvider::ZAi {
1876 base_url: ModelProvider::zai_base_url(),
1877 api_key: None,
1878 },
1879 "azure_openai" | "azure" => ModelProvider::AzureOpenAI {
1880 base_url: String::new(),
1881 api_key: None,
1882 deployment: model_id.to_string(),
1883 api_version: ModelProvider::azure_api_version(),
1884 },
1885 "gemini" => ModelProvider::Gemini {
1886 base_url: ModelProvider::gemini_base_url(),
1887 api_key: None,
1888 },
1889 "azure_ai_foundry" => ModelProvider::AzureAiFoundry {
1890 resource: String::new(),
1891 api_key: None,
1892 },
1893 "aws_bedrock" => ModelProvider::AwsBedrock {
1894 base_url: String::new(),
1895 api_key: None,
1896 },
1897 "google_vertex" => ModelProvider::GoogleVertex {
1898 base_url: String::new(),
1899 api_key: None,
1900 project_id: None,
1901 },
1902 "alibaba_cloud" => ModelProvider::AlibabaCloud {
1903 base_url: ModelProvider::alibaba_cloud_base_url(),
1904 api_key: None,
1905 },
1906 "fal_ai" => ModelProvider::FalAi { api_key: None },
1907 _ if provider_str.starts_with("custom_") => ModelProvider::OpenAICompatible {
1908 base_url: String::new(),
1909 api_key: None,
1910 project_id: None,
1911 },
1912 _ => {
1921 return Err(format!(
1922 "unknown model provider prefix '{provider_str}' in '{s}'. \
1923 Recognised prefixes: openai, anthropic, zai, azure_openai, \
1924 azure (alias for azure_openai), gemini, azure_ai_foundry, \
1925 aws_bedrock, google_vertex, alibaba_cloud, fal_ai, custom_*. \
1926 Pass just the model name with no slash to use the \
1927 workspace's default provider."
1928 ));
1929 }
1930 };
1931 Ok(Some(Self {
1932 model: model_id.to_string(),
1933 inner: ModelSettingsInner {
1934 provider,
1935 ..Default::default()
1936 },
1937 }))
1938 }
1939
1940 pub fn merge(&self, override_settings: &ModelSettings) -> Option<ModelSettings> {
1954 let default_provider = ModelProvider::OpenAI {};
1955 let override_has_explicit_provider =
1956 std::mem::discriminant(&override_settings.inner.provider)
1957 != std::mem::discriminant(&default_provider);
1958 let base_has_explicit_provider = std::mem::discriminant(&self.inner.provider)
1959 != std::mem::discriminant(&default_provider);
1960
1961 let (provider, model) = if override_has_explicit_provider {
1962 let model = if !override_settings.model.is_empty() {
1964 override_settings.model.clone()
1965 } else {
1966 self.model.clone()
1967 };
1968 (override_settings.inner.provider.clone(), model)
1969 } else if base_has_explicit_provider {
1970 let model = if !self.model.is_empty() {
1973 self.model.clone()
1974 } else if !override_settings.model.is_empty() {
1975 override_settings.model.clone()
1976 } else {
1977 String::new()
1978 };
1979 (self.inner.provider.clone(), model)
1980 } else {
1981 let model = if !override_settings.model.is_empty() {
1983 override_settings.model.clone()
1984 } else {
1985 self.model.clone()
1986 };
1987 (self.inner.provider.clone(), model)
1988 };
1989
1990 if model.is_empty() {
1991 return None;
1992 }
1993
1994 Some(ModelSettings {
1995 model,
1996 inner: ModelSettingsInner {
1997 temperature: override_settings
1998 .inner
1999 .temperature
2000 .or(self.inner.temperature),
2001 max_tokens: override_settings.inner.max_tokens.or(self.inner.max_tokens),
2002 context_size: override_settings
2003 .inner
2004 .context_size
2005 .or(self.inner.context_size),
2006 top_p: override_settings.inner.top_p.or(self.inner.top_p),
2007 frequency_penalty: override_settings
2008 .inner
2009 .frequency_penalty
2010 .or(self.inner.frequency_penalty),
2011 presence_penalty: override_settings
2012 .inner
2013 .presence_penalty
2014 .or(self.inner.presence_penalty),
2015 provider,
2016 parameters: if override_settings.inner.parameters.is_some() {
2017 override_settings.inner.parameters.clone()
2018 } else {
2019 self.inner.parameters.clone()
2020 },
2021 response_format: if override_settings.inner.response_format.is_some() {
2022 override_settings.inner.response_format.clone()
2023 } else {
2024 self.inner.response_format.clone()
2025 },
2026 api_format: if override_settings.inner.api_format != OpenAiApiFormat::Auto {
2027 override_settings.inner.api_format.clone()
2028 } else {
2029 self.inner.api_format.clone()
2030 },
2031 },
2032 })
2033 }
2034}
2035
2036pub fn default_agent_version() -> Option<String> {
2038 Some("0.2.2".to_string())
2039}
2040
2041fn default_model_provider() -> ModelProvider {
2042 ModelProvider::OpenAI {}
2043}
2044
2045fn default_context_size() -> u32 {
2048 20000
2049}
2050
2051fn de_context_size<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
2058where
2059 D: serde::Deserializer<'de>,
2060{
2061 let v = Option::<u32>::deserialize(deserializer)?;
2062 Ok(v.filter(|&s| s > 0 && s != 20000))
2063}
2064
2065fn is_default_api_format(f: &OpenAiApiFormat) -> bool {
2066 *f == OpenAiApiFormat::Auto
2067}
2068
2069impl StandardDefinition {
2070 pub fn validate(&self) -> anyhow::Result<()> {
2071 if self.name.is_empty() {
2073 return Err(anyhow::anyhow!("Agent name cannot be empty"));
2074 }
2075
2076 if let Some(ref reflection) = self.reflection
2078 && reflection.enabled
2079 {
2080 if let Some(ref agent_name) = reflection.reflection_agent
2082 && agent_name.is_empty()
2083 {
2084 return Err(anyhow::anyhow!(
2085 "Reflection agent name cannot be empty when specified"
2086 ));
2087 }
2088 }
2089
2090 Ok(())
2091 }
2092
2093 pub fn validate_reflection_agent(agent_def: &StandardDefinition) -> anyhow::Result<()> {
2096 let has_reflect_tool = agent_def
2097 .tools
2098 .as_ref()
2099 .map(|t| t.builtin.iter().any(|name| name == "reflect"))
2100 .unwrap_or(false);
2101
2102 if !has_reflect_tool {
2103 anyhow::bail!(
2106 "Reflection agent '{}' must have the 'reflect' tool in its tools.builtin configuration",
2107 agent_def.name
2108 );
2109 }
2110
2111 Ok(())
2112 }
2113}
2114
2115impl From<StandardDefinition> for LlmDefinition {
2116 fn from(definition: StandardDefinition) -> Self {
2117 let model_settings = match (definition.model_settings, definition.context_size) {
2118 (Some(mut ms), Some(ctx)) => {
2119 ms.inner.context_size = Some(ctx);
2120 Some(ms)
2121 }
2122 (ms, _) => ms,
2123 };
2124
2125 Self {
2126 name: definition.name,
2127 model_settings,
2128 tool_format: definition.tool_format,
2129 tool_delivery_mode: definition.tool_delivery_mode,
2130 }
2131 }
2132}
2133
2134impl ToolsConfig {
2135 pub fn builtin_only(tools: Vec<&str>) -> Self {
2137 Self {
2138 builtin: tools.into_iter().map(|s| s.to_string()).collect(),
2139 ..Default::default()
2140 }
2141 }
2142
2143 pub fn mcp_all(server: &str) -> Self {
2145 Self {
2146 mcp: vec![McpToolConfig {
2147 server: server.to_string(),
2148 include: vec!["*".to_string()],
2149 exclude: vec![],
2150 }],
2151 ..Default::default()
2152 }
2153 }
2154
2155 pub fn mcp_filtered(server: &str, include: Vec<&str>, exclude: Vec<&str>) -> Self {
2157 Self {
2158 mcp: vec![McpToolConfig {
2159 server: server.to_string(),
2160 include: include.into_iter().map(|s| s.to_string()).collect(),
2161 exclude: exclude.into_iter().map(|s| s.to_string()).collect(),
2162 }],
2163 ..Default::default()
2164 }
2165 }
2166}
2167
2168pub async fn parse_agent_markdown_content(content: &str) -> Result<StandardDefinition, AgentError> {
2169 let parts: Vec<&str> = content.split("---").collect();
2171
2172 if parts.len() < 3 {
2173 return Err(AgentError::Validation(
2174 "Invalid agent markdown format. Expected TOML frontmatter between --- markers"
2175 .to_string(),
2176 ));
2177 }
2178
2179 let toml_content = parts[1].trim();
2181 let mut agent_def: crate::StandardDefinition =
2182 toml::from_str(toml_content).map_err(|e| AgentError::Validation(e.to_string()))?;
2183
2184 if let Err(validation_error) = validate_plugin_name(&agent_def.name) {
2186 return Err(AgentError::Validation(format!(
2187 "Invalid agent name '{}': {}",
2188 agent_def.name, validation_error
2189 )));
2190 }
2191
2192 if !agent_def
2194 .name
2195 .chars()
2196 .all(|c| c.is_alphanumeric() || c == '_' || c == '/')
2197 || agent_def
2198 .name
2199 .chars()
2200 .next()
2201 .is_some_and(|c| c.is_numeric())
2202 || agent_def.name.chars().filter(|&c| c == '/').count() > 1
2203 {
2204 return Err(AgentError::Validation(format!(
2205 "Invalid agent name '{}': Agent names must be alphanumeric with underscores, at most one '/' for namespacing (e.g. '_system/plan'), cannot start with number.",
2206 agent_def.name
2207 )));
2208 }
2209
2210 let instructions = parts[2..].join("---").trim().to_string();
2212
2213 agent_def.instructions = instructions;
2215
2216 if let Some(ref mut ms) = agent_def.model_settings {
2225 if ms.model.contains('/') {
2226 let resolved = ModelSettings::from_provider_model_str(&ms.model)
2227 .map_err(AgentError::Validation)?
2228 .ok_or_else(|| {
2229 AgentError::Validation(format!(
2230 "agent '{}': invalid model_settings.model '{}' — empty model name after the provider prefix",
2231 agent_def.name, ms.model
2232 ))
2233 })?;
2234 ms.model = resolved.model;
2235 ms.inner.provider = resolved.inner.provider;
2236 }
2237 }
2238
2239 Ok(agent_def)
2240}
2241
2242pub fn validate_plugin_name(name: &str) -> Result<(), String> {
2245 if name.is_empty() {
2246 return Err("Plugin name cannot be empty".to_string());
2247 }
2248
2249 if name.contains('-') {
2250 return Err(format!(
2251 "Plugin name '{}' cannot contain hyphens. Use underscores instead.",
2252 name
2253 ));
2254 }
2255
2256 let slash_count = name.chars().filter(|&c| c == '/').count();
2257 if slash_count > 1 {
2258 return Err(format!(
2259 "Plugin name '{}' can contain at most one '/' for workspace namespacing (e.g. 'workspace/agent')",
2260 name
2261 ));
2262 }
2263
2264 let segments: Vec<&str> = name.split('/').collect();
2266 for segment in &segments {
2267 if segment.is_empty() {
2268 return Err(format!(
2269 "Plugin name '{}' has an empty segment around '/'",
2270 name
2271 ));
2272 }
2273
2274 if let Some(first_char) = segment.chars().next()
2275 && !first_char.is_ascii_alphabetic()
2276 && first_char != '_'
2277 {
2278 return Err(format!(
2279 "Each segment in '{}' must start with a letter or underscore",
2280 name
2281 ));
2282 }
2283
2284 for ch in segment.chars() {
2285 if !ch.is_ascii_alphanumeric() && ch != '_' {
2286 return Err(format!(
2287 "Plugin name '{}' can only contain letters, numbers, underscores, and at most one '/' for namespacing",
2288 name
2289 ));
2290 }
2291 }
2292 }
2293
2294 Ok(())
2295}
2296
2297#[cfg(test)]
2298mod tests {
2299 use super::*;
2300
2301 #[test]
2302 fn test_compaction_enabled_defaults_to_true_via_serde() {
2303 let json = r#"{"name": "test"}"#;
2305 let def: StandardDefinition = serde_json::from_str(json).unwrap();
2306 assert!(def.compaction_enabled);
2307 }
2308
2309 #[test]
2310 fn test_compaction_enabled_deserializes_true_when_absent() {
2311 let json = r#"{"name": "test", "description": "test agent"}"#;
2312 let def: StandardDefinition = serde_json::from_str(json).unwrap();
2313 assert!(def.compaction_enabled);
2314 }
2315
2316 #[test]
2317 fn test_compaction_enabled_deserializes_false() {
2318 let json = r#"{"name": "test", "description": "test agent", "compaction_enabled": false}"#;
2319 let def: StandardDefinition = serde_json::from_str(json).unwrap();
2320 assert!(!def.compaction_enabled);
2321 }
2322
2323 #[test]
2324 fn test_compaction_enabled_true_skipped_in_serialization() {
2325 let def = StandardDefinition {
2326 name: "test".to_string(),
2327 compaction_enabled: true,
2328 ..Default::default()
2329 };
2330 let json = serde_json::to_string(&def).unwrap();
2331 assert!(!json.contains("compaction_enabled"));
2332 }
2333
2334 #[test]
2335 fn test_compaction_enabled_false_serialized() {
2336 let def = StandardDefinition {
2337 name: "test".to_string(),
2338 compaction_enabled: false,
2339 ..Default::default()
2340 };
2341 let json = serde_json::to_string(&def).unwrap();
2342 assert!(json.contains("\"compaction_enabled\":false"));
2343 }
2344
2345 #[test]
2346 fn test_max_tokens_optional_defaults_to_none() {
2347 let def = StandardDefinition::default();
2348 assert!(def.model_settings().is_none());
2349 }
2350
2351 #[test]
2352 fn test_max_tokens_deserializes_when_present() {
2353 let json =
2354 r#"{"name": "test", "model_settings": {"model": "gpt-4.1", "max_tokens": 4096}}"#;
2355 let def: StandardDefinition = serde_json::from_str(json).unwrap();
2356 assert_eq!(def.model_settings().unwrap().inner.max_tokens, Some(4096));
2357 }
2358
2359 #[test]
2360 fn test_max_tokens_none_when_absent() {
2361 let json = r#"{"name": "test", "model_settings": {"model": "gpt-4.1"}}"#;
2362 let def: StandardDefinition = serde_json::from_str(json).unwrap();
2363 assert!(def.model_settings().unwrap().inner.max_tokens.is_none());
2364 }
2365
2366 #[test]
2367 fn test_max_tokens_none_skipped_in_serialization() {
2368 let settings = ModelSettings {
2369 model: "test-model".to_string(),
2370 inner: ModelSettingsInner {
2371 max_tokens: None,
2372 provider: ModelProvider::OpenAI {},
2373 ..Default::default()
2374 },
2375 };
2376 let json = serde_json::to_string(&settings).unwrap();
2377 assert!(!json.contains("max_tokens"));
2378 }
2379
2380 #[test]
2381 fn sparse_definition_round_trip_does_not_inject_defaults() {
2382 let toml_in = r#"name = "minimal""#;
2388 let def: StandardDefinition = toml::from_str(toml_in).unwrap();
2389 let toml_out = toml::to_string(&def).unwrap();
2390
2391 for field in [
2392 "version",
2393 "history_size",
2394 "description",
2395 "tool_format",
2396 "tool_delivery_mode",
2397 "sub_agents",
2398 "icon_url",
2399 ] {
2400 assert!(
2401 !toml_out.contains(field),
2402 "round-trip injected `{field}` into sparse definition:\n{toml_out}"
2403 );
2404 }
2405 }
2406
2407 #[test]
2408 fn explicit_values_survive_round_trip() {
2409 let toml_in = r#"
2411name = "explicit"
2412description = "a real description"
2413version = "1.2.3"
2414history_size = 7
2415sub_agents = ["helper"]
2416tool_format = "json_l"
2417"#;
2418 let def: StandardDefinition = toml::from_str(toml_in).unwrap();
2419 let toml_out = toml::to_string(&def).unwrap();
2420 assert!(toml_out.contains("description = \"a real description\""));
2421 assert!(toml_out.contains("version = \"1.2.3\""));
2422 assert!(toml_out.contains("history_size = 7"));
2423 assert!(toml_out.contains("sub_agents = [\"helper\"]"));
2424 assert!(toml_out.contains("tool_format = \"json_l\""));
2425 }
2426
2427 #[test]
2428 fn test_max_tokens_some_serialized() {
2429 let settings = ModelSettings {
2430 model: "test-model".to_string(),
2431 inner: ModelSettingsInner {
2432 max_tokens: Some(2048),
2433 provider: ModelProvider::OpenAI {},
2434 ..Default::default()
2435 },
2436 };
2437 let json = serde_json::to_string(&settings).unwrap();
2438 assert!(json.contains("\"max_tokens\":2048"));
2439 }
2440
2441 #[test]
2442 fn test_api_format_auto_detect_codex_prefix() {
2443 let fmt = OpenAiApiFormat::Auto;
2444 assert_eq!(
2445 fmt.resolve("codex-mini-latest"),
2446 ResolvedOpenAiApiFormat::Responses
2447 );
2448 assert_eq!(
2449 fmt.resolve("codex-mini-2025-01-24"),
2450 ResolvedOpenAiApiFormat::Responses
2451 );
2452 }
2453
2454 #[test]
2455 fn test_api_format_auto_detect_codex_suffix() {
2456 let fmt = OpenAiApiFormat::Auto;
2457 assert_eq!(
2458 fmt.resolve("gpt-5.1-codex"),
2459 ResolvedOpenAiApiFormat::Responses
2460 );
2461 assert_eq!(
2462 fmt.resolve("gpt-5.3-codex"),
2463 ResolvedOpenAiApiFormat::Responses
2464 );
2465 }
2466
2467 #[test]
2468 fn test_api_format_auto_detect_pro_models() {
2469 let fmt = OpenAiApiFormat::Auto;
2470 assert_eq!(fmt.resolve("gpt-5-pro"), ResolvedOpenAiApiFormat::Responses);
2471 assert_eq!(
2472 fmt.resolve("gpt-5.2-pro"),
2473 ResolvedOpenAiApiFormat::Responses
2474 );
2475 assert_eq!(
2476 fmt.resolve("gpt-5.4-pro"),
2477 ResolvedOpenAiApiFormat::Responses
2478 );
2479 assert_eq!(fmt.resolve("o3-pro"), ResolvedOpenAiApiFormat::Responses);
2480 }
2481
2482 #[test]
2483 fn test_api_format_auto_detect_deep_research_models() {
2484 let fmt = OpenAiApiFormat::Auto;
2485 assert_eq!(
2486 fmt.resolve("o3-deep-research"),
2487 ResolvedOpenAiApiFormat::Responses
2488 );
2489 assert_eq!(
2490 fmt.resolve("o4-mini-deep-research"),
2491 ResolvedOpenAiApiFormat::Responses
2492 );
2493 }
2494
2495 #[test]
2496 fn test_api_format_auto_detect_non_codex() {
2497 let fmt = OpenAiApiFormat::Auto;
2498 assert_eq!(fmt.resolve("gpt-4o"), ResolvedOpenAiApiFormat::Completions);
2499 assert_eq!(fmt.resolve("gpt-4.1"), ResolvedOpenAiApiFormat::Completions);
2500 assert_eq!(fmt.resolve("gpt-5"), ResolvedOpenAiApiFormat::Completions);
2501 assert_eq!(fmt.resolve("o1"), ResolvedOpenAiApiFormat::Completions);
2502 assert_eq!(
2503 fmt.resolve("gpt-5.4-mini"),
2504 ResolvedOpenAiApiFormat::Completions
2505 );
2506 assert_eq!(fmt.resolve("o3-mini"), ResolvedOpenAiApiFormat::Completions);
2507 }
2508
2509 #[test]
2510 fn test_api_format_explicit_override() {
2511 assert_eq!(
2513 OpenAiApiFormat::Responses.resolve("gpt-4o"),
2514 ResolvedOpenAiApiFormat::Responses
2515 );
2516 assert_eq!(
2518 OpenAiApiFormat::Completions.resolve("codex-mini-latest"),
2519 ResolvedOpenAiApiFormat::Completions
2520 );
2521 }
2522
2523 #[test]
2524 fn test_api_format_defaults_to_auto() {
2525 let inner = ModelSettingsInner::default();
2526 assert_eq!(inner.api_format, OpenAiApiFormat::Auto);
2527 }
2528
2529 #[test]
2530 fn test_api_format_auto_skipped_in_serialization() {
2531 let settings = ModelSettings {
2532 model: "test-model".to_string(),
2533 inner: ModelSettingsInner {
2534 provider: ModelProvider::OpenAI {},
2535 ..Default::default()
2536 },
2537 };
2538 let json = serde_json::to_string(&settings).unwrap();
2539 assert!(!json.contains("api_format"));
2540 }
2541
2542 #[test]
2543 fn test_api_format_responses_serialized() {
2544 let settings = ModelSettings {
2545 model: "test-model".to_string(),
2546 inner: ModelSettingsInner {
2547 api_format: OpenAiApiFormat::Responses,
2548 provider: ModelProvider::OpenAI {},
2549 ..Default::default()
2550 },
2551 };
2552 let json = serde_json::to_string(&settings).unwrap();
2553 assert!(json.contains("\"api_format\":\"responses\""));
2554 }
2555
2556 #[test]
2557 fn test_api_format_deserializes_from_toml() {
2558 let toml_str = r#"
2559 model = "codex-mini-latest"
2560 api_format = "responses"
2561 [provider]
2562 name = "openai"
2563 "#;
2564 let settings: ModelSettings = toml::from_str(toml_str).unwrap();
2565 assert_eq!(settings.inner.api_format, OpenAiApiFormat::Responses);
2566 }
2567
2568 #[test]
2571 fn test_tool_delivery_mode_defaults_to_deferred() {
2572 let mode: ToolDeliveryMode = Default::default();
2573 assert_eq!(mode, ToolDeliveryMode::Deferred);
2574 }
2575
2576 #[test]
2577 fn test_tool_delivery_mode_backwards_compat_all_tools() {
2578 let json = r#""all_tools""#;
2580 let mode: ToolDeliveryMode = serde_json::from_str(json).unwrap();
2581 assert_eq!(mode, ToolDeliveryMode::Full);
2582 }
2583
2584 #[test]
2585 fn test_tool_delivery_mode_backwards_compat_tool_search() {
2586 let json = r#""tool_search""#;
2588 let mode: ToolDeliveryMode = serde_json::from_str(json).unwrap();
2589 assert_eq!(mode, ToolDeliveryMode::Deferred);
2590 }
2591
2592 #[test]
2593 fn test_tools_config_is_core_tool() {
2594 let config = ToolsConfig::default();
2595 assert!(config.is_core_tool("final"));
2596 assert!(config.is_core_tool("tool_search"));
2597 assert!(config.is_core_tool("execute_shell"));
2598 assert!(config.is_core_tool("call_agent"));
2599 assert!(!config.is_core_tool("browsr_scrape"));
2600 }
2601
2602 #[test]
2603 fn test_tools_config_always_full_schema() {
2604 let config = ToolsConfig {
2605 always_full_schema: vec!["browsr_scrape".to_string()],
2606 ..Default::default()
2607 };
2608 assert!(config.is_core_tool("browsr_scrape"));
2609 assert!(!config.is_core_tool("browsr_browser"));
2610 }
2611
2612 #[test]
2613 fn test_effective_delivery_mode_full_stays_full() {
2614 let config = ToolsConfig {
2615 delivery_mode: ToolDeliveryMode::Full,
2616 ..Default::default()
2617 };
2618 assert_eq!(config.effective_delivery_mode(100), ToolDeliveryMode::Full);
2620 }
2621
2622 #[test]
2623 fn test_effective_delivery_mode_deferred_stays_deferred() {
2624 let config = ToolsConfig {
2625 delivery_mode: ToolDeliveryMode::Deferred,
2626 deferred_threshold: Some(20),
2627 ..Default::default()
2628 };
2629 assert_eq!(
2631 config.effective_delivery_mode(10),
2632 ToolDeliveryMode::Deferred
2633 );
2634 }
2635
2636 #[test]
2637 fn test_effective_delivery_mode_deferred_over_threshold() {
2638 let config = ToolsConfig {
2639 delivery_mode: ToolDeliveryMode::Deferred,
2640 deferred_threshold: Some(10),
2641 ..Default::default()
2642 };
2643 assert_eq!(
2645 config.effective_delivery_mode(15),
2646 ToolDeliveryMode::Deferred
2647 );
2648 }
2649
2650 #[test]
2651 fn test_runtime_mode_serde() {
2652 let mode: RuntimeMode = serde_json::from_str("\"cloud\"").unwrap();
2653 assert_eq!(mode, RuntimeMode::Cloud);
2654 let mode: RuntimeMode = serde_json::from_str("\"cli\"").unwrap();
2655 assert_eq!(mode, RuntimeMode::Cli);
2656 let mode: RuntimeMode = serde_json::from_str("\"browser\"").unwrap();
2657 assert_eq!(mode, RuntimeMode::Browser);
2658 assert_eq!(RuntimeMode::default(), RuntimeMode::Cloud);
2659 let json = serde_json::to_string(&RuntimeMode::Cli).unwrap();
2660 assert_eq!(json, "\"cli\"");
2661 }
2662
2663 #[test]
2666 fn merge_both_default_openai_agent_model_wins() {
2667 let base = ModelSettings::new("gpt-5.1");
2668 let agent = ModelSettings::new("gpt-4.1-mini");
2669
2670 let result = base.merge(&agent).unwrap();
2671 assert_eq!(result.model, "gpt-4.1-mini");
2672 assert!(matches!(result.inner.provider, ModelProvider::OpenAI {}));
2673 }
2674
2675 #[test]
2676 fn merge_both_default_openai_base_model_used_when_agent_empty() {
2677 let base = ModelSettings::new("gpt-5.1");
2678 let agent = ModelSettings::new("");
2679
2680 let result = base.merge(&agent).unwrap();
2681 assert_eq!(result.model, "gpt-5.1");
2682 }
2683
2684 #[test]
2685 fn merge_agent_explicit_provider_wins() {
2686 let base = ModelSettings {
2687 model: "gpt-5.1".into(),
2688 inner: ModelSettingsInner {
2689 provider: ModelProvider::OpenAICompatible {
2690 base_url: "https://custom.com/v1".into(),
2691 api_key: Some("key".into()),
2692 project_id: None,
2693 },
2694 ..Default::default()
2695 },
2696 };
2697 let agent = ModelSettings {
2698 model: "claude-sonnet-4".into(),
2699 inner: ModelSettingsInner {
2700 provider: ModelProvider::Anthropic {
2701 base_url: None,
2702 api_key: None,
2703 },
2704 ..Default::default()
2705 },
2706 };
2707
2708 let result = base.merge(&agent).unwrap();
2709 assert_eq!(result.model, "claude-sonnet-4");
2710 assert!(matches!(
2711 result.inner.provider,
2712 ModelProvider::Anthropic { .. }
2713 ));
2714 }
2715
2716 #[test]
2717 fn merge_agent_explicit_provider_no_model_uses_base() {
2718 let base = ModelSettings::new("gpt-5.1");
2719 let agent = ModelSettings {
2720 model: "".into(),
2721 inner: ModelSettingsInner {
2722 provider: ModelProvider::Anthropic {
2723 base_url: None,
2724 api_key: None,
2725 },
2726 ..Default::default()
2727 },
2728 };
2729
2730 let result = base.merge(&agent).unwrap();
2731 assert_eq!(result.model, "gpt-5.1");
2732 assert!(matches!(
2733 result.inner.provider,
2734 ModelProvider::Anthropic { .. }
2735 ));
2736 }
2737
2738 #[test]
2739 fn merge_workspace_custom_provider_overrides_agent_model() {
2740 let base = ModelSettings {
2741 model: "gpt-5.4".into(),
2742 inner: ModelSettingsInner {
2743 provider: ModelProvider::OpenAICompatible {
2744 base_url: "https://custom.azure.com/openai/v1".into(),
2745 api_key: Some("test-key".into()),
2746 project_id: None,
2747 },
2748 ..Default::default()
2749 },
2750 };
2751 let agent = ModelSettings::new("gpt-5.1");
2753
2754 let result = base.merge(&agent).unwrap();
2755 assert_eq!(result.model, "gpt-5.4");
2756 assert!(matches!(
2757 result.inner.provider,
2758 ModelProvider::OpenAICompatible { .. }
2759 ));
2760 }
2761
2762 #[test]
2763 fn merge_workspace_custom_provider_agent_empty_model() {
2764 let base = ModelSettings {
2765 model: "gpt-5.4".into(),
2766 inner: ModelSettingsInner {
2767 provider: ModelProvider::OpenAICompatible {
2768 base_url: "https://custom.azure.com/openai/v1".into(),
2769 api_key: Some("test-key".into()),
2770 project_id: None,
2771 },
2772 ..Default::default()
2773 },
2774 };
2775 let agent = ModelSettings::new("");
2776
2777 let result = base.merge(&agent).unwrap();
2778 assert_eq!(result.model, "gpt-5.4");
2779 }
2780
2781 #[test]
2782 fn merge_both_empty_returns_none() {
2783 let base = ModelSettings::new("");
2784 let agent = ModelSettings::new("");
2785
2786 assert!(base.merge(&agent).is_none());
2787 }
2788
2789 #[test]
2790 fn merge_workspace_empty_agent_empty_returns_none() {
2791 let base = ModelSettings {
2792 model: "".into(),
2793 inner: ModelSettingsInner {
2794 provider: ModelProvider::OpenAICompatible {
2795 base_url: "https://custom.com".into(),
2796 api_key: None,
2797 project_id: None,
2798 },
2799 ..Default::default()
2800 },
2801 };
2802 let agent = ModelSettings::new("");
2803
2804 assert!(base.merge(&agent).is_none());
2805 }
2806
2807 #[test]
2808 fn merge_temperature_max_tokens_override() {
2809 let base = ModelSettings {
2810 model: "gpt-5.1".into(),
2811 inner: ModelSettingsInner {
2812 temperature: Some(0.5),
2813 max_tokens: Some(1000),
2814 top_p: Some(0.9),
2815 ..Default::default()
2816 },
2817 };
2818 let agent = ModelSettings {
2819 model: "gpt-4.1-mini".into(),
2820 inner: ModelSettingsInner {
2821 temperature: Some(0.9),
2822 max_tokens: None, ..Default::default()
2824 },
2825 };
2826
2827 let result = base.merge(&agent).unwrap();
2828 assert_eq!(result.model, "gpt-4.1-mini");
2829 assert_eq!(result.inner.temperature, Some(0.9));
2830 assert_eq!(result.inner.max_tokens, Some(1000)); assert_eq!(result.inner.top_p, Some(0.9)); }
2833
2834 #[test]
2835 fn merge_context_size_non_default_wins() {
2836 let base = ModelSettings {
2837 model: "gpt-5.1".into(),
2838 inner: ModelSettingsInner {
2839 context_size: None, ..Default::default()
2841 },
2842 };
2843 let agent = ModelSettings {
2844 model: "gpt-4.1-mini".into(),
2845 inner: ModelSettingsInner {
2846 context_size: Some(100000), ..Default::default()
2848 },
2849 };
2850
2851 let result = base.merge(&agent).unwrap();
2852 assert_eq!(result.inner.context_size, Some(100000));
2853 }
2854
2855 #[test]
2856 fn merge_context_size_default_falls_back() {
2857 let base = ModelSettings {
2858 model: "gpt-5.1".into(),
2859 inner: ModelSettingsInner {
2860 context_size: Some(128000),
2861 ..Default::default()
2862 },
2863 };
2864 let agent = ModelSettings {
2865 model: "gpt-4.1-mini".into(),
2866 inner: ModelSettingsInner {
2867 context_size: None, ..Default::default()
2869 },
2870 };
2871
2872 let result = base.merge(&agent).unwrap();
2873 assert_eq!(result.inner.context_size, Some(128000));
2874 }
2875
2876 #[test]
2877 fn context_size_legacy_default_deserializes_as_unset() {
2878 let ms: ModelSettings =
2881 serde_json::from_str(r#"{"model":"gpt-5.1","context_size":20000}"#).unwrap();
2882 assert_eq!(ms.inner.context_size, None);
2883
2884 let ms: ModelSettings =
2885 serde_json::from_str(r#"{"model":"gpt-5.1","context_size":50000}"#).unwrap();
2886 assert_eq!(ms.inner.context_size, Some(50000));
2887 }
2888
2889 #[test]
2890 fn effective_context_size_prefers_catalog_over_fallback() {
2891 let ms = ModelSettings::new("gpt-4.1");
2894 let catalog = crate::model_lookup()
2895 .context_window("openai", "gpt-4.1")
2896 .expect("gpt-4.1 in built-in catalog");
2897 assert_eq!(ms.effective_context_size(), catalog);
2898
2899 let mut pinned = ModelSettings::new("gpt-4.1");
2901 pinned.inner.context_size = Some(64000);
2902 assert_eq!(pinned.effective_context_size(), 64000);
2903
2904 let unknown = ModelSettings::new("model-nobody-registered");
2906 assert_eq!(unknown.effective_context_size(), 20000);
2907 }
2908
2909 #[test]
2910 fn merge_azure_ai_foundry_resource_preserved() {
2911 let base = ModelSettings {
2912 model: "gpt-5.4".into(),
2913 inner: ModelSettingsInner {
2914 provider: ModelProvider::AzureAiFoundry {
2915 resource: "myresource".into(),
2916 api_key: Some("test-key".into()),
2917 },
2918 ..Default::default()
2919 },
2920 };
2921 let agent = ModelSettings::new("gpt-5.1");
2922
2923 let result = base.merge(&agent).unwrap();
2924 assert_eq!(result.model, "gpt-5.4"); assert!(matches!(
2926 result.inner.provider,
2927 ModelProvider::AzureAiFoundry { .. }
2928 ));
2929 if let ModelProvider::AzureAiFoundry { resource, .. } = &result.inner.provider {
2930 assert_eq!(resource, "myresource");
2931 }
2932 assert_eq!(
2933 result.inner.provider.completion_url().as_deref(),
2934 Some("https://myresource.openai.azure.com/openai/v1"),
2935 );
2936 }
2937
2938 #[test]
2939 fn azure_ai_foundry_resource_resolves_openai_url() {
2940 let p = ModelProvider::AzureAiFoundry {
2941 resource: "distri-tts-resource".into(),
2942 api_key: None,
2943 };
2944 assert_eq!(
2945 p.completion_url().as_deref(),
2946 Some("https://distri-tts-resource.openai.azure.com/openai/v1"),
2947 );
2948 assert_eq!(p.tts_url(), p.completion_url());
2950 let empty = ModelProvider::AzureAiFoundry {
2952 resource: String::new(),
2953 api_key: None,
2954 };
2955 assert_eq!(empty.completion_url(), None);
2956 }
2957
2958 #[test]
2959 fn merge_anthropic_provider_preserves_base_url() {
2960 let base = ModelSettings {
2961 model: "claude-sonnet-4".into(),
2962 inner: ModelSettingsInner {
2963 provider: ModelProvider::Anthropic {
2964 base_url: Some("https://custom.anthropic.com".into()),
2965 api_key: Some("key".into()),
2966 },
2967 temperature: Some(0.7),
2968 ..Default::default()
2969 },
2970 };
2971 let agent = ModelSettings::new("");
2972
2973 let result = base.merge(&agent).unwrap();
2974 assert_eq!(result.model, "claude-sonnet-4");
2975 assert_eq!(result.inner.temperature, Some(0.7));
2976 if let ModelProvider::Anthropic { base_url, api_key } = result.inner.provider {
2977 assert_eq!(base_url, Some("https://custom.anthropic.com".into()));
2978 assert_eq!(api_key, Some("key".into()));
2979 }
2980 }
2981
2982 #[test]
2983 fn merge_response_format_agent_wins() {
2984 let base = ModelSettings {
2985 model: "gpt-5.1".into(),
2986 inner: ModelSettingsInner {
2987 response_format: Some(serde_json::json!({"type": "text"})),
2988 ..Default::default()
2989 },
2990 };
2991 let agent = ModelSettings {
2992 model: "gpt-4.1-mini".into(),
2993 inner: ModelSettingsInner {
2994 response_format: Some(serde_json::json!({"type": "json_object"})),
2995 ..Default::default()
2996 },
2997 };
2998
2999 let result = base.merge(&agent).unwrap();
3000 assert_eq!(
3001 result.inner.response_format,
3002 Some(serde_json::json!({"type": "json_object"}))
3003 );
3004 }
3005
3006 #[test]
3007 fn merge_response_format_base_fallback() {
3008 let base = ModelSettings {
3009 model: "gpt-5.1".into(),
3010 inner: ModelSettingsInner {
3011 response_format: Some(serde_json::json!({"type": "text"})),
3012 ..Default::default()
3013 },
3014 };
3015 let agent = ModelSettings::new("gpt-4.1-mini");
3016
3017 let result = base.merge(&agent).unwrap();
3018 assert_eq!(
3019 result.inner.response_format,
3020 Some(serde_json::json!({"type": "text"}))
3021 );
3022 }
3023
3024 #[test]
3025 fn merge_parameters_agent_wins() {
3026 let base = ModelSettings {
3027 model: "gpt-5.1".into(),
3028 inner: ModelSettingsInner {
3029 parameters: Some(serde_json::json!({"key": "base"})),
3030 ..Default::default()
3031 },
3032 };
3033 let agent = ModelSettings {
3034 model: "gpt-4.1-mini".into(),
3035 inner: ModelSettingsInner {
3036 parameters: Some(serde_json::json!({"key": "agent"})),
3037 ..Default::default()
3038 },
3039 };
3040
3041 let result = base.merge(&agent).unwrap();
3042 assert_eq!(
3043 result.inner.parameters,
3044 Some(serde_json::json!({"key": "agent"}))
3045 );
3046 }
3047
3048 #[test]
3056 fn test_api_key_secret_canonical_names() {
3057 assert_eq!(ModelProvider::OpenAI {}.api_key_secret(), "OPENAI_API_KEY");
3058 assert_eq!(
3059 ModelProvider::Anthropic {
3060 base_url: None,
3061 api_key: None,
3062 }
3063 .api_key_secret(),
3064 "ANTHROPIC_API_KEY"
3065 );
3066 assert_eq!(
3067 ModelProvider::Gemini {
3068 base_url: ModelProvider::gemini_base_url(),
3069 api_key: None,
3070 }
3071 .api_key_secret(),
3072 "GEMINI_API_KEY"
3073 );
3074 assert_eq!(
3075 ModelProvider::AzureOpenAI {
3076 base_url: String::new(),
3077 api_key: None,
3078 deployment: "x".into(),
3079 api_version: ModelProvider::azure_api_version(),
3080 }
3081 .api_key_secret(),
3082 "AZURE_OPENAI_API_KEY"
3083 );
3084 assert_eq!(
3085 ModelProvider::AzureAiFoundry {
3086 resource: String::new(),
3087 api_key: None,
3088 }
3089 .api_key_secret(),
3090 "AZURE_AI_FOUNDRY_API_KEY"
3091 );
3092 assert_eq!(
3093 ModelProvider::AwsBedrock {
3094 base_url: String::new(),
3095 api_key: None,
3096 }
3097 .api_key_secret(),
3098 "AWS_ACCESS_KEY_ID"
3099 );
3100 assert_eq!(
3101 ModelProvider::GoogleVertex {
3102 base_url: String::new(),
3103 api_key: None,
3104 project_id: None,
3105 }
3106 .api_key_secret(),
3107 "GOOGLE_VERTEX_API_KEY"
3108 );
3109 assert_eq!(
3110 ModelProvider::AlibabaCloud {
3111 base_url: ModelProvider::alibaba_cloud_base_url(),
3112 api_key: None,
3113 }
3114 .api_key_secret(),
3115 "DASHSCOPE_API_KEY"
3116 );
3117 assert_eq!(
3118 ModelProvider::OpenAICompatible {
3119 base_url: String::new(),
3120 api_key: None,
3121 project_id: None,
3122 }
3123 .api_key_secret(),
3124 "OPENAI_API_KEY"
3125 );
3126 assert_eq!(
3127 ModelProvider::FalAi { api_key: None }.api_key_secret(),
3128 "FAL_KEY"
3129 );
3130 }
3131
3132 #[test]
3142 fn test_api_key_secret_matches_default_models_json() {
3143 let providers = ModelProvider::all_provider_definitions();
3144 let cases: &[(&str, ModelProvider)] = &[
3145 ("openai", ModelProvider::OpenAI {}),
3146 (
3147 "anthropic",
3148 ModelProvider::Anthropic {
3149 base_url: None,
3150 api_key: None,
3151 },
3152 ),
3153 (
3154 "gemini",
3155 ModelProvider::Gemini {
3156 base_url: ModelProvider::gemini_base_url(),
3157 api_key: None,
3158 },
3159 ),
3160 ];
3161
3162 for (id, variant) in cases {
3163 let def = providers
3164 .iter()
3165 .find(|p| p.id == *id)
3166 .unwrap_or_else(|| panic!("provider '{}' missing from default_models.json", id));
3167 let first_api_key_in_json = def
3168 .keys
3169 .iter()
3170 .map(|k| k.key.as_str())
3171 .find(|k| k.ends_with("_API_KEY") || *k == "AWS_ACCESS_KEY_ID")
3172 .unwrap_or_else(|| {
3173 panic!(
3174 "provider '{}' has no API key entry in default_models.json",
3175 id
3176 )
3177 });
3178 assert_eq!(
3179 first_api_key_in_json,
3180 variant.api_key_secret(),
3181 "provider '{}': default_models.json key {:?} != api_key_secret() {:?}",
3182 id,
3183 first_api_key_in_json,
3184 variant.api_key_secret(),
3185 );
3186 }
3187 }
3188
3189 fn entry(id: &str, label: &str) -> DefaultProviderEntry {
3190 DefaultProviderEntry {
3191 id: id.to_string(),
3192 label: label.to_string(),
3193 keys: vec![],
3194 models: vec![],
3195 test: None,
3196 category: None,
3197 }
3198 }
3199
3200 #[test]
3202 fn test_merge_provider_layers_overrides_and_appends() {
3203 let builtin = vec![entry("openai", "OpenAI"), entry("anthropic", "Anthropic")];
3204 let extensions = vec![
3205 entry("anthropic", "Anthropic (override)"),
3206 entry("azure_ai_foundry", "Azure AI Foundry"),
3207 ];
3208 let merged = merge_provider_layers(&builtin, &extensions);
3209
3210 assert_eq!(merged.len(), 3);
3211 assert_eq!(
3212 merged.iter().find(|p| p.id == "openai").unwrap().label,
3213 "OpenAI",
3214 "untouched built-in is preserved"
3215 );
3216 assert_eq!(
3217 merged.iter().find(|p| p.id == "anthropic").unwrap().label,
3218 "Anthropic (override)",
3219 "extension overrides the built-in with the same id"
3220 );
3221 assert!(
3222 merged.iter().any(|p| p.id == "azure_ai_foundry"),
3223 "extension with a new id is appended"
3224 );
3225 }
3226
3227 #[test]
3230 fn test_model_provider_definition_conversion_backfills_name() {
3231 use crate::models::{Model, ModelCapability, ModelProviderDefinition};
3232 let def = ModelProviderDefinition {
3233 id: "acme".to_string(),
3234 label: "Acme".to_string(),
3235 keys: vec![],
3236 models: vec![Model {
3237 id: "acme-large".to_string(),
3238 name: String::new(),
3239 capability: ModelCapability::Completion,
3240 context_window: None,
3241 pricing: None,
3242 voices: vec![],
3243 formats: vec![],
3244 }],
3245 is_custom: false,
3246 test: None,
3247 category: None,
3248 };
3249 let converted = DefaultProviderEntry::from(def);
3250 assert_eq!(converted.models[0].name, "acme-large");
3251 }
3252
3253 #[test]
3256 fn zai_provider_parses_with_default_base_url() {
3257 let ms = ModelSettings::from_provider_model_str("zai/glm-4.6")
3258 .unwrap()
3259 .expect("zai/glm-4.6 should parse");
3260 assert_eq!(ms.model, "glm-4.6");
3261 match ms.inner.provider {
3262 ModelProvider::ZAi { base_url, api_key } => {
3263 assert_eq!(base_url, "https://api.z.ai/api/anthropic");
3264 assert!(api_key.is_none());
3265 }
3266 other => panic!("expected ModelProvider::ZAi, got {other:?}"),
3267 }
3268 }
3269
3270 #[test]
3271 fn zai_provider_metadata() {
3272 let p = ModelProvider::ZAi {
3273 base_url: ModelProvider::zai_base_url(),
3274 api_key: None,
3275 };
3276 assert_eq!(p.api_key_secret(), "ZAI_API_KEY");
3277 assert_eq!(p.provider_id(), "zai");
3278 assert_eq!(p.display_name(), "Z.ai");
3279 let (base_url, _key) = p.resolved_endpoint();
3280 assert_eq!(base_url.as_deref(), Some("https://api.z.ai/api/anthropic"));
3281 }
3282
3283 #[test]
3284 fn zai_provider_hydrates_key_not_base_url() {
3285 let mut p = ModelProvider::ZAi {
3288 base_url: ModelProvider::zai_base_url(),
3289 api_key: None,
3290 };
3291 assert!(p.base_url_slot_mut().is_none());
3292 assert!(p.api_key_slot_mut().is_some());
3293 }
3294}