1#![allow(dead_code)]
2
3pub mod adapter;
4#[cfg(feature = "audit-logging")]
5pub mod audit;
6pub mod auth;
7pub mod circuit_breaker;
8pub mod error;
9pub mod hot_reload;
10pub mod http;
11pub mod retry;
12pub mod router;
13pub mod transformer;
14pub mod types;
15
16mod resolver;
17
18pub use adapter::{AdapterMatchRule, AdapterRegistry, AdapterSelection};
19pub use auth::{AuthError, AuthHandler, AuthScheme};
20pub use circuit_breaker::CircuitBreaker;
21pub use error::{Error, ProviderErrorKind, Result};
22pub use retry::RetryPolicy;
23pub use router::{PrimaryFallbackRouter, Router};
24pub use types::*;
25
26use crate::hot_reload::global_cache;
27use crate::http::get_client;
28#[cfg(feature = "cli-resolver")]
29use crate::resolver::default_provider_path_internal;
30use crate::resolver::resolve_provider_path_internal;
31use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
32use reqwest::StatusCode;
33use serde_json::Value;
34use std::path::{Path, PathBuf};
35
36#[cfg(feature = "audit-logging")]
37use crate::audit::AuditContext;
38
39#[derive(Debug, Default, Clone)]
41pub struct ExecuteOptions {
42 pub model: Option<String>,
44 pub providers_dir: Option<PathBuf>,
46}
47
48impl ExecuteOptions {
49 pub fn new() -> Self {
51 Self::default()
52 }
53
54 pub fn for_model(model: impl Into<String>) -> Self {
56 Self::default().with_model(model)
57 }
58
59 pub fn with_model(mut self, model: impl Into<String>) -> Self {
60 self.model = Some(model.into());
61 self
62 }
63
64 pub fn with_providers_dir<P: Into<PathBuf>>(mut self, dir: P) -> Self {
65 self.providers_dir = Some(dir.into());
66 self
67 }
68
69 pub fn model(&self) -> Option<&str> {
70 self.model.as_deref()
71 }
72
73 pub fn providers_dir(&self) -> Option<&Path> {
74 self.providers_dir.as_deref()
75 }
76}
77
78pub async fn execute(
80 prompt: PromptSpec,
81 provider: &str,
82 options: ExecuteOptions,
83 #[cfg(feature = "audit-logging")] audit: Option<AuditContext>,
84) -> Result<UniformResponse> {
85 let provider_path =
86 resolve_provider_path_internal(Some(provider), options.model(), options.providers_dir())?;
87
88 execute_from_path(
89 prompt,
90 &provider_path,
91 #[cfg(feature = "audit-logging")]
92 audit,
93 )
94 .await
95}
96
97pub async fn execute_from_path(
99 prompt: PromptSpec,
100 provider_path: impl AsRef<Path>,
101 #[cfg(feature = "audit-logging")] mut audit: Option<AuditContext>,
102) -> Result<UniformResponse> {
103 #[cfg(feature = "audit-logging")]
104 if let Some(ctx) = audit.as_mut() {
105 ctx.reset_timer();
106 }
107
108 let provider_path = provider_path.as_ref();
109
110 let provider_spec = match global_cache().load_or_read(provider_path) {
111 Ok(spec) => {
112 #[cfg(feature = "audit-logging")]
113 if let Some(ctx) = audit.as_mut() {
114 ctx.note_provider(&spec);
115 }
116 spec
117 }
118 Err(err) => {
119 #[cfg(feature = "audit-logging")]
120 if let Some(ctx) = audit.as_mut() {
121 ctx.record_error(None, &err, None);
122 }
123 return Err(err);
124 }
125 };
126
127 let auth_handler = AuthHandler::new(provider_spec.auth.clone());
128 auth_handler.validate()?;
129
130 let (translated, lossiness) = translate(&prompt, &provider_spec)?;
131
132 if prompt.strict_mode == StrictMode::Strict && lossiness.is_lossy {
133 #[cfg(feature = "audit-logging")]
134 if let Some(ctx) = audit.as_mut() {
135 ctx.record_error(Some(&translated), &Error::StrictModeViolation, None);
136 }
137 return Err(Error::StrictModeViolation);
138 }
139
140 let mut headers = provider_spec.endpoints.chat.headers.clone();
141 auth_handler.inject_headers(&mut headers)?;
142
143 let mut header_map = HeaderMap::with_capacity(headers.len());
144 for (key, value) in headers {
145 let name = HeaderName::from_bytes(key.as_bytes())
146 .map_err(|e| Error::Config(format!("Invalid header name '{}': {}", key, e)))?;
147 let header_value = HeaderValue::from_str(&value)
148 .map_err(|e| Error::Config(format!("Invalid header value for '{}': {}", key, e)))?;
149 header_map.insert(name, header_value);
150 }
151
152 let client = get_client();
153 let response = match client
154 .post(&provider_spec.endpoints.chat.url)
155 .headers(header_map)
156 .json(&translated)
157 .send()
158 .await
159 {
160 Ok(resp) => resp,
161 Err(err) => {
162 let error = Error::Http(err);
163 #[cfg(feature = "audit-logging")]
164 if let Some(ctx) = audit.as_mut() {
165 ctx.record_error(Some(&translated), &error, None);
166 }
167 return Err(error);
168 }
169 };
170
171 let status = response.status();
172 if !status.is_success() {
173 let kind = map_status_to_provider_error(status);
174 #[cfg(feature = "audit-logging")]
175 if let Some(ctx) = audit.as_mut() {
176 let body_text = response.text().await.unwrap_or_default();
177 let body_value = if body_text.trim().is_empty() {
178 Value::Null
179 } else {
180 serde_json::from_str(&body_text).unwrap_or(Value::String(body_text))
181 };
182 ctx.record_error(
183 Some(&translated),
184 &Error::Provider {
185 provider: provider_spec.provider.clone(),
186 kind: kind.clone(),
187 },
188 Some(&body_value),
189 );
190 return Err(Error::Provider {
191 provider: provider_spec.provider.clone(),
192 kind,
193 });
194 }
195 #[cfg(not(feature = "audit-logging"))]
196 {
197 let _ = response.text().await;
198 }
199 return Err(Error::Provider {
200 provider: provider_spec.provider.clone(),
201 kind,
202 });
203 }
204
205 let raw_response: Value = response.json().await.map_err(Error::Http)?;
206
207 let mut uniform_response = transformer::normalize(raw_response, &provider_spec)?;
208 uniform_response.extensions.lossiness = lossiness;
209
210 #[cfg(feature = "audit-logging")]
211 if let Some(ctx) = audit.as_mut() {
212 ctx.record_success(
213 &translated,
214 &uniform_response,
215 &uniform_response.extensions.lossiness,
216 );
217 }
218
219 Ok(uniform_response)
220}
221
222#[cfg(feature = "cli-resolver")]
223pub fn resolve_provider_path(
224 provider: Option<&str>,
225 model: Option<&str>,
226 providers_dir: Option<&Path>,
227) -> Result<PathBuf> {
228 resolve_provider_path_internal(provider, model, providers_dir)
229}
230
231#[cfg(feature = "cli-resolver")]
232pub fn default_provider_path(providers_dir: Option<&Path>) -> Result<PathBuf> {
233 default_provider_path_internal(providers_dir)
234}
235
236pub fn translate(prompt: &PromptSpec, provider: &ProviderSpec) -> Result<(Value, LossinessReport)> {
237 transformer::translate(prompt, provider)
238}
239
240fn map_status_to_provider_error(status: StatusCode) -> ProviderErrorKind {
241 match status {
242 StatusCode::TOO_MANY_REQUESTS => ProviderErrorKind::RateLimit,
243 StatusCode::REQUEST_TIMEOUT => ProviderErrorKind::Timeout,
244 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ProviderErrorKind::AuthenticationFailed,
245 code if code.is_client_error() => ProviderErrorKind::InvalidRequest,
246 code if code.is_server_error() => ProviderErrorKind::ServerError,
247 _ => ProviderErrorKind::Unknown,
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use httpmock::prelude::*;
255 use serde_json::json;
256 use std::io::Write;
257 use tempfile::NamedTempFile;
258
259 fn sample_prompt() -> PromptSpec {
260 PromptSpec {
261 version: "1".into(),
262 messages: vec![
263 Message {
264 role: MessageRole::System,
265 content: "You are helpful.".into(),
266 },
267 Message {
268 role: MessageRole::User,
269 content: "Hello".into(),
270 },
271 ],
272 sampling: SamplingConfig {
273 temperature: Some(0.5),
274 top_k: Some(20),
275 ..Default::default()
276 },
277 response: ResponseConfig::default(),
278 tools: Vec::new(),
279 tool_choice: None,
280 strict_mode: StrictMode::Warn,
281 metadata: Default::default(),
282 }
283 }
284
285 fn provider_yaml(url: &str, token_env: &str) -> String {
286 format!(
287 r#"provider: openai
288models:
289 - id: gpt-4o
290auth:
291 type: bearer
292 token_env: {token_env}
293endpoints:
294 chat:
295 method: POST
296 url: {url}
297 headers:
298 content-type: application/json
299mappings:
300 request:
301 - from: $.messages
302 to: $.body.messages
303 response:
304 - from: $.data.content
305 to: content
306 - from: $.data.finish_reason
307 to: finish_reason
308constraints:
309 supports:
310 json_mode: true
311 tools: true
312"#,
313 url = url,
314 token_env = token_env
315 )
316 }
317
318 #[tokio::test]
319 async fn execute_sends_request_and_normalizes_response() {
320 let server = MockServer::start();
321 let token_env = "SPECADO_TEST_TOKEN";
322 std::env::set_var(token_env, "secret-token");
323
324 let mock = server.mock(|when, then| {
325 when.method(POST)
326 .path("/chat")
327 .header("authorization", "Bearer secret-token");
328 then.status(200)
329 .header("content-type", "application/json")
330 .json_body(json!({
331 "data": {
332 "content": "hi there",
333 "finish_reason": "stop"
334 }
335 }));
336 });
337
338 let mut tmp = NamedTempFile::new().expect("temp file");
339 write!(tmp, "{}", provider_yaml(&server.url("/chat"), token_env)).expect("write spec");
340
341 let response = execute_from_path(
342 sample_prompt(),
343 tmp.path(),
344 #[cfg(feature = "audit-logging")]
345 None,
346 )
347 .await
348 .expect("execute succeeds");
349
350 mock.assert_hits(1);
351 assert_eq!(response.content, "hi there");
352 assert_eq!(response.finish_reason, FinishReason::Stop);
353 assert_eq!(response.provider_used, "openai");
354
355 std::env::remove_var(token_env);
356 }
357
358 #[tokio::test]
359 async fn execute_enforces_strict_mode_before_http() {
360 let server = MockServer::start();
361 let token_env = "SPECADO_TEST_STRICT_TOKEN";
362 std::env::set_var(token_env, "strict-token");
363
364 let mock = server.mock(|when, then| {
365 when.method(POST).path("/chat");
366 then.status(200)
367 .json_body(json!({"data": {"content": "unused"}}));
368 });
369
370 let provider_yaml = format!(
371 r#"provider: strict
372models:
373 - id: m
374auth:
375 type: bearer
376 token_env: {token_env}
377endpoints:
378 chat:
379 method: POST
380 url: {url}
381 headers: {{}}
382mappings:
383 request: []
384 response:
385 - from: $.data.content
386 to: content
387constraints:
388 supports:
389 json_mode: false
390 tools: false
391"#,
392 url = server.url("/chat"),
393 token_env = token_env
394 );
395
396 let mut tmp = NamedTempFile::new().expect("temp file");
397 write!(tmp, "{}", provider_yaml).expect("write spec");
398
399 let mut prompt = sample_prompt();
400 prompt.strict_mode = StrictMode::Strict;
401
402 let err = execute_from_path(
403 prompt,
404 tmp.path(),
405 #[cfg(feature = "audit-logging")]
406 None,
407 )
408 .await
409 .unwrap_err();
410 assert!(matches!(err, Error::StrictModeViolation));
411 mock.assert_hits(0);
412
413 std::env::remove_var(token_env);
414 }
415}