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, PartialEq)]
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    /// Whether zero-valued cache usage fields from this provider represent a
51    /// real cache miss. `None` means accounting has not been verified.
52    pub cache_usage_accounting: Option<bool>,
53    pub features: Vec<String>,
54    /// Fallback provider name to try if this provider fails.
55    pub fallback: Option<String>,
56    /// Number of retries before falling back (default 0).
57    pub retry_count: Option<u32>,
58    /// Delay between retries in milliseconds (default 1000).
59    pub retry_delay_ms: Option<u64>,
60    /// Maximum requests per minute. None = unlimited.
61    pub rpm: Option<u32>,
62    /// Rich provider quota metadata. `rpm` remains as a legacy shorthand;
63    /// when both are present, this nested shape is the authoritative catalog
64    /// record and callers can still read the flattened `rpm`.
65    pub rate_limits: Option<RateLimitsDef>,
66    /// Provider/catalog pricing in USD per 1k input tokens.
67    pub cost_per_1k_in: Option<f64>,
68    /// Provider/catalog pricing in USD per 1k output tokens.
69    pub cost_per_1k_out: Option<f64>,
70    /// Observed or configured p50 latency in milliseconds.
71    pub latency_p50_ms: Option<u64>,
72    /// Optional provider-level serving performance observations.
73    pub performance: Option<ServingPerformanceDef>,
74    #[doc(hidden)]
75    pub auth_style_explicit: bool,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
79pub struct ProviderRegionDef {
80    pub base_url: String,
81    #[serde(default)]
82    pub label: Option<String>,
83    #[serde(default)]
84    pub source_url: Option<String>,
85    #[serde(default)]
86    pub last_verified: Option<String>,
87    #[serde(default)]
88    pub notes: Option<String>,
89}
90
91#[derive(Debug, Clone, Deserialize)]
92struct ProviderDefWire {
93    #[serde(default)]
94    display_name: Option<String>,
95    #[serde(default)]
96    icon: Option<String>,
97    #[serde(default)]
98    protocol: Option<String>,
99    #[serde(default)]
100    base_url: String,
101    #[serde(default)]
102    base_url_env: Option<String>,
103    #[serde(default)]
104    region_env: Option<String>,
105    #[serde(default)]
106    regions: BTreeMap<String, ProviderRegionDef>,
107    #[serde(default)]
108    auth_style: Option<String>,
109    #[serde(default)]
110    auth_header: Option<String>,
111    #[serde(default)]
112    auth_env: AuthEnv,
113    #[serde(default)]
114    credential_resolution: Option<String>,
115    #[serde(default)]
116    extra_headers: BTreeMap<String, String>,
117    #[serde(default)]
118    chat_endpoint: String,
119    #[serde(default)]
120    completion_endpoint: Option<String>,
121    #[serde(default)]
122    command: Option<String>,
123    #[serde(default)]
124    args: Vec<String>,
125    #[serde(default)]
126    env: BTreeMap<String, String>,
127    #[serde(default)]
128    cwd: Option<String>,
129    #[serde(default)]
130    mcp_servers: Vec<serde_json::Value>,
131    #[serde(default)]
132    healthcheck: Option<HealthcheckDef>,
133    #[serde(default)]
134    local_runtime: Option<LocalRuntimeDef>,
135    #[serde(default)]
136    cache_usage_accounting: Option<bool>,
137    #[serde(default)]
138    features: Vec<String>,
139    #[serde(default)]
140    fallback: Option<String>,
141    #[serde(default)]
142    retry_count: Option<u32>,
143    #[serde(default)]
144    retry_delay_ms: Option<u64>,
145    #[serde(default)]
146    rpm: Option<u32>,
147    #[serde(default)]
148    rate_limits: Option<RateLimitsDef>,
149    #[serde(default)]
150    cost_per_1k_in: Option<f64>,
151    #[serde(default)]
152    cost_per_1k_out: Option<f64>,
153    #[serde(default)]
154    latency_p50_ms: Option<u64>,
155    #[serde(default)]
156    performance: Option<ServingPerformanceDef>,
157}
158
159impl<'de> Deserialize<'de> for ProviderDef {
160    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
161    where
162        D: serde::Deserializer<'de>,
163    {
164        let wire = ProviderDefWire::deserialize(deserializer)?;
165        let auth_style_explicit = wire.auth_style.is_some();
166        Ok(Self {
167            display_name: wire.display_name,
168            icon: wire.icon,
169            protocol: wire.protocol,
170            base_url: wire.base_url,
171            base_url_env: wire.base_url_env,
172            region_env: wire.region_env,
173            regions: wire.regions,
174            auth_style: wire.auth_style.unwrap_or_else(default_bearer),
175            auth_header: wire.auth_header,
176            auth_env: wire.auth_env,
177            credential_resolution: wire
178                .credential_resolution
179                .unwrap_or_else(default_credential_resolution),
180            extra_headers: wire.extra_headers,
181            chat_endpoint: wire.chat_endpoint,
182            completion_endpoint: wire.completion_endpoint,
183            command: wire.command,
184            args: wire.args,
185            env: wire.env,
186            cwd: wire.cwd,
187            mcp_servers: wire.mcp_servers,
188            healthcheck: wire.healthcheck,
189            local_runtime: wire.local_runtime,
190            cache_usage_accounting: wire.cache_usage_accounting,
191            features: wire.features,
192            fallback: wire.fallback,
193            retry_count: wire.retry_count,
194            retry_delay_ms: wire.retry_delay_ms,
195            rpm: wire.rpm,
196            rate_limits: wire.rate_limits,
197            cost_per_1k_in: wire.cost_per_1k_in,
198            cost_per_1k_out: wire.cost_per_1k_out,
199            latency_p50_ms: wire.latency_p50_ms,
200            performance: wire.performance,
201            auth_style_explicit,
202        })
203    }
204}
205
206impl Default for ProviderDef {
207    fn default() -> Self {
208        Self {
209            display_name: None,
210            icon: None,
211            protocol: None,
212            base_url: String::new(),
213            base_url_env: None,
214            region_env: None,
215            regions: BTreeMap::new(),
216            auth_style: default_bearer(),
217            auth_header: None,
218            auth_env: AuthEnv::None,
219            credential_resolution: default_credential_resolution(),
220            extra_headers: BTreeMap::new(),
221            chat_endpoint: String::new(),
222            completion_endpoint: None,
223            command: None,
224            args: Vec::new(),
225            env: BTreeMap::new(),
226            cwd: None,
227            mcp_servers: Vec::new(),
228            healthcheck: None,
229            local_runtime: None,
230            cache_usage_accounting: None,
231            features: Vec::new(),
232            fallback: None,
233            retry_count: None,
234            retry_delay_ms: None,
235            rpm: None,
236            rate_limits: None,
237            cost_per_1k_in: None,
238            cost_per_1k_out: None,
239            latency_p50_ms: None,
240            performance: None,
241            auth_style_explicit: false,
242        }
243    }
244}
245
246impl ProviderDef {
247    pub(crate) fn merge_from(&mut self, overlay: &ProviderDef) {
248        merge_option(&mut self.display_name, &overlay.display_name);
249        merge_option(&mut self.icon, &overlay.icon);
250        merge_option(&mut self.protocol, &overlay.protocol);
251        merge_string(&mut self.base_url, &overlay.base_url);
252        merge_option(&mut self.base_url_env, &overlay.base_url_env);
253        merge_option(&mut self.region_env, &overlay.region_env);
254        self.regions.extend(overlay.regions.clone());
255        let overlay_uses_default_auth_style = overlay.auth_style == default_bearer();
256        if overlay.auth_style_explicit
257            || !overlay_uses_default_auth_style
258            || self.auth_style == default_bearer()
259        {
260            self.auth_style = overlay.auth_style.clone();
261            self.auth_style_explicit |=
262                overlay.auth_style_explicit || !overlay_uses_default_auth_style;
263        }
264        merge_option(&mut self.auth_header, &overlay.auth_header);
265        if !overlay.auth_env.is_none() {
266            self.auth_env = overlay.auth_env.clone();
267        }
268        if overlay.credential_resolution != default_credential_resolution() {
269            self.credential_resolution = overlay.credential_resolution.clone();
270        }
271        self.extra_headers.extend(overlay.extra_headers.clone());
272        merge_string(&mut self.chat_endpoint, &overlay.chat_endpoint);
273        merge_option(&mut self.completion_endpoint, &overlay.completion_endpoint);
274        merge_option(&mut self.command, &overlay.command);
275        merge_vec(&mut self.args, &overlay.args);
276        self.env.extend(overlay.env.clone());
277        merge_option(&mut self.cwd, &overlay.cwd);
278        merge_vec(&mut self.mcp_servers, &overlay.mcp_servers);
279        merge_option(&mut self.healthcheck, &overlay.healthcheck);
280        merge_option(&mut self.local_runtime, &overlay.local_runtime);
281        merge_option(
282            &mut self.cache_usage_accounting,
283            &overlay.cache_usage_accounting,
284        );
285        merge_vec(&mut self.features, &overlay.features);
286        merge_option(&mut self.fallback, &overlay.fallback);
287        merge_option(&mut self.retry_count, &overlay.retry_count);
288        merge_option(&mut self.retry_delay_ms, &overlay.retry_delay_ms);
289        merge_option(&mut self.rpm, &overlay.rpm);
290        merge_option(&mut self.rate_limits, &overlay.rate_limits);
291        merge_option(&mut self.cost_per_1k_in, &overlay.cost_per_1k_in);
292        merge_option(&mut self.cost_per_1k_out, &overlay.cost_per_1k_out);
293        merge_option(&mut self.latency_p50_ms, &overlay.latency_p50_ms);
294        merge_option(&mut self.performance, &overlay.performance);
295    }
296}
297
298fn merge_option<T: Clone>(base: &mut Option<T>, overlay: &Option<T>) {
299    if overlay.is_some() {
300        *base = overlay.clone();
301    }
302}
303
304fn merge_string(base: &mut String, overlay: &str) {
305    if !overlay.is_empty() {
306        *base = overlay.to_string();
307    }
308}
309
310fn merge_vec<T: Clone>(base: &mut Vec<T>, overlay: &[T]) {
311    if !overlay.is_empty() {
312        *base = overlay.to_vec();
313    }
314}
315
316fn default_bearer() -> String {
317    "bearer".to_string()
318}
319
320fn default_credential_resolution() -> String {
321    "env".to_string()
322}
323
324impl ProviderDef {
325    /// Whether this provider resolves its own credentials through a
326    /// multi-step chain (AWS SigV4 credential chain, GCP ADC / service
327    /// account JSON, etc.) rather than the generic `auth_env` lookup.
328    /// Callers that would otherwise hardcode a provider-name match (e.g.
329    /// "does this provider need the generic missing-API-key error") should
330    /// read this instead.
331    pub fn is_credential_resolution_platform_managed(&self) -> bool {
332        self.credential_resolution == "platform_managed"
333    }
334}
335
336/// Auth env var name(s) for the provider. Can be a single string or an array
337/// (tried in order until one is set).
338#[derive(Debug, Clone, Deserialize, Default, PartialEq, Eq)]
339#[serde(untagged)]
340pub enum AuthEnv {
341    #[default]
342    None,
343    Single(String),
344    Multiple(Vec<String>),
345}
346
347impl AuthEnv {
348    fn is_none(&self) -> bool {
349        matches!(self, AuthEnv::None)
350    }
351}
352
353/// Resolve the configured base URL for a provider, checking `base_url_env`,
354/// any named `region_env`, then the catalog `base_url`. Host-verified runtime
355/// endpoints are applied by [`crate::llm_config::provider_config`] before this
356/// public catalog DTO reaches a transport consumer.
357pub fn resolve_base_url(pdef: &ProviderDef) -> String {
358    if let Some(env_name) = &pdef.base_url_env {
359        if let Some(val) = crate::stdlib::process::session_env_var(env_name)
360            .ok()
361            .flatten()
362        {
363            // Strip surrounding quotes that some .env parsers leave intact.
364            let trimmed = val.trim().trim_matches('"').trim_matches('\'');
365            if !trimmed.is_empty() {
366                return trimmed.to_string();
367            }
368        }
369    }
370    if let Some(env_name) = &pdef.region_env {
371        if let Some(val) = crate::stdlib::process::session_env_var(env_name)
372            .ok()
373            .flatten()
374        {
375            let region = val.trim().trim_matches('"').trim_matches('\'');
376            if !region.is_empty() {
377                let endpoint = pdef
378                    .regions
379                    .get(region)
380                    .or_else(|| pdef.regions.get(&region.to_ascii_lowercase()));
381                if let Some(endpoint) = endpoint {
382                    let base_url = endpoint.base_url.trim();
383                    if !base_url.is_empty() {
384                        return base_url.to_string();
385                    }
386                }
387            }
388        }
389    }
390    pdef.base_url.clone()
391}