specado_core/
lib.rs

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/// Options for steering name-based provider resolution.
40#[derive(Debug, Default, Clone)]
41pub struct ExecuteOptions {
42    /// Optional model identifier used to disambiguate provider directories.
43    pub model: Option<String>,
44    /// Directory containing provider specifications to search.
45    pub providers_dir: Option<PathBuf>,
46}
47
48impl ExecuteOptions {
49    /// Construct options with no overrides (equivalent to `Default::default()`).
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Convenience helper for specifying only the model override.
55    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
78/// Execute a prompt using a provider identified by name or path.
79pub 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
97/// Execute a prompt using an explicit provider spec path.
98pub 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}