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/// Per-agent auto-compaction configuration. Wired into the agent loop's
440/// pre-plan compaction trigger (see `context.evaluate_compaction()`).
441#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
442pub struct CompactionConfig {
443    /// Fraction of context window at which auto-compact fires. 0.0–1.0.
444    /// None = use built-in `ContextSizeManager` thresholds.
445    #[serde(default, skip_serializing_if = "Option::is_none")]
446    pub auto_threshold: Option<f32>,
447    /// Floor on entries kept untouched at the tail of the conversation
448    /// (so the model still sees the most recent turn verbatim). None = 4.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub keep_recent_entries: Option<usize>,
451    /// Compactor prompt variant id (for eval). None = default prompt.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub prompt_version: Option<String>,
454}
455
456/// Agent definition - complete configuration for an agent
457#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
458pub struct StandardDefinition {
459    /// The name of the agent.
460    pub name: String,
461    /// A brief description of the agent's purpose.
462    #[serde(default, skip_serializing_if = "String::is_empty")]
463    pub description: String,
464
465    /// The version of the agent. Runtime falls back to `default_agent_version()`.
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub version: Option<String>,
468
469    /// Instructions for the agent - serves as an introduction defining what the agent is and does.
470    #[serde(default, skip_serializing_if = "String::is_empty")]
471    pub instructions: String,
472
473    /// Settings related to the model used by the agent.
474    /// When `None`, the agent inherits model settings from the orchestrator context defaults.
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub model_settings: Option<ModelSettings>,
477    /// Optional lower-level model settings for lightweight analysis helpers
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub analysis_model_settings: Option<ModelSettings>,
480
481    /// The size of the history to maintain for the agent. Runtime falls back to `default_history_size()`.
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub history_size: Option<usize>,
484    /// The new strategy configuration for the agent.
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub strategy: Option<AgentStrategy>,
487    /// A2A-specific fields
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub icon_url: Option<String>,
490
491    /// Channel slash commands this agent exposes. Each command is a preset
492    /// prompt — invoking it on a bot sends `prompt` to the agent. Compiled
493    /// into the gateway's `CommandRouter` alongside a `WorkflowAgent`'s
494    /// entry-point commands.
495    #[serde(default, skip_serializing_if = "Vec::is_empty")]
496    pub commands: Vec<crate::channel_commands::SlashCommand>,
497
498    #[serde(default, skip_serializing_if = "Option::is_none")]
499    pub max_iterations: Option<usize>,
500
501    /// A2A agent card skills metadata (describes capabilities for agent-to-agent protocol)
502    #[serde(default, skip_serializing_if = "Vec::is_empty")]
503    pub skills_description: Vec<AgentSkill>,
504
505    /// Skills available for on-demand loading by this agent
506    #[serde(default, skip_serializing_if = "Vec::is_empty")]
507    pub available_skills: Vec<AvailableSkill>,
508
509    /// Connections this agent needs to function. Resolved at run start from the
510    /// workspace's connections: OAuth tokens / custom secrets / distri-native
511    /// sessions are injected into `ExecutorContext.env_vars` and surfaced to
512    /// the model via the `{{> connections}}` partial. Agents without declared
513    /// connections get neither env vars nor the partial.
514    #[serde(default, skip_serializing_if = "Vec::is_empty")]
515    pub connections: Vec<crate::connections::ConnectionRequirement>,
516
517    /// List of sub-agents that this agent can transfer control to
518    #[serde(default, skip_serializing_if = "Vec::is_empty")]
519    pub sub_agents: Vec<String>,
520
521    /// Tool calling configuration
522    #[serde(default, skip_serializing_if = "is_default_tool_format")]
523    pub tool_format: ToolCallFormat,
524
525    /// How tools are delivered to the LLM (all upfront vs on-demand search)
526    #[serde(default, skip_serializing_if = "is_default_tool_delivery_mode")]
527    pub tool_delivery_mode: ToolDeliveryMode,
528
529    /// Tools configuration for this agent
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub tools: Option<ToolsConfig>,
532
533    /// Custom handlebars partials (name -> template path) for use in custom prompts
534    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
535    pub partials: std::collections::HashMap<String, String>,
536
537    /// Reflection configuration for post-execution analysis using a subagent
538    #[serde(default, skip_serializing_if = "Option::is_none")]
539    pub reflection: Option<ReflectionConfig>,
540    /// Whether to enable TODO management functionality
541    #[serde(default, skip_serializing_if = "Option::is_none")]
542    pub enable_todos: Option<bool>,
543
544    /// Browser configuration for this agent (enables shared Chromium automation)
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub browser_config: Option<BrowserAgentConfig>,
547
548    /// Whether to include shell/code execution tools (start_shell, execute_shell, stop_shell)
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub include_shell: Option<bool>,
551
552    /// Context size override for this agent (overrides model_settings.context_size)
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub context_size: Option<u32>,
555
556    /// Strategy for prompt construction (append default template vs fully custom)
557    #[serde(
558        skip_serializing_if = "Option::is_none",
559        default = "default_append_default_instructions"
560    )]
561    pub append_default_instructions: Option<bool>,
562    /// Whether to include the built-in scratchpad/history in prompts (default: true)
563    #[serde(
564        skip_serializing_if = "Option::is_none",
565        default = "default_include_scratchpad"
566    )]
567    pub include_scratchpad: Option<bool>,
568
569    /// Optional hook names to attach to this agent
570    #[serde(default, skip_serializing_if = "Vec::is_empty")]
571    pub hooks: Vec<String>,
572
573    /// Custom user message construction (dynamic prompting)
574    #[serde(default, skip_serializing_if = "Option::is_none")]
575    pub user_message_overrides: Option<UserMessageOverrides>,
576
577    /// Whether context compaction is enabled for this agent (default: true)
578    #[serde(
579        default = "default_compaction_enabled",
580        skip_serializing_if = "is_true"
581    )]
582    pub compaction_enabled: bool,
583
584    /// Auto-compaction settings. None = use defaults (auto_threshold=0.75,
585    /// keep_recent_entries=4). See `CompactionConfig` for details.
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub compaction: Option<CompactionConfig>,
588
589    /// Runtime constraint for this agent. Like Docker's `platforms` field:
590    ///
591    /// - empty / omitted → runs in any runtime (default).
592    /// - `["cli"]` → only runs when `ExecutorContext.runtime_mode == Cli`,
593    ///   OR via a `RemoteTaskRunner` providing `Cli` (e.g. `SandboxLauncher`
594    ///   spawning `distri-cli` inside a browsr container).
595    /// - `["cli", "cloud"]` → runs in either Cli or Cloud, but not Browser.
596    ///
597    /// When the current runtime doesn't match any allowed value and no
598    /// compatible runner exists, the orchestrator fails fast at request entry.
599    ///
600    /// Accepts both scalar (`runtime = "cli"`) and array (`runtime = ["cli"]`)
601    /// syntax in TOML/JSON for ergonomics.
602    #[serde(
603        default,
604        deserialize_with = "deserialize_runtime_modes",
605        skip_serializing_if = "Vec::is_empty"
606    )]
607    pub runtime: Vec<RuntimeMode>,
608}
609
610/// Accept either a single `RuntimeMode` string or an array of them.
611fn deserialize_runtime_modes<'de, D>(deserializer: D) -> Result<Vec<RuntimeMode>, D::Error>
612where
613    D: serde::Deserializer<'de>,
614{
615    use serde::de::{self, Deserialize};
616
617    #[derive(Deserialize)]
618    #[serde(untagged)]
619    enum OneOrMany {
620        One(RuntimeMode),
621        Many(Vec<RuntimeMode>),
622    }
623
624    match Option::<OneOrMany>::deserialize(deserializer)? {
625        None => Ok(Vec::new()),
626        Some(OneOrMany::One(rt)) => Ok(vec![rt]),
627        Some(OneOrMany::Many(v)) => {
628            // Reject duplicates so authors notice typos like ["cli", "cli"].
629            let mut seen = std::collections::HashSet::new();
630            for rt in &v {
631                let key = format!("{:?}", rt);
632                if !seen.insert(key) {
633                    return Err(de::Error::custom(format!(
634                        "duplicate runtime entry: {:?}",
635                        rt
636                    )));
637                }
638            }
639            Ok(v)
640        }
641    }
642}
643fn default_append_default_instructions() -> Option<bool> {
644    Some(true)
645}
646fn default_include_scratchpad() -> Option<bool> {
647    Some(true)
648}
649fn default_compaction_enabled() -> bool {
650    true
651}
652fn is_true(v: &bool) -> bool {
653    *v
654}
655fn is_default_tool_format(v: &ToolCallFormat) -> bool {
656    *v == ToolCallFormat::default()
657}
658fn is_default_tool_delivery_mode(v: &ToolDeliveryMode) -> bool {
659    *v == ToolDeliveryMode::default()
660}
661impl StandardDefinition {
662    /// The set of runtimes this agent is allowed to run in.
663    ///
664    /// Empty result = no constraint = runs anywhere.
665    pub fn allowed_runtimes(&self) -> Vec<RuntimeMode> {
666        self.runtime.clone()
667    }
668
669    /// Whether this agent can execute given the caller's `current` runtime,
670    /// optionally with a `RemoteTaskRunner` providing an alternative runtime
671    /// via remote dispatch.
672    ///
673    /// Returns true when:
674    /// - the agent has no runtime constraint, OR
675    /// - the current runtime matches one of the allowed runtimes, OR
676    /// - a runner is available whose `provided_runtime` matches one of the
677    ///   allowed runtimes.
678    pub fn is_runnable_in(
679        &self,
680        current: &RuntimeMode,
681        runner_provides: Option<&RuntimeMode>,
682    ) -> bool {
683        let allowed = self.allowed_runtimes();
684        if allowed.is_empty() {
685            return true;
686        }
687        if allowed.iter().any(|rt| rt == current) {
688            return true;
689        }
690        match runner_provides {
691            Some(p) => allowed.iter().any(|rt| rt == p),
692            None => false,
693        }
694    }
695
696    /// Check if browser should be initialized automatically in orchestrator (default: false)
697    pub fn should_use_browser(&self) -> bool {
698        self.browser_config
699            .as_ref()
700            .map(|cfg| cfg.is_enabled())
701            .unwrap_or(false)
702    }
703
704    /// Returns browser config if defined
705    pub fn browser_settings(&self) -> Option<&BrowserAgentConfig> {
706        self.browser_config.as_ref()
707    }
708
709    /// Returns the runtime Chromium driver configuration if enabled
710    pub fn browser_runtime_config(&self) -> Option<BrowsrClientConfig> {
711        self.browser_config.as_ref().map(|cfg| cfg.runtime_config())
712    }
713
714    /// Should browser session state be serialized after tool runs
715    pub fn should_persist_browser_session(&self) -> bool {
716        self.browser_config
717            .as_ref()
718            .map(|cfg| cfg.should_persist_session())
719            .unwrap_or(false)
720    }
721
722    /// Check if reflection is enabled (default: false)
723    pub fn is_reflection_enabled(&self) -> bool {
724        self.reflection.as_ref().map(|r| r.enabled).unwrap_or(false)
725    }
726
727    /// Get the reflection configuration, if any
728    pub fn reflection_config(&self) -> Option<&ReflectionConfig> {
729        self.reflection.as_ref().filter(|r| r.enabled)
730    }
731    /// Check if TODO management functionality is enabled (default: false)
732    pub fn is_todos_enabled(&self) -> bool {
733        self.enable_todos.unwrap_or(false)
734    }
735
736    /// Check if shell/code execution tools should be included (default: false)
737    pub fn should_include_shell(&self) -> bool {
738        self.include_shell.unwrap_or(false)
739    }
740
741    /// Get model settings if configured.
742    pub fn model_settings(&self) -> Option<&ModelSettings> {
743        self.model_settings.as_ref()
744    }
745
746    /// Get a mutable reference to model settings, if present.
747    pub fn model_settings_mut(&mut self) -> Option<&mut ModelSettings> {
748        self.model_settings.as_mut()
749    }
750
751    /// Get the effective context size: agent-level override → model
752    /// settings override → the model catalog's advertised window for the
753    /// resolved (provider, model) → the conservative 20k fallback.
754    pub fn get_effective_context_size(&self) -> u32 {
755        self.context_size.filter(|&s| s > 0).unwrap_or_else(|| {
756            self.model_settings()
757                .map(|ms| ms.effective_context_size())
758                .unwrap_or_else(default_context_size)
759        })
760    }
761
762    /// Model settings to use for lightweight browser analysis helpers (e.g., observe_summary commands)
763    pub fn analysis_model_settings_config(&self) -> Option<&ModelSettings> {
764        self.analysis_model_settings
765            .as_ref()
766            .or_else(|| self.model_settings())
767    }
768
769    /// Whether to include the persistent scratchpad/history in prompts
770    pub fn include_scratchpad(&self) -> bool {
771        self.include_scratchpad.unwrap_or(true)
772    }
773
774    /// Apply definition overrides to this agent definition
775    pub fn apply_overrides(&mut self, overrides: DefinitionOverrides) {
776        // Override model settings (only if model_settings already exists)
777        if let Some(ref mut ms) = self.model_settings {
778            if let Some(model) = overrides.model {
779                // Strip provider prefix if present (e.g. "custom_microsoft_foundry/gpt-5.4" → "gpt-5.4")
780                ms.model = model
781                    .split_once('/')
782                    .map(|(_, m)| m.to_string())
783                    .unwrap_or(model);
784            }
785            if let Some(temperature) = overrides.temperature {
786                ms.inner.temperature = Some(temperature);
787            }
788            if let Some(max_tokens) = overrides.max_tokens {
789                ms.inner.max_tokens = Some(max_tokens);
790            }
791        }
792
793        // Override max_iterations
794        if let Some(max_iterations) = overrides.max_iterations {
795            self.max_iterations = Some(max_iterations);
796        }
797
798        // Override instructions
799        if let Some(instructions) = overrides.instructions {
800            self.instructions = instructions;
801        }
802
803        // Append to instructions (for `invoke_agent({system: ...})` —
804        // keeps the agent's base scaffolding and adds the caller's text
805        // below it, separated by a blank line).
806        if let Some(suffix) = overrides.instructions_append {
807            if self.instructions.is_empty() {
808                self.instructions = suffix;
809            } else {
810                self.instructions.push_str("\n\n");
811                self.instructions.push_str(&suffix);
812            }
813        }
814
815        if let Some(runtime) = overrides.runtime {
816            self.runtime = runtime;
817        }
818
819        if let Some(description) = overrides.description {
820            self.description = description;
821        }
822
823        if let Some(name) = overrides.name {
824            self.name = name;
825        }
826
827        if let Some(sub_agents) = overrides.sub_agents {
828            self.sub_agents = sub_agents;
829        }
830
831        if let Some(tools_override) = overrides.tools {
832            self.tools = Some(tools_override);
833        }
834
835        if let Some(use_browser) = overrides.use_browser {
836            let mut config = self.browser_config.clone().unwrap_or_default();
837            config.enabled = use_browser;
838            self.browser_config = Some(config);
839        }
840
841        // Append dynamic tool factories
842        if let Some(dynamic_tools) = overrides.dynamic_tools {
843            let tools = self.tools.get_or_insert_with(ToolsConfig::default);
844            tools.dynamic.extend(dynamic_tools);
845        }
846    }
847}
848
849/// Tools configuration for agents
850/// Canonical list of valid builtin tool names.
851///
852/// Includes both server-executed tools (search, start_shell, etc.) and
853/// client-executed tools (http_request). Agent definitions reference these
854/// by name in `tools.builtin = [...]`.
855pub const VALID_BUILTIN_TOOLS: &[&str] = &[
856    // Agent control
857    "final",
858    "reflect",
859    // Sub-agent dispatch (typed Invocation; replaces call_agent + run_skill).
860    "invoke_agent",
861    // Supervisor tools — query / wait / cancel / list children.
862    "get_task",
863    "wait_task",
864    "cancel_task",
865    "list_my_tasks",
866    // Browser & scraping
867    "browsr_scrape",
868    "browsr_browser",
869    "browsr_crawl",
870    "browser_step",
871    "search",
872    // Shell
873    "start_shell",
874    "execute_shell",
875    "stop_shell",
876    // Code execution
877    "distri_execute_code",
878    // Tool discovery
879    "tool_search",
880    // Skills (load body into current agent context; sub-agents call this
881    // themselves after being dispatched via invoke_agent).
882    "load_skill",
883    // Connection & secrets
884    "inject_connection_env",
885    // Logging
886    "console_log",
887    // Artifacts & filesystem
888    "artifact_tool",
889    // Todos — the tool's `Tool::get_name()` returns `write_todos`, so
890    // any agent definition that declared `builtin = ["todos"]` was
891    // silently broken (`resolve_tools_config` filters
892    // `get_builtin_tools()` by name and finds nothing). Single
893    // canonical name across validation / dispatch / skill bodies.
894    "write_todos",
895];
896
897/// Tools that always get full schemas, never deferred.
898/// These are the most commonly used tools that agents need immediately.
899pub const CORE_TOOLS: &[&str] = &[
900    "final",
901    "invoke_agent",
902    "tool_search",
903    "write_todos",
904    "execute_shell",
905    "start_shell",
906    "load_skill",
907];
908
909/// Default threshold: defer tools when total count exceeds this.
910pub const DEFAULT_DEFERRED_THRESHOLD: usize = 15;
911
912#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
913#[serde(deny_unknown_fields)]
914pub struct ToolsConfig {
915    /// Built-in tools to include (e.g., ["final", "call_agent"])
916    #[serde(default, skip_serializing_if = "Vec::is_empty")]
917    pub builtin: Vec<String>,
918
919    /// Dynamic tool factories — each creates a named tool at runtime.
920    #[serde(default, skip_serializing_if = "Vec::is_empty")]
921    pub dynamic: Vec<crate::dynamic_tool::DynamicToolFactory>,
922
923    /// MCP server tool configurations
924    #[serde(default, skip_serializing_if = "Vec::is_empty")]
925    pub mcp: Vec<McpToolConfig>,
926
927    /// External tools to include from client
928    #[serde(default, skip_serializing_if = "Option::is_none")]
929    pub external: Option<Vec<String>>,
930
931    /// How tools are delivered to the model. Defaults to `Full`.
932    /// When set to `Deferred`, only core tools get full schemas;
933    /// others appear as name+description and must be fetched via `tool_search`.
934    #[serde(default, skip_serializing_if = "is_default_delivery_mode")]
935    pub delivery_mode: ToolDeliveryMode,
936
937    /// Tool count threshold for automatic deferral.
938    /// When `delivery_mode` is `Deferred` and total tools exceed this,
939    /// non-core tools are deferred. Default: 15.
940    #[serde(default, skip_serializing_if = "Option::is_none")]
941    pub deferred_threshold: Option<usize>,
942
943    /// Additional tool names to always include with full schemas (beyond CORE_TOOLS).
944    /// Useful for agent-specific tools that should never be deferred.
945    #[serde(default, skip_serializing_if = "Vec::is_empty")]
946    pub always_full_schema: Vec<String>,
947}
948
949fn is_default_delivery_mode(mode: &ToolDeliveryMode) -> bool {
950    *mode == ToolDeliveryMode::Deferred
951}
952
953impl ToolsConfig {
954    /// Validate that all builtin tool names are recognized.
955    /// Returns a list of invalid tool names, or empty if all are valid.
956    pub fn invalid_builtin_tools(&self) -> Vec<String> {
957        self.builtin
958            .iter()
959            .filter(|name| !VALID_BUILTIN_TOOLS.contains(&name.as_str()))
960            .cloned()
961            .collect()
962    }
963
964    /// Whether a tool should always get a full schema (never deferred).
965    pub fn is_core_tool(&self, name: &str) -> bool {
966        CORE_TOOLS.contains(&name) || self.always_full_schema.iter().any(|n| n == name)
967    }
968
969    /// Effective threshold for automatic tool deferral.
970    pub fn effective_threshold(&self) -> usize {
971        self.deferred_threshold
972            .unwrap_or(DEFAULT_DEFERRED_THRESHOLD)
973    }
974
975    /// Determine the effective delivery mode given the total tool count.
976    /// If mode is `Full` but tool count exceeds threshold, stays `Full`
977    /// Deferred always stays Deferred — context efficiency is the default.
978    pub fn effective_delivery_mode(&self, _total_tools: usize) -> ToolDeliveryMode {
979        self.delivery_mode.clone()
980    }
981}
982
983// Where filesystem and artifact tools should execute.
984// Deprecated: filesystem tools are no longer included as server builtins.
985
986/// Configuration for tools from an MCP server
987#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
988#[serde(deny_unknown_fields)]
989pub struct McpToolConfig {
990    /// Name of the MCP server
991    pub server: String,
992
993    /// Include patterns (glob-style, e.g., ["fetch_*", "extract_text"])
994    /// Use ["*"] to include all tools from the server
995    #[serde(default, skip_serializing_if = "Vec::is_empty")]
996    pub include: Vec<String>,
997
998    /// Exclude patterns (glob-style, e.g., ["delete_*", "rm_*"])
999    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1000    pub exclude: Vec<String>,
1001}
1002
1003#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1004#[serde(deny_unknown_fields)]
1005pub struct McpDefinition {
1006    /// The filter applied to the tools in this MCP definition.
1007    #[serde(default)]
1008    pub filter: Option<Vec<String>>,
1009    /// The name of the MCP server.
1010    pub name: String,
1011    /// The type of the MCP server (Tool or Agent).
1012    #[serde(default)]
1013    pub r#type: McpServerType,
1014    /// Authentication configuration for this MCP server.
1015    #[serde(default)]
1016    pub auth_config: Option<crate::a2a::SecurityScheme>,
1017}
1018
1019#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
1020#[serde(rename_all = "lowercase")]
1021pub enum McpServerType {
1022    #[default]
1023    Tool,
1024    Agent,
1025}
1026
1027#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1028#[serde(deny_unknown_fields, rename_all = "lowercase", tag = "name")]
1029pub enum ModelProvider {
1030    #[serde(rename = "openai")]
1031    OpenAI {},
1032    #[serde(rename = "openai_compat")]
1033    OpenAICompatible {
1034        base_url: String,
1035        api_key: Option<String>,
1036        project_id: Option<String>,
1037    },
1038    #[serde(rename = "azure_openai")]
1039    AzureOpenAI {
1040        base_url: String,
1041        api_key: Option<String>,
1042        deployment: String,
1043        #[serde(default = "ModelProvider::azure_api_version")]
1044        api_version: String,
1045    },
1046    #[serde(rename = "anthropic")]
1047    Anthropic {
1048        #[serde(default = "ModelProvider::anthropic_base_url")]
1049        base_url: Option<String>,
1050        api_key: Option<String>,
1051    },
1052    /// Z.ai GLM coding plan — an Anthropic-compatible Messages API behind a
1053    /// subscription token (a "coding plan"). Same wire format as `Anthropic`
1054    /// (routed through `ClaudeLLMExecutor`), but a distinct identity + secret
1055    /// (`ZAI_API_KEY`) so it never collides with a real Anthropic key.
1056    #[serde(rename = "zai")]
1057    ZAi {
1058        #[serde(default = "ModelProvider::zai_base_url")]
1059        base_url: String,
1060        api_key: Option<String>,
1061    },
1062    #[serde(rename = "gemini")]
1063    Gemini {
1064        #[serde(default = "ModelProvider::gemini_base_url")]
1065        base_url: String,
1066        api_key: Option<String>,
1067    },
1068    #[serde(rename = "azure_ai_foundry")]
1069    AzureAiFoundry {
1070        /// Azure resource name (e.g. `distri-tts-resource`), **not** a URL.
1071        /// Every endpoint URL is derived from this — see
1072        /// [`ModelProvider::completion_url`] / [`ModelProvider::tts_url`].
1073        resource: String,
1074        api_key: Option<String>,
1075    },
1076    #[serde(rename = "aws_bedrock")]
1077    AwsBedrock {
1078        base_url: String,
1079        api_key: Option<String>,
1080    },
1081    #[serde(rename = "google_vertex")]
1082    GoogleVertex {
1083        base_url: String,
1084        api_key: Option<String>,
1085        project_id: Option<String>,
1086    },
1087    #[serde(rename = "alibaba_cloud")]
1088    AlibabaCloud {
1089        #[serde(default = "ModelProvider::alibaba_cloud_base_url")]
1090        base_url: String,
1091        api_key: Option<String>,
1092    },
1093    /// fal.ai — image-generation provider. The model id is the fal endpoint
1094    /// path (e.g. `fal-ai/flux/dev`); the gateway POSTs to
1095    /// `https://fal.run/<model_id>` with `Authorization: Key <api_key>`.
1096    #[serde(rename = "fal_ai")]
1097    FalAi { api_key: Option<String> },
1098}
1099/// Defines the secret requirements for a provider
1100#[derive(Debug, Clone, Serialize, Deserialize)]
1101pub struct ProviderSecretDefinition {
1102    /// Provider identifier (e.g., "openai", "anthropic")
1103    pub id: String,
1104    /// Human-readable label
1105    pub label: String,
1106    /// List of required secret keys with metadata
1107    pub keys: Vec<SecretKeyDefinition>,
1108    /// Coarse UI grouping, e.g. `"coding_plan"` for Z.ai. `None` for ordinary
1109    /// API-key providers.
1110    #[serde(default, skip_serializing_if = "Option::is_none")]
1111    pub category: Option<String>,
1112}
1113
1114/// Defines a single secret key requirement
1115#[derive(Debug, Clone, Serialize, Deserialize)]
1116pub struct SecretKeyDefinition {
1117    /// The environment variable / secret store key (e.g., "OPENAI_API_KEY")
1118    pub key: String,
1119    /// Human-readable label
1120    pub label: String,
1121    /// Placeholder for UI input
1122    #[serde(default)]
1123    pub placeholder: String,
1124    /// Whether this secret is required (vs optional)
1125    #[serde(default = "default_required")]
1126    pub required: bool,
1127    /// Whether this field contains sensitive data (masked in UI, stored encrypted).
1128    /// Defaults to true. Set to false for non-sensitive config like URLs, project IDs.
1129    #[serde(default = "default_sensitive")]
1130    pub sensitive: bool,
1131    /// When set, the UI renders this field as a resource segment embedded in
1132    /// the URL template (`{}` marks the editable segment), showing the full
1133    /// endpoint read-only around it. Azure AI Foundry uses this: the user
1134    /// edits only the resource name and that is all we store.
1135    #[serde(default, skip_serializing_if = "Option::is_none")]
1136    pub url_template: Option<String>,
1137}
1138
1139fn default_required() -> bool {
1140    true
1141}
1142
1143fn default_sensitive() -> bool {
1144    true
1145}
1146
1147/// A model entry within a provider
1148#[derive(Debug, Clone, Serialize, Deserialize)]
1149pub struct ModelInfo {
1150    /// Model identifier (e.g., "gpt-4o", "claude-sonnet-4")
1151    pub id: String,
1152    /// Human-readable name
1153    pub name: String,
1154}
1155
1156/// Combined provider definition used in default_models.json.
1157/// Merges secret key definitions and well-known models into one entry per provider.
1158#[derive(Debug, Clone, Serialize, Deserialize)]
1159struct DefaultProviderEntry {
1160    id: String,
1161    label: String,
1162    keys: Vec<SecretKeyDefinition>,
1163    models: Vec<crate::models::Model>,
1164    /// Optional per-provider override of `/v1/providers/test`.
1165    #[serde(default, skip_serializing_if = "Option::is_none")]
1166    test: Option<crate::models::ProviderTestConfig>,
1167    /// Coarse grouping for the UI, e.g. `"coding_plan"` for Z.ai.
1168    #[serde(default, skip_serializing_if = "Option::is_none")]
1169    category: Option<String>,
1170}
1171
1172#[derive(Debug, Clone, Serialize, Deserialize)]
1173struct DefaultModelsFile {
1174    providers: Vec<DefaultProviderEntry>,
1175}
1176
1177fn load_default_providers() -> &'static [DefaultProviderEntry] {
1178    use std::sync::OnceLock;
1179    static PROVIDERS: OnceLock<Vec<DefaultProviderEntry>> = OnceLock::new();
1180    PROVIDERS.get_or_init(|| {
1181        let json = include_str!("default_models.json");
1182        let file: DefaultModelsFile =
1183            serde_json::from_str(json).expect("Failed to parse default_models.json");
1184        file.providers
1185    })
1186}
1187
1188impl From<crate::models::ProviderKeyDefinition> for SecretKeyDefinition {
1189    fn from(k: crate::models::ProviderKeyDefinition) -> Self {
1190        SecretKeyDefinition {
1191            key: k.key,
1192            label: k.label,
1193            placeholder: k.placeholder,
1194            required: k.required,
1195            sensitive: k.sensitive,
1196            url_template: k.url_template,
1197        }
1198    }
1199}
1200
1201impl From<crate::models::ModelProviderDefinition> for DefaultProviderEntry {
1202    fn from(d: crate::models::ModelProviderDefinition) -> Self {
1203        let models = d
1204            .models
1205            .into_iter()
1206            .map(|mut m| {
1207                // Config sources may omit `name`; backfill it from `id` so
1208                // the catalog never surfaces a blank label.
1209                if m.name.trim().is_empty() {
1210                    m.name = m.id.clone();
1211                }
1212                m
1213            })
1214            .collect();
1215        DefaultProviderEntry {
1216            id: d.id,
1217            label: d.label,
1218            keys: d.keys.into_iter().map(SecretKeyDefinition::from).collect(),
1219            models,
1220            test: d.test,
1221            category: d.category,
1222        }
1223    }
1224}
1225
1226/// Look up the provider-test override for a registered provider. The
1227/// `/v1/providers/test` handler calls this and falls back to its default
1228/// `GET /models` probe when `None` is returned. Reads from the merged
1229/// layered registry (built-in basics + deployment extensions).
1230pub fn lookup_provider_test_config(provider_id: &str) -> Option<crate::models::ProviderTestConfig> {
1231    merged_providers()
1232        .into_iter()
1233        .find(|p| p.id == provider_id)
1234        .and_then(|p| p.test)
1235}
1236
1237/// Provider/model definitions contributed by a deployment, layered on top of
1238/// the built-in basics in `default_models.json`. Populated once at startup —
1239/// the OSS server folds in `distri.yaml`, the cloud folds in its own config
1240/// file. See [`register_provider_extensions`].
1241static PROVIDER_EXTENSIONS: std::sync::OnceLock<Vec<DefaultProviderEntry>> =
1242    std::sync::OnceLock::new();
1243
1244/// Register deployment-owned provider/model definitions — layer 2 of the
1245/// provider registry. Call once, at process startup, before any
1246/// provider/model catalog is served.
1247///
1248/// An extension whose `id` matches a built-in provider overrides it; a new
1249/// `id` is appended. Calling more than once logs a warning and keeps the
1250/// first registration.
1251pub fn register_provider_extensions(extensions: Vec<crate::models::ModelProviderDefinition>) {
1252    let entries: Vec<DefaultProviderEntry> = extensions
1253        .into_iter()
1254        .map(DefaultProviderEntry::from)
1255        .collect();
1256    let count = entries.len();
1257    if PROVIDER_EXTENSIONS.set(entries).is_err() {
1258        tracing::warn!("provider extensions already registered; ignoring {count} new entries");
1259    } else {
1260        tracing::info!("registered {count} provider extension(s)");
1261    }
1262}
1263
1264/// Merge built-in providers with extension providers — extensions override
1265/// built-ins by `id`, new ids are appended. Pure: no global state.
1266fn merge_provider_layers(
1267    builtin: &[DefaultProviderEntry],
1268    extensions: &[DefaultProviderEntry],
1269) -> Vec<DefaultProviderEntry> {
1270    let mut merged: Vec<DefaultProviderEntry> = builtin.to_vec();
1271    for ext in extensions {
1272        match merged.iter_mut().find(|p| p.id == ext.id) {
1273            Some(slot) => *slot = ext.clone(),
1274            None => merged.push(ext.clone()),
1275        }
1276    }
1277    merged
1278}
1279
1280/// Built-in providers plus any registered extensions — the full layered
1281/// registry, lowest-to-highest precedence.
1282fn merged_providers() -> Vec<DefaultProviderEntry> {
1283    let extensions = PROVIDER_EXTENSIONS.get().map(Vec::as_slice).unwrap_or(&[]);
1284    merge_provider_layers(load_default_providers(), extensions)
1285}
1286
1287/// The layered provider registry viewed as a [`crate::models::ModelLookup`].
1288struct RegistryModelLookup;
1289
1290impl crate::models::ModelLookup for RegistryModelLookup {
1291    fn model(&self, provider_id: &str, model_id: &str) -> Option<crate::models::Model> {
1292        merged_providers()
1293            .into_iter()
1294            .find(|p| p.id == provider_id)?
1295            .models
1296            .into_iter()
1297            .find(|m| m.id == model_id)
1298    }
1299
1300    fn find_model(&self, model_id: &str) -> Option<crate::models::Model> {
1301        merged_providers()
1302            .into_iter()
1303            .flat_map(|p| p.models)
1304            .find(|m| m.id == model_id)
1305    }
1306}
1307
1308/// The process-wide model catalog: built-in `default_models.json` layered
1309/// with the extensions a deployment registered at startup. This is how any
1310/// component that knows a `(provider, model)` pair asks for the model's
1311/// metadata — context window, pricing, capability — instead of hardcoding
1312/// per-model constants.
1313pub fn model_lookup() -> &'static dyn crate::models::ModelLookup {
1314    static LOOKUP: RegistryModelLookup = RegistryModelLookup;
1315    &LOOKUP
1316}
1317
1318/// Models grouped by provider, with configuration status
1319#[derive(Debug, Clone, Serialize, Deserialize)]
1320pub struct ProviderModels {
1321    /// Provider identifier
1322    pub provider_id: String,
1323    /// Human-readable provider name
1324    pub provider_label: String,
1325    /// Available models for this provider
1326    pub models: Vec<crate::models::Model>,
1327}
1328
1329/// Provider models with configuration status (returned by API)
1330#[derive(Debug, Clone, Serialize, Deserialize)]
1331pub struct ProviderModelsStatus {
1332    /// Provider identifier
1333    pub provider_id: String,
1334    /// Human-readable provider name
1335    pub provider_label: String,
1336    /// Whether the provider's API key is configured
1337    pub configured: bool,
1338    /// Available models for this provider
1339    pub models: Vec<crate::models::Model>,
1340}
1341
1342impl Default for ModelProvider {
1343    fn default() -> Self {
1344        ModelProvider::OpenAI {}
1345    }
1346}
1347
1348impl ModelProvider {
1349    pub fn openai_base_url() -> String {
1350        "https://api.openai.com/v1".to_string()
1351    }
1352
1353    pub fn anthropic_base_url() -> Option<String> {
1354        None
1355    }
1356
1357    pub fn gemini_base_url() -> String {
1358        "https://generativelanguage.googleapis.com/v1beta/openai".to_string()
1359    }
1360
1361    /// Z.ai's Anthropic-compatible Messages API root (the coding-plan endpoint).
1362    pub fn zai_base_url() -> String {
1363        "https://api.z.ai/api/anthropic".to_string()
1364    }
1365
1366    pub fn azure_api_version() -> String {
1367        "2024-06-01".to_string()
1368    }
1369
1370    pub fn alibaba_cloud_base_url() -> String {
1371        "https://dashscope-intl.aliyuncs.com/compatible-mode/v1".to_string()
1372    }
1373
1374    /// fal.ai sync invocation root. The full URL is
1375    /// `https://fal.run/<model_id>`; auth is `Authorization: Key <key>`.
1376    pub fn fal_ai_base_url() -> &'static str {
1377        "https://fal.run"
1378    }
1379
1380    /// Mutable reference to this provider's `api_key` slot, if any.
1381    /// Plain OpenAI returns `None` because it uses an env var directly.
1382    pub fn api_key_slot_mut(&mut self) -> Option<&mut Option<String>> {
1383        match self {
1384            Self::OpenAI {} => None,
1385            Self::OpenAICompatible { api_key, .. }
1386            | Self::AzureOpenAI { api_key, .. }
1387            | Self::Anthropic { api_key, .. }
1388            | Self::ZAi { api_key, .. }
1389            | Self::Gemini { api_key, .. }
1390            | Self::AzureAiFoundry { api_key, .. }
1391            | Self::AwsBedrock { api_key, .. }
1392            | Self::GoogleVertex { api_key, .. }
1393            | Self::AlibabaCloud { api_key, .. }
1394            | Self::FalAi { api_key } => Some(api_key),
1395        }
1396    }
1397
1398    /// Mutable reference to this provider's `base_url` slot when it
1399    /// participates in endpoint-secret hydration. Anthropic's
1400    /// `Option<String>` base_url is excluded — it has a default and no
1401    /// endpoint secret. Plain OpenAI has no base_url field.
1402    pub fn base_url_slot_mut(&mut self) -> Option<&mut String> {
1403        match self {
1404            // Azure AI Foundry hydrates a *resource name* (not a URL) into
1405            // this slot from the `AZURE_AI_FOUNDRY_RESOURCE` secret; the URL
1406            // is derived later via `completion_url()` / `tts_url()`.
1407            Self::AzureAiFoundry { resource, .. } => Some(resource),
1408            Self::AzureOpenAI { base_url, .. }
1409            | Self::AwsBedrock { base_url, .. }
1410            | Self::GoogleVertex { base_url, .. }
1411            | Self::Gemini { base_url, .. }
1412            | Self::OpenAICompatible { base_url, .. }
1413            | Self::AlibabaCloud { base_url, .. } => Some(base_url),
1414            // ZAi's base_url has a default and is not a hydrated secret —
1415            // excluded like Anthropic.
1416            Self::OpenAI {} | Self::Anthropic { .. } | Self::ZAi { .. } | Self::FalAi { .. } => {
1417                None
1418            }
1419        }
1420    }
1421
1422    /// Returns the provider type enum for this provider.
1423    pub fn provider_type(&self) -> crate::models::ProviderType {
1424        match self {
1425            ModelProvider::OpenAI {} => crate::models::ProviderType::OpenAI,
1426            ModelProvider::OpenAICompatible { .. } => {
1427                crate::models::ProviderType::Custom("openai_compat".to_string())
1428            }
1429            ModelProvider::AzureOpenAI { .. } => crate::models::ProviderType::Azure,
1430            ModelProvider::Anthropic { .. } => crate::models::ProviderType::Anthropic,
1431            // Z.ai speaks the Anthropic wire format.
1432            ModelProvider::ZAi { .. } => crate::models::ProviderType::Anthropic,
1433            ModelProvider::Gemini { .. } => crate::models::ProviderType::Gemini,
1434            ModelProvider::AzureAiFoundry { .. } => crate::models::ProviderType::AzureAiFoundry,
1435            ModelProvider::AwsBedrock { .. } => crate::models::ProviderType::AwsBedrock,
1436            ModelProvider::GoogleVertex { .. } => crate::models::ProviderType::GoogleVertex,
1437            ModelProvider::AlibabaCloud { .. } => crate::models::ProviderType::AlibabaCloud,
1438            ModelProvider::FalAi { .. } => crate::models::ProviderType::FalAi,
1439        }
1440    }
1441
1442    /// Returns the provider ID string for secret lookup and "provider/model" format.
1443    pub fn provider_id(&self) -> &str {
1444        match self {
1445            ModelProvider::OpenAI {} => "openai",
1446            ModelProvider::OpenAICompatible { .. } => "openai_compat",
1447            ModelProvider::AzureOpenAI { .. } => "azure_openai",
1448            ModelProvider::Anthropic { .. } => "anthropic",
1449            ModelProvider::ZAi { .. } => "zai",
1450            ModelProvider::Gemini { .. } => "gemini",
1451            ModelProvider::AzureAiFoundry { .. } => "azure_ai_foundry",
1452            ModelProvider::AwsBedrock { .. } => "aws_bedrock",
1453            ModelProvider::GoogleVertex { .. } => "google_vertex",
1454            ModelProvider::AlibabaCloud { .. } => "alibaba_cloud",
1455            ModelProvider::FalAi { .. } => "fal_ai",
1456        }
1457    }
1458
1459    /// The canonical secret-store key under which this provider's API key
1460    /// lives.
1461    ///
1462    /// **Single source of truth.** Every layer that needs to look up or
1463    /// validate the API key MUST go through this method — the gateway
1464    /// (`ProviderClientConfig`), workspace-level resolution
1465    /// (`WorkspaceStore::resolve_model_settings`), and the validator
1466    /// (`required_secret_keys`) all rely on it. The UI's user-facing key list
1467    /// in `default_models.json` is kept in sync with this via a unit test.
1468    pub fn api_key_secret(&self) -> &'static str {
1469        match self {
1470            ModelProvider::OpenAI {} => "OPENAI_API_KEY",
1471            // Custom OpenAI-compatible providers all share the OPENAI_API_KEY
1472            // fallback; per-provider keys are passed inline on the provider
1473            // config, not stored in the secret store under a different name.
1474            ModelProvider::OpenAICompatible { .. } => "OPENAI_API_KEY",
1475            ModelProvider::AzureOpenAI { .. } => "AZURE_OPENAI_API_KEY",
1476            ModelProvider::Anthropic { .. } => "ANTHROPIC_API_KEY",
1477            ModelProvider::ZAi { .. } => "ZAI_API_KEY",
1478            ModelProvider::Gemini { .. } => "GEMINI_API_KEY",
1479            ModelProvider::AzureAiFoundry { .. } => "AZURE_AI_FOUNDRY_API_KEY",
1480            // AWS Bedrock authenticates with sigv4 — AWS_ACCESS_KEY_ID is the
1481            // primary key the gateway looks up; AWS_SECRET_ACCESS_KEY and
1482            // AWS_REGION are looked up alongside but are not "the" api_key.
1483            ModelProvider::AwsBedrock { .. } => "AWS_ACCESS_KEY_ID",
1484            ModelProvider::GoogleVertex { .. } => "GOOGLE_VERTEX_API_KEY",
1485            ModelProvider::AlibabaCloud { .. } => "DASHSCOPE_API_KEY",
1486            ModelProvider::FalAi { .. } => "FAL_KEY",
1487        }
1488    }
1489
1490    /// The canonical secret-store key for this provider's endpoint URL, or
1491    /// `None` if the provider has a fixed endpoint baked into the variant.
1492    ///
1493    /// Only providers that require a tenant-specific endpoint (Azure, Bedrock,
1494    /// Vertex) return `Some`; everything else uses a default base URL.
1495    pub fn endpoint_secret(&self) -> Option<&'static str> {
1496        match self {
1497            ModelProvider::AzureOpenAI { .. } => Some("AZURE_OPENAI_ENDPOINT"),
1498            // Holds the Azure resource name, not a URL — see the variant doc.
1499            ModelProvider::AzureAiFoundry { .. } => Some("AZURE_AI_FOUNDRY_RESOURCE"),
1500            ModelProvider::AwsBedrock { .. } => Some("AWS_BEDROCK_ENDPOINT"),
1501            ModelProvider::GoogleVertex { .. } => Some("GOOGLE_VERTEX_ENDPOINT"),
1502            ModelProvider::OpenAI {}
1503            | ModelProvider::OpenAICompatible { .. }
1504            | ModelProvider::Anthropic { .. }
1505            | ModelProvider::ZAi { .. }
1506            | ModelProvider::Gemini { .. }
1507            | ModelProvider::AlibabaCloud { .. }
1508            | ModelProvider::FalAi { .. } => None,
1509        }
1510    }
1511
1512    /// OpenAI-compatible API base URL for an Azure AI Foundry resource name.
1513    ///
1514    /// Azure AI Foundry exposes the OpenAI v1 API at
1515    /// `https://<resource>.openai.azure.com/openai/v1` — one endpoint serving
1516    /// chat completions and OpenAI-style audio (TTS/STT). The Foundry
1517    /// *project* endpoint (`*.services.ai.azure.com/api/projects/...`) is the
1518    /// Agents SDK surface and is deliberately not used for model calls.
1519    pub fn azure_ai_foundry_base_url(resource: &str) -> String {
1520        let r = resource.trim().trim_matches('/');
1521        format!("https://{r}.openai.azure.com/openai/v1")
1522    }
1523
1524    /// Azure AI Foundry resource name, if this is that provider.
1525    pub fn azure_ai_foundry_resource(&self) -> Option<&str> {
1526        match self {
1527            ModelProvider::AzureAiFoundry { resource, .. } => Some(resource.as_str()),
1528            _ => None,
1529        }
1530    }
1531
1532    /// Resolved chat-completions base URL for providers whose endpoint is
1533    /// *derived* from config (Azure AI Foundry) rather than user-supplied.
1534    /// `None` means the caller should fall back to the provider's own
1535    /// `base_url`/default.
1536    pub fn completion_url(&self) -> Option<String> {
1537        match self {
1538            ModelProvider::AzureAiFoundry { resource, .. } if !resource.trim().is_empty() => {
1539                Some(Self::azure_ai_foundry_base_url(resource))
1540            }
1541            _ => None,
1542        }
1543    }
1544
1545    /// Resolved TTS base URL. Azure AI Foundry serves TTS on the same
1546    /// OpenAI-compatible endpoint as chat today; kept as a separate method so
1547    /// a future non-OpenAI surface (e.g. Azure Speech) changes only here.
1548    pub fn tts_url(&self) -> Option<String> {
1549        self.completion_url()
1550    }
1551
1552    /// Resolved STT base URL — see [`ModelProvider::tts_url`].
1553    pub fn stt_url(&self) -> Option<String> {
1554        self.completion_url()
1555    }
1556
1557    /// Resolved image-generation base URL.
1558    ///
1559    /// Azure AI Foundry exposes image generation on
1560    /// `https://<resource>.services.ai.azure.com/openai/v1` — a different
1561    /// subdomain from chat/TTS (which use `*.openai.azure.com`). The image
1562    /// dispatcher consults this method so a Foundry resource routes to the
1563    /// right host without changing the chat path.
1564    pub fn image_url(&self) -> Option<String> {
1565        match self {
1566            ModelProvider::AzureAiFoundry { resource, .. } if !resource.trim().is_empty() => {
1567                let r = resource.trim().trim_matches('/');
1568                Some(format!("https://{r}.services.ai.azure.com/openai/v1"))
1569            }
1570            _ => self.completion_url(),
1571        }
1572    }
1573
1574    /// `(base_url, api_key)` for this provider — call after `hydrate_creds`.
1575    /// `base_url` is the OpenAI-compatible endpoint to probe; used by the
1576    /// `/providers/test` validation flow.
1577    pub fn resolved_endpoint(&self) -> (Option<String>, Option<String>) {
1578        match self {
1579            ModelProvider::OpenAI {} => (Some(Self::openai_base_url()), None),
1580            ModelProvider::AzureAiFoundry { api_key, .. } => {
1581                (self.completion_url(), api_key.clone())
1582            }
1583            ModelProvider::Anthropic { base_url, api_key } => (
1584                Some(
1585                    base_url
1586                        .clone()
1587                        .unwrap_or_else(|| "https://api.anthropic.com".to_string()),
1588                ),
1589                api_key.clone(),
1590            ),
1591            ModelProvider::ZAi { base_url, api_key } => (Some(base_url.clone()), api_key.clone()),
1592            ModelProvider::Gemini { base_url, api_key } => {
1593                (Some(base_url.clone()), api_key.clone())
1594            }
1595            ModelProvider::OpenAICompatible {
1596                base_url, api_key, ..
1597            } => (Some(base_url.clone()), api_key.clone()),
1598            ModelProvider::AlibabaCloud { base_url, api_key } => {
1599                (Some(base_url.clone()), api_key.clone())
1600            }
1601            ModelProvider::AzureOpenAI {
1602                base_url, api_key, ..
1603            } => (Some(base_url.clone()), api_key.clone()),
1604            ModelProvider::AwsBedrock { base_url, api_key } => {
1605                (Some(base_url.clone()), api_key.clone())
1606            }
1607            ModelProvider::GoogleVertex {
1608                base_url, api_key, ..
1609            } => (Some(base_url.clone()), api_key.clone()),
1610            ModelProvider::FalAi { api_key } => {
1611                (Some(Self::fal_ai_base_url().to_string()), api_key.clone())
1612            }
1613        }
1614    }
1615
1616    /// Returns the required secret keys for this provider — i.e. keys that
1617    /// must resolve via the secret store or environment for the LLM call to
1618    /// succeed. If the provider has an inline `api_key` already configured on
1619    /// the provider variant, no secret lookup is required.
1620    pub fn required_secret_keys(&self) -> Vec<&'static str> {
1621        let api_key_present = match self {
1622            ModelProvider::OpenAI {} => false,
1623            ModelProvider::OpenAICompatible { api_key, .. }
1624            | ModelProvider::AzureOpenAI { api_key, .. }
1625            | ModelProvider::Gemini { api_key, .. }
1626            | ModelProvider::AzureAiFoundry { api_key, .. }
1627            | ModelProvider::AwsBedrock { api_key, .. }
1628            | ModelProvider::GoogleVertex { api_key, .. }
1629            | ModelProvider::AlibabaCloud { api_key, .. }
1630            | ModelProvider::FalAi { api_key } => api_key.is_some(),
1631            ModelProvider::Anthropic { api_key, .. } | ModelProvider::ZAi { api_key, .. } => {
1632                api_key.is_some()
1633            }
1634        };
1635        if api_key_present {
1636            vec![]
1637        } else {
1638            vec![self.api_key_secret()]
1639        }
1640    }
1641
1642    /// Returns all provider secret definitions — the built-in basics from
1643    /// `default_models.json` merged with any registered extensions.
1644    pub fn all_provider_definitions() -> Vec<ProviderSecretDefinition> {
1645        merged_providers()
1646            .into_iter()
1647            .map(|p| ProviderSecretDefinition {
1648                id: p.id,
1649                label: p.label,
1650                keys: p.keys,
1651                category: p.category,
1652            })
1653            .collect()
1654    }
1655
1656    /// Returns the well-known models grouped by provider — the built-in
1657    /// basics from `default_models.json` merged with registered extensions.
1658    pub fn well_known_models() -> Vec<ProviderModels> {
1659        merged_providers()
1660            .into_iter()
1661            .filter(|p| !p.models.is_empty())
1662            .map(|p| ProviderModels {
1663                provider_id: p.id,
1664                provider_label: p.label,
1665                models: p.models,
1666            })
1667            .collect()
1668    }
1669
1670    /// Get the human-readable name for a provider
1671    pub fn display_name(&self) -> &'static str {
1672        match self {
1673            ModelProvider::OpenAI {} => "OpenAI",
1674            ModelProvider::OpenAICompatible { .. } => "OpenAI Compatible",
1675            ModelProvider::AzureOpenAI { .. } => "Azure",
1676            ModelProvider::Anthropic { .. } => "Anthropic",
1677            ModelProvider::ZAi { .. } => "Z.ai",
1678            ModelProvider::Gemini { .. } => "Google Gemini",
1679            ModelProvider::AzureAiFoundry { .. } => "Azure AI Foundry",
1680            ModelProvider::AwsBedrock { .. } => "AWS Bedrock",
1681            ModelProvider::GoogleVertex { .. } => "Google Vertex AI",
1682            ModelProvider::AlibabaCloud { .. } => "Alibaba Cloud",
1683            ModelProvider::FalAi { .. } => "fal.ai",
1684        }
1685    }
1686
1687    /// OTel `gen_ai.provider.name` attribute value for this provider.
1688    /// Uses the semantic convention identifiers from the 2025 GenAI spec.
1689    pub fn otel_provider_name(&self) -> &'static str {
1690        match self {
1691            ModelProvider::OpenAI { .. } => "openai",
1692            ModelProvider::OpenAICompatible { .. } => "openai",
1693            ModelProvider::AzureOpenAI { .. } => "azure.ai.openai",
1694            ModelProvider::Anthropic { .. } => "anthropic",
1695            ModelProvider::ZAi { .. } => "zai",
1696            ModelProvider::Gemini { .. } => "google.gemini",
1697            ModelProvider::AzureAiFoundry { .. } => "azure.ai.inference",
1698            ModelProvider::AwsBedrock { .. } => "aws.bedrock",
1699            ModelProvider::GoogleVertex { .. } => "gcp.vertex_ai",
1700            ModelProvider::AlibabaCloud { .. } => "alibaba_cloud",
1701            ModelProvider::FalAi { .. } => "fal.ai",
1702        }
1703    }
1704}
1705
1706/// Model settings configuration.
1707/// A `ModelSettings` always has a valid model string.
1708/// Use `Option<ModelSettings>` when no model is configured yet.
1709#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1710pub struct ModelSettings {
1711    pub model: String,
1712    #[serde(flatten)]
1713    pub inner: ModelSettingsInner,
1714}
1715
1716/// Optional/defaultable model parameters. Split from `ModelSettings` so callers
1717/// can construct `ModelSettings { model: "...", ..Default::default() }` easily
1718/// via the `inner` field having `Default`.
1719#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
1720pub struct ModelSettingsInner {
1721    #[serde(default, skip_serializing_if = "Option::is_none")]
1722    pub temperature: Option<f32>,
1723    #[serde(default, skip_serializing_if = "Option::is_none")]
1724    pub max_tokens: Option<u32>,
1725    /// Explicit context-window override in tokens. Leave unset to use the
1726    /// model catalog's advertised window for the resolved (provider, model)
1727    /// — see [`ModelSettings::effective_context_size`].
1728    #[serde(
1729        default,
1730        deserialize_with = "de_context_size",
1731        skip_serializing_if = "Option::is_none"
1732    )]
1733    pub context_size: Option<u32>,
1734    #[serde(default, skip_serializing_if = "Option::is_none")]
1735    pub top_p: Option<f32>,
1736    #[serde(default, skip_serializing_if = "Option::is_none")]
1737    pub frequency_penalty: Option<f32>,
1738    #[serde(default, skip_serializing_if = "Option::is_none")]
1739    pub presence_penalty: Option<f32>,
1740    #[serde(default = "default_model_provider")]
1741    pub provider: ModelProvider,
1742    /// Additional parameters for the agent, if any.
1743    #[serde(default)]
1744    pub parameters: Option<serde_json::Value>,
1745    /// The format of the response, if specified.
1746    #[serde(default)]
1747    pub response_format: Option<serde_json::Value>,
1748    /// Which OpenAI-family API format to use (auto-detected by default).
1749    /// Only relevant for OpenAI, OpenAI-compatible, and Azure OpenAI providers.
1750    #[serde(default, skip_serializing_if = "is_default_api_format")]
1751    pub api_format: OpenAiApiFormat,
1752}
1753
1754impl ModelSettings {
1755    /// Create a new `ModelSettings` with the given model and default inner settings.
1756    pub fn new(model: impl Into<String>) -> Self {
1757        Self {
1758            model: model.into(),
1759            inner: ModelSettingsInner::default(),
1760        }
1761    }
1762
1763    /// The context window to use for this model, resolved in order:
1764    /// explicit `context_size` override → the model catalog's advertised
1765    /// `context_window` for (provider, model) → the conservative 20k
1766    /// fallback. LLM clients and the context budget MUST go through this
1767    /// (or [`StandardDefinition::get_effective_context_size`]) rather than
1768    /// reading `inner.context_size` directly.
1769    pub fn effective_context_size(&self) -> u32 {
1770        self.inner
1771            .context_size
1772            .filter(|&s| s > 0)
1773            .or_else(|| {
1774                crate::model_lookup().context_window(self.inner.provider.provider_id(), &self.model)
1775            })
1776            .unwrap_or_else(default_context_size)
1777    }
1778
1779    /// Fill empty `api_key` and `base_url` fields on this provider by
1780    /// looking up the canonical secret keys
1781    /// ([`ModelProvider::api_key_secret`] /
1782    /// [`ModelProvider::endpoint_secret`]) in the workspace secret
1783    /// store. **Single source of truth** for provider credential
1784    /// resolution — both the workspace's default-model resolution and
1785    /// the orchestrator's per-task agent resolution call this so any
1786    /// provider-pinned ModelSettings (workspace default OR agent
1787    /// override) gets the same hydration treatment.
1788    ///
1789    /// Errors if a required endpoint secret is missing
1790    /// (`AzureOpenAI` / `AzureAiFoundry` / `AwsBedrock` /
1791    /// `GoogleVertex` all need a tenant-specific endpoint that has no
1792    /// safe default — silently dropping it produces an unparseable
1793    /// base URL and a downstream client panic). Missing api_keys
1794    /// downgrade to a warning since some providers can fall back to
1795    /// other auth (env vars, IAM roles, etc.).
1796    pub async fn hydrate_creds(
1797        &mut self,
1798        secret_store: &dyn crate::stores::SecretStore,
1799    ) -> Result<(), String> {
1800        let provider_label = self.inner.provider.provider_id().to_string();
1801        let api_key_secret = self.inner.provider.api_key_secret();
1802        let endpoint_secret = self.inner.provider.endpoint_secret();
1803
1804        // api_key — fill if the slot is currently None.
1805        if let Some(slot) = self.inner.provider.api_key_slot_mut() {
1806            if slot.is_none() {
1807                match secret_store.get(api_key_secret).await {
1808                    Ok(Some(secret)) => *slot = Some(secret.value),
1809                    Ok(None) => tracing::warn!(
1810                        "{} secret not found for provider '{}'",
1811                        api_key_secret,
1812                        provider_label
1813                    ),
1814                    Err(e) => tracing::error!(
1815                        "failed to fetch {} for provider '{}': {}",
1816                        api_key_secret,
1817                        provider_label,
1818                        e
1819                    ),
1820                }
1821            }
1822        }
1823
1824        // base_url — fill if the slot is currently empty AND the
1825        // provider exposes an endpoint_secret. Hard-error for
1826        // endpoint-required providers when the secret is missing.
1827        if let Some(endpoint_key) = endpoint_secret {
1828            if let Some(slot) = self.inner.provider.base_url_slot_mut() {
1829                if slot.is_empty() {
1830                    match secret_store.get(endpoint_key).await {
1831                        Ok(Some(secret)) => *slot = secret.value,
1832                        Ok(None) => {
1833                            return Err(format!(
1834                                "{} secret not set for provider '{}'. \
1835                                 Configure the workspace's '{}' provider \
1836                                 (POST /v1/providers) before pinning a model \
1837                                 with this provider prefix.",
1838                                endpoint_key, provider_label, provider_label
1839                            ));
1840                        }
1841                        Err(e) => {
1842                            return Err(format!(
1843                                "failed to fetch {} for provider '{}': {e}",
1844                                endpoint_key, provider_label
1845                            ));
1846                        }
1847                    }
1848                }
1849            }
1850        }
1851
1852        Ok(())
1853    }
1854
1855    /// Parse a "provider/model" string (e.g. "anthropic/claude-sonnet-4") into ModelSettings.
1856    /// Returns None if the format is invalid.
1857    /// For custom providers (prefixed with "custom_"), returns an OpenAICompatible provider
1858    /// with empty base_url/api_key — the caller must fill these from secrets/config.
1859    /// Parse "provider/model" string into ModelSettings.
1860    /// Returns Err with a descriptive message if the provider is not recognized.
1861    /// Returns Ok(None) if the input is empty or has no slash.
1862    pub fn from_provider_model_str(s: &str) -> Result<Option<Self>, String> {
1863        let Some((provider_str, model_id)) = s.split_once('/') else {
1864            return Ok(None);
1865        };
1866        if model_id.is_empty() {
1867            return Ok(None);
1868        }
1869        let provider = match provider_str {
1870            "openai" => ModelProvider::OpenAI {},
1871            "anthropic" => ModelProvider::Anthropic {
1872                base_url: None,
1873                api_key: None,
1874            },
1875            "zai" => ModelProvider::ZAi {
1876                base_url: ModelProvider::zai_base_url(),
1877                api_key: None,
1878            },
1879            "azure_openai" | "azure" => ModelProvider::AzureOpenAI {
1880                base_url: String::new(),
1881                api_key: None,
1882                deployment: model_id.to_string(),
1883                api_version: ModelProvider::azure_api_version(),
1884            },
1885            "gemini" => ModelProvider::Gemini {
1886                base_url: ModelProvider::gemini_base_url(),
1887                api_key: None,
1888            },
1889            "azure_ai_foundry" => ModelProvider::AzureAiFoundry {
1890                resource: String::new(),
1891                api_key: None,
1892            },
1893            "aws_bedrock" => ModelProvider::AwsBedrock {
1894                base_url: String::new(),
1895                api_key: None,
1896            },
1897            "google_vertex" => ModelProvider::GoogleVertex {
1898                base_url: String::new(),
1899                api_key: None,
1900                project_id: None,
1901            },
1902            "alibaba_cloud" => ModelProvider::AlibabaCloud {
1903                base_url: ModelProvider::alibaba_cloud_base_url(),
1904                api_key: None,
1905            },
1906            "fal_ai" => ModelProvider::FalAi { api_key: None },
1907            _ if provider_str.starts_with("custom_") => ModelProvider::OpenAICompatible {
1908                base_url: String::new(),
1909                api_key: None,
1910                project_id: None,
1911            },
1912            // Unknown provider — error out instead of silently falling
1913            // through to OpenAI-compatible. The previous behaviour
1914            // (silent fallback) caused agent definitions like
1915            // `model = "azure_foundry/gpt-5.4"` to typo'd → resolve to
1916            // a generic OpenAI-compatible client and silently load the
1917            // workspace's default provider's credentials, with no
1918            // signal to the caller that their agent's model pin was
1919            // ignored.
1920            _ => {
1921                return Err(format!(
1922                    "unknown model provider prefix '{provider_str}' in '{s}'. \
1923                     Recognised prefixes: openai, anthropic, zai, azure_openai, \
1924                     azure (alias for azure_openai), gemini, azure_ai_foundry, \
1925                     aws_bedrock, google_vertex, alibaba_cloud, fal_ai, custom_*. \
1926                     Pass just the model name with no slash to use the \
1927                     workspace's default provider."
1928                ));
1929            }
1930        };
1931        Ok(Some(Self {
1932            model: model_id.to_string(),
1933            inner: ModelSettingsInner {
1934                provider,
1935                ..Default::default()
1936            },
1937        }))
1938    }
1939
1940    /// Merge base (workspace) model settings with agent/request-level overrides.
1941    ///
1942    /// Provider resolution:
1943    /// - If the override explicitly sets a provider (not the default OpenAI),
1944    ///   the override's provider and model are used.
1945    /// - If only the base has a non-default provider and the override uses default
1946    ///   OpenAI, the base's provider AND model win — the override's bare model name
1947    ///   is ignored because it may not exist on the base provider.
1948    /// - Otherwise, the override's model wins if non-empty.
1949    ///
1950    /// Scalar fields (temperature, max_tokens, etc.) use override if present, else base.
1951    ///
1952    /// Returns `None` if the final model string is empty.
1953    pub fn merge(&self, override_settings: &ModelSettings) -> Option<ModelSettings> {
1954        let default_provider = ModelProvider::OpenAI {};
1955        let override_has_explicit_provider =
1956            std::mem::discriminant(&override_settings.inner.provider)
1957                != std::mem::discriminant(&default_provider);
1958        let base_has_explicit_provider = std::mem::discriminant(&self.inner.provider)
1959            != std::mem::discriminant(&default_provider);
1960
1961        let (provider, model) = if override_has_explicit_provider {
1962            // Override explicitly set a provider — use override's provider and model.
1963            let model = if !override_settings.model.is_empty() {
1964                override_settings.model.clone()
1965            } else {
1966                self.model.clone()
1967            };
1968            (override_settings.inner.provider.clone(), model)
1969        } else if base_has_explicit_provider {
1970            // Base uses a non-default provider and override didn't specify one — use
1971            // base's provider AND model to avoid mismatching model names.
1972            let model = if !self.model.is_empty() {
1973                self.model.clone()
1974            } else if !override_settings.model.is_empty() {
1975                override_settings.model.clone()
1976            } else {
1977                String::new()
1978            };
1979            (self.inner.provider.clone(), model)
1980        } else {
1981            // Both use default OpenAI — override model can win.
1982            let model = if !override_settings.model.is_empty() {
1983                override_settings.model.clone()
1984            } else {
1985                self.model.clone()
1986            };
1987            (self.inner.provider.clone(), model)
1988        };
1989
1990        if model.is_empty() {
1991            return None;
1992        }
1993
1994        Some(ModelSettings {
1995            model,
1996            inner: ModelSettingsInner {
1997                temperature: override_settings
1998                    .inner
1999                    .temperature
2000                    .or(self.inner.temperature),
2001                max_tokens: override_settings.inner.max_tokens.or(self.inner.max_tokens),
2002                context_size: override_settings
2003                    .inner
2004                    .context_size
2005                    .or(self.inner.context_size),
2006                top_p: override_settings.inner.top_p.or(self.inner.top_p),
2007                frequency_penalty: override_settings
2008                    .inner
2009                    .frequency_penalty
2010                    .or(self.inner.frequency_penalty),
2011                presence_penalty: override_settings
2012                    .inner
2013                    .presence_penalty
2014                    .or(self.inner.presence_penalty),
2015                provider,
2016                parameters: if override_settings.inner.parameters.is_some() {
2017                    override_settings.inner.parameters.clone()
2018                } else {
2019                    self.inner.parameters.clone()
2020                },
2021                response_format: if override_settings.inner.response_format.is_some() {
2022                    override_settings.inner.response_format.clone()
2023                } else {
2024                    self.inner.response_format.clone()
2025                },
2026                api_format: if override_settings.inner.api_format != OpenAiApiFormat::Auto {
2027                    override_settings.inner.api_format.clone()
2028                } else {
2029                    self.inner.api_format.clone()
2030                },
2031            },
2032        })
2033    }
2034}
2035
2036// Default functions
2037pub fn default_agent_version() -> Option<String> {
2038    Some("0.2.2".to_string())
2039}
2040
2041fn default_model_provider() -> ModelProvider {
2042    ModelProvider::OpenAI {}
2043}
2044
2045/// Last-resort context window when neither an explicit override nor a
2046/// catalog entry for the model exists.
2047fn default_context_size() -> u32 {
2048    20000
2049}
2050
2051/// `context_size` was a plain `u32` with a serde default of 20000 for years,
2052/// so every stored config has `"context_size": 20000` baked in whether or
2053/// not anyone chose it — the value never carried intent. Deserialize that
2054/// legacy default (and 0) as "unset" so the model catalog's real window can
2055/// take over; a deliberate cap goes in the agent-level `context_size` field
2056/// or any value other than 20000.
2057fn de_context_size<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
2058where
2059    D: serde::Deserializer<'de>,
2060{
2061    let v = Option::<u32>::deserialize(deserializer)?;
2062    Ok(v.filter(|&s| s > 0 && s != 20000))
2063}
2064
2065fn is_default_api_format(f: &OpenAiApiFormat) -> bool {
2066    *f == OpenAiApiFormat::Auto
2067}
2068
2069impl StandardDefinition {
2070    pub fn validate(&self) -> anyhow::Result<()> {
2071        // Basic validation - can be expanded
2072        if self.name.is_empty() {
2073            return Err(anyhow::anyhow!("Agent name cannot be empty"));
2074        }
2075
2076        // Validate reflection configuration
2077        if let Some(ref reflection) = self.reflection
2078            && reflection.enabled
2079        {
2080            // If a custom reflection_agent is specified, validate the name
2081            if let Some(ref agent_name) = reflection.reflection_agent
2082                && agent_name.is_empty()
2083            {
2084                return Err(anyhow::anyhow!(
2085                    "Reflection agent name cannot be empty when specified"
2086                ));
2087            }
2088        }
2089
2090        Ok(())
2091    }
2092
2093    /// Validate that a reflection agent definition has the "reflect" tool configured.
2094    /// This is called at registration time when we have access to the full agent config.
2095    pub fn validate_reflection_agent(agent_def: &StandardDefinition) -> anyhow::Result<()> {
2096        let has_reflect_tool = agent_def
2097            .tools
2098            .as_ref()
2099            .map(|t| t.builtin.iter().any(|name| name == "reflect"))
2100            .unwrap_or(false);
2101
2102        if !has_reflect_tool {
2103            // The built-in reflection_agent gets the reflect tool automatically,
2104            // but custom reflection agents must explicitly list it
2105            anyhow::bail!(
2106                "Reflection agent '{}' must have the 'reflect' tool in its tools.builtin configuration",
2107                agent_def.name
2108            );
2109        }
2110
2111        Ok(())
2112    }
2113}
2114
2115impl From<StandardDefinition> for LlmDefinition {
2116    fn from(definition: StandardDefinition) -> Self {
2117        let model_settings = match (definition.model_settings, definition.context_size) {
2118            (Some(mut ms), Some(ctx)) => {
2119                ms.inner.context_size = Some(ctx);
2120                Some(ms)
2121            }
2122            (ms, _) => ms,
2123        };
2124
2125        Self {
2126            name: definition.name,
2127            model_settings,
2128            tool_format: definition.tool_format,
2129            tool_delivery_mode: definition.tool_delivery_mode,
2130        }
2131    }
2132}
2133
2134impl ToolsConfig {
2135    /// Create a simple configuration with just built-in tools
2136    pub fn builtin_only(tools: Vec<&str>) -> Self {
2137        Self {
2138            builtin: tools.into_iter().map(|s| s.to_string()).collect(),
2139            ..Default::default()
2140        }
2141    }
2142
2143    /// Create a configuration that includes all tools from an MCP server
2144    pub fn mcp_all(server: &str) -> Self {
2145        Self {
2146            mcp: vec![McpToolConfig {
2147                server: server.to_string(),
2148                include: vec!["*".to_string()],
2149                exclude: vec![],
2150            }],
2151            ..Default::default()
2152        }
2153    }
2154
2155    /// Create a configuration with specific MCP tool patterns
2156    pub fn mcp_filtered(server: &str, include: Vec<&str>, exclude: Vec<&str>) -> Self {
2157        Self {
2158            mcp: vec![McpToolConfig {
2159                server: server.to_string(),
2160                include: include.into_iter().map(|s| s.to_string()).collect(),
2161                exclude: exclude.into_iter().map(|s| s.to_string()).collect(),
2162            }],
2163            ..Default::default()
2164        }
2165    }
2166}
2167
2168pub async fn parse_agent_markdown_content(content: &str) -> Result<StandardDefinition, AgentError> {
2169    // Split by --- to separate TOML frontmatter from markdown content
2170    let parts: Vec<&str> = content.split("---").collect();
2171
2172    if parts.len() < 3 {
2173        return Err(AgentError::Validation(
2174            "Invalid agent markdown format. Expected TOML frontmatter between --- markers"
2175                .to_string(),
2176        ));
2177    }
2178
2179    // Parse TOML frontmatter (parts[1] is between the first two --- markers)
2180    let toml_content = parts[1].trim();
2181    let mut agent_def: crate::StandardDefinition =
2182        toml::from_str(toml_content).map_err(|e| AgentError::Validation(e.to_string()))?;
2183
2184    // Validate agent name format using centralized validation
2185    if let Err(validation_error) = validate_plugin_name(&agent_def.name) {
2186        return Err(AgentError::Validation(format!(
2187            "Invalid agent name '{}': {}",
2188            agent_def.name, validation_error
2189        )));
2190    }
2191
2192    // Validate that agent name characters are valid (alphanumeric, underscore, or single '/' for namespacing)
2193    if !agent_def
2194        .name
2195        .chars()
2196        .all(|c| c.is_alphanumeric() || c == '_' || c == '/')
2197        || agent_def
2198            .name
2199            .chars()
2200            .next()
2201            .is_some_and(|c| c.is_numeric())
2202        || agent_def.name.chars().filter(|&c| c == '/').count() > 1
2203    {
2204        return Err(AgentError::Validation(format!(
2205            "Invalid agent name '{}': Agent names must be alphanumeric with underscores, at most one '/' for namespacing (e.g. '_system/plan'), cannot start with number.",
2206            agent_def.name
2207        )));
2208    }
2209
2210    // Extract markdown instructions (everything after the second ---)
2211    let instructions = parts[2..].join("---").trim().to_string();
2212
2213    // Set the instructions in the agent definition
2214    agent_def.instructions = instructions;
2215
2216    // Resolve `provider/model` prefix on `model_settings.model`. When the
2217    // agent author writes `model = "azure_ai_foundry/gpt-5.4"` we need
2218    // to (a) split the prefix, (b) set `provider` explicitly so
2219    // ModelSettings::merge() doesn't fall back to the workspace default
2220    // provider, and (c) rewrite `model` to just the bare model name.
2221    // Unknown prefixes error out — the caller is making a clearly
2222    // invalid claim ("dispatch to provider X") that must not silently
2223    // fall back to "use whatever the workspace default is".
2224    if let Some(ref mut ms) = agent_def.model_settings {
2225        if ms.model.contains('/') {
2226            let resolved = ModelSettings::from_provider_model_str(&ms.model)
2227                .map_err(AgentError::Validation)?
2228                .ok_or_else(|| {
2229                    AgentError::Validation(format!(
2230                        "agent '{}': invalid model_settings.model '{}' — empty model name after the provider prefix",
2231                        agent_def.name, ms.model
2232                    ))
2233                })?;
2234            ms.model = resolved.model;
2235            ms.inner.provider = resolved.inner.provider;
2236        }
2237    }
2238
2239    Ok(agent_def)
2240}
2241
2242/// Validate plugin name follows naming conventions
2243/// Plugin names must be valid identifiers. At most one '/' is allowed for workspace namespacing (e.g. 'workspace/agent').
2244pub fn validate_plugin_name(name: &str) -> Result<(), String> {
2245    if name.is_empty() {
2246        return Err("Plugin name cannot be empty".to_string());
2247    }
2248
2249    if name.contains('-') {
2250        return Err(format!(
2251            "Plugin name '{}' cannot contain hyphens. Use underscores instead.",
2252            name
2253        ));
2254    }
2255
2256    let slash_count = name.chars().filter(|&c| c == '/').count();
2257    if slash_count > 1 {
2258        return Err(format!(
2259            "Plugin name '{}' can contain at most one '/' for workspace namespacing (e.g. 'workspace/agent')",
2260            name
2261        ));
2262    }
2263
2264    // Validate each segment (split by optional slash)
2265    let segments: Vec<&str> = name.split('/').collect();
2266    for segment in &segments {
2267        if segment.is_empty() {
2268            return Err(format!(
2269                "Plugin name '{}' has an empty segment around '/'",
2270                name
2271            ));
2272        }
2273
2274        if let Some(first_char) = segment.chars().next()
2275            && !first_char.is_ascii_alphabetic()
2276            && first_char != '_'
2277        {
2278            return Err(format!(
2279                "Each segment in '{}' must start with a letter or underscore",
2280                name
2281            ));
2282        }
2283
2284        for ch in segment.chars() {
2285            if !ch.is_ascii_alphanumeric() && ch != '_' {
2286                return Err(format!(
2287                    "Plugin name '{}' can only contain letters, numbers, underscores, and at most one '/' for namespacing",
2288                    name
2289                ));
2290            }
2291        }
2292    }
2293
2294    Ok(())
2295}
2296
2297#[cfg(test)]
2298mod tests {
2299    use super::*;
2300
2301    #[test]
2302    fn test_compaction_enabled_defaults_to_true_via_serde() {
2303        // serde default uses default_compaction_enabled() -> true
2304        let json = r#"{"name": "test"}"#;
2305        let def: StandardDefinition = serde_json::from_str(json).unwrap();
2306        assert!(def.compaction_enabled);
2307    }
2308
2309    #[test]
2310    fn test_compaction_enabled_deserializes_true_when_absent() {
2311        let json = r#"{"name": "test", "description": "test agent"}"#;
2312        let def: StandardDefinition = serde_json::from_str(json).unwrap();
2313        assert!(def.compaction_enabled);
2314    }
2315
2316    #[test]
2317    fn test_compaction_enabled_deserializes_false() {
2318        let json = r#"{"name": "test", "description": "test agent", "compaction_enabled": false}"#;
2319        let def: StandardDefinition = serde_json::from_str(json).unwrap();
2320        assert!(!def.compaction_enabled);
2321    }
2322
2323    #[test]
2324    fn test_compaction_enabled_true_skipped_in_serialization() {
2325        let def = StandardDefinition {
2326            name: "test".to_string(),
2327            compaction_enabled: true,
2328            ..Default::default()
2329        };
2330        let json = serde_json::to_string(&def).unwrap();
2331        assert!(!json.contains("compaction_enabled"));
2332    }
2333
2334    #[test]
2335    fn test_compaction_enabled_false_serialized() {
2336        let def = StandardDefinition {
2337            name: "test".to_string(),
2338            compaction_enabled: false,
2339            ..Default::default()
2340        };
2341        let json = serde_json::to_string(&def).unwrap();
2342        assert!(json.contains("\"compaction_enabled\":false"));
2343    }
2344
2345    #[test]
2346    fn test_max_tokens_optional_defaults_to_none() {
2347        let def = StandardDefinition::default();
2348        assert!(def.model_settings().is_none());
2349    }
2350
2351    #[test]
2352    fn test_max_tokens_deserializes_when_present() {
2353        let json =
2354            r#"{"name": "test", "model_settings": {"model": "gpt-4.1", "max_tokens": 4096}}"#;
2355        let def: StandardDefinition = serde_json::from_str(json).unwrap();
2356        assert_eq!(def.model_settings().unwrap().inner.max_tokens, Some(4096));
2357    }
2358
2359    #[test]
2360    fn test_max_tokens_none_when_absent() {
2361        let json = r#"{"name": "test", "model_settings": {"model": "gpt-4.1"}}"#;
2362        let def: StandardDefinition = serde_json::from_str(json).unwrap();
2363        assert!(def.model_settings().unwrap().inner.max_tokens.is_none());
2364    }
2365
2366    #[test]
2367    fn test_max_tokens_none_skipped_in_serialization() {
2368        let settings = ModelSettings {
2369            model: "test-model".to_string(),
2370            inner: ModelSettingsInner {
2371                max_tokens: None,
2372                provider: ModelProvider::OpenAI {},
2373                ..Default::default()
2374            },
2375        };
2376        let json = serde_json::to_string(&settings).unwrap();
2377        assert!(!json.contains("max_tokens"));
2378    }
2379
2380    #[test]
2381    fn sparse_definition_round_trip_does_not_inject_defaults() {
2382        // A user authors a minimal agent with only `name`. Parsing then
2383        // re-serializing must not bake in `version`, `history_size`,
2384        // `description`, `tool_format`, `tool_delivery_mode`, `sub_agents`,
2385        // or `icon_url` — those are runtime-resolved defaults, not
2386        // user-provided values.
2387        let toml_in = r#"name = "minimal""#;
2388        let def: StandardDefinition = toml::from_str(toml_in).unwrap();
2389        let toml_out = toml::to_string(&def).unwrap();
2390
2391        for field in [
2392            "version",
2393            "history_size",
2394            "description",
2395            "tool_format",
2396            "tool_delivery_mode",
2397            "sub_agents",
2398            "icon_url",
2399        ] {
2400            assert!(
2401                !toml_out.contains(field),
2402                "round-trip injected `{field}` into sparse definition:\n{toml_out}"
2403            );
2404        }
2405    }
2406
2407    #[test]
2408    fn explicit_values_survive_round_trip() {
2409        // Conversely, fields the user *does* set must round-trip intact.
2410        let toml_in = r#"
2411name = "explicit"
2412description = "a real description"
2413version = "1.2.3"
2414history_size = 7
2415sub_agents = ["helper"]
2416tool_format = "json_l"
2417"#;
2418        let def: StandardDefinition = toml::from_str(toml_in).unwrap();
2419        let toml_out = toml::to_string(&def).unwrap();
2420        assert!(toml_out.contains("description = \"a real description\""));
2421        assert!(toml_out.contains("version = \"1.2.3\""));
2422        assert!(toml_out.contains("history_size = 7"));
2423        assert!(toml_out.contains("sub_agents = [\"helper\"]"));
2424        assert!(toml_out.contains("tool_format = \"json_l\""));
2425    }
2426
2427    #[test]
2428    fn test_max_tokens_some_serialized() {
2429        let settings = ModelSettings {
2430            model: "test-model".to_string(),
2431            inner: ModelSettingsInner {
2432                max_tokens: Some(2048),
2433                provider: ModelProvider::OpenAI {},
2434                ..Default::default()
2435            },
2436        };
2437        let json = serde_json::to_string(&settings).unwrap();
2438        assert!(json.contains("\"max_tokens\":2048"));
2439    }
2440
2441    #[test]
2442    fn test_api_format_auto_detect_codex_prefix() {
2443        let fmt = OpenAiApiFormat::Auto;
2444        assert_eq!(
2445            fmt.resolve("codex-mini-latest"),
2446            ResolvedOpenAiApiFormat::Responses
2447        );
2448        assert_eq!(
2449            fmt.resolve("codex-mini-2025-01-24"),
2450            ResolvedOpenAiApiFormat::Responses
2451        );
2452    }
2453
2454    #[test]
2455    fn test_api_format_auto_detect_codex_suffix() {
2456        let fmt = OpenAiApiFormat::Auto;
2457        assert_eq!(
2458            fmt.resolve("gpt-5.1-codex"),
2459            ResolvedOpenAiApiFormat::Responses
2460        );
2461        assert_eq!(
2462            fmt.resolve("gpt-5.3-codex"),
2463            ResolvedOpenAiApiFormat::Responses
2464        );
2465    }
2466
2467    #[test]
2468    fn test_api_format_auto_detect_pro_models() {
2469        let fmt = OpenAiApiFormat::Auto;
2470        assert_eq!(fmt.resolve("gpt-5-pro"), ResolvedOpenAiApiFormat::Responses);
2471        assert_eq!(
2472            fmt.resolve("gpt-5.2-pro"),
2473            ResolvedOpenAiApiFormat::Responses
2474        );
2475        assert_eq!(
2476            fmt.resolve("gpt-5.4-pro"),
2477            ResolvedOpenAiApiFormat::Responses
2478        );
2479        assert_eq!(fmt.resolve("o3-pro"), ResolvedOpenAiApiFormat::Responses);
2480    }
2481
2482    #[test]
2483    fn test_api_format_auto_detect_deep_research_models() {
2484        let fmt = OpenAiApiFormat::Auto;
2485        assert_eq!(
2486            fmt.resolve("o3-deep-research"),
2487            ResolvedOpenAiApiFormat::Responses
2488        );
2489        assert_eq!(
2490            fmt.resolve("o4-mini-deep-research"),
2491            ResolvedOpenAiApiFormat::Responses
2492        );
2493    }
2494
2495    #[test]
2496    fn test_api_format_auto_detect_non_codex() {
2497        let fmt = OpenAiApiFormat::Auto;
2498        assert_eq!(fmt.resolve("gpt-4o"), ResolvedOpenAiApiFormat::Completions);
2499        assert_eq!(fmt.resolve("gpt-4.1"), ResolvedOpenAiApiFormat::Completions);
2500        assert_eq!(fmt.resolve("gpt-5"), ResolvedOpenAiApiFormat::Completions);
2501        assert_eq!(fmt.resolve("o1"), ResolvedOpenAiApiFormat::Completions);
2502        assert_eq!(
2503            fmt.resolve("gpt-5.4-mini"),
2504            ResolvedOpenAiApiFormat::Completions
2505        );
2506        assert_eq!(fmt.resolve("o3-mini"), ResolvedOpenAiApiFormat::Completions);
2507    }
2508
2509    #[test]
2510    fn test_api_format_explicit_override() {
2511        // Explicit Responses overrides auto-detect even for non-codex models
2512        assert_eq!(
2513            OpenAiApiFormat::Responses.resolve("gpt-4o"),
2514            ResolvedOpenAiApiFormat::Responses
2515        );
2516        // Explicit Completions overrides auto-detect even for codex models
2517        assert_eq!(
2518            OpenAiApiFormat::Completions.resolve("codex-mini-latest"),
2519            ResolvedOpenAiApiFormat::Completions
2520        );
2521    }
2522
2523    #[test]
2524    fn test_api_format_defaults_to_auto() {
2525        let inner = ModelSettingsInner::default();
2526        assert_eq!(inner.api_format, OpenAiApiFormat::Auto);
2527    }
2528
2529    #[test]
2530    fn test_api_format_auto_skipped_in_serialization() {
2531        let settings = ModelSettings {
2532            model: "test-model".to_string(),
2533            inner: ModelSettingsInner {
2534                provider: ModelProvider::OpenAI {},
2535                ..Default::default()
2536            },
2537        };
2538        let json = serde_json::to_string(&settings).unwrap();
2539        assert!(!json.contains("api_format"));
2540    }
2541
2542    #[test]
2543    fn test_api_format_responses_serialized() {
2544        let settings = ModelSettings {
2545            model: "test-model".to_string(),
2546            inner: ModelSettingsInner {
2547                api_format: OpenAiApiFormat::Responses,
2548                provider: ModelProvider::OpenAI {},
2549                ..Default::default()
2550            },
2551        };
2552        let json = serde_json::to_string(&settings).unwrap();
2553        assert!(json.contains("\"api_format\":\"responses\""));
2554    }
2555
2556    #[test]
2557    fn test_api_format_deserializes_from_toml() {
2558        let toml_str = r#"
2559            model = "codex-mini-latest"
2560            api_format = "responses"
2561            [provider]
2562            name = "openai"
2563        "#;
2564        let settings: ModelSettings = toml::from_str(toml_str).unwrap();
2565        assert_eq!(settings.inner.api_format, OpenAiApiFormat::Responses);
2566    }
2567
2568    // ── ToolDeliveryMode tests ────────────────────────────────────
2569
2570    #[test]
2571    fn test_tool_delivery_mode_defaults_to_deferred() {
2572        let mode: ToolDeliveryMode = Default::default();
2573        assert_eq!(mode, ToolDeliveryMode::Deferred);
2574    }
2575
2576    #[test]
2577    fn test_tool_delivery_mode_backwards_compat_all_tools() {
2578        // Old configs that used "all_tools" should deserialize to Full
2579        let json = r#""all_tools""#;
2580        let mode: ToolDeliveryMode = serde_json::from_str(json).unwrap();
2581        assert_eq!(mode, ToolDeliveryMode::Full);
2582    }
2583
2584    #[test]
2585    fn test_tool_delivery_mode_backwards_compat_tool_search() {
2586        // Old configs that used "tool_search" should deserialize to Deferred
2587        let json = r#""tool_search""#;
2588        let mode: ToolDeliveryMode = serde_json::from_str(json).unwrap();
2589        assert_eq!(mode, ToolDeliveryMode::Deferred);
2590    }
2591
2592    #[test]
2593    fn test_tools_config_is_core_tool() {
2594        let config = ToolsConfig::default();
2595        assert!(config.is_core_tool("final"));
2596        assert!(config.is_core_tool("tool_search"));
2597        assert!(config.is_core_tool("execute_shell"));
2598        assert!(config.is_core_tool("call_agent"));
2599        assert!(!config.is_core_tool("browsr_scrape"));
2600    }
2601
2602    #[test]
2603    fn test_tools_config_always_full_schema() {
2604        let config = ToolsConfig {
2605            always_full_schema: vec!["browsr_scrape".to_string()],
2606            ..Default::default()
2607        };
2608        assert!(config.is_core_tool("browsr_scrape"));
2609        assert!(!config.is_core_tool("browsr_browser"));
2610    }
2611
2612    #[test]
2613    fn test_effective_delivery_mode_full_stays_full() {
2614        let config = ToolsConfig {
2615            delivery_mode: ToolDeliveryMode::Full,
2616            ..Default::default()
2617        };
2618        // Even with many tools, Full stays Full
2619        assert_eq!(config.effective_delivery_mode(100), ToolDeliveryMode::Full);
2620    }
2621
2622    #[test]
2623    fn test_effective_delivery_mode_deferred_stays_deferred() {
2624        let config = ToolsConfig {
2625            delivery_mode: ToolDeliveryMode::Deferred,
2626            deferred_threshold: Some(20),
2627            ..Default::default()
2628        };
2629        // Deferred always stays Deferred regardless of count
2630        assert_eq!(
2631            config.effective_delivery_mode(10),
2632            ToolDeliveryMode::Deferred
2633        );
2634    }
2635
2636    #[test]
2637    fn test_effective_delivery_mode_deferred_over_threshold() {
2638        let config = ToolsConfig {
2639            delivery_mode: ToolDeliveryMode::Deferred,
2640            deferred_threshold: Some(10),
2641            ..Default::default()
2642        };
2643        // Over threshold: stays Deferred
2644        assert_eq!(
2645            config.effective_delivery_mode(15),
2646            ToolDeliveryMode::Deferred
2647        );
2648    }
2649
2650    #[test]
2651    fn test_runtime_mode_serde() {
2652        let mode: RuntimeMode = serde_json::from_str("\"cloud\"").unwrap();
2653        assert_eq!(mode, RuntimeMode::Cloud);
2654        let mode: RuntimeMode = serde_json::from_str("\"cli\"").unwrap();
2655        assert_eq!(mode, RuntimeMode::Cli);
2656        let mode: RuntimeMode = serde_json::from_str("\"browser\"").unwrap();
2657        assert_eq!(mode, RuntimeMode::Browser);
2658        assert_eq!(RuntimeMode::default(), RuntimeMode::Cloud);
2659        let json = serde_json::to_string(&RuntimeMode::Cli).unwrap();
2660        assert_eq!(json, "\"cli\"");
2661    }
2662
2663    // ── ModelSettings::merge tests ──────────────────────────────────────────
2664
2665    #[test]
2666    fn merge_both_default_openai_agent_model_wins() {
2667        let base = ModelSettings::new("gpt-5.1");
2668        let agent = ModelSettings::new("gpt-4.1-mini");
2669
2670        let result = base.merge(&agent).unwrap();
2671        assert_eq!(result.model, "gpt-4.1-mini");
2672        assert!(matches!(result.inner.provider, ModelProvider::OpenAI {}));
2673    }
2674
2675    #[test]
2676    fn merge_both_default_openai_base_model_used_when_agent_empty() {
2677        let base = ModelSettings::new("gpt-5.1");
2678        let agent = ModelSettings::new("");
2679
2680        let result = base.merge(&agent).unwrap();
2681        assert_eq!(result.model, "gpt-5.1");
2682    }
2683
2684    #[test]
2685    fn merge_agent_explicit_provider_wins() {
2686        let base = ModelSettings {
2687            model: "gpt-5.1".into(),
2688            inner: ModelSettingsInner {
2689                provider: ModelProvider::OpenAICompatible {
2690                    base_url: "https://custom.com/v1".into(),
2691                    api_key: Some("key".into()),
2692                    project_id: None,
2693                },
2694                ..Default::default()
2695            },
2696        };
2697        let agent = ModelSettings {
2698            model: "claude-sonnet-4".into(),
2699            inner: ModelSettingsInner {
2700                provider: ModelProvider::Anthropic {
2701                    base_url: None,
2702                    api_key: None,
2703                },
2704                ..Default::default()
2705            },
2706        };
2707
2708        let result = base.merge(&agent).unwrap();
2709        assert_eq!(result.model, "claude-sonnet-4");
2710        assert!(matches!(
2711            result.inner.provider,
2712            ModelProvider::Anthropic { .. }
2713        ));
2714    }
2715
2716    #[test]
2717    fn merge_agent_explicit_provider_no_model_uses_base() {
2718        let base = ModelSettings::new("gpt-5.1");
2719        let agent = ModelSettings {
2720            model: "".into(),
2721            inner: ModelSettingsInner {
2722                provider: ModelProvider::Anthropic {
2723                    base_url: None,
2724                    api_key: None,
2725                },
2726                ..Default::default()
2727            },
2728        };
2729
2730        let result = base.merge(&agent).unwrap();
2731        assert_eq!(result.model, "gpt-5.1");
2732        assert!(matches!(
2733            result.inner.provider,
2734            ModelProvider::Anthropic { .. }
2735        ));
2736    }
2737
2738    #[test]
2739    fn merge_workspace_custom_provider_overrides_agent_model() {
2740        let base = ModelSettings {
2741            model: "gpt-5.4".into(),
2742            inner: ModelSettingsInner {
2743                provider: ModelProvider::OpenAICompatible {
2744                    base_url: "https://custom.azure.com/openai/v1".into(),
2745                    api_key: Some("test-key".into()),
2746                    project_id: None,
2747                },
2748                ..Default::default()
2749            },
2750        };
2751        // Agent has no explicit provider (default OpenAI) but different model
2752        let agent = ModelSettings::new("gpt-5.1");
2753
2754        let result = base.merge(&agent).unwrap();
2755        assert_eq!(result.model, "gpt-5.4");
2756        assert!(matches!(
2757            result.inner.provider,
2758            ModelProvider::OpenAICompatible { .. }
2759        ));
2760    }
2761
2762    #[test]
2763    fn merge_workspace_custom_provider_agent_empty_model() {
2764        let base = ModelSettings {
2765            model: "gpt-5.4".into(),
2766            inner: ModelSettingsInner {
2767                provider: ModelProvider::OpenAICompatible {
2768                    base_url: "https://custom.azure.com/openai/v1".into(),
2769                    api_key: Some("test-key".into()),
2770                    project_id: None,
2771                },
2772                ..Default::default()
2773            },
2774        };
2775        let agent = ModelSettings::new("");
2776
2777        let result = base.merge(&agent).unwrap();
2778        assert_eq!(result.model, "gpt-5.4");
2779    }
2780
2781    #[test]
2782    fn merge_both_empty_returns_none() {
2783        let base = ModelSettings::new("");
2784        let agent = ModelSettings::new("");
2785
2786        assert!(base.merge(&agent).is_none());
2787    }
2788
2789    #[test]
2790    fn merge_workspace_empty_agent_empty_returns_none() {
2791        let base = ModelSettings {
2792            model: "".into(),
2793            inner: ModelSettingsInner {
2794                provider: ModelProvider::OpenAICompatible {
2795                    base_url: "https://custom.com".into(),
2796                    api_key: None,
2797                    project_id: None,
2798                },
2799                ..Default::default()
2800            },
2801        };
2802        let agent = ModelSettings::new("");
2803
2804        assert!(base.merge(&agent).is_none());
2805    }
2806
2807    #[test]
2808    fn merge_temperature_max_tokens_override() {
2809        let base = ModelSettings {
2810            model: "gpt-5.1".into(),
2811            inner: ModelSettingsInner {
2812                temperature: Some(0.5),
2813                max_tokens: Some(1000),
2814                top_p: Some(0.9),
2815                ..Default::default()
2816            },
2817        };
2818        let agent = ModelSettings {
2819            model: "gpt-4.1-mini".into(),
2820            inner: ModelSettingsInner {
2821                temperature: Some(0.9),
2822                max_tokens: None, // no override
2823                ..Default::default()
2824            },
2825        };
2826
2827        let result = base.merge(&agent).unwrap();
2828        assert_eq!(result.model, "gpt-4.1-mini");
2829        assert_eq!(result.inner.temperature, Some(0.9));
2830        assert_eq!(result.inner.max_tokens, Some(1000)); // base value preserved
2831        assert_eq!(result.inner.top_p, Some(0.9)); // base value preserved
2832    }
2833
2834    #[test]
2835    fn merge_context_size_non_default_wins() {
2836        let base = ModelSettings {
2837            model: "gpt-5.1".into(),
2838            inner: ModelSettingsInner {
2839                context_size: None, // unset
2840                ..Default::default()
2841            },
2842        };
2843        let agent = ModelSettings {
2844            model: "gpt-4.1-mini".into(),
2845            inner: ModelSettingsInner {
2846                context_size: Some(100000), // explicitly set
2847                ..Default::default()
2848            },
2849        };
2850
2851        let result = base.merge(&agent).unwrap();
2852        assert_eq!(result.inner.context_size, Some(100000));
2853    }
2854
2855    #[test]
2856    fn merge_context_size_default_falls_back() {
2857        let base = ModelSettings {
2858            model: "gpt-5.1".into(),
2859            inner: ModelSettingsInner {
2860                context_size: Some(128000),
2861                ..Default::default()
2862            },
2863        };
2864        let agent = ModelSettings {
2865            model: "gpt-4.1-mini".into(),
2866            inner: ModelSettingsInner {
2867                context_size: None, // unset — should use base
2868                ..Default::default()
2869            },
2870        };
2871
2872        let result = base.merge(&agent).unwrap();
2873        assert_eq!(result.inner.context_size, Some(128000));
2874    }
2875
2876    #[test]
2877    fn context_size_legacy_default_deserializes_as_unset() {
2878        // Stored configs carry the old serde default (20000) whether or not
2879        // anyone chose it — it must not mask the catalog's real window.
2880        let ms: ModelSettings =
2881            serde_json::from_str(r#"{"model":"gpt-5.1","context_size":20000}"#).unwrap();
2882        assert_eq!(ms.inner.context_size, None);
2883
2884        let ms: ModelSettings =
2885            serde_json::from_str(r#"{"model":"gpt-5.1","context_size":50000}"#).unwrap();
2886        assert_eq!(ms.inner.context_size, Some(50000));
2887    }
2888
2889    #[test]
2890    fn effective_context_size_prefers_catalog_over_fallback() {
2891        // gpt-4.1 ships in the built-in default_models.json with a real
2892        // context_window — an unset override must resolve to it.
2893        let ms = ModelSettings::new("gpt-4.1");
2894        let catalog = crate::model_lookup()
2895            .context_window("openai", "gpt-4.1")
2896            .expect("gpt-4.1 in built-in catalog");
2897        assert_eq!(ms.effective_context_size(), catalog);
2898
2899        // Explicit override still wins over the catalog.
2900        let mut pinned = ModelSettings::new("gpt-4.1");
2901        pinned.inner.context_size = Some(64000);
2902        assert_eq!(pinned.effective_context_size(), 64000);
2903
2904        // Unknown model → conservative fallback.
2905        let unknown = ModelSettings::new("model-nobody-registered");
2906        assert_eq!(unknown.effective_context_size(), 20000);
2907    }
2908
2909    #[test]
2910    fn merge_azure_ai_foundry_resource_preserved() {
2911        let base = ModelSettings {
2912            model: "gpt-5.4".into(),
2913            inner: ModelSettingsInner {
2914                provider: ModelProvider::AzureAiFoundry {
2915                    resource: "myresource".into(),
2916                    api_key: Some("test-key".into()),
2917                },
2918                ..Default::default()
2919            },
2920        };
2921        let agent = ModelSettings::new("gpt-5.1");
2922
2923        let result = base.merge(&agent).unwrap();
2924        assert_eq!(result.model, "gpt-5.4"); // workspace model wins
2925        assert!(matches!(
2926            result.inner.provider,
2927            ModelProvider::AzureAiFoundry { .. }
2928        ));
2929        if let ModelProvider::AzureAiFoundry { resource, .. } = &result.inner.provider {
2930            assert_eq!(resource, "myresource");
2931        }
2932        assert_eq!(
2933            result.inner.provider.completion_url().as_deref(),
2934            Some("https://myresource.openai.azure.com/openai/v1"),
2935        );
2936    }
2937
2938    #[test]
2939    fn azure_ai_foundry_resource_resolves_openai_url() {
2940        let p = ModelProvider::AzureAiFoundry {
2941            resource: "distri-tts-resource".into(),
2942            api_key: None,
2943        };
2944        assert_eq!(
2945            p.completion_url().as_deref(),
2946            Some("https://distri-tts-resource.openai.azure.com/openai/v1"),
2947        );
2948        // TTS rides the same OpenAI-compatible endpoint today.
2949        assert_eq!(p.tts_url(), p.completion_url());
2950        // An empty resource yields no URL — the caller must hydrate it first.
2951        let empty = ModelProvider::AzureAiFoundry {
2952            resource: String::new(),
2953            api_key: None,
2954        };
2955        assert_eq!(empty.completion_url(), None);
2956    }
2957
2958    #[test]
2959    fn merge_anthropic_provider_preserves_base_url() {
2960        let base = ModelSettings {
2961            model: "claude-sonnet-4".into(),
2962            inner: ModelSettingsInner {
2963                provider: ModelProvider::Anthropic {
2964                    base_url: Some("https://custom.anthropic.com".into()),
2965                    api_key: Some("key".into()),
2966                },
2967                temperature: Some(0.7),
2968                ..Default::default()
2969            },
2970        };
2971        let agent = ModelSettings::new("");
2972
2973        let result = base.merge(&agent).unwrap();
2974        assert_eq!(result.model, "claude-sonnet-4");
2975        assert_eq!(result.inner.temperature, Some(0.7));
2976        if let ModelProvider::Anthropic { base_url, api_key } = result.inner.provider {
2977            assert_eq!(base_url, Some("https://custom.anthropic.com".into()));
2978            assert_eq!(api_key, Some("key".into()));
2979        }
2980    }
2981
2982    #[test]
2983    fn merge_response_format_agent_wins() {
2984        let base = ModelSettings {
2985            model: "gpt-5.1".into(),
2986            inner: ModelSettingsInner {
2987                response_format: Some(serde_json::json!({"type": "text"})),
2988                ..Default::default()
2989            },
2990        };
2991        let agent = ModelSettings {
2992            model: "gpt-4.1-mini".into(),
2993            inner: ModelSettingsInner {
2994                response_format: Some(serde_json::json!({"type": "json_object"})),
2995                ..Default::default()
2996            },
2997        };
2998
2999        let result = base.merge(&agent).unwrap();
3000        assert_eq!(
3001            result.inner.response_format,
3002            Some(serde_json::json!({"type": "json_object"}))
3003        );
3004    }
3005
3006    #[test]
3007    fn merge_response_format_base_fallback() {
3008        let base = ModelSettings {
3009            model: "gpt-5.1".into(),
3010            inner: ModelSettingsInner {
3011                response_format: Some(serde_json::json!({"type": "text"})),
3012                ..Default::default()
3013            },
3014        };
3015        let agent = ModelSettings::new("gpt-4.1-mini");
3016
3017        let result = base.merge(&agent).unwrap();
3018        assert_eq!(
3019            result.inner.response_format,
3020            Some(serde_json::json!({"type": "text"}))
3021        );
3022    }
3023
3024    #[test]
3025    fn merge_parameters_agent_wins() {
3026        let base = ModelSettings {
3027            model: "gpt-5.1".into(),
3028            inner: ModelSettingsInner {
3029                parameters: Some(serde_json::json!({"key": "base"})),
3030                ..Default::default()
3031            },
3032        };
3033        let agent = ModelSettings {
3034            model: "gpt-4.1-mini".into(),
3035            inner: ModelSettingsInner {
3036                parameters: Some(serde_json::json!({"key": "agent"})),
3037                ..Default::default()
3038            },
3039        };
3040
3041        let result = base.merge(&agent).unwrap();
3042        assert_eq!(
3043            result.inner.parameters,
3044            Some(serde_json::json!({"key": "agent"}))
3045        );
3046    }
3047
3048    /// Lock the canonical API-key secret name for every provider variant.
3049    /// Three layers depend on this: gateway (`provider_config.rs`),
3050    /// validator (`required_secret_keys`), and workspace resolution
3051    /// (`cloud::WorkspaceStore::resolve_model_settings`). They all flow
3052    /// through `ModelProvider::api_key_secret()` — keep this list in sync
3053    /// with `default_models.json` (asserted in
3054    /// `test_api_key_secret_matches_default_models_json`).
3055    #[test]
3056    fn test_api_key_secret_canonical_names() {
3057        assert_eq!(ModelProvider::OpenAI {}.api_key_secret(), "OPENAI_API_KEY");
3058        assert_eq!(
3059            ModelProvider::Anthropic {
3060                base_url: None,
3061                api_key: None,
3062            }
3063            .api_key_secret(),
3064            "ANTHROPIC_API_KEY"
3065        );
3066        assert_eq!(
3067            ModelProvider::Gemini {
3068                base_url: ModelProvider::gemini_base_url(),
3069                api_key: None,
3070            }
3071            .api_key_secret(),
3072            "GEMINI_API_KEY"
3073        );
3074        assert_eq!(
3075            ModelProvider::AzureOpenAI {
3076                base_url: String::new(),
3077                api_key: None,
3078                deployment: "x".into(),
3079                api_version: ModelProvider::azure_api_version(),
3080            }
3081            .api_key_secret(),
3082            "AZURE_OPENAI_API_KEY"
3083        );
3084        assert_eq!(
3085            ModelProvider::AzureAiFoundry {
3086                resource: String::new(),
3087                api_key: None,
3088            }
3089            .api_key_secret(),
3090            "AZURE_AI_FOUNDRY_API_KEY"
3091        );
3092        assert_eq!(
3093            ModelProvider::AwsBedrock {
3094                base_url: String::new(),
3095                api_key: None,
3096            }
3097            .api_key_secret(),
3098            "AWS_ACCESS_KEY_ID"
3099        );
3100        assert_eq!(
3101            ModelProvider::GoogleVertex {
3102                base_url: String::new(),
3103                api_key: None,
3104                project_id: None,
3105            }
3106            .api_key_secret(),
3107            "GOOGLE_VERTEX_API_KEY"
3108        );
3109        assert_eq!(
3110            ModelProvider::AlibabaCloud {
3111                base_url: ModelProvider::alibaba_cloud_base_url(),
3112                api_key: None,
3113            }
3114            .api_key_secret(),
3115            "DASHSCOPE_API_KEY"
3116        );
3117        assert_eq!(
3118            ModelProvider::OpenAICompatible {
3119                base_url: String::new(),
3120                api_key: None,
3121                project_id: None,
3122            }
3123            .api_key_secret(),
3124            "OPENAI_API_KEY"
3125        );
3126        assert_eq!(
3127            ModelProvider::FalAi { api_key: None }.api_key_secret(),
3128            "FAL_KEY"
3129        );
3130    }
3131
3132    /// `default_models.json` drives the UI's secret editor (via
3133    /// `/v1/providers`). For every built-in provider listed there, the first
3134    /// `*_API_KEY` entry MUST equal what `ModelProvider::api_key_secret()`
3135    /// returns — otherwise the UI will tell users to enter a secret name
3136    /// that the backend won't look up. This test catches drift.
3137    ///
3138    /// Only the OSS basics live in `default_models.json`; bespoke providers
3139    /// (Azure AI Foundry, Bedrock, …) ship as deployment extensions and are
3140    /// covered by `test_api_key_secret` above plus the cloud's config test.
3141    #[test]
3142    fn test_api_key_secret_matches_default_models_json() {
3143        let providers = ModelProvider::all_provider_definitions();
3144        let cases: &[(&str, ModelProvider)] = &[
3145            ("openai", ModelProvider::OpenAI {}),
3146            (
3147                "anthropic",
3148                ModelProvider::Anthropic {
3149                    base_url: None,
3150                    api_key: None,
3151                },
3152            ),
3153            (
3154                "gemini",
3155                ModelProvider::Gemini {
3156                    base_url: ModelProvider::gemini_base_url(),
3157                    api_key: None,
3158                },
3159            ),
3160        ];
3161
3162        for (id, variant) in cases {
3163            let def = providers
3164                .iter()
3165                .find(|p| p.id == *id)
3166                .unwrap_or_else(|| panic!("provider '{}' missing from default_models.json", id));
3167            let first_api_key_in_json = def
3168                .keys
3169                .iter()
3170                .map(|k| k.key.as_str())
3171                .find(|k| k.ends_with("_API_KEY") || *k == "AWS_ACCESS_KEY_ID")
3172                .unwrap_or_else(|| {
3173                    panic!(
3174                        "provider '{}' has no API key entry in default_models.json",
3175                        id
3176                    )
3177                });
3178            assert_eq!(
3179                first_api_key_in_json,
3180                variant.api_key_secret(),
3181                "provider '{}': default_models.json key {:?} != api_key_secret() {:?}",
3182                id,
3183                first_api_key_in_json,
3184                variant.api_key_secret(),
3185            );
3186        }
3187    }
3188
3189    fn entry(id: &str, label: &str) -> DefaultProviderEntry {
3190        DefaultProviderEntry {
3191            id: id.to_string(),
3192            label: label.to_string(),
3193            keys: vec![],
3194            models: vec![],
3195            test: None,
3196            category: None,
3197        }
3198    }
3199
3200    /// Layer 2 (extensions) overrides built-ins by `id` and appends new ones.
3201    #[test]
3202    fn test_merge_provider_layers_overrides_and_appends() {
3203        let builtin = vec![entry("openai", "OpenAI"), entry("anthropic", "Anthropic")];
3204        let extensions = vec![
3205            entry("anthropic", "Anthropic (override)"),
3206            entry("azure_ai_foundry", "Azure AI Foundry"),
3207        ];
3208        let merged = merge_provider_layers(&builtin, &extensions);
3209
3210        assert_eq!(merged.len(), 3);
3211        assert_eq!(
3212            merged.iter().find(|p| p.id == "openai").unwrap().label,
3213            "OpenAI",
3214            "untouched built-in is preserved"
3215        );
3216        assert_eq!(
3217            merged.iter().find(|p| p.id == "anthropic").unwrap().label,
3218            "Anthropic (override)",
3219            "extension overrides the built-in with the same id"
3220        );
3221        assert!(
3222            merged.iter().any(|p| p.id == "azure_ai_foundry"),
3223            "extension with a new id is appended"
3224        );
3225    }
3226
3227    /// `register_provider_extensions` accepts `ModelProviderDefinition` and
3228    /// backfills an empty model `name` from `id`.
3229    #[test]
3230    fn test_model_provider_definition_conversion_backfills_name() {
3231        use crate::models::{Model, ModelCapability, ModelProviderDefinition};
3232        let def = ModelProviderDefinition {
3233            id: "acme".to_string(),
3234            label: "Acme".to_string(),
3235            keys: vec![],
3236            models: vec![Model {
3237                id: "acme-large".to_string(),
3238                name: String::new(),
3239                capability: ModelCapability::Completion,
3240                context_window: None,
3241                pricing: None,
3242                voices: vec![],
3243                formats: vec![],
3244            }],
3245            is_custom: false,
3246            test: None,
3247            category: None,
3248        };
3249        let converted = DefaultProviderEntry::from(def);
3250        assert_eq!(converted.models[0].name, "acme-large");
3251    }
3252
3253    // ── Z.ai coding-plan provider (spec 2026-07-07) ──────────────────────
3254
3255    #[test]
3256    fn zai_provider_parses_with_default_base_url() {
3257        let ms = ModelSettings::from_provider_model_str("zai/glm-4.6")
3258            .unwrap()
3259            .expect("zai/glm-4.6 should parse");
3260        assert_eq!(ms.model, "glm-4.6");
3261        match ms.inner.provider {
3262            ModelProvider::ZAi { base_url, api_key } => {
3263                assert_eq!(base_url, "https://api.z.ai/api/anthropic");
3264                assert!(api_key.is_none());
3265            }
3266            other => panic!("expected ModelProvider::ZAi, got {other:?}"),
3267        }
3268    }
3269
3270    #[test]
3271    fn zai_provider_metadata() {
3272        let p = ModelProvider::ZAi {
3273            base_url: ModelProvider::zai_base_url(),
3274            api_key: None,
3275        };
3276        assert_eq!(p.api_key_secret(), "ZAI_API_KEY");
3277        assert_eq!(p.provider_id(), "zai");
3278        assert_eq!(p.display_name(), "Z.ai");
3279        let (base_url, _key) = p.resolved_endpoint();
3280        assert_eq!(base_url.as_deref(), Some("https://api.z.ai/api/anthropic"));
3281    }
3282
3283    #[test]
3284    fn zai_provider_hydrates_key_not_base_url() {
3285        // base_url has a default and is NOT a hydrated secret (like Anthropic);
3286        // api_key IS hydrated from ZAI_API_KEY.
3287        let mut p = ModelProvider::ZAi {
3288            base_url: ModelProvider::zai_base_url(),
3289            api_key: None,
3290        };
3291        assert!(p.base_url_slot_mut().is_none());
3292        assert!(p.api_key_slot_mut().is_some());
3293    }
3294}