Skip to main content

ogham_server/
lib.rs

1//! Embeddable HTTP server for the [Ogham](https://github.com/signalbreak-labs/ogham)
2//! context engineering SDK.
3//!
4//! Run standalone (`ogham-server` binary, configured via `OGHAM_*`
5//! environment variables) or mount the router into an existing Axum
6//! application:
7//!
8//! ```no_run
9//! use ogham_server::{app_with_state, AppState};
10//!
11//! let router = axum::Router::new().nest("/ogham", app_with_state(AppState::new()));
12//! ```
13//!
14//! Endpoints: `POST /compress`, `POST /retrieve`, `POST /detect`,
15//! `GET /health`, `GET /stats`. Request/response shapes are documented
16//! on [`app`]. The binary binds `127.0.0.1:3000` by default and never
17//! listens on all interfaces unless explicitly configured.
18
19use axum::{
20    Json, Router,
21    extract::State,
22    routing::{get, post},
23};
24use ogham::{
25    CompressionPipeline, Message,
26    ccr::{CcrStore, in_memory::InMemoryCcrStore},
27    detect,
28    pipeline::DefaultCompressionPipeline,
29};
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32use std::sync::Arc;
33use std::time::Instant;
34use tokio::sync::Mutex;
35
36/// Backend selection for the server's CCR store.
37#[derive(Debug, Clone)]
38pub enum CcrBackendConfig {
39    InMemory,
40    Sqlite {
41        path: std::path::PathBuf,
42        ttl_seconds: u64,
43    },
44    Fjall {
45        path: std::path::PathBuf,
46    },
47}
48
49/// Server configuration.
50#[derive(Debug, Clone)]
51pub struct ServerConfig {
52    /// Default: 127.0.0.1:3000 (was 0.0.0.0 — do not listen on all
53    /// interfaces by default).
54    pub bind: std::net::SocketAddr,
55    pub ccr: CcrBackendConfig,
56}
57
58impl Default for ServerConfig {
59    fn default() -> Self {
60        Self {
61            bind: std::net::SocketAddr::from(([127, 0, 0, 1], 3000)),
62            ccr: CcrBackendConfig::InMemory,
63        }
64    }
65}
66
67/// Application state shared across handlers.
68#[derive(Clone)]
69pub struct AppState {
70    pub start_time: Instant,
71    pub request_count: Arc<Mutex<u64>>,
72    pub pipeline: Arc<DefaultCompressionPipeline>,
73    pub ccr_store: Arc<dyn CcrStore>,
74}
75
76impl AppState {
77    pub fn new() -> Self {
78        let ccr_store = Arc::new(InMemoryCcrStore::new());
79        let pipeline = Arc::new(
80            DefaultCompressionPipeline::builder()
81                .ccr_store(ccr_store.clone())
82                .align_cache()
83                .build(),
84        );
85        Self {
86            start_time: Instant::now(),
87            request_count: Arc::new(Mutex::new(0)),
88            pipeline,
89            ccr_store,
90        }
91    }
92
93    /// Build with any store.
94    pub fn with_store(store: Arc<dyn CcrStore>) -> Self {
95        let pipeline = Arc::new(
96            DefaultCompressionPipeline::builder()
97                .ccr_store(store.clone())
98                .align_cache()
99                .build(),
100        );
101        Self {
102            start_time: Instant::now(),
103            request_count: Arc::new(Mutex::new(0)),
104            pipeline,
105            ccr_store: store,
106        }
107    }
108
109    pub async fn bump_requests(&self) {
110        let mut guard = self.request_count.lock().await;
111        *guard += 1;
112    }
113}
114
115impl Default for AppState {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121/// Build the MCP/REST router with shared state.
122///
123/// Request/response contracts:
124///
125/// ```jsonc
126/// // POST /compress   request:  {"messages":[{"role":"user","content":"..."}]}
127/// //                  response: {"messages":[...], "stats":{"original_tokens":N,
128/// //                             "compressed_tokens":N,"ratio":F,"compressor_used":"..."}}
129/// // POST /retrieve   request:  {"id":"<md5hex>"}
130/// //                  response: {"found":true,"original":"..."} or {"found":false}
131/// // POST /detect     request:  {"content":"..."}
132/// //                  response: {"content_type":"json_array","confidence":0.9}
133/// // GET  /health     {"status":"ok"}
134/// // GET  /stats      {"uptime_seconds":N,"requests":N}
135/// ```
136pub fn app() -> Router {
137    app_with_state(AppState::new())
138}
139
140/// Mountable router over caller-provided state — this is what host applications embed.
141pub fn app_with_state(state: AppState) -> Router {
142    Router::new()
143        .route("/health", get(health))
144        .route("/compress", post(compress_handler))
145        .route("/retrieve", post(retrieve_handler))
146        .route("/detect", post(detect_handler))
147        .route("/stats", get(stats_handler))
148        .with_state(state)
149}
150
151#[derive(Serialize)]
152struct HealthResponse {
153    status: &'static str,
154}
155
156async fn health() -> Json<HealthResponse> {
157    Json(HealthResponse { status: "ok" })
158}
159
160#[derive(Deserialize)]
161struct CompressRequest {
162    messages: Vec<Message>,
163}
164
165#[derive(Serialize)]
166struct CompressResponse {
167    messages: Vec<Message>,
168    stats: Value,
169}
170
171async fn compress_handler(
172    State(state): State<AppState>,
173    Json(req): Json<CompressRequest>,
174) -> Json<CompressResponse> {
175    state.bump_requests().await;
176
177    match state.pipeline.run(&req.messages).await {
178        Ok(result) => Json(CompressResponse {
179            messages: result.messages,
180            stats: serde_json::to_value(&result.stats).unwrap_or_else(|_| serde_json::json!({})),
181        }),
182        Err(e) => Json(CompressResponse {
183            // Fail-closed: return the originals unchanged rather than nothing.
184            messages: req.messages,
185            stats: serde_json::json!({"error": e.to_string()}),
186        }),
187    }
188}
189
190#[derive(Deserialize)]
191struct RetrieveRequest {
192    id: String,
193}
194
195#[derive(Serialize)]
196struct RetrieveResponse {
197    found: bool,
198    original: Option<String>,
199}
200
201async fn retrieve_handler(
202    State(state): State<AppState>,
203    Json(req): Json<RetrieveRequest>,
204) -> Json<RetrieveResponse> {
205    state.bump_requests().await;
206    let original = state.ccr_store.retrieve(&req.id).await.ok().flatten();
207    Json(RetrieveResponse {
208        found: original.is_some(),
209        original,
210    })
211}
212
213#[derive(Deserialize)]
214struct DetectRequest {
215    content: String,
216}
217
218#[derive(Serialize)]
219struct DetectResponse {
220    content_type: String,
221    confidence: f64,
222    metadata: Value,
223}
224
225async fn detect_handler(
226    State(state): State<AppState>,
227    Json(req): Json<DetectRequest>,
228) -> Json<DetectResponse> {
229    state.bump_requests().await;
230    let result = detect(&req.content);
231    Json(DetectResponse {
232        content_type: result.content_type.as_str().to_string(),
233        confidence: result.confidence,
234        metadata: serde_json::to_value(&result.metadata).unwrap_or_else(|_| serde_json::json!({})),
235    })
236}
237
238#[derive(Serialize)]
239struct StatsResponse {
240    uptime_seconds: u64,
241    requests_total: u64,
242}
243
244async fn stats_handler(State(state): State<AppState>) -> Json<StatsResponse> {
245    let requests = *state.request_count.lock().await;
246    Json(StatsResponse {
247        uptime_seconds: state.start_time.elapsed().as_secs(),
248        requests_total: requests,
249    })
250}