Skip to main content

agentic_server/
app.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
3
4use axum::Router;
5use axum::middleware;
6use axum::routing::{get, post};
7use http::HeaderValue;
8#[cfg(debug_assertions)]
9use tokio::sync::oneshot;
10use tokio::sync::{Notify, Semaphore, SemaphorePermit};
11use tokio_util::sync::CancellationToken;
12use tower_http::cors::{AllowOrigin, Any, CorsLayer};
13
14use agentic_core::executor::ExecutionContext;
15use agentic_core::proxy::ProxyState;
16
17use crate::auth::{ANTHROPIC_COUNT_TOKENS_PATH, ANTHROPIC_MESSAGES_PATH, OidcAuthenticator, require_oidc};
18use crate::handler::{
19    compact_response, conversations, count_tokens, health, messages, models, ready, responses, responses_ws_with_auth,
20};
21
22#[derive(Clone, Default)]
23pub struct WebSocketTracker {
24    inner: Arc<WebSocketTrackerInner>,
25}
26
27#[derive(Default)]
28struct WebSocketTrackerInner {
29    active: AtomicUsize,
30    idle: Notify,
31    #[cfg(debug_assertions)]
32    local_completion_barrier: std::sync::Mutex<Option<LocalCompletionBarrier>>,
33}
34
35/// Bounds readiness work and records dependency health transitions per server.
36#[derive(Clone)]
37pub struct ReadinessTracker {
38    inner: Arc<ReadinessTrackerInner>,
39}
40
41struct ReadinessTrackerInner {
42    probe: Semaphore,
43    status: AtomicU8,
44}
45
46const READINESS_UNKNOWN: u8 = 0;
47const READINESS_READY: u8 = 1;
48const READINESS_NOT_READY: u8 = 2;
49
50/// Exclusive ownership of one dependency readiness probe.
51pub struct ReadinessProbe<'a> {
52    tracker: &'a ReadinessTracker,
53    _permit: SemaphorePermit<'a>,
54}
55
56impl Default for ReadinessTracker {
57    fn default() -> Self {
58        Self {
59            inner: Arc::new(ReadinessTrackerInner {
60                probe: Semaphore::new(1),
61                status: AtomicU8::new(READINESS_UNKNOWN),
62            }),
63        }
64    }
65}
66
67impl ReadinessTracker {
68    /// Start the only allowed in-flight dependency probe.
69    #[must_use]
70    pub fn try_start_probe(&self) -> Option<ReadinessProbe<'_>> {
71        let permit = self.inner.probe.try_acquire().ok()?;
72        Some(ReadinessProbe {
73            tracker: self,
74            _permit: permit,
75        })
76    }
77
78    /// Return the last completed dependency result, if any.
79    #[must_use]
80    pub fn last_result(&self) -> Option<bool> {
81        match self.inner.status.load(Ordering::Relaxed) {
82            READINESS_READY => Some(true),
83            READINESS_NOT_READY => Some(false),
84            _ => None,
85        }
86    }
87}
88
89impl ReadinessProbe<'_> {
90    /// Complete this probe and return whether dependency readiness changed.
91    #[must_use]
92    pub fn finish(self, ready: bool) -> bool {
93        let current = if ready { READINESS_READY } else { READINESS_NOT_READY };
94        self.tracker.inner.status.swap(current, Ordering::Relaxed) != current
95    }
96}
97
98#[cfg(debug_assertions)]
99struct LocalCompletionBarrier {
100    rehydrated: oneshot::Sender<()>,
101    release: oneshot::Receiver<()>,
102}
103
104pub(crate) struct WebSocketGuard {
105    inner: Arc<WebSocketTrackerInner>,
106}
107
108impl WebSocketTracker {
109    pub(crate) fn track(&self) -> WebSocketGuard {
110        self.inner.active.fetch_add(1, Ordering::AcqRel);
111        WebSocketGuard {
112            inner: Arc::clone(&self.inner),
113        }
114    }
115
116    /// Wait until every upgraded WebSocket task has finished.
117    pub async fn wait_until_idle(&self) {
118        loop {
119            let idle = self.inner.idle.notified();
120            tokio::pin!(idle);
121            idle.as_mut().enable();
122            if self.inner.active.load(Ordering::Acquire) == 0 {
123                return;
124            }
125            idle.await;
126        }
127    }
128
129    /// Installs a one-shot test barrier after local WebSocket rehydration.
130    #[cfg(debug_assertions)]
131    #[doc(hidden)]
132    #[must_use]
133    pub fn install_local_completion_test_barrier(&self) -> (oneshot::Receiver<()>, oneshot::Sender<()>) {
134        let (rehydrated_tx, rehydrated_rx) = oneshot::channel();
135        let (release_tx, release_rx) = oneshot::channel();
136        let barrier = LocalCompletionBarrier {
137            rehydrated: rehydrated_tx,
138            release: release_rx,
139        };
140        self.inner
141            .local_completion_barrier
142            .lock()
143            .expect("local completion test barrier mutex poisoned")
144            .replace(barrier);
145        (rehydrated_rx, release_tx)
146    }
147
148    #[cfg(debug_assertions)]
149    pub(crate) async fn pause_local_completion_after_rehydration(&self) {
150        let barrier = self
151            .inner
152            .local_completion_barrier
153            .lock()
154            .expect("local completion test barrier mutex poisoned")
155            .take();
156        if let Some(barrier) = barrier {
157            if barrier.rehydrated.send(()).is_ok() {
158                let _ = barrier.release.await;
159            }
160        }
161    }
162}
163
164impl Drop for WebSocketGuard {
165    fn drop(&mut self) {
166        if self.inner.active.fetch_sub(1, Ordering::AcqRel) == 1 {
167            self.inner.idle.notify_waiters();
168        }
169    }
170}
171
172/// Server-level configuration read from environment variables.
173pub struct ServerConfig {
174    pub cors_allowed_origins: Vec<String>,
175}
176
177impl ServerConfig {
178    #[must_use]
179    pub fn from_env() -> Self {
180        let cors_allowed_origins = std::env::var("CORS_ALLOWED_ORIGINS")
181            .ok()
182            .map(|s| {
183                s.split(',')
184                    .map(str::trim)
185                    .filter(|o| !o.is_empty())
186                    .map(str::to_owned)
187                    .collect::<Vec<_>>()
188            })
189            .unwrap_or_default();
190        Self { cors_allowed_origins }
191    }
192
193    fn cors_layer(&self) -> CorsLayer {
194        let allow_origin = if self.cors_allowed_origins.is_empty() {
195            AllowOrigin::any()
196        } else {
197            let origins: Vec<HeaderValue> = self
198                .cors_allowed_origins
199                .iter()
200                .filter_map(|o| o.parse().ok())
201                .collect();
202            AllowOrigin::list(origins)
203        };
204
205        CorsLayer::new()
206            .allow_origin(allow_origin)
207            .allow_methods(Any)
208            .allow_headers(Any)
209    }
210}
211
212/// Shared application state injected into every handler.
213///
214/// Both states are always present:
215/// - `proxy_state` handles `store=false` requests (direct passthrough to vLLM)
216/// - `exec_ctx` handles `store=true` requests (stateful executor with DB)
217#[derive(Clone)]
218pub struct AppState {
219    pub proxy_state: ProxyState,
220    pub exec_ctx: Arc<ExecutionContext>,
221    /// Dedicated no-redirect client for inference-service health probes.
222    pub llm_readiness_client: reqwest::Client,
223    /// Prevents public probes from multiplying dependency work and tracks transitions.
224    pub readiness_tracker: ReadinessTracker,
225    /// Shared cancellation signal used to drain long-lived handlers.
226    pub shutdown_token: CancellationToken,
227    /// Tracks upgraded WebSocket tasks, which Axum does not await during HTTP drain.
228    pub websocket_tracker: WebSocketTracker,
229    /// vLLM base URL — used by the `/ready` health probe.
230    pub llm_api_base: String,
231    /// Whether `/ready` should omit the upstream health check.
232    pub skip_llm_ready_check: bool,
233    /// Server-configured API key; used as fallback when the request carries no
234    /// `Authorization` header on the executor path.
235    pub openai_api_key: Option<String>,
236}
237
238pub fn build_router(state: AppState, server_config: &ServerConfig) -> Router {
239    build_router_with_auth(state, server_config, None)
240}
241
242pub fn build_router_with_auth(
243    state: AppState,
244    server_config: &ServerConfig,
245    authenticator: Option<OidcAuthenticator>,
246) -> Router {
247    let public_routes = Router::new().route("/health", get(health)).route("/ready", get(ready));
248    let protected_routes = Router::new()
249        .route("/v1/conversations", post(conversations))
250        .route("/v1/models", get(models))
251        .route(ANTHROPIC_MESSAGES_PATH, post(messages))
252        .route(ANTHROPIC_COUNT_TOKENS_PATH, post(count_tokens))
253        .route("/v1/responses", post(responses).get(responses_ws_with_auth))
254        .route("/v1/responses/compact", post(compact_response));
255    let protected_routes = match authenticator {
256        Some(authenticator) => {
257            protected_routes.route_layer(middleware::from_fn_with_state(authenticator, require_oidc))
258        }
259        None => protected_routes,
260    };
261
262    public_routes
263        .merge(protected_routes)
264        .layer(server_config.cors_layer())
265        .with_state(state)
266}