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