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