#[cfg(feature = "openai")]
use crate::backend::OpenAiBackend;
use crate::backend::{Backend, BackoffConfig, OllamaBackend};
use crate::events::EventHandler;
use crate::limits::PipelineLimits;
#[allow(deprecated)]
use crate::trace::TraceId;
use reqwest::Client;
use stack_ids::TraceCtx;
use std::collections::HashMap;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::time::Duration;
#[allow(deprecated)]
pub struct ExecCtx {
pub client: Client,
pub base_url: String,
pub backend: Arc<dyn Backend>,
pub backoff: BackoffConfig,
pub vars: HashMap<String, String>,
pub cancellation: Option<Arc<AtomicBool>>,
pub event_handler: Option<Arc<dyn EventHandler>>,
#[deprecated(
note = "Use trace_ctx instead. Will be removed when all callers migrate to TraceCtx."
)]
pub trace_id: TraceId,
pub trace_ctx: TraceCtx,
pub limits: PipelineLimits,
}
#[allow(deprecated)]
impl ExecCtx {
pub fn builder(base_url: impl Into<String>) -> ExecCtxBuilder {
ExecCtxBuilder {
client: None,
base_url: base_url.into(),
backend: None,
backoff: None,
vars: HashMap::new(),
cancellation: None,
event_handler: None,
timeout: None,
trace_id: None,
trace_ctx: None,
limits: None,
}
}
pub fn is_cancelled(&self) -> bool {
self.cancellation
.as_ref()
.is_some_and(|c| c.load(Ordering::Relaxed))
}
pub fn check_cancelled(&self) -> crate::error::Result<()> {
if self.is_cancelled() {
return Err(crate::PipelineError::Cancelled);
}
Ok(())
}
pub fn cancel_flag(&self) -> Option<&AtomicBool> {
self.cancellation.as_deref()
}
}
#[allow(deprecated)]
impl std::fmt::Debug for ExecCtx {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExecCtx")
.field("base_url", &self.base_url)
.field("backend", &self.backend.name())
.field("backoff", &self.backoff)
.field("vars_count", &self.vars.len())
.field("has_cancellation", &self.cancellation.is_some())
.field("has_event_handler", &self.event_handler.is_some())
.field("trace_id", &self.trace_id)
.field("trace_ctx", &self.trace_ctx)
.field("limits", &self.limits)
.finish()
}
}
pub struct ExecCtxBuilder {
client: Option<Client>,
base_url: String,
backend: Option<Arc<dyn Backend>>,
backoff: Option<BackoffConfig>,
vars: HashMap<String, String>,
cancellation: Option<Arc<AtomicBool>>,
event_handler: Option<Arc<dyn EventHandler>>,
timeout: Option<Duration>,
trace_id: Option<TraceId>,
trace_ctx: Option<TraceCtx>,
limits: Option<PipelineLimits>,
}
#[allow(deprecated)]
impl ExecCtxBuilder {
pub fn client(mut self, client: Client) -> Self {
self.client = Some(client);
self
}
pub fn backend(mut self, backend: Arc<dyn Backend>) -> Self {
self.backend = Some(backend);
self
}
#[cfg(feature = "openai")]
pub fn openai(mut self) -> Self {
self.backend = Some(Arc::new(OpenAiBackend::new()));
self
}
#[cfg(feature = "openai")]
pub fn openai_with_key(mut self, api_key: impl Into<String>) -> Self {
self.backend = Some(Arc::new(OpenAiBackend::new().with_api_key(api_key)));
self
}
pub fn backoff(mut self, config: BackoffConfig) -> Self {
self.backoff = Some(config);
self
}
pub fn vars(mut self, vars: HashMap<String, String>) -> Self {
self.vars = vars;
self
}
pub fn var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.vars.insert(key.into(), value.into());
self
}
pub fn cancellation(mut self, cancel: Option<Arc<AtomicBool>>) -> Self {
self.cancellation = cancel;
self
}
pub fn event_handler(mut self, handler: Arc<dyn EventHandler>) -> Self {
self.event_handler = Some(handler);
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
#[deprecated(
since = "0.6.0",
note = "Use with_trace_ctx() instead. This method will be removed in v1.0."
)]
pub fn with_trace_id(mut self, trace_id: TraceId) -> Self {
self.trace_id = Some(trace_id);
self
}
pub fn with_trace_ctx(mut self, trace_ctx: TraceCtx) -> Self {
self.trace_ctx = Some(trace_ctx);
self
}
pub fn with_limits(mut self, limits: PipelineLimits) -> Self {
self.limits = Some(limits);
self
}
pub fn build(self) -> ExecCtx {
let limits = self.limits.unwrap_or_default();
let client_timeout = self.timeout.unwrap_or(Duration::from_secs(300));
let client = self.client.unwrap_or_else(|| {
Client::builder()
.timeout(client_timeout)
.build()
.expect("Failed to build HTTP client")
});
let (trace_id, trace_ctx) = match (self.trace_ctx, self.trace_id) {
(Some(ctx), _) => {
let legacy = TraceId::from_trace_ctx(&ctx);
(legacy, ctx)
}
(None, Some(id)) => {
let ctx = id.to_trace_ctx();
(id, ctx)
}
(None, None) => {
let ctx = TraceCtx::generate();
let legacy = TraceId::from_trace_ctx(&ctx);
(legacy, ctx)
}
};
ExecCtx {
client,
base_url: normalize_base_url(&self.base_url),
backend: self.backend.unwrap_or_else(|| Arc::new(OllamaBackend)),
backoff: self.backoff.unwrap_or_else(BackoffConfig::none),
vars: self.vars,
cancellation: self.cancellation,
event_handler: self.event_handler,
trace_id,
trace_ctx,
limits,
}
}
}
fn normalize_base_url(url: &str) -> String {
let trimmed = url.trim_end_matches('/');
for suffix in &[
"/v1/chat/completions",
"/v1/chat",
"/v1",
"/api/generate",
"/api/chat",
"/api",
] {
if let Some(stripped) = trimmed.strip_suffix(suffix) {
return stripped.to_string();
}
}
trimmed.to_string()
}
#[allow(deprecated)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_base_url_strips_v1() {
assert_eq!(
normalize_base_url("https://api.openai.com/v1"),
"https://api.openai.com"
);
assert_eq!(
normalize_base_url("https://api.openai.com/v1/"),
"https://api.openai.com"
);
}
#[test]
fn test_normalize_base_url_strips_api() {
assert_eq!(
normalize_base_url("http://localhost:11434/api"),
"http://localhost:11434"
);
assert_eq!(
normalize_base_url("http://localhost:11434/api/"),
"http://localhost:11434"
);
}
#[test]
fn test_normalize_base_url_preserves_clean() {
assert_eq!(
normalize_base_url("http://localhost:11434"),
"http://localhost:11434"
);
assert_eq!(
normalize_base_url("https://api.openai.com"),
"https://api.openai.com"
);
}
#[test]
fn test_normalize_base_url_strips_full_path() {
assert_eq!(
normalize_base_url("https://api.openai.com/v1/chat/completions"),
"https://api.openai.com"
);
}
#[test]
fn test_normalize_base_url_trailing_slash() {
assert_eq!(
normalize_base_url("http://localhost:11434/"),
"http://localhost:11434"
);
}
#[test]
fn test_default_timeout_applied() {
let _ctx = ExecCtx::builder("http://localhost:11434")
.timeout(Duration::from_secs(120))
.build();
}
#[test]
fn test_trace_ctx_generated_by_default() {
let ctx = ExecCtx::builder("http://localhost:11434").build();
assert!(!ctx.trace_id.as_str().is_empty());
assert!(!ctx.trace_ctx.trace_id.is_empty());
}
#[test]
fn test_trace_ctx_explicit_sets_legacy() {
let trace = TraceCtx::from_trace_id("0af7651916cd43dd8448eb211c80319c");
let ctx = ExecCtx::builder("http://localhost:11434")
.with_trace_ctx(trace.clone())
.build();
assert_eq!(ctx.trace_ctx.trace_id, "0af7651916cd43dd8448eb211c80319c");
assert_eq!(ctx.trace_id.as_str(), "0af7651916cd43dd8448eb211c80319c");
}
#[test]
fn test_legacy_trace_id_derives_trace_ctx() {
let id = TraceId::from_string("my-legacy-trace");
let ctx = ExecCtx::builder("http://localhost:11434")
.with_trace_id(id)
.build();
assert_eq!(ctx.trace_id.as_str(), "my-legacy-trace");
assert_eq!(ctx.trace_ctx.trace_id, "my-legacy-trace");
}
#[test]
fn test_trace_ctx_takes_priority_over_trace_id() {
let trace = TraceCtx::from_trace_id("canonical-trace");
let id = TraceId::from_string("legacy-trace");
let ctx = ExecCtx::builder("http://localhost:11434")
.with_trace_id(id)
.with_trace_ctx(trace)
.build();
assert_eq!(ctx.trace_ctx.trace_id, "canonical-trace");
assert_eq!(ctx.trace_id.as_str(), "canonical-trace");
}
}