Skip to main content

distri_types/
agent.rs

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
9/// Default timeout for external tool execution in seconds
10pub const DEFAULT_EXTERNAL_TOOL_TIMEOUT_SECS: u64 = 120;
11
12/// A reference to a stored skill that an agent can load on demand
13#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
14pub struct AvailableSkill {
15    /// The skill ID (UUID)
16    pub id: String,
17    /// Human-readable skill name (for display in the partial)
18    pub name: String,
19    /// Brief description of what this skill does (shown to the agent)
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub description: Option<String>,
22}
23
24/// Unified Agent Strategy Configuration
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
26#[serde(deny_unknown_fields, rename_all = "snake_case")]
27#[derive(Default)]
28pub struct AgentStrategy {
29    /// Depth of reasoning (shallow, standard, deep)
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub reasoning_depth: Option<ReasoningDepth>,
32
33    /// Execution mode - tools vs code
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub execution_mode: Option<ExecutionMode>,
36    /// When to replan
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub replanning: Option<ReplanningConfig>,
39
40    /// Timeout in seconds for external tool execution (default: 120)
41    /// External tools are tools that delegate execution to the frontend/client.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub external_tool_timeout_secs: Option<u64>,
44}
45
46impl AgentStrategy {
47    /// Get reasoning depth with default fallback
48    pub fn get_reasoning_depth(&self) -> ReasoningDepth {
49        self.reasoning_depth.clone().unwrap_or_default()
50    }
51
52    /// Get execution mode with default fallback
53    pub fn get_execution_mode(&self) -> ExecutionMode {
54        self.execution_mode.clone().unwrap_or_default()
55    }
56
57    /// Get replanning config with default fallback
58    pub fn get_replanning(&self) -> ReplanningConfig {
59        self.replanning.clone().unwrap_or_default()
60    }
61
62    /// Get external tool timeout with default fallback
63    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/// Reflection configuration
85#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
86pub struct ReflectionConfig {
87    /// Whether to enable reflection
88    #[serde(default)]
89    pub enabled: bool,
90    /// Name of the agent definition to use for reflection.
91    /// Must be an agent that has the "reflect" tool configured.
92    /// If not set, uses the built-in reflection_agent.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub reflection_agent: Option<String>,
95    /// When to trigger reflection
96    #[serde(default)]
97    pub trigger: ReflectionTrigger,
98    /// Depth of reflection
99    #[serde(default)]
100    pub depth: ReflectionDepth,
101}
102
103/// When to trigger reflection
104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
105#[serde(rename_all = "snake_case")]
106pub enum ReflectionTrigger {
107    /// At the end of execution
108    #[default]
109    EndOfExecution,
110    /// After each step
111    AfterEachStep,
112    /// After failures only
113    AfterFailures,
114    /// After N steps
115    AfterNSteps(usize),
116}
117
118/// Depth of reflection
119#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
120#[serde(rename_all = "snake_case")]
121pub enum ReflectionDepth {
122    /// Light reflection
123    #[default]
124    Light,
125    /// Standard reflection
126    Standard,
127    /// Deep reflection
128    Deep,
129}
130
131/// Configuration for planning operations
132#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
133pub struct PlanConfig {
134    /// The model settings for the planning agent
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub model_settings: Option<ModelSettings>,
137    /// The maximum number of iterations allowed during planning
138    #[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/// Depth of reasoning for planning
156#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
157#[serde(rename_all = "snake_case")]
158pub enum ReasoningDepth {
159    /// Shallow reasoning - direct action with minimal thought, skip reasoning sections
160    Shallow,
161    /// Standard reasoning - moderate planning and thought
162    #[default]
163    Standard,
164    /// Deep reasoning - extensive planning, multi-step analysis, and comprehensive thinking
165    Deep,
166}
167
168/// Execution mode - tools vs code
169#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
170#[serde(rename_all = "snake_case", tag = "type")]
171pub enum ExecutionMode {
172    /// Use tools for execution
173    #[default]
174    Tools,
175    /// Use code execution
176    Code { language: CodeLanguage },
177}
178
179/// Replanning configuration
180#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
181#[serde(rename_all = "snake_case")]
182pub struct ReplanningConfig {
183    /// When to trigger replanning
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub trigger: Option<ReplanningTrigger>,
186    /// Whether to replan at all
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub enabled: Option<bool>,
189}
190
191/// When to trigger replanning
192#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
193#[serde(rename_all = "snake_case")]
194pub enum ReplanningTrigger {
195    /// Never replan (default)
196    #[default]
197    Never,
198    /// Replan after execution reflection
199    AfterReflection,
200    /// Replan after N iterations
201    AfterNIterations(usize),
202    /// Replan after failures
203    AfterFailures,
204}
205
206impl ReplanningConfig {
207    /// Get trigger with default fallback
208    pub fn get_trigger(&self) -> ReplanningTrigger {
209        self.trigger.clone().unwrap_or_default()
210    }
211
212    /// Get enabled with default fallback
213    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/// How tools are delivered to the LLM in the prompt.
236///
237/// Controls the tradeoff between prompt size and tool discoverability:
238/// - `Full`: All tools get full schemas (classic behavior, largest prompt)
239/// - `Deferred`: Core tools get full schemas; others are name+description only
240/// - `NamesOnly`: Maximum savings — only core tools have schemas
241#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
242#[serde(rename_all = "snake_case")]
243pub enum ToolDeliveryMode {
244    /// All tools get full schemas in the prompt.
245    #[serde(alias = "all_tools")]
246    Full,
247    /// Core tools get full schemas; others get name+description only (default).
248    #[default]
249    #[serde(alias = "tool_search")]
250    Deferred,
251    /// Only tool names are listed. Model must use `tool_search` for everything
252    /// except core tools. Maximum context savings.
253    NamesOnly,
254}
255
256/// Which OpenAI-family API format to use when talking to the LLM.
257///
258/// - `Auto` (default): Auto-detects from the model name. Codex models use Responses API,
259///   everything else uses Chat Completions.
260/// - `Completions`: Forces the Chat Completions API (`/v1/chat/completions`)
261/// - `Responses`: Forces the Responses API (`/v1/responses`)
262///
263/// Most OpenAI models (GPT-4o, GPT-4.1, GPT-5, o1, etc.) support both APIs.
264/// Codex models (`codex-*`, `*-codex`) are Responses API only.
265/// OpenAI recommends the Responses API for new projects (better caching, reasoning).
266///
267/// Can be set at the model_settings level in agent definitions:
268/// ```toml
269/// [model_settings]
270/// model = "codex-mini-latest"
271/// api_format = "responses"  # or "completions" or "auto"
272/// ```
273#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
274#[serde(rename_all = "snake_case")]
275pub enum OpenAiApiFormat {
276    /// Auto-detect based on model name (codex models → Responses, everything else → Completions)
277    #[default]
278    Auto,
279    /// Chat Completions API (`/v1/chat/completions`)
280    Completions,
281    /// Responses API (`/v1/responses`) — required for Codex models, recommended for new projects
282    Responses,
283}
284
285impl OpenAiApiFormat {
286    /// Resolve the effective format given a model name.
287    /// When `Auto`, inspects the model name to decide.
288    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    /// Heuristic: models that require the Responses API.
303    ///
304    /// These models return errors on /v1/chat/completions and MUST use /v1/responses:
305    /// - Codex models: codex-mini-latest, gpt-5.1-codex, gpt-5.3-codex, etc.
306    /// - Pro models: gpt-5-pro, gpt-5.2-pro, gpt-5.4-pro, o3-pro
307    /// - Deep research models: o3-deep-research, o4-mini-deep-research
308    fn model_requires_responses_api(model: &str) -> bool {
309        let m = model.to_lowercase();
310        // Codex models (codex-*, *-codex, */codex*)
311        m.starts_with("codex")
312            || m.ends_with("-codex")
313            || m.contains("/codex")
314            // Pro models (*-pro) — require multi-turn interactions only Responses supports
315            || m.ends_with("-pro")
316            // Deep research models (*-deep-research)
317            || m.ends_with("-deep-research")
318    }
319}
320
321/// Resolved (non-Auto) API format
322#[derive(Debug, Clone, PartialEq)]
323pub enum ResolvedOpenAiApiFormat {
324    Completions,
325    Responses,
326}
327
328/// Supported tool call formats
329#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq)]
330#[serde(rename_all = "snake_case")]
331pub enum ToolCallFormat {
332    /// New XML format: Streaming-capable XML tool calls
333    /// Example: <search><query>test</query></search>
334    #[default]
335    Xml,
336    /// New JSON format: JSONL with tool_calls blocks
337    /// Example: ```tool_calls\n{"name":"search","arguments":{"query":"test"}}```
338    JsonL,
339
340    /// Code execution format: TypeScript/JavaScript code blocks
341    /// Example: ```typescript ... ```
342    Code,
343    #[serde(rename = "provider")]
344    Provider,
345    None,
346}
347
348#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, Default)]
349pub struct UserMessageOverrides {
350    /// The parts to include in the user message
351    pub parts: Vec<PartDefinition>,
352    /// If true, artifacts will be expanded to their actual content (e.g., image artifacts become Part::Image)
353    #[serde(default)]
354    pub include_artifacts: bool,
355    /// If true (default), step count information will be included at the end of the user message
356    #[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),   // Prompt Template Key
368    SessionKey(String), // Session key reference
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
372#[serde(deny_unknown_fields)]
373pub struct LlmDefinition {
374    /// The name of the agent.
375    pub name: String,
376    /// Settings related to the model used by the agent.
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub model_settings: Option<ModelSettings>,
379    /// Tool calling configuration
380    #[serde(default)]
381    pub tool_format: ToolCallFormat,
382    /// How tools are delivered to the LLM (all upfront vs on-demand search)
383    #[serde(default)]
384    pub tool_delivery_mode: ToolDeliveryMode,
385}
386
387impl LlmDefinition {
388    /// Get a reference to model_settings.
389    /// Returns an error if model_settings is None.
390    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    /// Get a mutable reference to model_settings.
398    /// Returns an error if model_settings is None.
399    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/// Runtime environment in which the agent is executing.
408/// Determines which built-in agent variants and tools are available.
409#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash, Default)]
410#[serde(rename_all = "snake_case")]
411pub enum RuntimeMode {
412    /// Running from distri-cli with local filesystem access
413    Cli,
414    /// Running on distri-cloud server (browsr sandbox for code execution)
415    #[default]
416    Cloud,
417    /// Running in browser with IndexedDB filesystem
418    Browser,
419}
420
421impl RuntimeMode {
422    /// Canonical wire/template name (matches `#[serde(rename_all =
423    /// "snake_case")]`). Single source of truth for template
424    /// substitution (`{{runtime_mode}}` / `{{#if (eq runtime_mode
425    /// "cli")}}`), span attributes, and any string-keyed runtime
426    /// dispatch table. Both
427    /// `agent::strategy::planning::formatter` and
428    /// `tools::skill_script::LoadSkillTool` use this so a future
429    /// rename can't desync the system prompt from the skill body.
430    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/// Agent definition - complete configuration for an agent
440#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
441pub struct StandardDefinition {
442    /// The name of the agent.
443    pub name: String,
444    /// A brief description of the agent's purpose.
445    #[serde(default, skip_serializing_if = "String::is_empty")]
446    pub description: String,
447
448    /// The version of the agent. Runtime falls back to `default_agent_version()`.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub version: Option<String>,
451
452    /// Instructions for the agent - serves as an introduction defining what the agent is and does.
453    #[serde(default, skip_serializing_if = "String::is_empty")]
454    pub instructions: String,
455
456    /// Settings related to the model used by the agent.
457    /// When `None`, the agent inherits model settings from the orchestrator context defaults.
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub model_settings: Option<ModelSettings>,
460    /// Optional lower-level model settings for lightweight analysis helpers
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub analysis_model_settings: Option<ModelSettings>,
463
464    /// The size of the history to maintain for the agent. Runtime falls back to `default_history_size()`.
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub history_size: Option<usize>,
467    /// The new strategy configuration for the agent.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub strategy: Option<AgentStrategy>,
470    /// A2A-specific fields
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub icon_url: Option<String>,
473
474    /// Channel slash commands this agent exposes. Each command is a preset
475    /// prompt — invoking it on a bot sends `prompt` to the agent. Compiled
476    /// into the gateway's `CommandRouter` alongside a `WorkflowAgent`'s
477    /// entry-point commands.
478    #[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    /// A2A agent card skills metadata (describes capabilities for agent-to-agent protocol)
485    #[serde(default, skip_serializing_if = "Vec::is_empty")]
486    pub skills_description: Vec<AgentSkill>,
487
488    /// Skills available for on-demand loading by this agent
489    #[serde(default, skip_serializing_if = "Vec::is_empty")]
490    pub available_skills: Vec<AvailableSkill>,
491
492    /// Connections this agent needs to function. Resolved at run start from the
493    /// workspace's connections: OAuth tokens / custom secrets / distri-native
494    /// sessions are injected into `ExecutorContext.env_vars` and surfaced to
495    /// the model via the `{{> connections}}` partial. Agents without declared
496    /// connections get neither env vars nor the partial.
497    #[serde(default, skip_serializing_if = "Vec::is_empty")]
498    pub connections: Vec<crate::connections::ConnectionRequirement>,
499
500    /// List of sub-agents that this agent can transfer control to
501    #[serde(default, skip_serializing_if = "Vec::is_empty")]
502    pub sub_agents: Vec<String>,
503
504    /// Tool calling configuration
505    #[serde(default, skip_serializing_if = "is_default_tool_format")]
506    pub tool_format: ToolCallFormat,
507
508    /// How tools are delivered to the LLM (all upfront vs on-demand search)
509    #[serde(default, skip_serializing_if = "is_default_tool_delivery_mode")]
510    pub tool_delivery_mode: ToolDeliveryMode,
511
512    /// Tools configuration for this agent
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub tools: Option<ToolsConfig>,
515
516    /// Custom handlebars partials (name -> template path) for use in custom prompts
517    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
518    pub partials: std::collections::HashMap<String, String>,
519
520    /// Reflection configuration for post-execution analysis using a subagent
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub reflection: Option<ReflectionConfig>,
523    /// Whether to enable TODO management functionality
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub enable_todos: Option<bool>,
526
527    /// Browser configuration for this agent (enables shared Chromium automation)
528    #[serde(default, skip_serializing_if = "Option::is_none")]
529    pub browser_config: Option<BrowserAgentConfig>,
530
531    /// Whether to include shell/code execution tools (start_shell, execute_shell, stop_shell)
532    #[serde(default, skip_serializing_if = "Option::is_none")]
533    pub include_shell: Option<bool>,
534
535    /// Context size override for this agent (overrides model_settings.context_size)
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub context_size: Option<u32>,
538
539    /// Strategy for prompt construction (append default template vs fully custom)
540    #[serde(
541        skip_serializing_if = "Option::is_none",
542        default = "default_append_default_instructions"
543    )]
544    pub append_default_instructions: Option<bool>,
545    /// Whether to include the built-in scratchpad/history in prompts (default: true)
546    #[serde(
547        skip_serializing_if = "Option::is_none",
548        default = "default_include_scratchpad"
549    )]
550    pub include_scratchpad: Option<bool>,
551
552    /// Optional hook names to attach to this agent
553    #[serde(default, skip_serializing_if = "Vec::is_empty")]
554    pub hooks: Vec<String>,
555
556    /// Custom user message construction (dynamic prompting)
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub user_message_overrides: Option<UserMessageOverrides>,
559
560    /// Whether context compaction is enabled for this agent (default: true)
561    #[serde(
562        default = "default_compaction_enabled",
563        skip_serializing_if = "is_true"
564    )]
565    pub compaction_enabled: bool,
566
567    /// Runtime constraint for this agent. Like Docker's `platforms` field:
568    ///
569    /// - empty / omitted → runs in any runtime (default).
570    /// - `["cli"]` → only runs when `ExecutorContext.runtime_mode == Cli`,
571    ///   OR via a `RemoteTaskRunner` providing `Cli` (e.g. `SandboxLauncher`
572    ///   spawning `distri-cli` inside a browsr container).
573    /// - `["cli", "cloud"]` → runs in either Cli or Cloud, but not Browser.
574    ///
575    /// When the current runtime doesn't match any allowed value and no
576    /// compatible runner exists, the orchestrator fails fast at request entry.
577    ///
578    /// Accepts both scalar (`runtime = "cli"`) and array (`runtime = ["cli"]`)
579    /// syntax in TOML/JSON for ergonomics.
580    #[serde(
581        default,
582        deserialize_with = "deserialize_runtime_modes",
583        skip_serializing_if = "Vec::is_empty"
584    )]
585    pub runtime: Vec<RuntimeMode>,
586}
587
588/// Accept either a single `RuntimeMode` string or an array of them.
589fn 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            // Reject duplicates so authors notice typos like ["cli", "cli"].
607            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    /// The set of runtimes this agent is allowed to run in.
641    ///
642    /// Empty result = no constraint = runs anywhere.
643    pub fn allowed_runtimes(&self) -> Vec<RuntimeMode> {
644        self.runtime.clone()
645    }
646
647    /// Whether this agent can execute given the caller's `current` runtime,
648    /// optionally with a `RemoteTaskRunner` providing an alternative runtime
649    /// via remote dispatch.
650    ///
651    /// Returns true when:
652    /// - the agent has no runtime constraint, OR
653    /// - the current runtime matches one of the allowed runtimes, OR
654    /// - a runner is available whose `provided_runtime` matches one of the
655    ///   allowed runtimes.
656    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    /// Check if browser should be initialized automatically in orchestrator (default: false)
675    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    /// Returns browser config if defined
683    pub fn browser_settings(&self) -> Option<&BrowserAgentConfig> {
684        self.browser_config.as_ref()
685    }
686
687    /// Returns the runtime Chromium driver configuration if enabled
688    pub fn browser_runtime_config(&self) -> Option<BrowsrClientConfig> {
689        self.browser_config.as_ref().map(|cfg| cfg.runtime_config())
690    }
691
692    /// Should browser session state be serialized after tool runs
693    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    /// Check if reflection is enabled (default: false)
701    pub fn is_reflection_enabled(&self) -> bool {
702        self.reflection.as_ref().map(|r| r.enabled).unwrap_or(false)
703    }
704
705    /// Get the reflection configuration, if any
706    pub fn reflection_config(&self) -> Option<&ReflectionConfig> {
707        self.reflection.as_ref().filter(|r| r.enabled)
708    }
709    /// Check if TODO management functionality is enabled (default: false)
710    pub fn is_todos_enabled(&self) -> bool {
711        self.enable_todos.unwrap_or(false)
712    }
713
714    /// Check if shell/code execution tools should be included (default: false)
715    pub fn should_include_shell(&self) -> bool {
716        self.include_shell.unwrap_or(false)
717    }
718
719    /// Get model settings if configured.
720    pub fn model_settings(&self) -> Option<&ModelSettings> {
721        self.model_settings.as_ref()
722    }
723
724    /// Get a mutable reference to model settings, if present.
725    pub fn model_settings_mut(&mut self) -> Option<&mut ModelSettings> {
726        self.model_settings.as_mut()
727    }
728
729    /// Get the effective context size (agent-level override or model settings)
730    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    /// Model settings to use for lightweight browser analysis helpers (e.g., observe_summary commands)
742    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    /// Whether to include the persistent scratchpad/history in prompts
749    pub fn include_scratchpad(&self) -> bool {
750        self.include_scratchpad.unwrap_or(true)
751    }
752
753    /// Apply definition overrides to this agent definition
754    pub fn apply_overrides(&mut self, overrides: DefinitionOverrides) {
755        // Override model settings (only if model_settings already exists)
756        if let Some(ref mut ms) = self.model_settings {
757            if let Some(model) = overrides.model {
758                // Strip provider prefix if present (e.g. "custom_microsoft_foundry/gpt-5.4" → "gpt-5.4")
759                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        // Override max_iterations
773        if let Some(max_iterations) = overrides.max_iterations {
774            self.max_iterations = Some(max_iterations);
775        }
776
777        // Override instructions
778        if let Some(instructions) = overrides.instructions {
779            self.instructions = instructions;
780        }
781
782        // Append to instructions (for `invoke_agent({system: ...})` —
783        // keeps the agent's base scaffolding and adds the caller's text
784        // below it, separated by a blank line).
785        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        // Append dynamic tool factories
821        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
828/// Tools configuration for agents
829/// Canonical list of valid builtin tool names.
830///
831/// Includes both server-executed tools (search, start_shell, etc.) and
832/// client-executed tools (http_request). Agent definitions reference these
833/// by name in `tools.builtin = [...]`.
834pub const VALID_BUILTIN_TOOLS: &[&str] = &[
835    // Agent control
836    "final",
837    "reflect",
838    // Sub-agent dispatch (typed Invocation; replaces call_agent + run_skill).
839    "invoke_agent",
840    // Supervisor tools — query / wait / cancel / list children.
841    "get_task",
842    "wait_task",
843    "cancel_task",
844    "list_my_tasks",
845    // Browser & scraping
846    "browsr_scrape",
847    "browsr_browser",
848    "browsr_crawl",
849    "browser_step",
850    "search",
851    // Shell
852    "start_shell",
853    "execute_shell",
854    "stop_shell",
855    // Code execution
856    "distri_execute_code",
857    // Tool discovery
858    "tool_search",
859    // Skills (load body into current agent context; sub-agents call this
860    // themselves after being dispatched via invoke_agent).
861    "load_skill",
862    // Connection & secrets
863    "inject_connection_env",
864    // Logging
865    "console_log",
866    // Artifacts & filesystem
867    "artifact_tool",
868    // Todos — the tool's `Tool::get_name()` returns `write_todos`, so
869    // any agent definition that declared `builtin = ["todos"]` was
870    // silently broken (`resolve_tools_config` filters
871    // `get_builtin_tools()` by name and finds nothing). Single
872    // canonical name across validation / dispatch / skill bodies.
873    "write_todos",
874];
875
876/// Tools that always get full schemas, never deferred.
877/// These are the most commonly used tools that agents need immediately.
878pub 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
888/// Default threshold: defer tools when total count exceeds this.
889pub const DEFAULT_DEFERRED_THRESHOLD: usize = 15;
890
891#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
892#[serde(deny_unknown_fields)]
893pub struct ToolsConfig {
894    /// Built-in tools to include (e.g., ["final", "call_agent"])
895    #[serde(default, skip_serializing_if = "Vec::is_empty")]
896    pub builtin: Vec<String>,
897
898    /// Dynamic tool factories — each creates a named tool at runtime.
899    #[serde(default, skip_serializing_if = "Vec::is_empty")]
900    pub dynamic: Vec<crate::dynamic_tool::DynamicToolFactory>,
901
902    /// MCP server tool configurations
903    #[serde(default, skip_serializing_if = "Vec::is_empty")]
904    pub mcp: Vec<McpToolConfig>,
905
906    /// External tools to include from client
907    #[serde(default, skip_serializing_if = "Option::is_none")]
908    pub external: Option<Vec<String>>,
909
910    /// How tools are delivered to the model. Defaults to `Full`.
911    /// When set to `Deferred`, only core tools get full schemas;
912    /// others appear as name+description and must be fetched via `tool_search`.
913    #[serde(default, skip_serializing_if = "is_default_delivery_mode")]
914    pub delivery_mode: ToolDeliveryMode,
915
916    /// Tool count threshold for automatic deferral.
917    /// When `delivery_mode` is `Deferred` and total tools exceed this,
918    /// non-core tools are deferred. Default: 15.
919    #[serde(default, skip_serializing_if = "Option::is_none")]
920    pub deferred_threshold: Option<usize>,
921
922    /// Additional tool names to always include with full schemas (beyond CORE_TOOLS).
923    /// Useful for agent-specific tools that should never be deferred.
924    #[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    /// Validate that all builtin tool names are recognized.
934    /// Returns a list of invalid tool names, or empty if all are valid.
935    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    /// Whether a tool should always get a full schema (never deferred).
944    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    /// Effective threshold for automatic tool deferral.
949    pub fn effective_threshold(&self) -> usize {
950        self.deferred_threshold
951            .unwrap_or(DEFAULT_DEFERRED_THRESHOLD)
952    }
953
954    /// Determine the effective delivery mode given the total tool count.
955    /// If mode is `Full` but tool count exceeds threshold, stays `Full`
956    /// Deferred always stays Deferred — context efficiency is the default.
957    pub fn effective_delivery_mode(&self, _total_tools: usize) -> ToolDeliveryMode {
958        self.delivery_mode.clone()
959    }
960}
961
962// Where filesystem and artifact tools should execute.
963// Deprecated: filesystem tools are no longer included as server builtins.
964
965/// Configuration for tools from an MCP server
966#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
967#[serde(deny_unknown_fields)]
968pub struct McpToolConfig {
969    /// Name of the MCP server
970    pub server: String,
971
972    /// Include patterns (glob-style, e.g., ["fetch_*", "extract_text"])
973    /// Use ["*"] to include all tools from the server
974    #[serde(default, skip_serializing_if = "Vec::is_empty")]
975    pub include: Vec<String>,
976
977    /// Exclude patterns (glob-style, e.g., ["delete_*", "rm_*"])
978    #[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    /// The filter applied to the tools in this MCP definition.
986    #[serde(default)]
987    pub filter: Option<Vec<String>>,
988    /// The name of the MCP server.
989    pub name: String,
990    /// The type of the MCP server (Tool or Agent).
991    #[serde(default)]
992    pub r#type: McpServerType,
993    /// Authentication configuration for this MCP server.
994    #[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        /// Azure resource name (e.g. `distri-tts-resource`), **not** a URL.
1040        /// Every endpoint URL is derived from this — see
1041        /// [`ModelProvider::completion_url`] / [`ModelProvider::tts_url`].
1042        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    /// fal.ai — image-generation provider. The model id is the fal endpoint
1063    /// path (e.g. `fal-ai/flux/dev`); the gateway POSTs to
1064    /// `https://fal.run/<model_id>` with `Authorization: Key <api_key>`.
1065    #[serde(rename = "fal_ai")]
1066    FalAi { api_key: Option<String> },
1067}
1068/// Defines the secret requirements for a provider
1069#[derive(Debug, Clone, Serialize, Deserialize)]
1070pub struct ProviderSecretDefinition {
1071    /// Provider identifier (e.g., "openai", "anthropic")
1072    pub id: String,
1073    /// Human-readable label
1074    pub label: String,
1075    /// List of required secret keys with metadata
1076    pub keys: Vec<SecretKeyDefinition>,
1077}
1078
1079/// Defines a single secret key requirement
1080#[derive(Debug, Clone, Serialize, Deserialize)]
1081pub struct SecretKeyDefinition {
1082    /// The environment variable / secret store key (e.g., "OPENAI_API_KEY")
1083    pub key: String,
1084    /// Human-readable label
1085    pub label: String,
1086    /// Placeholder for UI input
1087    #[serde(default)]
1088    pub placeholder: String,
1089    /// Whether this secret is required (vs optional)
1090    #[serde(default = "default_required")]
1091    pub required: bool,
1092    /// Whether this field contains sensitive data (masked in UI, stored encrypted).
1093    /// Defaults to true. Set to false for non-sensitive config like URLs, project IDs.
1094    #[serde(default = "default_sensitive")]
1095    pub sensitive: bool,
1096    /// When set, the UI renders this field as a resource segment embedded in
1097    /// the URL template (`{}` marks the editable segment), showing the full
1098    /// endpoint read-only around it. Azure AI Foundry uses this: the user
1099    /// edits only the resource name and that is all we store.
1100    #[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/// A model entry within a provider
1113#[derive(Debug, Clone, Serialize, Deserialize)]
1114pub struct ModelInfo {
1115    /// Model identifier (e.g., "gpt-4o", "claude-sonnet-4")
1116    pub id: String,
1117    /// Human-readable name
1118    pub name: String,
1119}
1120
1121/// Combined provider definition used in default_models.json.
1122/// Merges secret key definitions and well-known models into one entry per provider.
1123#[derive(Debug, Clone, Serialize, Deserialize)]
1124struct DefaultProviderEntry {
1125    id: String,
1126    label: String,
1127    keys: Vec<SecretKeyDefinition>,
1128    models: Vec<crate::models::Model>,
1129    /// Optional per-provider override of `/v1/providers/test`.
1130    #[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                // Config sources may omit `name`; backfill it from `id` so
1170                // the catalog never surfaces a blank label.
1171                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
1187/// Look up the provider-test override for a registered provider. The
1188/// `/v1/providers/test` handler calls this and falls back to its default
1189/// `GET /models` probe when `None` is returned. Reads from the merged
1190/// layered registry (built-in basics + deployment extensions).
1191pub 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
1198/// Provider/model definitions contributed by a deployment, layered on top of
1199/// the built-in basics in `default_models.json`. Populated once at startup —
1200/// the OSS server folds in `distri.yaml`, the cloud folds in its own config
1201/// file. See [`register_provider_extensions`].
1202static PROVIDER_EXTENSIONS: std::sync::OnceLock<Vec<DefaultProviderEntry>> =
1203    std::sync::OnceLock::new();
1204
1205/// Register deployment-owned provider/model definitions — layer 2 of the
1206/// provider registry. Call once, at process startup, before any
1207/// provider/model catalog is served.
1208///
1209/// An extension whose `id` matches a built-in provider overrides it; a new
1210/// `id` is appended. Calling more than once logs a warning and keeps the
1211/// first registration.
1212pub 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
1225/// Merge built-in providers with extension providers — extensions override
1226/// built-ins by `id`, new ids are appended. Pure: no global state.
1227fn 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
1241/// Built-in providers plus any registered extensions — the full layered
1242/// registry, lowest-to-highest precedence.
1243fn 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/// Models grouped by provider, with configuration status
1249#[derive(Debug, Clone, Serialize, Deserialize)]
1250pub struct ProviderModels {
1251    /// Provider identifier
1252    pub provider_id: String,
1253    /// Human-readable provider name
1254    pub provider_label: String,
1255    /// Available models for this provider
1256    pub models: Vec<crate::models::Model>,
1257}
1258
1259/// Provider models with configuration status (returned by API)
1260#[derive(Debug, Clone, Serialize, Deserialize)]
1261pub struct ProviderModelsStatus {
1262    /// Provider identifier
1263    pub provider_id: String,
1264    /// Human-readable provider name
1265    pub provider_label: String,
1266    /// Whether the provider's API key is configured
1267    pub configured: bool,
1268    /// Available models for this provider
1269    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    /// fal.ai sync invocation root. The full URL is
1300    /// `https://fal.run/<model_id>`; auth is `Authorization: Key <key>`.
1301    pub fn fal_ai_base_url() -> &'static str {
1302        "https://fal.run"
1303    }
1304
1305    /// Mutable reference to this provider's `api_key` slot, if any.
1306    /// Plain OpenAI returns `None` because it uses an env var directly.
1307    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    /// Mutable reference to this provider's `base_url` slot when it
1323    /// participates in endpoint-secret hydration. Anthropic's
1324    /// `Option<String>` base_url is excluded — it has a default and no
1325    /// endpoint secret. Plain OpenAI has no base_url field.
1326    pub fn base_url_slot_mut(&mut self) -> Option<&mut String> {
1327        match self {
1328            // Azure AI Foundry hydrates a *resource name* (not a URL) into
1329            // this slot from the `AZURE_AI_FOUNDRY_RESOURCE` secret; the URL
1330            // is derived later via `completion_url()` / `tts_url()`.
1331            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    /// Returns the provider type enum for this provider.
1343    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    /// Returns the provider ID string for secret lookup and "provider/model" format.
1361    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    /// The canonical secret-store key under which this provider's API key
1377    /// lives.
1378    ///
1379    /// **Single source of truth.** Every layer that needs to look up or
1380    /// validate the API key MUST go through this method — the gateway
1381    /// (`ProviderClientConfig`), workspace-level resolution
1382    /// (`WorkspaceStore::resolve_model_settings`), and the validator
1383    /// (`required_secret_keys`) all rely on it. The UI's user-facing key list
1384    /// in `default_models.json` is kept in sync with this via a unit test.
1385    pub fn api_key_secret(&self) -> &'static str {
1386        match self {
1387            ModelProvider::OpenAI {} => "OPENAI_API_KEY",
1388            // Custom OpenAI-compatible providers all share the OPENAI_API_KEY
1389            // fallback; per-provider keys are passed inline on the provider
1390            // config, not stored in the secret store under a different name.
1391            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            // AWS Bedrock authenticates with sigv4 — AWS_ACCESS_KEY_ID is the
1397            // primary key the gateway looks up; AWS_SECRET_ACCESS_KEY and
1398            // AWS_REGION are looked up alongside but are not "the" api_key.
1399            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    /// The canonical secret-store key for this provider's endpoint URL, or
1407    /// `None` if the provider has a fixed endpoint baked into the variant.
1408    ///
1409    /// Only providers that require a tenant-specific endpoint (Azure, Bedrock,
1410    /// Vertex) return `Some`; everything else uses a default base URL.
1411    pub fn endpoint_secret(&self) -> Option<&'static str> {
1412        match self {
1413            ModelProvider::AzureOpenAI { .. } => Some("AZURE_OPENAI_ENDPOINT"),
1414            // Holds the Azure resource name, not a URL — see the variant doc.
1415            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    /// OpenAI-compatible API base URL for an Azure AI Foundry resource name.
1428    ///
1429    /// Azure AI Foundry exposes the OpenAI v1 API at
1430    /// `https://<resource>.openai.azure.com/openai/v1` — one endpoint serving
1431    /// chat completions and OpenAI-style audio (TTS/STT). The Foundry
1432    /// *project* endpoint (`*.services.ai.azure.com/api/projects/...`) is the
1433    /// Agents SDK surface and is deliberately not used for model calls.
1434    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    /// Azure AI Foundry resource name, if this is that provider.
1440    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    /// Resolved chat-completions base URL for providers whose endpoint is
1448    /// *derived* from config (Azure AI Foundry) rather than user-supplied.
1449    /// `None` means the caller should fall back to the provider's own
1450    /// `base_url`/default.
1451    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    /// Resolved TTS base URL. Azure AI Foundry serves TTS on the same
1461    /// OpenAI-compatible endpoint as chat today; kept as a separate method so
1462    /// a future non-OpenAI surface (e.g. Azure Speech) changes only here.
1463    pub fn tts_url(&self) -> Option<String> {
1464        self.completion_url()
1465    }
1466
1467    /// Resolved STT base URL — see [`ModelProvider::tts_url`].
1468    pub fn stt_url(&self) -> Option<String> {
1469        self.completion_url()
1470    }
1471
1472    /// Resolved image-generation base URL.
1473    ///
1474    /// Azure AI Foundry exposes image generation on
1475    /// `https://<resource>.services.ai.azure.com/openai/v1` — a different
1476    /// subdomain from chat/TTS (which use `*.openai.azure.com`). The image
1477    /// dispatcher consults this method so a Foundry resource routes to the
1478    /// right host without changing the chat path.
1479    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    /// `(base_url, api_key)` for this provider — call after `hydrate_creds`.
1490    /// `base_url` is the OpenAI-compatible endpoint to probe; used by the
1491    /// `/providers/test` validation flow.
1492    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    /// Returns the required secret keys for this provider — i.e. keys that
1531    /// must resolve via the secret store or environment for the LLM call to
1532    /// succeed. If the provider has an inline `api_key` already configured on
1533    /// the provider variant, no secret lookup is required.
1534    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    /// Returns all provider secret definitions — the built-in basics from
1555    /// `default_models.json` merged with any registered extensions.
1556    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    /// Returns the well-known models grouped by provider — the built-in
1568    /// basics from `default_models.json` merged with registered extensions.
1569    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    /// Get the human-readable name for a provider
1582    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    /// OTel `gen_ai.provider.name` attribute value for this provider.
1598    /// Uses the semantic convention identifiers from the 2025 GenAI spec.
1599    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/// Model settings configuration.
1616/// A `ModelSettings` always has a valid model string.
1617/// Use `Option<ModelSettings>` when no model is configured yet.
1618#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1619pub struct ModelSettings {
1620    pub model: String,
1621    #[serde(flatten)]
1622    pub inner: ModelSettingsInner,
1623}
1624
1625/// Optional/defaultable model parameters. Split from `ModelSettings` so callers
1626/// can construct `ModelSettings { model: "...", ..Default::default() }` easily
1627/// via the `inner` field having `Default`.
1628#[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    /// Additional parameters for the agent, if any.
1645    #[serde(default)]
1646    pub parameters: Option<serde_json::Value>,
1647    /// The format of the response, if specified.
1648    #[serde(default)]
1649    pub response_format: Option<serde_json::Value>,
1650    /// Which OpenAI-family API format to use (auto-detected by default).
1651    /// Only relevant for OpenAI, OpenAI-compatible, and Azure OpenAI providers.
1652    #[serde(default, skip_serializing_if = "is_default_api_format")]
1653    pub api_format: OpenAiApiFormat,
1654}
1655
1656impl ModelSettings {
1657    /// Create a new `ModelSettings` with the given model and default inner settings.
1658    pub fn new(model: impl Into<String>) -> Self {
1659        Self {
1660            model: model.into(),
1661            inner: ModelSettingsInner::default(),
1662        }
1663    }
1664
1665    /// Fill empty `api_key` and `base_url` fields on this provider by
1666    /// looking up the canonical secret keys
1667    /// ([`ModelProvider::api_key_secret`] /
1668    /// [`ModelProvider::endpoint_secret`]) in the workspace secret
1669    /// store. **Single source of truth** for provider credential
1670    /// resolution — both the workspace's default-model resolution and
1671    /// the orchestrator's per-task agent resolution call this so any
1672    /// provider-pinned ModelSettings (workspace default OR agent
1673    /// override) gets the same hydration treatment.
1674    ///
1675    /// Errors if a required endpoint secret is missing
1676    /// (`AzureOpenAI` / `AzureAiFoundry` / `AwsBedrock` /
1677    /// `GoogleVertex` all need a tenant-specific endpoint that has no
1678    /// safe default — silently dropping it produces an unparseable
1679    /// base URL and a downstream client panic). Missing api_keys
1680    /// downgrade to a warning since some providers can fall back to
1681    /// other auth (env vars, IAM roles, etc.).
1682    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        // api_key — fill if the slot is currently None.
1691        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        // base_url — fill if the slot is currently empty AND the
1711        // provider exposes an endpoint_secret. Hard-error for
1712        // endpoint-required providers when the secret is missing.
1713        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    /// Parse a "provider/model" string (e.g. "anthropic/claude-sonnet-4") into ModelSettings.
1742    /// Returns None if the format is invalid.
1743    /// For custom providers (prefixed with "custom_"), returns an OpenAICompatible provider
1744    /// with empty base_url/api_key — the caller must fill these from secrets/config.
1745    /// Parse "provider/model" string into ModelSettings.
1746    /// Returns Err with a descriptive message if the provider is not recognized.
1747    /// Returns Ok(None) if the input is empty or has no slash.
1748    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            // Unknown provider — error out instead of silently falling
1795            // through to OpenAI-compatible. The previous behaviour
1796            // (silent fallback) caused agent definitions like
1797            // `model = "azure_foundry/gpt-5.4"` to typo'd → resolve to
1798            // a generic OpenAI-compatible client and silently load the
1799            // workspace's default provider's credentials, with no
1800            // signal to the caller that their agent's model pin was
1801            // ignored.
1802            _ => {
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    /// Merge base (workspace) model settings with agent/request-level overrides.
1823    ///
1824    /// Provider resolution:
1825    /// - If the override explicitly sets a provider (not the default OpenAI),
1826    ///   the override's provider and model are used.
1827    /// - If only the base has a non-default provider and the override uses default
1828    ///   OpenAI, the base's provider AND model win — the override's bare model name
1829    ///   is ignored because it may not exist on the base provider.
1830    /// - Otherwise, the override's model wins if non-empty.
1831    ///
1832    /// Scalar fields (temperature, max_tokens, etc.) use override if present, else base.
1833    ///
1834    /// Returns `None` if the final model string is empty.
1835    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            // Override explicitly set a provider — use override's provider and model.
1845            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            // Base uses a non-default provider and override didn't specify one — use
1853            // base's provider AND model to avoid mismatching model names.
1854            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            // Both use default OpenAI — override model can win.
1864            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
1920// Default functions
1921pub 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 // Default limit for general use - agents can override with higher values as needed
1931}
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        // Basic validation - can be expanded
1940        if self.name.is_empty() {
1941            return Err(anyhow::anyhow!("Agent name cannot be empty"));
1942        }
1943
1944        // Validate reflection configuration
1945        if let Some(ref reflection) = self.reflection
1946            && reflection.enabled
1947        {
1948            // If a custom reflection_agent is specified, validate the name
1949            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    /// Validate that a reflection agent definition has the "reflect" tool configured.
1962    /// This is called at registration time when we have access to the full agent config.
1963    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            // The built-in reflection_agent gets the reflect tool automatically,
1972            // but custom reflection agents must explicitly list it
1973            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    /// Create a simple configuration with just built-in tools
2004    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    /// Create a configuration that includes all tools from an MCP server
2012    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    /// Create a configuration with specific MCP tool patterns
2024    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    // Split by --- to separate TOML frontmatter from markdown content
2038    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    // Parse TOML frontmatter (parts[1] is between the first two --- markers)
2048    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    // Validate agent name format using centralized validation
2053    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    // Validate that agent name characters are valid (alphanumeric, underscore, or single '/' for namespacing)
2061    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    // Extract markdown instructions (everything after the second ---)
2079    let instructions = parts[2..].join("---").trim().to_string();
2080
2081    // Set the instructions in the agent definition
2082    agent_def.instructions = instructions;
2083
2084    // Resolve `provider/model` prefix on `model_settings.model`. When the
2085    // agent author writes `model = "azure_ai_foundry/gpt-5.4"` we need
2086    // to (a) split the prefix, (b) set `provider` explicitly so
2087    // ModelSettings::merge() doesn't fall back to the workspace default
2088    // provider, and (c) rewrite `model` to just the bare model name.
2089    // Unknown prefixes error out — the caller is making a clearly
2090    // invalid claim ("dispatch to provider X") that must not silently
2091    // fall back to "use whatever the workspace default is".
2092    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
2110/// Validate plugin name follows naming conventions
2111/// Plugin names must be valid identifiers. At most one '/' is allowed for workspace namespacing (e.g. 'workspace/agent').
2112pub 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    // Validate each segment (split by optional slash)
2133    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        // serde default uses default_compaction_enabled() -> true
2172        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        // A user authors a minimal agent with only `name`. Parsing then
2251        // re-serializing must not bake in `version`, `history_size`,
2252        // `description`, `tool_format`, `tool_delivery_mode`, `sub_agents`,
2253        // or `icon_url` — those are runtime-resolved defaults, not
2254        // user-provided values.
2255        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        // Conversely, fields the user *does* set must round-trip intact.
2278        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        // Explicit Responses overrides auto-detect even for non-codex models
2380        assert_eq!(
2381            OpenAiApiFormat::Responses.resolve("gpt-4o"),
2382            ResolvedOpenAiApiFormat::Responses
2383        );
2384        // Explicit Completions overrides auto-detect even for codex models
2385        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    // ── ToolDeliveryMode tests ────────────────────────────────────
2437
2438    #[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        // Old configs that used "all_tools" should deserialize to Full
2447        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        // Old configs that used "tool_search" should deserialize to Deferred
2455        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        // Even with many tools, Full stays Full
2487        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        // Deferred always stays Deferred regardless of count
2498        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        // Over threshold: stays Deferred
2512        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    // ── ModelSettings::merge tests ──────────────────────────────────────────
2532
2533    #[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        // Agent has no explicit provider (default OpenAI) but different model
2620        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, // no override
2691                ..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)); // base value preserved
2699        assert_eq!(result.inner.top_p, Some(0.9)); // base value preserved
2700    }
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
2708                ..Default::default()
2709            },
2710        };
2711        let agent = ModelSettings {
2712            model: "gpt-4.1-mini".into(),
2713            inner: ModelSettingsInner {
2714                context_size: 100000, // explicitly set
2715                ..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 — should use base
2736                ..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"); // workspace model wins
2760        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        // TTS rides the same OpenAI-compatible endpoint today.
2784        assert_eq!(p.tts_url(), p.completion_url());
2785        // An empty resource yields no URL — the caller must hydrate it first.
2786        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    /// Lock the canonical API-key secret name for every provider variant.
2884    /// Three layers depend on this: gateway (`provider_config.rs`),
2885    /// validator (`required_secret_keys`), and workspace resolution
2886    /// (`cloud::WorkspaceStore::resolve_model_settings`). They all flow
2887    /// through `ModelProvider::api_key_secret()` — keep this list in sync
2888    /// with `default_models.json` (asserted in
2889    /// `test_api_key_secret_matches_default_models_json`).
2890    #[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    /// `default_models.json` drives the UI's secret editor (via
2968    /// `/v1/providers`). For every built-in provider listed there, the first
2969    /// `*_API_KEY` entry MUST equal what `ModelProvider::api_key_secret()`
2970    /// returns — otherwise the UI will tell users to enter a secret name
2971    /// that the backend won't look up. This test catches drift.
2972    ///
2973    /// Only the OSS basics live in `default_models.json`; bespoke providers
2974    /// (Azure AI Foundry, Bedrock, …) ship as deployment extensions and are
2975    /// covered by `test_api_key_secret` above plus the cloud's config test.
2976    #[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    /// Layer 2 (extensions) overrides built-ins by `id` and appends new ones.
3035    #[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    /// `register_provider_extensions` accepts `ModelProviderDefinition` and
3062    /// backfills an empty model `name` from `id`.
3063    #[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}