Skip to main content

harn_vm/llm_config/
provider_def.rs

1//! Provider serving definitions: the `ProviderDef` runtime shape, its wire
2//! deserialization form, overlay merge, auth-env selector, and base-URL
3//! resolution.
4use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7
8use super::*;
9
10#[derive(Debug, Clone)]
11pub struct ProviderDef {
12    pub display_name: Option<String>,
13    pub icon: Option<String>,
14    /// Provider protocol. Omitted providers use Harn's normal HTTP provider
15    /// path; `acp` launches an Agent Client Protocol server and drives it as
16    /// an agent-backed provider.
17    pub protocol: Option<String>,
18    pub base_url: String,
19    pub base_url_env: Option<String>,
20    /// Optional env var that selects one of this provider's named regional
21    /// endpoints. `base_url_env` remains the absolute override when set.
22    pub region_env: Option<String>,
23    pub regions: BTreeMap<String, ProviderRegionDef>,
24    pub auth_style: String,
25    pub auth_header: Option<String>,
26    pub auth_env: AuthEnv,
27    /// How this provider's credentials are resolved. `"env"` (default) means
28    /// the generic `auth_env` lookup is authoritative: missing env vars are a
29    /// hard "missing API key" error. `"platform_managed"` means the provider's
30    /// own shim resolves credentials through a multi-step chain the generic
31    /// `auth_env` lookup cannot see (e.g. Bedrock's AWS credential chain —
32    /// env/profile/container/instance-role — or Vertex's bearer token /
33    /// service-account JSON / ADC). Callers must skip the generic `auth_env`
34    /// requirement for these providers and let the shim fail on its own if
35    /// credentials are truly absent, instead of hardcoding provider names.
36    pub credential_resolution: String,
37    pub extra_headers: BTreeMap<String, String>,
38    pub chat_endpoint: String,
39    pub completion_endpoint: Option<String>,
40    pub command: Option<String>,
41    pub args: Vec<String>,
42    pub env: BTreeMap<String, String>,
43    pub cwd: Option<String>,
44    pub mcp_servers: Vec<serde_json::Value>,
45    pub healthcheck: Option<HealthcheckDef>,
46    /// Local runtime lifecycle metadata used by `harn local launch/stop`.
47    /// This is intentionally separate from provider process fields such as
48    /// `command`/`args`, which are used for ACP or external provider adapters.
49    pub local_runtime: Option<LocalRuntimeDef>,
50    pub features: Vec<String>,
51    /// Fallback provider name to try if this provider fails.
52    pub fallback: Option<String>,
53    /// Number of retries before falling back (default 0).
54    pub retry_count: Option<u32>,
55    /// Delay between retries in milliseconds (default 1000).
56    pub retry_delay_ms: Option<u64>,
57    /// Maximum requests per minute. None = unlimited.
58    pub rpm: Option<u32>,
59    /// Rich provider quota metadata. `rpm` remains as a legacy shorthand;
60    /// when both are present, this nested shape is the authoritative catalog
61    /// record and callers can still read the flattened `rpm`.
62    pub rate_limits: Option<RateLimitsDef>,
63    /// Provider/catalog pricing in USD per 1k input tokens.
64    pub cost_per_1k_in: Option<f64>,
65    /// Provider/catalog pricing in USD per 1k output tokens.
66    pub cost_per_1k_out: Option<f64>,
67    /// Observed or configured p50 latency in milliseconds.
68    pub latency_p50_ms: Option<u64>,
69    /// Optional provider-level serving performance observations.
70    pub performance: Option<ServingPerformanceDef>,
71    #[doc(hidden)]
72    pub auth_style_explicit: bool,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
76pub struct ProviderRegionDef {
77    pub base_url: String,
78    #[serde(default)]
79    pub label: Option<String>,
80    #[serde(default)]
81    pub source_url: Option<String>,
82    #[serde(default)]
83    pub last_verified: Option<String>,
84    #[serde(default)]
85    pub notes: Option<String>,
86}
87
88#[derive(Debug, Clone, Deserialize)]
89struct ProviderDefWire {
90    #[serde(default)]
91    display_name: Option<String>,
92    #[serde(default)]
93    icon: Option<String>,
94    #[serde(default)]
95    protocol: Option<String>,
96    #[serde(default)]
97    base_url: String,
98    #[serde(default)]
99    base_url_env: Option<String>,
100    #[serde(default)]
101    region_env: Option<String>,
102    #[serde(default)]
103    regions: BTreeMap<String, ProviderRegionDef>,
104    #[serde(default)]
105    auth_style: Option<String>,
106    #[serde(default)]
107    auth_header: Option<String>,
108    #[serde(default)]
109    auth_env: AuthEnv,
110    #[serde(default)]
111    credential_resolution: Option<String>,
112    #[serde(default)]
113    extra_headers: BTreeMap<String, String>,
114    #[serde(default)]
115    chat_endpoint: String,
116    #[serde(default)]
117    completion_endpoint: Option<String>,
118    #[serde(default)]
119    command: Option<String>,
120    #[serde(default)]
121    args: Vec<String>,
122    #[serde(default)]
123    env: BTreeMap<String, String>,
124    #[serde(default)]
125    cwd: Option<String>,
126    #[serde(default)]
127    mcp_servers: Vec<serde_json::Value>,
128    #[serde(default)]
129    healthcheck: Option<HealthcheckDef>,
130    #[serde(default)]
131    local_runtime: Option<LocalRuntimeDef>,
132    #[serde(default)]
133    features: Vec<String>,
134    #[serde(default)]
135    fallback: Option<String>,
136    #[serde(default)]
137    retry_count: Option<u32>,
138    #[serde(default)]
139    retry_delay_ms: Option<u64>,
140    #[serde(default)]
141    rpm: Option<u32>,
142    #[serde(default)]
143    rate_limits: Option<RateLimitsDef>,
144    #[serde(default)]
145    cost_per_1k_in: Option<f64>,
146    #[serde(default)]
147    cost_per_1k_out: Option<f64>,
148    #[serde(default)]
149    latency_p50_ms: Option<u64>,
150    #[serde(default)]
151    performance: Option<ServingPerformanceDef>,
152}
153
154impl<'de> Deserialize<'de> for ProviderDef {
155    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
156    where
157        D: serde::Deserializer<'de>,
158    {
159        let wire = ProviderDefWire::deserialize(deserializer)?;
160        let auth_style_explicit = wire.auth_style.is_some();
161        Ok(Self {
162            display_name: wire.display_name,
163            icon: wire.icon,
164            protocol: wire.protocol,
165            base_url: wire.base_url,
166            base_url_env: wire.base_url_env,
167            region_env: wire.region_env,
168            regions: wire.regions,
169            auth_style: wire.auth_style.unwrap_or_else(default_bearer),
170            auth_header: wire.auth_header,
171            auth_env: wire.auth_env,
172            credential_resolution: wire
173                .credential_resolution
174                .unwrap_or_else(default_credential_resolution),
175            extra_headers: wire.extra_headers,
176            chat_endpoint: wire.chat_endpoint,
177            completion_endpoint: wire.completion_endpoint,
178            command: wire.command,
179            args: wire.args,
180            env: wire.env,
181            cwd: wire.cwd,
182            mcp_servers: wire.mcp_servers,
183            healthcheck: wire.healthcheck,
184            local_runtime: wire.local_runtime,
185            features: wire.features,
186            fallback: wire.fallback,
187            retry_count: wire.retry_count,
188            retry_delay_ms: wire.retry_delay_ms,
189            rpm: wire.rpm,
190            rate_limits: wire.rate_limits,
191            cost_per_1k_in: wire.cost_per_1k_in,
192            cost_per_1k_out: wire.cost_per_1k_out,
193            latency_p50_ms: wire.latency_p50_ms,
194            performance: wire.performance,
195            auth_style_explicit,
196        })
197    }
198}
199
200impl Default for ProviderDef {
201    fn default() -> Self {
202        Self {
203            display_name: None,
204            icon: None,
205            protocol: None,
206            base_url: String::new(),
207            base_url_env: None,
208            region_env: None,
209            regions: BTreeMap::new(),
210            auth_style: default_bearer(),
211            auth_header: None,
212            auth_env: AuthEnv::None,
213            credential_resolution: default_credential_resolution(),
214            extra_headers: BTreeMap::new(),
215            chat_endpoint: String::new(),
216            completion_endpoint: None,
217            command: None,
218            args: Vec::new(),
219            env: BTreeMap::new(),
220            cwd: None,
221            mcp_servers: Vec::new(),
222            healthcheck: None,
223            local_runtime: None,
224            features: Vec::new(),
225            fallback: None,
226            retry_count: None,
227            retry_delay_ms: None,
228            rpm: None,
229            rate_limits: None,
230            cost_per_1k_in: None,
231            cost_per_1k_out: None,
232            latency_p50_ms: None,
233            performance: None,
234            auth_style_explicit: false,
235        }
236    }
237}
238
239impl ProviderDef {
240    pub(crate) fn merge_from(&mut self, overlay: &ProviderDef) {
241        merge_option(&mut self.display_name, &overlay.display_name);
242        merge_option(&mut self.icon, &overlay.icon);
243        merge_option(&mut self.protocol, &overlay.protocol);
244        merge_string(&mut self.base_url, &overlay.base_url);
245        merge_option(&mut self.base_url_env, &overlay.base_url_env);
246        merge_option(&mut self.region_env, &overlay.region_env);
247        self.regions.extend(overlay.regions.clone());
248        let overlay_uses_default_auth_style = overlay.auth_style == default_bearer();
249        if overlay.auth_style_explicit
250            || !overlay_uses_default_auth_style
251            || self.auth_style == default_bearer()
252        {
253            self.auth_style = overlay.auth_style.clone();
254            self.auth_style_explicit |=
255                overlay.auth_style_explicit || !overlay_uses_default_auth_style;
256        }
257        merge_option(&mut self.auth_header, &overlay.auth_header);
258        if !overlay.auth_env.is_none() {
259            self.auth_env = overlay.auth_env.clone();
260        }
261        if overlay.credential_resolution != default_credential_resolution() {
262            self.credential_resolution = overlay.credential_resolution.clone();
263        }
264        self.extra_headers.extend(overlay.extra_headers.clone());
265        merge_string(&mut self.chat_endpoint, &overlay.chat_endpoint);
266        merge_option(&mut self.completion_endpoint, &overlay.completion_endpoint);
267        merge_option(&mut self.command, &overlay.command);
268        merge_vec(&mut self.args, &overlay.args);
269        self.env.extend(overlay.env.clone());
270        merge_option(&mut self.cwd, &overlay.cwd);
271        merge_vec(&mut self.mcp_servers, &overlay.mcp_servers);
272        merge_option(&mut self.healthcheck, &overlay.healthcheck);
273        merge_option(&mut self.local_runtime, &overlay.local_runtime);
274        merge_vec(&mut self.features, &overlay.features);
275        merge_option(&mut self.fallback, &overlay.fallback);
276        merge_option(&mut self.retry_count, &overlay.retry_count);
277        merge_option(&mut self.retry_delay_ms, &overlay.retry_delay_ms);
278        merge_option(&mut self.rpm, &overlay.rpm);
279        merge_option(&mut self.rate_limits, &overlay.rate_limits);
280        merge_option(&mut self.cost_per_1k_in, &overlay.cost_per_1k_in);
281        merge_option(&mut self.cost_per_1k_out, &overlay.cost_per_1k_out);
282        merge_option(&mut self.latency_p50_ms, &overlay.latency_p50_ms);
283        merge_option(&mut self.performance, &overlay.performance);
284    }
285}
286
287fn merge_option<T: Clone>(base: &mut Option<T>, overlay: &Option<T>) {
288    if overlay.is_some() {
289        *base = overlay.clone();
290    }
291}
292
293fn merge_string(base: &mut String, overlay: &str) {
294    if !overlay.is_empty() {
295        *base = overlay.to_string();
296    }
297}
298
299fn merge_vec<T: Clone>(base: &mut Vec<T>, overlay: &[T]) {
300    if !overlay.is_empty() {
301        *base = overlay.to_vec();
302    }
303}
304
305fn default_bearer() -> String {
306    "bearer".to_string()
307}
308
309fn default_credential_resolution() -> String {
310    "env".to_string()
311}
312
313impl ProviderDef {
314    /// Whether this provider resolves its own credentials through a
315    /// multi-step chain (AWS SigV4 credential chain, GCP ADC / service
316    /// account JSON, etc.) rather than the generic `auth_env` lookup.
317    /// Callers that would otherwise hardcode a provider-name match (e.g.
318    /// "does this provider need the generic missing-API-key error") should
319    /// read this instead.
320    pub fn is_credential_resolution_platform_managed(&self) -> bool {
321        self.credential_resolution == "platform_managed"
322    }
323}
324
325/// Auth env var name(s) for the provider. Can be a single string or an array
326/// (tried in order until one is set).
327#[derive(Debug, Clone, Deserialize, Default)]
328#[serde(untagged)]
329pub enum AuthEnv {
330    #[default]
331    None,
332    Single(String),
333    Multiple(Vec<String>),
334}
335
336impl AuthEnv {
337    fn is_none(&self) -> bool {
338        matches!(self, AuthEnv::None)
339    }
340}
341
342/// Resolve the effective base URL for a provider, checking the `base_url_env`
343/// override first, then any named region selected by `region_env`, then the
344/// configured `base_url`.
345pub fn resolve_base_url(pdef: &ProviderDef) -> String {
346    if let Some(env_name) = &pdef.base_url_env {
347        if let Ok(val) = std::env::var(env_name) {
348            // Strip surrounding quotes that some .env parsers leave intact.
349            let trimmed = val.trim().trim_matches('"').trim_matches('\'');
350            if !trimmed.is_empty() {
351                return trimmed.to_string();
352            }
353        }
354    }
355    if let Some(env_name) = &pdef.region_env {
356        if let Ok(val) = std::env::var(env_name) {
357            let region = val.trim().trim_matches('"').trim_matches('\'');
358            if !region.is_empty() {
359                let endpoint = pdef
360                    .regions
361                    .get(region)
362                    .or_else(|| pdef.regions.get(&region.to_ascii_lowercase()));
363                if let Some(endpoint) = endpoint {
364                    let base_url = endpoint.base_url.trim();
365                    if !base_url.is_empty() {
366                        return base_url.to_string();
367                    }
368                }
369            }
370        }
371    }
372    pdef.base_url.clone()
373}