Skip to main content

starweaver_model/transport/
config.rs

1use std::{collections::BTreeMap, time::Duration};
2
3use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6
7use super::{HttpMethod, HttpRequest, MaxTokensParameter, RetryPolicy};
8
9/// Authentication strategy for HTTP model adapters.
10#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
11#[serde(tag = "kind", rename_all = "snake_case")]
12pub enum AuthConfig {
13    /// Bearer token sent through the `Authorization` header.
14    Bearer {
15        /// Token value.
16        token: String,
17    },
18    /// API key sent through a named header.
19    Header {
20        /// Header name.
21        name: String,
22        /// Header value.
23        value: String,
24    },
25}
26
27/// Provider HTTP configuration shared by protocol clients.
28#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
29pub struct HttpModelConfig {
30    /// Provider or gateway base URL.
31    pub base_url: String,
32    /// Provider-specific endpoint path.
33    pub endpoint_path: String,
34    /// Provider API root path inserted when a configured base URL has no path.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub api_root_path: Option<String>,
37    /// Provider endpoint path relative to the API root.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub provider_endpoint_path: Option<String>,
40    /// Authentication config.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub auth: Option<AuthConfig>,
43    /// Headers applied to all requests.
44    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
45    pub headers: BTreeMap<String, String>,
46    /// Extra JSON body merged into every provider request.
47    #[serde(default, skip_serializing_if = "Map::is_empty")]
48    pub extra_body: Map<String, Value>,
49    /// Default timeout in milliseconds.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub timeout_ms: Option<u64>,
52    /// Retry policy for transient failures.
53    #[serde(default)]
54    pub retry_policy: RetryPolicy,
55    /// Provider or gateway max-token parameter mapping.
56    #[serde(default)]
57    pub max_tokens_parameter: MaxTokensParameter,
58    /// Adapter-level metadata copied into every request.
59    #[serde(default, skip_serializing_if = "Map::is_empty")]
60    pub metadata: Map<String, Value>,
61}
62
63impl HttpModelConfig {
64    /// Create HTTP config from base URL and endpoint path.
65    #[must_use]
66    pub fn new(base_url: impl Into<String>, endpoint_path: impl Into<String>) -> Self {
67        Self {
68            base_url: base_url.into(),
69            endpoint_path: endpoint_path.into(),
70            api_root_path: None,
71            provider_endpoint_path: None,
72            auth: None,
73            headers: BTreeMap::new(),
74            extra_body: Map::new(),
75            timeout_ms: None,
76            retry_policy: RetryPolicy::default(),
77            max_tokens_parameter: MaxTokensParameter::Default,
78            metadata: Map::new(),
79        }
80    }
81
82    /// Create HTTP config for a provider endpoint with a known API root path.
83    ///
84    /// When `base_url` already includes a path, it is treated as a gateway mount point and the
85    /// resolved endpoint is appended directly. When `base_url` has no path, `api_root_path` is
86    /// inserted before the provider endpoint path.
87    #[must_use]
88    pub fn provider_endpoint(
89        base_url: impl Into<String>,
90        api_root_path: impl Into<String>,
91        endpoint_path: impl Into<String>,
92    ) -> Self {
93        let endpoint_path = endpoint_path.into();
94        let mut config = Self::new(base_url, endpoint_path.clone());
95        config.api_root_path = Some(api_root_path.into());
96        config.provider_endpoint_path = Some(endpoint_path);
97        config
98    }
99
100    /// Replace the base URL while preserving provider endpoint root semantics.
101    #[must_use]
102    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
103        self.set_base_url(base_url);
104        self
105    }
106
107    /// Replace the base URL while preserving provider endpoint root semantics.
108    pub fn set_base_url(&mut self, base_url: impl Into<String>) {
109        self.base_url = base_url.into();
110    }
111
112    /// Replace the endpoint path with an explicit caller-provided path.
113    #[must_use]
114    pub fn with_endpoint_path(mut self, endpoint_path: impl Into<String>) -> Self {
115        self.set_endpoint_path(endpoint_path);
116        self
117    }
118
119    /// Replace the endpoint path with an explicit caller-provided path.
120    pub fn set_endpoint_path(&mut self, endpoint_path: impl Into<String>) {
121        self.endpoint_path = endpoint_path.into();
122        self.api_root_path = None;
123        self.provider_endpoint_path = None;
124    }
125
126    /// Resolve the absolute endpoint URL.
127    #[must_use]
128    pub fn endpoint_url(&self) -> String {
129        let base = self.base_url.trim_end_matches('/');
130        let path = self.resolved_endpoint_path();
131        format!("{base}/{path}")
132    }
133
134    fn resolved_endpoint_path(&self) -> String {
135        if let (Some(api_root_path), Some(provider_endpoint_path)) =
136            (&self.api_root_path, &self.provider_endpoint_path)
137        {
138            if base_url_has_path(&self.base_url) {
139                provider_endpoint_path.trim_start_matches('/').to_string()
140            } else {
141                join_paths(api_root_path, provider_endpoint_path)
142            }
143        } else {
144            self.endpoint_path.trim_start_matches('/').to_string()
145        }
146    }
147}
148
149fn base_url_has_path(base_url: &str) -> bool {
150    let trimmed = base_url.trim();
151    let after_scheme = trimmed.split_once("://").map_or(trimmed, |(_, rest)| rest);
152    let Some(path_start) = after_scheme.find('/') else {
153        return false;
154    };
155    after_scheme[path_start + 1..]
156        .split(['?', '#'])
157        .next()
158        .is_some_and(|path| !path.trim_matches('/').is_empty())
159}
160
161fn join_paths(prefix: &str, suffix: &str) -> String {
162    let prefix = prefix.trim_matches('/');
163    let suffix = suffix.trim_start_matches('/');
164    if prefix.is_empty() {
165        suffix.to_string()
166    } else if suffix.is_empty() {
167        prefix.to_string()
168    } else {
169        format!("{prefix}/{suffix}")
170    }
171}
172
173/// Per-request HTTP overrides for gateway, audit, and routing use cases.
174#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
175pub struct HttpRequestOptions {
176    /// Headers applied to this request.
177    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
178    pub headers: BTreeMap<String, String>,
179    /// Extra JSON body merged into this request.
180    #[serde(default, skip_serializing_if = "Map::is_empty")]
181    pub extra_body: Map<String, Value>,
182    /// Endpoint override for this request.
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub endpoint_url: Option<String>,
185    /// Timeout override in milliseconds.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub timeout_ms: Option<u64>,
188    /// Request metadata for tracing and auditing.
189    #[serde(default, skip_serializing_if = "Map::is_empty")]
190    pub metadata: Map<String, Value>,
191}
192
193/// Merge extra JSON body object into a provider request object.
194#[must_use]
195pub fn merge_extra_body(mut body: Value, extra: &Map<String, Value>) -> Value {
196    if let Value::Object(object) = &mut body {
197        for (key, value) in extra {
198            object.insert(key.clone(), value.clone());
199        }
200    }
201    body
202}
203
204/// Extend HTTP headers using case-insensitive header-name replacement.
205pub fn extend_headers_case_insensitive(
206    headers: &mut BTreeMap<String, String>,
207    overlay: impl IntoIterator<Item = (String, String)>,
208) {
209    for (key, value) in overlay {
210        headers.retain(|existing, _| !existing.eq_ignore_ascii_case(&key));
211        headers.insert(key, value);
212    }
213}
214
215fn merge_metadata(config: &HttpModelConfig, options: &HttpRequestOptions) -> Map<String, Value> {
216    let mut metadata = config.metadata.clone();
217    metadata.extend(options.metadata.clone());
218    metadata
219}
220
221/// Build a concrete HTTP request from provider config and overrides.
222#[must_use]
223pub fn build_http_request(
224    config: &HttpModelConfig,
225    options: &HttpRequestOptions,
226    body: Value,
227) -> HttpRequest {
228    let mut headers = BTreeMap::from([(
229        CONTENT_TYPE.as_str().to_string(),
230        "application/json".to_string(),
231    )]);
232
233    match &config.auth {
234        Some(AuthConfig::Bearer { token }) => {
235            extend_headers_case_insensitive(
236                &mut headers,
237                [(
238                    AUTHORIZATION.as_str().to_string(),
239                    format!("Bearer {token}"),
240                )],
241            );
242        }
243        Some(AuthConfig::Header { name, value }) => {
244            extend_headers_case_insensitive(&mut headers, [(name.clone(), value.clone())]);
245        }
246        None => {}
247    }
248
249    extend_headers_case_insensitive(&mut headers, config.headers.clone());
250    extend_headers_case_insensitive(&mut headers, options.headers.clone());
251
252    let body = merge_extra_body(
253        merge_extra_body(body, &config.extra_body),
254        &options.extra_body,
255    );
256    let timeout_ms = options.timeout_ms.or(config.timeout_ms);
257
258    HttpRequest {
259        method: HttpMethod::Post,
260        url: options
261            .endpoint_url
262            .clone()
263            .unwrap_or_else(|| config.endpoint_url()),
264        headers,
265        body,
266        timeout: timeout_ms.map(Duration::from_millis),
267        metadata: merge_metadata(config, options),
268        cancellation_token: starweaver_core::CancellationToken::default(),
269    }
270}