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)]
441pub struct StandardDefinition {
442 pub name: String,
444 #[serde(default, skip_serializing_if = "String::is_empty")]
446 pub description: String,
447
448 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub version: Option<String>,
451
452 #[serde(default, skip_serializing_if = "String::is_empty")]
454 pub instructions: String,
455
456 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub model_settings: Option<ModelSettings>,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub analysis_model_settings: Option<ModelSettings>,
463
464 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub history_size: Option<usize>,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub strategy: Option<AgentStrategy>,
470 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub icon_url: Option<String>,
473
474 #[serde(default, skip_serializing_if = "Vec::is_empty")]
479 pub commands: Vec<crate::channel_commands::SlashCommand>,
480
481 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub max_iterations: Option<usize>,
483
484 #[serde(default, skip_serializing_if = "Vec::is_empty")]
486 pub skills_description: Vec<AgentSkill>,
487
488 #[serde(default, skip_serializing_if = "Vec::is_empty")]
490 pub available_skills: Vec<AvailableSkill>,
491
492 #[serde(default, skip_serializing_if = "Vec::is_empty")]
498 pub connections: Vec<crate::connections::ConnectionRequirement>,
499
500 #[serde(default, skip_serializing_if = "Vec::is_empty")]
502 pub sub_agents: Vec<String>,
503
504 #[serde(default, skip_serializing_if = "is_default_tool_format")]
506 pub tool_format: ToolCallFormat,
507
508 #[serde(default, skip_serializing_if = "is_default_tool_delivery_mode")]
510 pub tool_delivery_mode: ToolDeliveryMode,
511
512 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub tools: Option<ToolsConfig>,
515
516 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
518 pub partials: std::collections::HashMap<String, String>,
519
520 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub reflection: Option<ReflectionConfig>,
523 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub enable_todos: Option<bool>,
526
527 #[serde(default, skip_serializing_if = "Option::is_none")]
529 pub browser_config: Option<BrowserAgentConfig>,
530
531 #[serde(default, skip_serializing_if = "Option::is_none")]
533 pub include_shell: Option<bool>,
534
535 #[serde(default, skip_serializing_if = "Option::is_none")]
537 pub context_size: Option<u32>,
538
539 #[serde(
541 skip_serializing_if = "Option::is_none",
542 default = "default_append_default_instructions"
543 )]
544 pub append_default_instructions: Option<bool>,
545 #[serde(
547 skip_serializing_if = "Option::is_none",
548 default = "default_include_scratchpad"
549 )]
550 pub include_scratchpad: Option<bool>,
551
552 #[serde(default, skip_serializing_if = "Vec::is_empty")]
554 pub hooks: Vec<String>,
555
556 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub user_message_overrides: Option<UserMessageOverrides>,
559
560 #[serde(
562 default = "default_compaction_enabled",
563 skip_serializing_if = "is_true"
564 )]
565 pub compaction_enabled: bool,
566
567 #[serde(
581 default,
582 deserialize_with = "deserialize_runtime_modes",
583 skip_serializing_if = "Vec::is_empty"
584 )]
585 pub runtime: Vec<RuntimeMode>,
586}
587
588fn deserialize_runtime_modes<'de, D>(deserializer: D) -> Result<Vec<RuntimeMode>, D::Error>
590where
591 D: serde::Deserializer<'de>,
592{
593 use serde::de::{self, Deserialize};
594
595 #[derive(Deserialize)]
596 #[serde(untagged)]
597 enum OneOrMany {
598 One(RuntimeMode),
599 Many(Vec<RuntimeMode>),
600 }
601
602 match Option::<OneOrMany>::deserialize(deserializer)? {
603 None => Ok(Vec::new()),
604 Some(OneOrMany::One(rt)) => Ok(vec![rt]),
605 Some(OneOrMany::Many(v)) => {
606 let mut seen = std::collections::HashSet::new();
608 for rt in &v {
609 let key = format!("{:?}", rt);
610 if !seen.insert(key) {
611 return Err(de::Error::custom(format!(
612 "duplicate runtime entry: {:?}",
613 rt
614 )));
615 }
616 }
617 Ok(v)
618 }
619 }
620}
621fn default_append_default_instructions() -> Option<bool> {
622 Some(true)
623}
624fn default_include_scratchpad() -> Option<bool> {
625 Some(true)
626}
627fn default_compaction_enabled() -> bool {
628 true
629}
630fn is_true(v: &bool) -> bool {
631 *v
632}
633fn is_default_tool_format(v: &ToolCallFormat) -> bool {
634 *v == ToolCallFormat::default()
635}
636fn is_default_tool_delivery_mode(v: &ToolDeliveryMode) -> bool {
637 *v == ToolDeliveryMode::default()
638}
639impl StandardDefinition {
640 pub fn allowed_runtimes(&self) -> Vec<RuntimeMode> {
644 self.runtime.clone()
645 }
646
647 pub fn is_runnable_in(
657 &self,
658 current: &RuntimeMode,
659 runner_provides: Option<&RuntimeMode>,
660 ) -> bool {
661 let allowed = self.allowed_runtimes();
662 if allowed.is_empty() {
663 return true;
664 }
665 if allowed.iter().any(|rt| rt == current) {
666 return true;
667 }
668 match runner_provides {
669 Some(p) => allowed.iter().any(|rt| rt == p),
670 None => false,
671 }
672 }
673
674 pub fn should_use_browser(&self) -> bool {
676 self.browser_config
677 .as_ref()
678 .map(|cfg| cfg.is_enabled())
679 .unwrap_or(false)
680 }
681
682 pub fn browser_settings(&self) -> Option<&BrowserAgentConfig> {
684 self.browser_config.as_ref()
685 }
686
687 pub fn browser_runtime_config(&self) -> Option<BrowsrClientConfig> {
689 self.browser_config.as_ref().map(|cfg| cfg.runtime_config())
690 }
691
692 pub fn should_persist_browser_session(&self) -> bool {
694 self.browser_config
695 .as_ref()
696 .map(|cfg| cfg.should_persist_session())
697 .unwrap_or(false)
698 }
699
700 pub fn is_reflection_enabled(&self) -> bool {
702 self.reflection.as_ref().map(|r| r.enabled).unwrap_or(false)
703 }
704
705 pub fn reflection_config(&self) -> Option<&ReflectionConfig> {
707 self.reflection.as_ref().filter(|r| r.enabled)
708 }
709 pub fn is_todos_enabled(&self) -> bool {
711 self.enable_todos.unwrap_or(false)
712 }
713
714 pub fn should_include_shell(&self) -> bool {
716 self.include_shell.unwrap_or(false)
717 }
718
719 pub fn model_settings(&self) -> Option<&ModelSettings> {
721 self.model_settings.as_ref()
722 }
723
724 pub fn model_settings_mut(&mut self) -> Option<&mut ModelSettings> {
726 self.model_settings.as_mut()
727 }
728
729 pub fn get_effective_context_size(&self) -> u32 {
731 self.context_size
732 .filter(|&s| s > 0)
733 .or_else(|| {
734 self.model_settings()
735 .map(|ms| ms.inner.context_size)
736 .filter(|&s| s > 0)
737 })
738 .unwrap_or_else(default_context_size)
739 }
740
741 pub fn analysis_model_settings_config(&self) -> Option<&ModelSettings> {
743 self.analysis_model_settings
744 .as_ref()
745 .or_else(|| self.model_settings())
746 }
747
748 pub fn include_scratchpad(&self) -> bool {
750 self.include_scratchpad.unwrap_or(true)
751 }
752
753 pub fn apply_overrides(&mut self, overrides: DefinitionOverrides) {
755 if let Some(ref mut ms) = self.model_settings {
757 if let Some(model) = overrides.model {
758 ms.model = model
760 .split_once('/')
761 .map(|(_, m)| m.to_string())
762 .unwrap_or(model);
763 }
764 if let Some(temperature) = overrides.temperature {
765 ms.inner.temperature = Some(temperature);
766 }
767 if let Some(max_tokens) = overrides.max_tokens {
768 ms.inner.max_tokens = Some(max_tokens);
769 }
770 }
771
772 if let Some(max_iterations) = overrides.max_iterations {
774 self.max_iterations = Some(max_iterations);
775 }
776
777 if let Some(instructions) = overrides.instructions {
779 self.instructions = instructions;
780 }
781
782 if let Some(suffix) = overrides.instructions_append {
786 if self.instructions.is_empty() {
787 self.instructions = suffix;
788 } else {
789 self.instructions.push_str("\n\n");
790 self.instructions.push_str(&suffix);
791 }
792 }
793
794 if let Some(runtime) = overrides.runtime {
795 self.runtime = runtime;
796 }
797
798 if let Some(description) = overrides.description {
799 self.description = description;
800 }
801
802 if let Some(name) = overrides.name {
803 self.name = name;
804 }
805
806 if let Some(sub_agents) = overrides.sub_agents {
807 self.sub_agents = sub_agents;
808 }
809
810 if let Some(tools_override) = overrides.tools {
811 self.tools = Some(tools_override);
812 }
813
814 if let Some(use_browser) = overrides.use_browser {
815 let mut config = self.browser_config.clone().unwrap_or_default();
816 config.enabled = use_browser;
817 self.browser_config = Some(config);
818 }
819
820 if let Some(dynamic_tools) = overrides.dynamic_tools {
822 let tools = self.tools.get_or_insert_with(ToolsConfig::default);
823 tools.dynamic.extend(dynamic_tools);
824 }
825 }
826}
827
828pub const VALID_BUILTIN_TOOLS: &[&str] = &[
835 "final",
837 "reflect",
838 "invoke_agent",
840 "get_task",
842 "wait_task",
843 "cancel_task",
844 "list_my_tasks",
845 "browsr_scrape",
847 "browsr_browser",
848 "browsr_crawl",
849 "browser_step",
850 "search",
851 "start_shell",
853 "execute_shell",
854 "stop_shell",
855 "distri_execute_code",
857 "tool_search",
859 "load_skill",
862 "inject_connection_env",
864 "console_log",
866 "artifact_tool",
868 "write_todos",
874];
875
876pub const CORE_TOOLS: &[&str] = &[
879 "final",
880 "invoke_agent",
881 "tool_search",
882 "write_todos",
883 "execute_shell",
884 "start_shell",
885 "load_skill",
886];
887
888pub const DEFAULT_DEFERRED_THRESHOLD: usize = 15;
890
891#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
892#[serde(deny_unknown_fields)]
893pub struct ToolsConfig {
894 #[serde(default, skip_serializing_if = "Vec::is_empty")]
896 pub builtin: Vec<String>,
897
898 #[serde(default, skip_serializing_if = "Vec::is_empty")]
900 pub dynamic: Vec<crate::dynamic_tool::DynamicToolFactory>,
901
902 #[serde(default, skip_serializing_if = "Vec::is_empty")]
904 pub mcp: Vec<McpToolConfig>,
905
906 #[serde(default, skip_serializing_if = "Option::is_none")]
908 pub external: Option<Vec<String>>,
909
910 #[serde(default, skip_serializing_if = "is_default_delivery_mode")]
914 pub delivery_mode: ToolDeliveryMode,
915
916 #[serde(default, skip_serializing_if = "Option::is_none")]
920 pub deferred_threshold: Option<usize>,
921
922 #[serde(default, skip_serializing_if = "Vec::is_empty")]
925 pub always_full_schema: Vec<String>,
926}
927
928fn is_default_delivery_mode(mode: &ToolDeliveryMode) -> bool {
929 *mode == ToolDeliveryMode::Deferred
930}
931
932impl ToolsConfig {
933 pub fn invalid_builtin_tools(&self) -> Vec<String> {
936 self.builtin
937 .iter()
938 .filter(|name| !VALID_BUILTIN_TOOLS.contains(&name.as_str()))
939 .cloned()
940 .collect()
941 }
942
943 pub fn is_core_tool(&self, name: &str) -> bool {
945 CORE_TOOLS.contains(&name) || self.always_full_schema.iter().any(|n| n == name)
946 }
947
948 pub fn effective_threshold(&self) -> usize {
950 self.deferred_threshold
951 .unwrap_or(DEFAULT_DEFERRED_THRESHOLD)
952 }
953
954 pub fn effective_delivery_mode(&self, _total_tools: usize) -> ToolDeliveryMode {
958 self.delivery_mode.clone()
959 }
960}
961
962#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
967#[serde(deny_unknown_fields)]
968pub struct McpToolConfig {
969 pub server: String,
971
972 #[serde(default, skip_serializing_if = "Vec::is_empty")]
975 pub include: Vec<String>,
976
977 #[serde(default, skip_serializing_if = "Vec::is_empty")]
979 pub exclude: Vec<String>,
980}
981
982#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
983#[serde(deny_unknown_fields)]
984pub struct McpDefinition {
985 #[serde(default)]
987 pub filter: Option<Vec<String>>,
988 pub name: String,
990 #[serde(default)]
992 pub r#type: McpServerType,
993 #[serde(default)]
995 pub auth_config: Option<crate::a2a::SecurityScheme>,
996}
997
998#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
999#[serde(rename_all = "lowercase")]
1000pub enum McpServerType {
1001 #[default]
1002 Tool,
1003 Agent,
1004}
1005
1006#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1007#[serde(deny_unknown_fields, rename_all = "lowercase", tag = "name")]
1008pub enum ModelProvider {
1009 #[serde(rename = "openai")]
1010 OpenAI {},
1011 #[serde(rename = "openai_compat")]
1012 OpenAICompatible {
1013 base_url: String,
1014 api_key: Option<String>,
1015 project_id: Option<String>,
1016 },
1017 #[serde(rename = "azure_openai")]
1018 AzureOpenAI {
1019 base_url: String,
1020 api_key: Option<String>,
1021 deployment: String,
1022 #[serde(default = "ModelProvider::azure_api_version")]
1023 api_version: String,
1024 },
1025 #[serde(rename = "anthropic")]
1026 Anthropic {
1027 #[serde(default = "ModelProvider::anthropic_base_url")]
1028 base_url: Option<String>,
1029 api_key: Option<String>,
1030 },
1031 #[serde(rename = "gemini")]
1032 Gemini {
1033 #[serde(default = "ModelProvider::gemini_base_url")]
1034 base_url: String,
1035 api_key: Option<String>,
1036 },
1037 #[serde(rename = "azure_ai_foundry")]
1038 AzureAiFoundry {
1039 resource: String,
1043 api_key: Option<String>,
1044 },
1045 #[serde(rename = "aws_bedrock")]
1046 AwsBedrock {
1047 base_url: String,
1048 api_key: Option<String>,
1049 },
1050 #[serde(rename = "google_vertex")]
1051 GoogleVertex {
1052 base_url: String,
1053 api_key: Option<String>,
1054 project_id: Option<String>,
1055 },
1056 #[serde(rename = "alibaba_cloud")]
1057 AlibabaCloud {
1058 #[serde(default = "ModelProvider::alibaba_cloud_base_url")]
1059 base_url: String,
1060 api_key: Option<String>,
1061 },
1062 #[serde(rename = "fal_ai")]
1066 FalAi { api_key: Option<String> },
1067}
1068#[derive(Debug, Clone, Serialize, Deserialize)]
1070pub struct ProviderSecretDefinition {
1071 pub id: String,
1073 pub label: String,
1075 pub keys: Vec<SecretKeyDefinition>,
1077}
1078
1079#[derive(Debug, Clone, Serialize, Deserialize)]
1081pub struct SecretKeyDefinition {
1082 pub key: String,
1084 pub label: String,
1086 #[serde(default)]
1088 pub placeholder: String,
1089 #[serde(default = "default_required")]
1091 pub required: bool,
1092 #[serde(default = "default_sensitive")]
1095 pub sensitive: bool,
1096 #[serde(default, skip_serializing_if = "Option::is_none")]
1101 pub url_template: Option<String>,
1102}
1103
1104fn default_required() -> bool {
1105 true
1106}
1107
1108fn default_sensitive() -> bool {
1109 true
1110}
1111
1112#[derive(Debug, Clone, Serialize, Deserialize)]
1114pub struct ModelInfo {
1115 pub id: String,
1117 pub name: String,
1119}
1120
1121#[derive(Debug, Clone, Serialize, Deserialize)]
1124struct DefaultProviderEntry {
1125 id: String,
1126 label: String,
1127 keys: Vec<SecretKeyDefinition>,
1128 models: Vec<crate::models::Model>,
1129 #[serde(default, skip_serializing_if = "Option::is_none")]
1131 test: Option<crate::models::ProviderTestConfig>,
1132}
1133
1134#[derive(Debug, Clone, Serialize, Deserialize)]
1135struct DefaultModelsFile {
1136 providers: Vec<DefaultProviderEntry>,
1137}
1138
1139fn load_default_providers() -> &'static [DefaultProviderEntry] {
1140 use std::sync::OnceLock;
1141 static PROVIDERS: OnceLock<Vec<DefaultProviderEntry>> = OnceLock::new();
1142 PROVIDERS.get_or_init(|| {
1143 let json = include_str!("default_models.json");
1144 let file: DefaultModelsFile =
1145 serde_json::from_str(json).expect("Failed to parse default_models.json");
1146 file.providers
1147 })
1148}
1149
1150impl From<crate::models::ProviderKeyDefinition> for SecretKeyDefinition {
1151 fn from(k: crate::models::ProviderKeyDefinition) -> Self {
1152 SecretKeyDefinition {
1153 key: k.key,
1154 label: k.label,
1155 placeholder: k.placeholder,
1156 required: k.required,
1157 sensitive: k.sensitive,
1158 url_template: k.url_template,
1159 }
1160 }
1161}
1162
1163impl From<crate::models::ModelProviderDefinition> for DefaultProviderEntry {
1164 fn from(d: crate::models::ModelProviderDefinition) -> Self {
1165 let models = d
1166 .models
1167 .into_iter()
1168 .map(|mut m| {
1169 if m.name.trim().is_empty() {
1172 m.name = m.id.clone();
1173 }
1174 m
1175 })
1176 .collect();
1177 DefaultProviderEntry {
1178 id: d.id,
1179 label: d.label,
1180 keys: d.keys.into_iter().map(SecretKeyDefinition::from).collect(),
1181 models,
1182 test: d.test,
1183 }
1184 }
1185}
1186
1187pub fn lookup_provider_test_config(provider_id: &str) -> Option<crate::models::ProviderTestConfig> {
1192 merged_providers()
1193 .into_iter()
1194 .find(|p| p.id == provider_id)
1195 .and_then(|p| p.test)
1196}
1197
1198static PROVIDER_EXTENSIONS: std::sync::OnceLock<Vec<DefaultProviderEntry>> =
1203 std::sync::OnceLock::new();
1204
1205pub fn register_provider_extensions(extensions: Vec<crate::models::ModelProviderDefinition>) {
1213 let entries: Vec<DefaultProviderEntry> = extensions
1214 .into_iter()
1215 .map(DefaultProviderEntry::from)
1216 .collect();
1217 let count = entries.len();
1218 if PROVIDER_EXTENSIONS.set(entries).is_err() {
1219 tracing::warn!("provider extensions already registered; ignoring {count} new entries");
1220 } else {
1221 tracing::info!("registered {count} provider extension(s)");
1222 }
1223}
1224
1225fn merge_provider_layers(
1228 builtin: &[DefaultProviderEntry],
1229 extensions: &[DefaultProviderEntry],
1230) -> Vec<DefaultProviderEntry> {
1231 let mut merged: Vec<DefaultProviderEntry> = builtin.to_vec();
1232 for ext in extensions {
1233 match merged.iter_mut().find(|p| p.id == ext.id) {
1234 Some(slot) => *slot = ext.clone(),
1235 None => merged.push(ext.clone()),
1236 }
1237 }
1238 merged
1239}
1240
1241fn merged_providers() -> Vec<DefaultProviderEntry> {
1244 let extensions = PROVIDER_EXTENSIONS.get().map(Vec::as_slice).unwrap_or(&[]);
1245 merge_provider_layers(load_default_providers(), extensions)
1246}
1247
1248#[derive(Debug, Clone, Serialize, Deserialize)]
1250pub struct ProviderModels {
1251 pub provider_id: String,
1253 pub provider_label: String,
1255 pub models: Vec<crate::models::Model>,
1257}
1258
1259#[derive(Debug, Clone, Serialize, Deserialize)]
1261pub struct ProviderModelsStatus {
1262 pub provider_id: String,
1264 pub provider_label: String,
1266 pub configured: bool,
1268 pub models: Vec<crate::models::Model>,
1270}
1271
1272impl Default for ModelProvider {
1273 fn default() -> Self {
1274 ModelProvider::OpenAI {}
1275 }
1276}
1277
1278impl ModelProvider {
1279 pub fn openai_base_url() -> String {
1280 "https://api.openai.com/v1".to_string()
1281 }
1282
1283 pub fn anthropic_base_url() -> Option<String> {
1284 None
1285 }
1286
1287 pub fn gemini_base_url() -> String {
1288 "https://generativelanguage.googleapis.com/v1beta/openai".to_string()
1289 }
1290
1291 pub fn azure_api_version() -> String {
1292 "2024-06-01".to_string()
1293 }
1294
1295 pub fn alibaba_cloud_base_url() -> String {
1296 "https://dashscope-intl.aliyuncs.com/compatible-mode/v1".to_string()
1297 }
1298
1299 pub fn fal_ai_base_url() -> &'static str {
1302 "https://fal.run"
1303 }
1304
1305 pub fn api_key_slot_mut(&mut self) -> Option<&mut Option<String>> {
1308 match self {
1309 Self::OpenAI {} => None,
1310 Self::OpenAICompatible { api_key, .. }
1311 | Self::AzureOpenAI { api_key, .. }
1312 | Self::Anthropic { api_key, .. }
1313 | Self::Gemini { api_key, .. }
1314 | Self::AzureAiFoundry { api_key, .. }
1315 | Self::AwsBedrock { api_key, .. }
1316 | Self::GoogleVertex { api_key, .. }
1317 | Self::AlibabaCloud { api_key, .. }
1318 | Self::FalAi { api_key } => Some(api_key),
1319 }
1320 }
1321
1322 pub fn base_url_slot_mut(&mut self) -> Option<&mut String> {
1327 match self {
1328 Self::AzureAiFoundry { resource, .. } => Some(resource),
1332 Self::AzureOpenAI { base_url, .. }
1333 | Self::AwsBedrock { base_url, .. }
1334 | Self::GoogleVertex { base_url, .. }
1335 | Self::Gemini { base_url, .. }
1336 | Self::OpenAICompatible { base_url, .. }
1337 | Self::AlibabaCloud { base_url, .. } => Some(base_url),
1338 Self::OpenAI {} | Self::Anthropic { .. } | Self::FalAi { .. } => None,
1339 }
1340 }
1341
1342 pub fn provider_type(&self) -> crate::models::ProviderType {
1344 match self {
1345 ModelProvider::OpenAI {} => crate::models::ProviderType::OpenAI,
1346 ModelProvider::OpenAICompatible { .. } => {
1347 crate::models::ProviderType::Custom("openai_compat".to_string())
1348 }
1349 ModelProvider::AzureOpenAI { .. } => crate::models::ProviderType::Azure,
1350 ModelProvider::Anthropic { .. } => crate::models::ProviderType::Anthropic,
1351 ModelProvider::Gemini { .. } => crate::models::ProviderType::Gemini,
1352 ModelProvider::AzureAiFoundry { .. } => crate::models::ProviderType::AzureAiFoundry,
1353 ModelProvider::AwsBedrock { .. } => crate::models::ProviderType::AwsBedrock,
1354 ModelProvider::GoogleVertex { .. } => crate::models::ProviderType::GoogleVertex,
1355 ModelProvider::AlibabaCloud { .. } => crate::models::ProviderType::AlibabaCloud,
1356 ModelProvider::FalAi { .. } => crate::models::ProviderType::FalAi,
1357 }
1358 }
1359
1360 pub fn provider_id(&self) -> &str {
1362 match self {
1363 ModelProvider::OpenAI {} => "openai",
1364 ModelProvider::OpenAICompatible { .. } => "openai_compat",
1365 ModelProvider::AzureOpenAI { .. } => "azure_openai",
1366 ModelProvider::Anthropic { .. } => "anthropic",
1367 ModelProvider::Gemini { .. } => "gemini",
1368 ModelProvider::AzureAiFoundry { .. } => "azure_ai_foundry",
1369 ModelProvider::AwsBedrock { .. } => "aws_bedrock",
1370 ModelProvider::GoogleVertex { .. } => "google_vertex",
1371 ModelProvider::AlibabaCloud { .. } => "alibaba_cloud",
1372 ModelProvider::FalAi { .. } => "fal_ai",
1373 }
1374 }
1375
1376 pub fn api_key_secret(&self) -> &'static str {
1386 match self {
1387 ModelProvider::OpenAI {} => "OPENAI_API_KEY",
1388 ModelProvider::OpenAICompatible { .. } => "OPENAI_API_KEY",
1392 ModelProvider::AzureOpenAI { .. } => "AZURE_OPENAI_API_KEY",
1393 ModelProvider::Anthropic { .. } => "ANTHROPIC_API_KEY",
1394 ModelProvider::Gemini { .. } => "GEMINI_API_KEY",
1395 ModelProvider::AzureAiFoundry { .. } => "AZURE_AI_FOUNDRY_API_KEY",
1396 ModelProvider::AwsBedrock { .. } => "AWS_ACCESS_KEY_ID",
1400 ModelProvider::GoogleVertex { .. } => "GOOGLE_VERTEX_API_KEY",
1401 ModelProvider::AlibabaCloud { .. } => "DASHSCOPE_API_KEY",
1402 ModelProvider::FalAi { .. } => "FAL_KEY",
1403 }
1404 }
1405
1406 pub fn endpoint_secret(&self) -> Option<&'static str> {
1412 match self {
1413 ModelProvider::AzureOpenAI { .. } => Some("AZURE_OPENAI_ENDPOINT"),
1414 ModelProvider::AzureAiFoundry { .. } => Some("AZURE_AI_FOUNDRY_RESOURCE"),
1416 ModelProvider::AwsBedrock { .. } => Some("AWS_BEDROCK_ENDPOINT"),
1417 ModelProvider::GoogleVertex { .. } => Some("GOOGLE_VERTEX_ENDPOINT"),
1418 ModelProvider::OpenAI {}
1419 | ModelProvider::OpenAICompatible { .. }
1420 | ModelProvider::Anthropic { .. }
1421 | ModelProvider::Gemini { .. }
1422 | ModelProvider::AlibabaCloud { .. }
1423 | ModelProvider::FalAi { .. } => None,
1424 }
1425 }
1426
1427 pub fn azure_ai_foundry_base_url(resource: &str) -> String {
1435 let r = resource.trim().trim_matches('/');
1436 format!("https://{r}.openai.azure.com/openai/v1")
1437 }
1438
1439 pub fn azure_ai_foundry_resource(&self) -> Option<&str> {
1441 match self {
1442 ModelProvider::AzureAiFoundry { resource, .. } => Some(resource.as_str()),
1443 _ => None,
1444 }
1445 }
1446
1447 pub fn completion_url(&self) -> Option<String> {
1452 match self {
1453 ModelProvider::AzureAiFoundry { resource, .. } if !resource.trim().is_empty() => {
1454 Some(Self::azure_ai_foundry_base_url(resource))
1455 }
1456 _ => None,
1457 }
1458 }
1459
1460 pub fn tts_url(&self) -> Option<String> {
1464 self.completion_url()
1465 }
1466
1467 pub fn stt_url(&self) -> Option<String> {
1469 self.completion_url()
1470 }
1471
1472 pub fn image_url(&self) -> Option<String> {
1480 match self {
1481 ModelProvider::AzureAiFoundry { resource, .. } if !resource.trim().is_empty() => {
1482 let r = resource.trim().trim_matches('/');
1483 Some(format!("https://{r}.services.ai.azure.com/openai/v1"))
1484 }
1485 _ => self.completion_url(),
1486 }
1487 }
1488
1489 pub fn resolved_endpoint(&self) -> (Option<String>, Option<String>) {
1493 match self {
1494 ModelProvider::OpenAI {} => (Some(Self::openai_base_url()), None),
1495 ModelProvider::AzureAiFoundry { api_key, .. } => {
1496 (self.completion_url(), api_key.clone())
1497 }
1498 ModelProvider::Anthropic { base_url, api_key } => (
1499 Some(
1500 base_url
1501 .clone()
1502 .unwrap_or_else(|| "https://api.anthropic.com".to_string()),
1503 ),
1504 api_key.clone(),
1505 ),
1506 ModelProvider::Gemini { base_url, api_key } => {
1507 (Some(base_url.clone()), api_key.clone())
1508 }
1509 ModelProvider::OpenAICompatible {
1510 base_url, api_key, ..
1511 } => (Some(base_url.clone()), api_key.clone()),
1512 ModelProvider::AlibabaCloud { base_url, api_key } => {
1513 (Some(base_url.clone()), api_key.clone())
1514 }
1515 ModelProvider::AzureOpenAI {
1516 base_url, api_key, ..
1517 } => (Some(base_url.clone()), api_key.clone()),
1518 ModelProvider::AwsBedrock { base_url, api_key } => {
1519 (Some(base_url.clone()), api_key.clone())
1520 }
1521 ModelProvider::GoogleVertex {
1522 base_url, api_key, ..
1523 } => (Some(base_url.clone()), api_key.clone()),
1524 ModelProvider::FalAi { api_key } => {
1525 (Some(Self::fal_ai_base_url().to_string()), api_key.clone())
1526 }
1527 }
1528 }
1529
1530 pub fn required_secret_keys(&self) -> Vec<&'static str> {
1535 let api_key_present = match self {
1536 ModelProvider::OpenAI {} => false,
1537 ModelProvider::OpenAICompatible { api_key, .. }
1538 | ModelProvider::AzureOpenAI { api_key, .. }
1539 | ModelProvider::Gemini { api_key, .. }
1540 | ModelProvider::AzureAiFoundry { api_key, .. }
1541 | ModelProvider::AwsBedrock { api_key, .. }
1542 | ModelProvider::GoogleVertex { api_key, .. }
1543 | ModelProvider::AlibabaCloud { api_key, .. }
1544 | ModelProvider::FalAi { api_key } => api_key.is_some(),
1545 ModelProvider::Anthropic { api_key, .. } => api_key.is_some(),
1546 };
1547 if api_key_present {
1548 vec![]
1549 } else {
1550 vec![self.api_key_secret()]
1551 }
1552 }
1553
1554 pub fn all_provider_definitions() -> Vec<ProviderSecretDefinition> {
1557 merged_providers()
1558 .into_iter()
1559 .map(|p| ProviderSecretDefinition {
1560 id: p.id,
1561 label: p.label,
1562 keys: p.keys,
1563 })
1564 .collect()
1565 }
1566
1567 pub fn well_known_models() -> Vec<ProviderModels> {
1570 merged_providers()
1571 .into_iter()
1572 .filter(|p| !p.models.is_empty())
1573 .map(|p| ProviderModels {
1574 provider_id: p.id,
1575 provider_label: p.label,
1576 models: p.models,
1577 })
1578 .collect()
1579 }
1580
1581 pub fn display_name(&self) -> &'static str {
1583 match self {
1584 ModelProvider::OpenAI {} => "OpenAI",
1585 ModelProvider::OpenAICompatible { .. } => "OpenAI Compatible",
1586 ModelProvider::AzureOpenAI { .. } => "Azure",
1587 ModelProvider::Anthropic { .. } => "Anthropic",
1588 ModelProvider::Gemini { .. } => "Google Gemini",
1589 ModelProvider::AzureAiFoundry { .. } => "Azure AI Foundry",
1590 ModelProvider::AwsBedrock { .. } => "AWS Bedrock",
1591 ModelProvider::GoogleVertex { .. } => "Google Vertex AI",
1592 ModelProvider::AlibabaCloud { .. } => "Alibaba Cloud",
1593 ModelProvider::FalAi { .. } => "fal.ai",
1594 }
1595 }
1596
1597 pub fn otel_provider_name(&self) -> &'static str {
1600 match self {
1601 ModelProvider::OpenAI { .. } => "openai",
1602 ModelProvider::OpenAICompatible { .. } => "openai",
1603 ModelProvider::AzureOpenAI { .. } => "azure.ai.openai",
1604 ModelProvider::Anthropic { .. } => "anthropic",
1605 ModelProvider::Gemini { .. } => "google.gemini",
1606 ModelProvider::AzureAiFoundry { .. } => "azure.ai.inference",
1607 ModelProvider::AwsBedrock { .. } => "aws.bedrock",
1608 ModelProvider::GoogleVertex { .. } => "gcp.vertex_ai",
1609 ModelProvider::AlibabaCloud { .. } => "alibaba_cloud",
1610 ModelProvider::FalAi { .. } => "fal.ai",
1611 }
1612 }
1613}
1614
1615#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1619pub struct ModelSettings {
1620 pub model: String,
1621 #[serde(flatten)]
1622 pub inner: ModelSettingsInner,
1623}
1624
1625#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
1629pub struct ModelSettingsInner {
1630 #[serde(default, skip_serializing_if = "Option::is_none")]
1631 pub temperature: Option<f32>,
1632 #[serde(default, skip_serializing_if = "Option::is_none")]
1633 pub max_tokens: Option<u32>,
1634 #[serde(default = "default_context_size")]
1635 pub context_size: u32,
1636 #[serde(default, skip_serializing_if = "Option::is_none")]
1637 pub top_p: Option<f32>,
1638 #[serde(default, skip_serializing_if = "Option::is_none")]
1639 pub frequency_penalty: Option<f32>,
1640 #[serde(default, skip_serializing_if = "Option::is_none")]
1641 pub presence_penalty: Option<f32>,
1642 #[serde(default = "default_model_provider")]
1643 pub provider: ModelProvider,
1644 #[serde(default)]
1646 pub parameters: Option<serde_json::Value>,
1647 #[serde(default)]
1649 pub response_format: Option<serde_json::Value>,
1650 #[serde(default, skip_serializing_if = "is_default_api_format")]
1653 pub api_format: OpenAiApiFormat,
1654}
1655
1656impl ModelSettings {
1657 pub fn new(model: impl Into<String>) -> Self {
1659 Self {
1660 model: model.into(),
1661 inner: ModelSettingsInner::default(),
1662 }
1663 }
1664
1665 pub async fn hydrate_creds(
1683 &mut self,
1684 secret_store: &dyn crate::stores::SecretStore,
1685 ) -> Result<(), String> {
1686 let provider_label = self.inner.provider.provider_id().to_string();
1687 let api_key_secret = self.inner.provider.api_key_secret();
1688 let endpoint_secret = self.inner.provider.endpoint_secret();
1689
1690 if let Some(slot) = self.inner.provider.api_key_slot_mut() {
1692 if slot.is_none() {
1693 match secret_store.get(api_key_secret).await {
1694 Ok(Some(secret)) => *slot = Some(secret.value),
1695 Ok(None) => tracing::warn!(
1696 "{} secret not found for provider '{}'",
1697 api_key_secret,
1698 provider_label
1699 ),
1700 Err(e) => tracing::error!(
1701 "failed to fetch {} for provider '{}': {}",
1702 api_key_secret,
1703 provider_label,
1704 e
1705 ),
1706 }
1707 }
1708 }
1709
1710 if let Some(endpoint_key) = endpoint_secret {
1714 if let Some(slot) = self.inner.provider.base_url_slot_mut() {
1715 if slot.is_empty() {
1716 match secret_store.get(endpoint_key).await {
1717 Ok(Some(secret)) => *slot = secret.value,
1718 Ok(None) => {
1719 return Err(format!(
1720 "{} secret not set for provider '{}'. \
1721 Configure the workspace's '{}' provider \
1722 (POST /v1/providers) before pinning a model \
1723 with this provider prefix.",
1724 endpoint_key, provider_label, provider_label
1725 ));
1726 }
1727 Err(e) => {
1728 return Err(format!(
1729 "failed to fetch {} for provider '{}': {e}",
1730 endpoint_key, provider_label
1731 ));
1732 }
1733 }
1734 }
1735 }
1736 }
1737
1738 Ok(())
1739 }
1740
1741 pub fn from_provider_model_str(s: &str) -> Result<Option<Self>, String> {
1749 let Some((provider_str, model_id)) = s.split_once('/') else {
1750 return Ok(None);
1751 };
1752 if model_id.is_empty() {
1753 return Ok(None);
1754 }
1755 let provider = match provider_str {
1756 "openai" => ModelProvider::OpenAI {},
1757 "anthropic" => ModelProvider::Anthropic {
1758 base_url: None,
1759 api_key: None,
1760 },
1761 "azure_openai" | "azure" => ModelProvider::AzureOpenAI {
1762 base_url: String::new(),
1763 api_key: None,
1764 deployment: model_id.to_string(),
1765 api_version: ModelProvider::azure_api_version(),
1766 },
1767 "gemini" => ModelProvider::Gemini {
1768 base_url: ModelProvider::gemini_base_url(),
1769 api_key: None,
1770 },
1771 "azure_ai_foundry" => ModelProvider::AzureAiFoundry {
1772 resource: String::new(),
1773 api_key: None,
1774 },
1775 "aws_bedrock" => ModelProvider::AwsBedrock {
1776 base_url: String::new(),
1777 api_key: None,
1778 },
1779 "google_vertex" => ModelProvider::GoogleVertex {
1780 base_url: String::new(),
1781 api_key: None,
1782 project_id: None,
1783 },
1784 "alibaba_cloud" => ModelProvider::AlibabaCloud {
1785 base_url: ModelProvider::alibaba_cloud_base_url(),
1786 api_key: None,
1787 },
1788 "fal_ai" => ModelProvider::FalAi { api_key: None },
1789 _ if provider_str.starts_with("custom_") => ModelProvider::OpenAICompatible {
1790 base_url: String::new(),
1791 api_key: None,
1792 project_id: None,
1793 },
1794 _ => {
1803 return Err(format!(
1804 "unknown model provider prefix '{provider_str}' in '{s}'. \
1805 Recognised prefixes: openai, anthropic, azure_openai, \
1806 azure (alias for azure_openai), gemini, azure_ai_foundry, \
1807 aws_bedrock, google_vertex, alibaba_cloud, fal_ai, custom_*. \
1808 Pass just the model name with no slash to use the \
1809 workspace's default provider."
1810 ));
1811 }
1812 };
1813 Ok(Some(Self {
1814 model: model_id.to_string(),
1815 inner: ModelSettingsInner {
1816 provider,
1817 ..Default::default()
1818 },
1819 }))
1820 }
1821
1822 pub fn merge(&self, override_settings: &ModelSettings) -> Option<ModelSettings> {
1836 let default_provider = ModelProvider::OpenAI {};
1837 let override_has_explicit_provider =
1838 std::mem::discriminant(&override_settings.inner.provider)
1839 != std::mem::discriminant(&default_provider);
1840 let base_has_explicit_provider = std::mem::discriminant(&self.inner.provider)
1841 != std::mem::discriminant(&default_provider);
1842
1843 let (provider, model) = if override_has_explicit_provider {
1844 let model = if !override_settings.model.is_empty() {
1846 override_settings.model.clone()
1847 } else {
1848 self.model.clone()
1849 };
1850 (override_settings.inner.provider.clone(), model)
1851 } else if base_has_explicit_provider {
1852 let model = if !self.model.is_empty() {
1855 self.model.clone()
1856 } else if !override_settings.model.is_empty() {
1857 override_settings.model.clone()
1858 } else {
1859 String::new()
1860 };
1861 (self.inner.provider.clone(), model)
1862 } else {
1863 let model = if !override_settings.model.is_empty() {
1865 override_settings.model.clone()
1866 } else {
1867 self.model.clone()
1868 };
1869 (self.inner.provider.clone(), model)
1870 };
1871
1872 if model.is_empty() {
1873 return None;
1874 }
1875
1876 let default_context_size = 20000u32;
1877 Some(ModelSettings {
1878 model,
1879 inner: ModelSettingsInner {
1880 temperature: override_settings
1881 .inner
1882 .temperature
1883 .or(self.inner.temperature),
1884 max_tokens: override_settings.inner.max_tokens.or(self.inner.max_tokens),
1885 context_size: if override_settings.inner.context_size != default_context_size {
1886 override_settings.inner.context_size
1887 } else {
1888 self.inner.context_size
1889 },
1890 top_p: override_settings.inner.top_p.or(self.inner.top_p),
1891 frequency_penalty: override_settings
1892 .inner
1893 .frequency_penalty
1894 .or(self.inner.frequency_penalty),
1895 presence_penalty: override_settings
1896 .inner
1897 .presence_penalty
1898 .or(self.inner.presence_penalty),
1899 provider,
1900 parameters: if override_settings.inner.parameters.is_some() {
1901 override_settings.inner.parameters.clone()
1902 } else {
1903 self.inner.parameters.clone()
1904 },
1905 response_format: if override_settings.inner.response_format.is_some() {
1906 override_settings.inner.response_format.clone()
1907 } else {
1908 self.inner.response_format.clone()
1909 },
1910 api_format: if override_settings.inner.api_format != OpenAiApiFormat::Auto {
1911 override_settings.inner.api_format.clone()
1912 } else {
1913 self.inner.api_format.clone()
1914 },
1915 },
1916 })
1917 }
1918}
1919
1920pub fn default_agent_version() -> Option<String> {
1922 Some("0.2.2".to_string())
1923}
1924
1925fn default_model_provider() -> ModelProvider {
1926 ModelProvider::OpenAI {}
1927}
1928
1929fn default_context_size() -> u32 {
1930 20000 }
1932
1933fn is_default_api_format(f: &OpenAiApiFormat) -> bool {
1934 *f == OpenAiApiFormat::Auto
1935}
1936
1937impl StandardDefinition {
1938 pub fn validate(&self) -> anyhow::Result<()> {
1939 if self.name.is_empty() {
1941 return Err(anyhow::anyhow!("Agent name cannot be empty"));
1942 }
1943
1944 if let Some(ref reflection) = self.reflection
1946 && reflection.enabled
1947 {
1948 if let Some(ref agent_name) = reflection.reflection_agent
1950 && agent_name.is_empty()
1951 {
1952 return Err(anyhow::anyhow!(
1953 "Reflection agent name cannot be empty when specified"
1954 ));
1955 }
1956 }
1957
1958 Ok(())
1959 }
1960
1961 pub fn validate_reflection_agent(agent_def: &StandardDefinition) -> anyhow::Result<()> {
1964 let has_reflect_tool = agent_def
1965 .tools
1966 .as_ref()
1967 .map(|t| t.builtin.iter().any(|name| name == "reflect"))
1968 .unwrap_or(false);
1969
1970 if !has_reflect_tool {
1971 anyhow::bail!(
1974 "Reflection agent '{}' must have the 'reflect' tool in its tools.builtin configuration",
1975 agent_def.name
1976 );
1977 }
1978
1979 Ok(())
1980 }
1981}
1982
1983impl From<StandardDefinition> for LlmDefinition {
1984 fn from(definition: StandardDefinition) -> Self {
1985 let model_settings = match (definition.model_settings, definition.context_size) {
1986 (Some(mut ms), Some(ctx)) => {
1987 ms.inner.context_size = ctx;
1988 Some(ms)
1989 }
1990 (ms, _) => ms,
1991 };
1992
1993 Self {
1994 name: definition.name,
1995 model_settings,
1996 tool_format: definition.tool_format,
1997 tool_delivery_mode: definition.tool_delivery_mode,
1998 }
1999 }
2000}
2001
2002impl ToolsConfig {
2003 pub fn builtin_only(tools: Vec<&str>) -> Self {
2005 Self {
2006 builtin: tools.into_iter().map(|s| s.to_string()).collect(),
2007 ..Default::default()
2008 }
2009 }
2010
2011 pub fn mcp_all(server: &str) -> Self {
2013 Self {
2014 mcp: vec![McpToolConfig {
2015 server: server.to_string(),
2016 include: vec!["*".to_string()],
2017 exclude: vec![],
2018 }],
2019 ..Default::default()
2020 }
2021 }
2022
2023 pub fn mcp_filtered(server: &str, include: Vec<&str>, exclude: Vec<&str>) -> Self {
2025 Self {
2026 mcp: vec![McpToolConfig {
2027 server: server.to_string(),
2028 include: include.into_iter().map(|s| s.to_string()).collect(),
2029 exclude: exclude.into_iter().map(|s| s.to_string()).collect(),
2030 }],
2031 ..Default::default()
2032 }
2033 }
2034}
2035
2036pub async fn parse_agent_markdown_content(content: &str) -> Result<StandardDefinition, AgentError> {
2037 let parts: Vec<&str> = content.split("---").collect();
2039
2040 if parts.len() < 3 {
2041 return Err(AgentError::Validation(
2042 "Invalid agent markdown format. Expected TOML frontmatter between --- markers"
2043 .to_string(),
2044 ));
2045 }
2046
2047 let toml_content = parts[1].trim();
2049 let mut agent_def: crate::StandardDefinition =
2050 toml::from_str(toml_content).map_err(|e| AgentError::Validation(e.to_string()))?;
2051
2052 if let Err(validation_error) = validate_plugin_name(&agent_def.name) {
2054 return Err(AgentError::Validation(format!(
2055 "Invalid agent name '{}': {}",
2056 agent_def.name, validation_error
2057 )));
2058 }
2059
2060 if !agent_def
2062 .name
2063 .chars()
2064 .all(|c| c.is_alphanumeric() || c == '_' || c == '/')
2065 || agent_def
2066 .name
2067 .chars()
2068 .next()
2069 .is_some_and(|c| c.is_numeric())
2070 || agent_def.name.chars().filter(|&c| c == '/').count() > 1
2071 {
2072 return Err(AgentError::Validation(format!(
2073 "Invalid agent name '{}': Agent names must be alphanumeric with underscores, at most one '/' for namespacing (e.g. '_system/plan'), cannot start with number.",
2074 agent_def.name
2075 )));
2076 }
2077
2078 let instructions = parts[2..].join("---").trim().to_string();
2080
2081 agent_def.instructions = instructions;
2083
2084 if let Some(ref mut ms) = agent_def.model_settings {
2093 if ms.model.contains('/') {
2094 let resolved = ModelSettings::from_provider_model_str(&ms.model)
2095 .map_err(AgentError::Validation)?
2096 .ok_or_else(|| {
2097 AgentError::Validation(format!(
2098 "agent '{}': invalid model_settings.model '{}' — empty model name after the provider prefix",
2099 agent_def.name, ms.model
2100 ))
2101 })?;
2102 ms.model = resolved.model;
2103 ms.inner.provider = resolved.inner.provider;
2104 }
2105 }
2106
2107 Ok(agent_def)
2108}
2109
2110pub fn validate_plugin_name(name: &str) -> Result<(), String> {
2113 if name.is_empty() {
2114 return Err("Plugin name cannot be empty".to_string());
2115 }
2116
2117 if name.contains('-') {
2118 return Err(format!(
2119 "Plugin name '{}' cannot contain hyphens. Use underscores instead.",
2120 name
2121 ));
2122 }
2123
2124 let slash_count = name.chars().filter(|&c| c == '/').count();
2125 if slash_count > 1 {
2126 return Err(format!(
2127 "Plugin name '{}' can contain at most one '/' for workspace namespacing (e.g. 'workspace/agent')",
2128 name
2129 ));
2130 }
2131
2132 let segments: Vec<&str> = name.split('/').collect();
2134 for segment in &segments {
2135 if segment.is_empty() {
2136 return Err(format!(
2137 "Plugin name '{}' has an empty segment around '/'",
2138 name
2139 ));
2140 }
2141
2142 if let Some(first_char) = segment.chars().next()
2143 && !first_char.is_ascii_alphabetic()
2144 && first_char != '_'
2145 {
2146 return Err(format!(
2147 "Each segment in '{}' must start with a letter or underscore",
2148 name
2149 ));
2150 }
2151
2152 for ch in segment.chars() {
2153 if !ch.is_ascii_alphanumeric() && ch != '_' {
2154 return Err(format!(
2155 "Plugin name '{}' can only contain letters, numbers, underscores, and at most one '/' for namespacing",
2156 name
2157 ));
2158 }
2159 }
2160 }
2161
2162 Ok(())
2163}
2164
2165#[cfg(test)]
2166mod tests {
2167 use super::*;
2168
2169 #[test]
2170 fn test_compaction_enabled_defaults_to_true_via_serde() {
2171 let json = r#"{"name": "test"}"#;
2173 let def: StandardDefinition = serde_json::from_str(json).unwrap();
2174 assert!(def.compaction_enabled);
2175 }
2176
2177 #[test]
2178 fn test_compaction_enabled_deserializes_true_when_absent() {
2179 let json = r#"{"name": "test", "description": "test agent"}"#;
2180 let def: StandardDefinition = serde_json::from_str(json).unwrap();
2181 assert!(def.compaction_enabled);
2182 }
2183
2184 #[test]
2185 fn test_compaction_enabled_deserializes_false() {
2186 let json = r#"{"name": "test", "description": "test agent", "compaction_enabled": false}"#;
2187 let def: StandardDefinition = serde_json::from_str(json).unwrap();
2188 assert!(!def.compaction_enabled);
2189 }
2190
2191 #[test]
2192 fn test_compaction_enabled_true_skipped_in_serialization() {
2193 let def = StandardDefinition {
2194 name: "test".to_string(),
2195 compaction_enabled: true,
2196 ..Default::default()
2197 };
2198 let json = serde_json::to_string(&def).unwrap();
2199 assert!(!json.contains("compaction_enabled"));
2200 }
2201
2202 #[test]
2203 fn test_compaction_enabled_false_serialized() {
2204 let def = StandardDefinition {
2205 name: "test".to_string(),
2206 compaction_enabled: false,
2207 ..Default::default()
2208 };
2209 let json = serde_json::to_string(&def).unwrap();
2210 assert!(json.contains("\"compaction_enabled\":false"));
2211 }
2212
2213 #[test]
2214 fn test_max_tokens_optional_defaults_to_none() {
2215 let def = StandardDefinition::default();
2216 assert!(def.model_settings().is_none());
2217 }
2218
2219 #[test]
2220 fn test_max_tokens_deserializes_when_present() {
2221 let json =
2222 r#"{"name": "test", "model_settings": {"model": "gpt-4.1", "max_tokens": 4096}}"#;
2223 let def: StandardDefinition = serde_json::from_str(json).unwrap();
2224 assert_eq!(def.model_settings().unwrap().inner.max_tokens, Some(4096));
2225 }
2226
2227 #[test]
2228 fn test_max_tokens_none_when_absent() {
2229 let json = r#"{"name": "test", "model_settings": {"model": "gpt-4.1"}}"#;
2230 let def: StandardDefinition = serde_json::from_str(json).unwrap();
2231 assert!(def.model_settings().unwrap().inner.max_tokens.is_none());
2232 }
2233
2234 #[test]
2235 fn test_max_tokens_none_skipped_in_serialization() {
2236 let settings = ModelSettings {
2237 model: "test-model".to_string(),
2238 inner: ModelSettingsInner {
2239 max_tokens: None,
2240 provider: ModelProvider::OpenAI {},
2241 ..Default::default()
2242 },
2243 };
2244 let json = serde_json::to_string(&settings).unwrap();
2245 assert!(!json.contains("max_tokens"));
2246 }
2247
2248 #[test]
2249 fn sparse_definition_round_trip_does_not_inject_defaults() {
2250 let toml_in = r#"name = "minimal""#;
2256 let def: StandardDefinition = toml::from_str(toml_in).unwrap();
2257 let toml_out = toml::to_string(&def).unwrap();
2258
2259 for field in [
2260 "version",
2261 "history_size",
2262 "description",
2263 "tool_format",
2264 "tool_delivery_mode",
2265 "sub_agents",
2266 "icon_url",
2267 ] {
2268 assert!(
2269 !toml_out.contains(field),
2270 "round-trip injected `{field}` into sparse definition:\n{toml_out}"
2271 );
2272 }
2273 }
2274
2275 #[test]
2276 fn explicit_values_survive_round_trip() {
2277 let toml_in = r#"
2279name = "explicit"
2280description = "a real description"
2281version = "1.2.3"
2282history_size = 7
2283sub_agents = ["helper"]
2284tool_format = "json_l"
2285"#;
2286 let def: StandardDefinition = toml::from_str(toml_in).unwrap();
2287 let toml_out = toml::to_string(&def).unwrap();
2288 assert!(toml_out.contains("description = \"a real description\""));
2289 assert!(toml_out.contains("version = \"1.2.3\""));
2290 assert!(toml_out.contains("history_size = 7"));
2291 assert!(toml_out.contains("sub_agents = [\"helper\"]"));
2292 assert!(toml_out.contains("tool_format = \"json_l\""));
2293 }
2294
2295 #[test]
2296 fn test_max_tokens_some_serialized() {
2297 let settings = ModelSettings {
2298 model: "test-model".to_string(),
2299 inner: ModelSettingsInner {
2300 max_tokens: Some(2048),
2301 provider: ModelProvider::OpenAI {},
2302 ..Default::default()
2303 },
2304 };
2305 let json = serde_json::to_string(&settings).unwrap();
2306 assert!(json.contains("\"max_tokens\":2048"));
2307 }
2308
2309 #[test]
2310 fn test_api_format_auto_detect_codex_prefix() {
2311 let fmt = OpenAiApiFormat::Auto;
2312 assert_eq!(
2313 fmt.resolve("codex-mini-latest"),
2314 ResolvedOpenAiApiFormat::Responses
2315 );
2316 assert_eq!(
2317 fmt.resolve("codex-mini-2025-01-24"),
2318 ResolvedOpenAiApiFormat::Responses
2319 );
2320 }
2321
2322 #[test]
2323 fn test_api_format_auto_detect_codex_suffix() {
2324 let fmt = OpenAiApiFormat::Auto;
2325 assert_eq!(
2326 fmt.resolve("gpt-5.1-codex"),
2327 ResolvedOpenAiApiFormat::Responses
2328 );
2329 assert_eq!(
2330 fmt.resolve("gpt-5.3-codex"),
2331 ResolvedOpenAiApiFormat::Responses
2332 );
2333 }
2334
2335 #[test]
2336 fn test_api_format_auto_detect_pro_models() {
2337 let fmt = OpenAiApiFormat::Auto;
2338 assert_eq!(fmt.resolve("gpt-5-pro"), ResolvedOpenAiApiFormat::Responses);
2339 assert_eq!(
2340 fmt.resolve("gpt-5.2-pro"),
2341 ResolvedOpenAiApiFormat::Responses
2342 );
2343 assert_eq!(
2344 fmt.resolve("gpt-5.4-pro"),
2345 ResolvedOpenAiApiFormat::Responses
2346 );
2347 assert_eq!(fmt.resolve("o3-pro"), ResolvedOpenAiApiFormat::Responses);
2348 }
2349
2350 #[test]
2351 fn test_api_format_auto_detect_deep_research_models() {
2352 let fmt = OpenAiApiFormat::Auto;
2353 assert_eq!(
2354 fmt.resolve("o3-deep-research"),
2355 ResolvedOpenAiApiFormat::Responses
2356 );
2357 assert_eq!(
2358 fmt.resolve("o4-mini-deep-research"),
2359 ResolvedOpenAiApiFormat::Responses
2360 );
2361 }
2362
2363 #[test]
2364 fn test_api_format_auto_detect_non_codex() {
2365 let fmt = OpenAiApiFormat::Auto;
2366 assert_eq!(fmt.resolve("gpt-4o"), ResolvedOpenAiApiFormat::Completions);
2367 assert_eq!(fmt.resolve("gpt-4.1"), ResolvedOpenAiApiFormat::Completions);
2368 assert_eq!(fmt.resolve("gpt-5"), ResolvedOpenAiApiFormat::Completions);
2369 assert_eq!(fmt.resolve("o1"), ResolvedOpenAiApiFormat::Completions);
2370 assert_eq!(
2371 fmt.resolve("gpt-5.4-mini"),
2372 ResolvedOpenAiApiFormat::Completions
2373 );
2374 assert_eq!(fmt.resolve("o3-mini"), ResolvedOpenAiApiFormat::Completions);
2375 }
2376
2377 #[test]
2378 fn test_api_format_explicit_override() {
2379 assert_eq!(
2381 OpenAiApiFormat::Responses.resolve("gpt-4o"),
2382 ResolvedOpenAiApiFormat::Responses
2383 );
2384 assert_eq!(
2386 OpenAiApiFormat::Completions.resolve("codex-mini-latest"),
2387 ResolvedOpenAiApiFormat::Completions
2388 );
2389 }
2390
2391 #[test]
2392 fn test_api_format_defaults_to_auto() {
2393 let inner = ModelSettingsInner::default();
2394 assert_eq!(inner.api_format, OpenAiApiFormat::Auto);
2395 }
2396
2397 #[test]
2398 fn test_api_format_auto_skipped_in_serialization() {
2399 let settings = ModelSettings {
2400 model: "test-model".to_string(),
2401 inner: ModelSettingsInner {
2402 provider: ModelProvider::OpenAI {},
2403 ..Default::default()
2404 },
2405 };
2406 let json = serde_json::to_string(&settings).unwrap();
2407 assert!(!json.contains("api_format"));
2408 }
2409
2410 #[test]
2411 fn test_api_format_responses_serialized() {
2412 let settings = ModelSettings {
2413 model: "test-model".to_string(),
2414 inner: ModelSettingsInner {
2415 api_format: OpenAiApiFormat::Responses,
2416 provider: ModelProvider::OpenAI {},
2417 ..Default::default()
2418 },
2419 };
2420 let json = serde_json::to_string(&settings).unwrap();
2421 assert!(json.contains("\"api_format\":\"responses\""));
2422 }
2423
2424 #[test]
2425 fn test_api_format_deserializes_from_toml() {
2426 let toml_str = r#"
2427 model = "codex-mini-latest"
2428 api_format = "responses"
2429 [provider]
2430 name = "openai"
2431 "#;
2432 let settings: ModelSettings = toml::from_str(toml_str).unwrap();
2433 assert_eq!(settings.inner.api_format, OpenAiApiFormat::Responses);
2434 }
2435
2436 #[test]
2439 fn test_tool_delivery_mode_defaults_to_deferred() {
2440 let mode: ToolDeliveryMode = Default::default();
2441 assert_eq!(mode, ToolDeliveryMode::Deferred);
2442 }
2443
2444 #[test]
2445 fn test_tool_delivery_mode_backwards_compat_all_tools() {
2446 let json = r#""all_tools""#;
2448 let mode: ToolDeliveryMode = serde_json::from_str(json).unwrap();
2449 assert_eq!(mode, ToolDeliveryMode::Full);
2450 }
2451
2452 #[test]
2453 fn test_tool_delivery_mode_backwards_compat_tool_search() {
2454 let json = r#""tool_search""#;
2456 let mode: ToolDeliveryMode = serde_json::from_str(json).unwrap();
2457 assert_eq!(mode, ToolDeliveryMode::Deferred);
2458 }
2459
2460 #[test]
2461 fn test_tools_config_is_core_tool() {
2462 let config = ToolsConfig::default();
2463 assert!(config.is_core_tool("final"));
2464 assert!(config.is_core_tool("tool_search"));
2465 assert!(config.is_core_tool("execute_shell"));
2466 assert!(config.is_core_tool("call_agent"));
2467 assert!(!config.is_core_tool("browsr_scrape"));
2468 }
2469
2470 #[test]
2471 fn test_tools_config_always_full_schema() {
2472 let config = ToolsConfig {
2473 always_full_schema: vec!["browsr_scrape".to_string()],
2474 ..Default::default()
2475 };
2476 assert!(config.is_core_tool("browsr_scrape"));
2477 assert!(!config.is_core_tool("browsr_browser"));
2478 }
2479
2480 #[test]
2481 fn test_effective_delivery_mode_full_stays_full() {
2482 let config = ToolsConfig {
2483 delivery_mode: ToolDeliveryMode::Full,
2484 ..Default::default()
2485 };
2486 assert_eq!(config.effective_delivery_mode(100), ToolDeliveryMode::Full);
2488 }
2489
2490 #[test]
2491 fn test_effective_delivery_mode_deferred_stays_deferred() {
2492 let config = ToolsConfig {
2493 delivery_mode: ToolDeliveryMode::Deferred,
2494 deferred_threshold: Some(20),
2495 ..Default::default()
2496 };
2497 assert_eq!(
2499 config.effective_delivery_mode(10),
2500 ToolDeliveryMode::Deferred
2501 );
2502 }
2503
2504 #[test]
2505 fn test_effective_delivery_mode_deferred_over_threshold() {
2506 let config = ToolsConfig {
2507 delivery_mode: ToolDeliveryMode::Deferred,
2508 deferred_threshold: Some(10),
2509 ..Default::default()
2510 };
2511 assert_eq!(
2513 config.effective_delivery_mode(15),
2514 ToolDeliveryMode::Deferred
2515 );
2516 }
2517
2518 #[test]
2519 fn test_runtime_mode_serde() {
2520 let mode: RuntimeMode = serde_json::from_str("\"cloud\"").unwrap();
2521 assert_eq!(mode, RuntimeMode::Cloud);
2522 let mode: RuntimeMode = serde_json::from_str("\"cli\"").unwrap();
2523 assert_eq!(mode, RuntimeMode::Cli);
2524 let mode: RuntimeMode = serde_json::from_str("\"browser\"").unwrap();
2525 assert_eq!(mode, RuntimeMode::Browser);
2526 assert_eq!(RuntimeMode::default(), RuntimeMode::Cloud);
2527 let json = serde_json::to_string(&RuntimeMode::Cli).unwrap();
2528 assert_eq!(json, "\"cli\"");
2529 }
2530
2531 #[test]
2534 fn merge_both_default_openai_agent_model_wins() {
2535 let base = ModelSettings::new("gpt-5.1");
2536 let agent = ModelSettings::new("gpt-4.1-mini");
2537
2538 let result = base.merge(&agent).unwrap();
2539 assert_eq!(result.model, "gpt-4.1-mini");
2540 assert!(matches!(result.inner.provider, ModelProvider::OpenAI {}));
2541 }
2542
2543 #[test]
2544 fn merge_both_default_openai_base_model_used_when_agent_empty() {
2545 let base = ModelSettings::new("gpt-5.1");
2546 let agent = ModelSettings::new("");
2547
2548 let result = base.merge(&agent).unwrap();
2549 assert_eq!(result.model, "gpt-5.1");
2550 }
2551
2552 #[test]
2553 fn merge_agent_explicit_provider_wins() {
2554 let base = ModelSettings {
2555 model: "gpt-5.1".into(),
2556 inner: ModelSettingsInner {
2557 provider: ModelProvider::OpenAICompatible {
2558 base_url: "https://custom.com/v1".into(),
2559 api_key: Some("key".into()),
2560 project_id: None,
2561 },
2562 ..Default::default()
2563 },
2564 };
2565 let agent = ModelSettings {
2566 model: "claude-sonnet-4".into(),
2567 inner: ModelSettingsInner {
2568 provider: ModelProvider::Anthropic {
2569 base_url: None,
2570 api_key: None,
2571 },
2572 ..Default::default()
2573 },
2574 };
2575
2576 let result = base.merge(&agent).unwrap();
2577 assert_eq!(result.model, "claude-sonnet-4");
2578 assert!(matches!(
2579 result.inner.provider,
2580 ModelProvider::Anthropic { .. }
2581 ));
2582 }
2583
2584 #[test]
2585 fn merge_agent_explicit_provider_no_model_uses_base() {
2586 let base = ModelSettings::new("gpt-5.1");
2587 let agent = ModelSettings {
2588 model: "".into(),
2589 inner: ModelSettingsInner {
2590 provider: ModelProvider::Anthropic {
2591 base_url: None,
2592 api_key: None,
2593 },
2594 ..Default::default()
2595 },
2596 };
2597
2598 let result = base.merge(&agent).unwrap();
2599 assert_eq!(result.model, "gpt-5.1");
2600 assert!(matches!(
2601 result.inner.provider,
2602 ModelProvider::Anthropic { .. }
2603 ));
2604 }
2605
2606 #[test]
2607 fn merge_workspace_custom_provider_overrides_agent_model() {
2608 let base = ModelSettings {
2609 model: "gpt-5.4".into(),
2610 inner: ModelSettingsInner {
2611 provider: ModelProvider::OpenAICompatible {
2612 base_url: "https://custom.azure.com/openai/v1".into(),
2613 api_key: Some("test-key".into()),
2614 project_id: None,
2615 },
2616 ..Default::default()
2617 },
2618 };
2619 let agent = ModelSettings::new("gpt-5.1");
2621
2622 let result = base.merge(&agent).unwrap();
2623 assert_eq!(result.model, "gpt-5.4");
2624 assert!(matches!(
2625 result.inner.provider,
2626 ModelProvider::OpenAICompatible { .. }
2627 ));
2628 }
2629
2630 #[test]
2631 fn merge_workspace_custom_provider_agent_empty_model() {
2632 let base = ModelSettings {
2633 model: "gpt-5.4".into(),
2634 inner: ModelSettingsInner {
2635 provider: ModelProvider::OpenAICompatible {
2636 base_url: "https://custom.azure.com/openai/v1".into(),
2637 api_key: Some("test-key".into()),
2638 project_id: None,
2639 },
2640 ..Default::default()
2641 },
2642 };
2643 let agent = ModelSettings::new("");
2644
2645 let result = base.merge(&agent).unwrap();
2646 assert_eq!(result.model, "gpt-5.4");
2647 }
2648
2649 #[test]
2650 fn merge_both_empty_returns_none() {
2651 let base = ModelSettings::new("");
2652 let agent = ModelSettings::new("");
2653
2654 assert!(base.merge(&agent).is_none());
2655 }
2656
2657 #[test]
2658 fn merge_workspace_empty_agent_empty_returns_none() {
2659 let base = ModelSettings {
2660 model: "".into(),
2661 inner: ModelSettingsInner {
2662 provider: ModelProvider::OpenAICompatible {
2663 base_url: "https://custom.com".into(),
2664 api_key: None,
2665 project_id: None,
2666 },
2667 ..Default::default()
2668 },
2669 };
2670 let agent = ModelSettings::new("");
2671
2672 assert!(base.merge(&agent).is_none());
2673 }
2674
2675 #[test]
2676 fn merge_temperature_max_tokens_override() {
2677 let base = ModelSettings {
2678 model: "gpt-5.1".into(),
2679 inner: ModelSettingsInner {
2680 temperature: Some(0.5),
2681 max_tokens: Some(1000),
2682 top_p: Some(0.9),
2683 ..Default::default()
2684 },
2685 };
2686 let agent = ModelSettings {
2687 model: "gpt-4.1-mini".into(),
2688 inner: ModelSettingsInner {
2689 temperature: Some(0.9),
2690 max_tokens: None, ..Default::default()
2692 },
2693 };
2694
2695 let result = base.merge(&agent).unwrap();
2696 assert_eq!(result.model, "gpt-4.1-mini");
2697 assert_eq!(result.inner.temperature, Some(0.9));
2698 assert_eq!(result.inner.max_tokens, Some(1000)); assert_eq!(result.inner.top_p, Some(0.9)); }
2701
2702 #[test]
2703 fn merge_context_size_non_default_wins() {
2704 let base = ModelSettings {
2705 model: "gpt-5.1".into(),
2706 inner: ModelSettingsInner {
2707 context_size: 20000, ..Default::default()
2709 },
2710 };
2711 let agent = ModelSettings {
2712 model: "gpt-4.1-mini".into(),
2713 inner: ModelSettingsInner {
2714 context_size: 100000, ..Default::default()
2716 },
2717 };
2718
2719 let result = base.merge(&agent).unwrap();
2720 assert_eq!(result.inner.context_size, 100000);
2721 }
2722
2723 #[test]
2724 fn merge_context_size_default_falls_back() {
2725 let base = ModelSettings {
2726 model: "gpt-5.1".into(),
2727 inner: ModelSettingsInner {
2728 context_size: 128000,
2729 ..Default::default()
2730 },
2731 };
2732 let agent = ModelSettings {
2733 model: "gpt-4.1-mini".into(),
2734 inner: ModelSettingsInner {
2735 context_size: 20000, ..Default::default()
2737 },
2738 };
2739
2740 let result = base.merge(&agent).unwrap();
2741 assert_eq!(result.inner.context_size, 128000);
2742 }
2743
2744 #[test]
2745 fn merge_azure_ai_foundry_resource_preserved() {
2746 let base = ModelSettings {
2747 model: "gpt-5.4".into(),
2748 inner: ModelSettingsInner {
2749 provider: ModelProvider::AzureAiFoundry {
2750 resource: "myresource".into(),
2751 api_key: Some("test-key".into()),
2752 },
2753 ..Default::default()
2754 },
2755 };
2756 let agent = ModelSettings::new("gpt-5.1");
2757
2758 let result = base.merge(&agent).unwrap();
2759 assert_eq!(result.model, "gpt-5.4"); assert!(matches!(
2761 result.inner.provider,
2762 ModelProvider::AzureAiFoundry { .. }
2763 ));
2764 if let ModelProvider::AzureAiFoundry { resource, .. } = &result.inner.provider {
2765 assert_eq!(resource, "myresource");
2766 }
2767 assert_eq!(
2768 result.inner.provider.completion_url().as_deref(),
2769 Some("https://myresource.openai.azure.com/openai/v1"),
2770 );
2771 }
2772
2773 #[test]
2774 fn azure_ai_foundry_resource_resolves_openai_url() {
2775 let p = ModelProvider::AzureAiFoundry {
2776 resource: "distri-tts-resource".into(),
2777 api_key: None,
2778 };
2779 assert_eq!(
2780 p.completion_url().as_deref(),
2781 Some("https://distri-tts-resource.openai.azure.com/openai/v1"),
2782 );
2783 assert_eq!(p.tts_url(), p.completion_url());
2785 let empty = ModelProvider::AzureAiFoundry {
2787 resource: String::new(),
2788 api_key: None,
2789 };
2790 assert_eq!(empty.completion_url(), None);
2791 }
2792
2793 #[test]
2794 fn merge_anthropic_provider_preserves_base_url() {
2795 let base = ModelSettings {
2796 model: "claude-sonnet-4".into(),
2797 inner: ModelSettingsInner {
2798 provider: ModelProvider::Anthropic {
2799 base_url: Some("https://custom.anthropic.com".into()),
2800 api_key: Some("key".into()),
2801 },
2802 temperature: Some(0.7),
2803 ..Default::default()
2804 },
2805 };
2806 let agent = ModelSettings::new("");
2807
2808 let result = base.merge(&agent).unwrap();
2809 assert_eq!(result.model, "claude-sonnet-4");
2810 assert_eq!(result.inner.temperature, Some(0.7));
2811 if let ModelProvider::Anthropic { base_url, api_key } = result.inner.provider {
2812 assert_eq!(base_url, Some("https://custom.anthropic.com".into()));
2813 assert_eq!(api_key, Some("key".into()));
2814 }
2815 }
2816
2817 #[test]
2818 fn merge_response_format_agent_wins() {
2819 let base = ModelSettings {
2820 model: "gpt-5.1".into(),
2821 inner: ModelSettingsInner {
2822 response_format: Some(serde_json::json!({"type": "text"})),
2823 ..Default::default()
2824 },
2825 };
2826 let agent = ModelSettings {
2827 model: "gpt-4.1-mini".into(),
2828 inner: ModelSettingsInner {
2829 response_format: Some(serde_json::json!({"type": "json_object"})),
2830 ..Default::default()
2831 },
2832 };
2833
2834 let result = base.merge(&agent).unwrap();
2835 assert_eq!(
2836 result.inner.response_format,
2837 Some(serde_json::json!({"type": "json_object"}))
2838 );
2839 }
2840
2841 #[test]
2842 fn merge_response_format_base_fallback() {
2843 let base = ModelSettings {
2844 model: "gpt-5.1".into(),
2845 inner: ModelSettingsInner {
2846 response_format: Some(serde_json::json!({"type": "text"})),
2847 ..Default::default()
2848 },
2849 };
2850 let agent = ModelSettings::new("gpt-4.1-mini");
2851
2852 let result = base.merge(&agent).unwrap();
2853 assert_eq!(
2854 result.inner.response_format,
2855 Some(serde_json::json!({"type": "text"}))
2856 );
2857 }
2858
2859 #[test]
2860 fn merge_parameters_agent_wins() {
2861 let base = ModelSettings {
2862 model: "gpt-5.1".into(),
2863 inner: ModelSettingsInner {
2864 parameters: Some(serde_json::json!({"key": "base"})),
2865 ..Default::default()
2866 },
2867 };
2868 let agent = ModelSettings {
2869 model: "gpt-4.1-mini".into(),
2870 inner: ModelSettingsInner {
2871 parameters: Some(serde_json::json!({"key": "agent"})),
2872 ..Default::default()
2873 },
2874 };
2875
2876 let result = base.merge(&agent).unwrap();
2877 assert_eq!(
2878 result.inner.parameters,
2879 Some(serde_json::json!({"key": "agent"}))
2880 );
2881 }
2882
2883 #[test]
2891 fn test_api_key_secret_canonical_names() {
2892 assert_eq!(ModelProvider::OpenAI {}.api_key_secret(), "OPENAI_API_KEY");
2893 assert_eq!(
2894 ModelProvider::Anthropic {
2895 base_url: None,
2896 api_key: None,
2897 }
2898 .api_key_secret(),
2899 "ANTHROPIC_API_KEY"
2900 );
2901 assert_eq!(
2902 ModelProvider::Gemini {
2903 base_url: ModelProvider::gemini_base_url(),
2904 api_key: None,
2905 }
2906 .api_key_secret(),
2907 "GEMINI_API_KEY"
2908 );
2909 assert_eq!(
2910 ModelProvider::AzureOpenAI {
2911 base_url: String::new(),
2912 api_key: None,
2913 deployment: "x".into(),
2914 api_version: ModelProvider::azure_api_version(),
2915 }
2916 .api_key_secret(),
2917 "AZURE_OPENAI_API_KEY"
2918 );
2919 assert_eq!(
2920 ModelProvider::AzureAiFoundry {
2921 resource: String::new(),
2922 api_key: None,
2923 }
2924 .api_key_secret(),
2925 "AZURE_AI_FOUNDRY_API_KEY"
2926 );
2927 assert_eq!(
2928 ModelProvider::AwsBedrock {
2929 base_url: String::new(),
2930 api_key: None,
2931 }
2932 .api_key_secret(),
2933 "AWS_ACCESS_KEY_ID"
2934 );
2935 assert_eq!(
2936 ModelProvider::GoogleVertex {
2937 base_url: String::new(),
2938 api_key: None,
2939 project_id: None,
2940 }
2941 .api_key_secret(),
2942 "GOOGLE_VERTEX_API_KEY"
2943 );
2944 assert_eq!(
2945 ModelProvider::AlibabaCloud {
2946 base_url: ModelProvider::alibaba_cloud_base_url(),
2947 api_key: None,
2948 }
2949 .api_key_secret(),
2950 "DASHSCOPE_API_KEY"
2951 );
2952 assert_eq!(
2953 ModelProvider::OpenAICompatible {
2954 base_url: String::new(),
2955 api_key: None,
2956 project_id: None,
2957 }
2958 .api_key_secret(),
2959 "OPENAI_API_KEY"
2960 );
2961 assert_eq!(
2962 ModelProvider::FalAi { api_key: None }.api_key_secret(),
2963 "FAL_KEY"
2964 );
2965 }
2966
2967 #[test]
2977 fn test_api_key_secret_matches_default_models_json() {
2978 let providers = ModelProvider::all_provider_definitions();
2979 let cases: &[(&str, ModelProvider)] = &[
2980 ("openai", ModelProvider::OpenAI {}),
2981 (
2982 "anthropic",
2983 ModelProvider::Anthropic {
2984 base_url: None,
2985 api_key: None,
2986 },
2987 ),
2988 (
2989 "gemini",
2990 ModelProvider::Gemini {
2991 base_url: ModelProvider::gemini_base_url(),
2992 api_key: None,
2993 },
2994 ),
2995 ];
2996
2997 for (id, variant) in cases {
2998 let def = providers
2999 .iter()
3000 .find(|p| p.id == *id)
3001 .unwrap_or_else(|| panic!("provider '{}' missing from default_models.json", id));
3002 let first_api_key_in_json = def
3003 .keys
3004 .iter()
3005 .map(|k| k.key.as_str())
3006 .find(|k| k.ends_with("_API_KEY") || *k == "AWS_ACCESS_KEY_ID")
3007 .unwrap_or_else(|| {
3008 panic!(
3009 "provider '{}' has no API key entry in default_models.json",
3010 id
3011 )
3012 });
3013 assert_eq!(
3014 first_api_key_in_json,
3015 variant.api_key_secret(),
3016 "provider '{}': default_models.json key {:?} != api_key_secret() {:?}",
3017 id,
3018 first_api_key_in_json,
3019 variant.api_key_secret(),
3020 );
3021 }
3022 }
3023
3024 fn entry(id: &str, label: &str) -> DefaultProviderEntry {
3025 DefaultProviderEntry {
3026 id: id.to_string(),
3027 label: label.to_string(),
3028 keys: vec![],
3029 models: vec![],
3030 test: None,
3031 }
3032 }
3033
3034 #[test]
3036 fn test_merge_provider_layers_overrides_and_appends() {
3037 let builtin = vec![entry("openai", "OpenAI"), entry("anthropic", "Anthropic")];
3038 let extensions = vec![
3039 entry("anthropic", "Anthropic (override)"),
3040 entry("azure_ai_foundry", "Azure AI Foundry"),
3041 ];
3042 let merged = merge_provider_layers(&builtin, &extensions);
3043
3044 assert_eq!(merged.len(), 3);
3045 assert_eq!(
3046 merged.iter().find(|p| p.id == "openai").unwrap().label,
3047 "OpenAI",
3048 "untouched built-in is preserved"
3049 );
3050 assert_eq!(
3051 merged.iter().find(|p| p.id == "anthropic").unwrap().label,
3052 "Anthropic (override)",
3053 "extension overrides the built-in with the same id"
3054 );
3055 assert!(
3056 merged.iter().any(|p| p.id == "azure_ai_foundry"),
3057 "extension with a new id is appended"
3058 );
3059 }
3060
3061 #[test]
3064 fn test_model_provider_definition_conversion_backfills_name() {
3065 use crate::models::{Model, ModelCapability, ModelProviderDefinition};
3066 let def = ModelProviderDefinition {
3067 id: "acme".to_string(),
3068 label: "Acme".to_string(),
3069 keys: vec![],
3070 models: vec![Model {
3071 id: "acme-large".to_string(),
3072 name: String::new(),
3073 capability: ModelCapability::Completion,
3074 context_window: None,
3075 pricing: None,
3076 voices: vec![],
3077 formats: vec![],
3078 }],
3079 is_custom: false,
3080 test: None,
3081 };
3082 let converted = DefaultProviderEntry::from(def);
3083 assert_eq!(converted.models[0].name, "acme-large");
3084 }
3085}