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#[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
50pub 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 #[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 #[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 #[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 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 #[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
172pub 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#[derive(Clone)]
218pub struct AppState {
219 pub proxy_state: ProxyState,
220 pub exec_ctx: Arc<ExecutionContext>,
221 pub llm_readiness_client: reqwest::Client,
223 pub readiness_tracker: ReadinessTracker,
225 pub shutdown_token: CancellationToken,
227 pub websocket_tracker: WebSocketTracker,
229 pub llm_api_base: String,
231 pub skip_llm_ready_check: bool,
233 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}