starweaver_model/transport/
config.rs1use 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
11#[serde(tag = "kind", rename_all = "snake_case")]
12pub enum AuthConfig {
13 Bearer {
15 token: String,
17 },
18 Header {
20 name: String,
22 value: String,
24 },
25}
26
27#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
29pub struct HttpModelConfig {
30 pub base_url: String,
32 pub endpoint_path: String,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub api_root_path: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub provider_endpoint_path: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub auth: Option<AuthConfig>,
43 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
45 pub headers: BTreeMap<String, String>,
46 #[serde(default, skip_serializing_if = "Map::is_empty")]
48 pub extra_body: Map<String, Value>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub timeout_ms: Option<u64>,
52 #[serde(default)]
54 pub retry_policy: RetryPolicy,
55 #[serde(default)]
57 pub max_tokens_parameter: MaxTokensParameter,
58 #[serde(default, skip_serializing_if = "Map::is_empty")]
60 pub metadata: Map<String, Value>,
61}
62
63impl HttpModelConfig {
64 #[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 #[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 #[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 pub fn set_base_url(&mut self, base_url: impl Into<String>) {
109 self.base_url = base_url.into();
110 }
111
112 #[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 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 #[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#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
175pub struct HttpRequestOptions {
176 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
178 pub headers: BTreeMap<String, String>,
179 #[serde(default, skip_serializing_if = "Map::is_empty")]
181 pub extra_body: Map<String, Value>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub endpoint_url: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub timeout_ms: Option<u64>,
188 #[serde(default, skip_serializing_if = "Map::is_empty")]
190 pub metadata: Map<String, Value>,
191}
192
193#[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
204pub 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#[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}