//! Phase 38 — Universal API skeleton (`/v1/*`). //! //! OpenAI-compatible shape on top of the existing aibridge → Ollama //! path. This is the thin slice: single provider, stateless, no //! streaming. Phase 39 replaces the direct Ollama call with a //! `ProviderAdapter` trait dispatch; Phase 40 adds routing + fallback. //! //! The shape matches OpenAI's `/v1/chat/completions` closely enough //! that clients using openai-compatible SDKs can point at us with the //! URL swap alone. We keep the endpoint path `/v1/chat` (not //! `/v1/chat/completions`) because our PRD declares the terser form; //! adding the alias is one line in Phase 39 when it matters. pub mod ollama; pub mod ollama_cloud; pub mod openrouter; pub mod gemini; pub mod claude; pub mod kimi; pub mod opencode; pub mod validate; pub mod iterate; pub mod langfuse_trace; pub mod mode; pub mod respond; pub mod truth; use axum::{ Router, extract::State, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, }; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; #[derive(Clone)] pub struct V1State { pub ai_client: aibridge::client::AiClient, pub usage: Arc>, /// Ollama Cloud bearer token. Loaded at startup via /// `ollama_cloud::resolve_cloud_key()`. None = cloud routes 503. pub ollama_cloud_key: Option, /// OpenRouter bearer token — free-tier rescue rung. Loaded at /// startup via `openrouter::resolve_openrouter_key()`. None means /// provider="openrouter" calls 503 rather than attempt. Same key /// sourcing as LLM Team UI so the two share one API quota. pub openrouter_key: Option, /// Gemini API key (Google Generative Language). Loaded at startup /// via `gemini::resolve_gemini_key()`. None = provider="gemini" /// calls 503. Phase 40 deliverable. pub gemini_key: Option, /// Anthropic Claude API key. Loaded at startup via /// `claude::resolve_claude_key()`. None = provider="claude" calls /// 503. Phase 40 deliverable. pub claude_key: Option, /// Kimi For Coding (api.kimi.com) bearer token — direct provider /// for `kimi-for-coding`. Used when Ollama Cloud's `kimi-k2:1t` is /// upstream-broken. Loaded at startup via `kimi::resolve_kimi_key()` /// from `KIMI_API_KEY` env or `/etc/lakehouse/kimi.env`. None = /// provider="kimi" calls 503. pub kimi_key: Option, /// OpenCode GO (opencode.ai) bearer token — multi-vendor curated /// gateway. One sk-* key reaches Claude Opus 4.7, GPT-5.5-pro, /// Gemini 3.1-pro, Kimi K2.6, DeepSeek, GLM, Qwen + free-tier. /// Loaded at startup via `opencode::resolve_opencode_key()` from /// `OPENCODE_API_KEY` env or `/etc/lakehouse/opencode.env`. None = /// provider="opencode" calls 503. pub opencode_key: Option, /// Shared WorkerLookup loaded once at startup from /// workers_500k.parquet (path: LH_WORKERS_PARQUET env, default /// data/datasets/workers_500k.parquet). Used by /v1/validate to /// run FillValidator/EmailValidator with worker-existence checks. /// Falls back to an empty InMemoryWorkerLookup if the file is /// missing — validators still run schema/PII checks but every /// worker-existence check fails (Consistency error), which is /// the correct behavior when the roster isn't configured. pub validate_workers: std::sync::Arc, /// Phase 40 early deliverable — Langfuse client. None = tracing /// disabled (keys missing or container unreachable). Traces are /// fire-and-forget: never block the response path. pub langfuse: Option, } #[derive(Default, Clone, Serialize)] pub struct Usage { pub requests: u64, pub prompt_tokens: u64, pub completion_tokens: u64, pub total_tokens: u64, #[serde(default)] pub by_provider: std::collections::HashMap, } #[derive(Default, Clone, Serialize)] pub struct ProviderUsage { pub requests: u64, pub prompt_tokens: u64, pub completion_tokens: u64, pub total_tokens: u64, } pub fn router(state: V1State) -> Router { Router::new() .route("/chat", post(chat)) // Canonical OpenAI path alias — lets any client built on the // openai SDK (pi-ai, langchain-js, etc.) treat the gateway as // a drop-in middleware via OPENAI_BASE_URL=http://gw/v1 alone. // Same handler as /chat; same OpenAI-compatible request shape. .route("/chat/completions", post(chat)) .route("/respond", post(respond::respond)) .route("/usage", get(usage)) .route("/sessions", get(sessions)) .route("/context", get(truth::context)) .route("/mode", post(mode::route)) .route("/mode/list", get(mode::list)) .route("/mode/execute", post(mode::execute)) .route("/validate", post(validate::validate)) .route("/iterate", post(iterate::iterate)) .route("/health", get(health)) .with_state(state) } // -- Shared types (OpenAI-compatible) -- /// OpenAI-compatible message. `content` accepts either a plain string or /// an array of content parts (the modern multimodal shape: /// `[{type:"text", text:"..."}, {type:"image_url", ...}]`). We store as /// `serde_json::Value` to preserve client shape on forward; downstream /// providers can take it verbatim. `Message::text()` flattens for /// places that need a plain string (Ollama prompt assembly, char /// counts, the assistant's own response synthesis). #[derive(Serialize, Deserialize, Clone, Debug)] pub struct Message { pub role: String, pub content: serde_json::Value, } impl Message { /// Construct a plain text message — the common shape for callers /// that don't need multimodal content. Wraps the body in /// `serde_json::Value::String` so downstream serializers see the /// canonical OpenAI shape. pub fn new_text(role: impl Into, body: impl Into) -> Self { Self { role: role.into(), content: serde_json::Value::String(body.into()), } } /// Flatten content to a plain string. Strings pass through; content- /// part arrays concatenate the `text` fields with newlines and skip /// non-text parts (images etc.) — Phase 38/39 callers are text-only, /// real multimodal forwarding is queued. pub fn text(&self) -> String { match &self.content { serde_json::Value::String(s) => s.clone(), serde_json::Value::Array(parts) => { let mut out = String::new(); for p in parts { if let Some(t) = p.get("text").and_then(|v| v.as_str()) { if !out.is_empty() { out.push('\n'); } out.push_str(t); } } out } other => other.to_string(), } } } #[derive(Deserialize, Debug, Clone)] pub struct ChatRequest { pub model: String, pub messages: Vec, #[serde(default)] pub temperature: Option, #[serde(default)] pub max_tokens: Option, /// Accepted for shape-compat but ignored in the thin slice — /// Phase 38 returns non-streaming even when the client asked for it. /// Phase 39+ wires real streaming. #[serde(default)] pub stream: Option, /// Non-OpenAI extension. Passes through to the provider's thinking /// toggle. Default: **false** — hot-path discipline for thinking /// models (qwen3.5, qwen3, gpt-oss) that otherwise burn the token /// budget on hidden reasoning before visible output starts, /// producing empty responses. Set true explicitly when calling an /// overseer / reasoning-heavy path. #[serde(default)] pub think: Option, /// Non-OpenAI extension. Selects the provider adapter. Accepted: /// - None / "ollama" / "local" → local Ollama via sidecar (default) /// - "ollama_cloud" / "cloud" → Ollama Cloud direct HTTPS /// Phase 40 adds a routing engine that picks this automatically /// from the model name; Phase 38/39 requires explicit selection. #[serde(default)] pub provider: Option, } #[derive(Serialize)] pub struct ChatResponse { pub id: String, pub object: &'static str, pub created: i64, pub model: String, pub choices: Vec, pub usage: UsageBlock, } #[derive(Serialize)] pub struct Choice { pub index: u32, pub message: Message, pub finish_reason: String, } #[derive(Serialize, Deserialize, Clone)] pub struct UsageBlock { pub prompt_tokens: u32, pub completion_tokens: u32, pub total_tokens: u32, } // -- Handlers -- /// Phase 39: resolve (provider, effective_model) from a ChatRequest. /// /// Explicit `req.provider` wins. If absent, infer from a model-name /// prefix: "openrouter/..." → openrouter (strip prefix), "cloud/..." → /// ollama_cloud (strip prefix). Bare names default to "ollama". /// /// The stripped model is what the upstream adapter expects: /// OpenRouter's API wants "openai/gpt-4o-mini", not /// "openrouter/openai/gpt-4o-mini". fn resolve_provider(req: &ChatRequest) -> (String, String) { if let Some(p) = req.provider.as_deref() { return (p.to_ascii_lowercase(), req.model.clone()); } if let Some(rest) = req.model.strip_prefix("openrouter/") { return ("openrouter".to_string(), rest.to_string()); } if let Some(rest) = req.model.strip_prefix("cloud/") { return ("ollama_cloud".to_string(), rest.to_string()); } if let Some(rest) = req.model.strip_prefix("gemini/") { return ("gemini".to_string(), rest.to_string()); } if let Some(rest) = req.model.strip_prefix("claude/") { return ("claude".to_string(), rest.to_string()); } if let Some(rest) = req.model.strip_prefix("kimi/") { return ("kimi".to_string(), rest.to_string()); } if let Some(rest) = req.model.strip_prefix("opencode/") { return ("opencode".to_string(), rest.to_string()); } // Bare `vendor/model` shape (e.g. `x-ai/grok-4.1-fast`, // `moonshotai/kimi-k2`, `openai/gpt-oss-120b:free`) → OpenRouter. // This makes the gateway a drop-in OpenAI-compatible middleware: // clients using the official `openai` SDK only set OPENAI_BASE_URL // + a model name and get correct upstream routing without needing // our custom `provider` field. Ollama models in J's stack use // `model:tag` form with NO slash (`qwen3.5:latest`, `kimi-k2:1t`), // so a slash here unambiguously means "namespaced provider/model". if req.model.contains('/') { return ("openrouter".to_string(), req.model.clone()); } // Vendor-bare model names (no slash, no colon) — `gpt-4o-mini`, // `claude-3-5-sonnet-20241022`, etc. Tools like pi-ai validate // models against an OpenAI-style catalog (no namespace prefix), // so they send the bare name. Map to OpenRouter's namespaced form // by inferring the vendor from the leading token. Falls through to // ollama if no pattern matches — preserves existing behavior. if !req.model.contains(':') && !req.model.contains('/') { let m = req.model.as_str(); if m.starts_with("gpt-") || m.starts_with("o1-") || m.starts_with("o3-") || m.starts_with("o4-") || m == "o1" || m == "o3" || m == "o4-mini" { return ("openrouter".to_string(), format!("openai/{}", m)); } if m.starts_with("claude-") { return ("openrouter".to_string(), format!("anthropic/{}", m)); } if m.starts_with("grok-") { return ("openrouter".to_string(), format!("x-ai/{}", m)); } } ("ollama".to_string(), req.model.clone()) } #[cfg(test)] mod resolve_provider_tests { use super::*; fn mk_req(provider: Option<&str>, model: &str) -> ChatRequest { ChatRequest { model: model.to_string(), messages: vec![], temperature: None, max_tokens: None, stream: None, think: None, provider: provider.map(|s| s.to_string()), } } #[test] fn explicit_provider_wins() { let r = mk_req(Some("openrouter"), "qwen3.5:latest"); assert_eq!(resolve_provider(&r), ("openrouter".into(), "qwen3.5:latest".into())); } #[test] fn bare_model_defaults_to_ollama() { let r = mk_req(None, "qwen3.5:latest"); assert_eq!(resolve_provider(&r), ("ollama".into(), "qwen3.5:latest".into())); } #[test] fn openrouter_prefix_infers_and_strips() { let r = mk_req(None, "openrouter/openai/gpt-4o-mini"); assert_eq!(resolve_provider(&r), ("openrouter".into(), "openai/gpt-4o-mini".into())); } #[test] fn cloud_prefix_infers_and_strips() { let r = mk_req(None, "cloud/kimi-k2:1t"); assert_eq!(resolve_provider(&r), ("ollama_cloud".into(), "kimi-k2:1t".into())); } #[test] fn explicit_provider_preserves_full_model_even_with_prefix() { // If caller provides both provider and a model with a prefix, // trust them — don't strip. The adapter will get the full model // string as-is. let r = mk_req(Some("openrouter"), "openrouter/openai/gpt-4o-mini"); assert_eq!(resolve_provider(&r), ("openrouter".into(), "openrouter/openai/gpt-4o-mini".into())); } #[test] fn gemini_prefix_infers_and_strips() { let r = mk_req(None, "gemini/gemini-2.0-flash"); assert_eq!(resolve_provider(&r), ("gemini".into(), "gemini-2.0-flash".into())); } #[test] fn claude_prefix_infers_and_strips() { let r = mk_req(None, "claude/claude-3-5-sonnet-latest"); assert_eq!(resolve_provider(&r), ("claude".into(), "claude-3-5-sonnet-latest".into())); } #[test] fn kimi_prefix_infers_and_strips() { let r = mk_req(None, "kimi/kimi-for-coding"); assert_eq!(resolve_provider(&r), ("kimi".into(), "kimi-for-coding".into())); } } async fn chat( State(state): State, Json(req): Json, ) -> Result, (StatusCode, String)> { if req.messages.is_empty() { return Err((StatusCode::BAD_REQUEST, "messages must be non-empty".into())); } if req.stream.unwrap_or(false) { tracing::warn!("/v1/chat: stream=true requested but Phase 38 returns non-streaming"); } // Provider resolution: explicit `req.provider` wins; otherwise // infer from a model-name prefix. Phase 39 PRD gate example: // `model: "openrouter/openai/gpt-4o-mini"` → provider "openrouter", // adapter gets the stripped "openai/gpt-4o-mini". let (provider, effective_model) = resolve_provider(&req); let start_time = chrono::Utc::now(); let start_instant = std::time::Instant::now(); // If we stripped a prefix, clone req with the effective model so // the adapter sees what the upstream provider expects (OpenRouter // wants "openai/gpt-4o-mini", not "openrouter/openai/gpt-4o-mini"). let req_for_adapter: std::borrow::Cow<'_, ChatRequest> = if effective_model == req.model { std::borrow::Cow::Borrowed(&req) } else { let mut cloned = req.clone(); cloned.model = effective_model.clone(); std::borrow::Cow::Owned(cloned) }; let (resp, used_provider) = match provider.as_str() { "ollama" | "local" | "" => { let r = ollama::chat(&state.ai_client, &*req_for_adapter) .await .map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama local: {e}")))?; (r, "ollama".to_string()) } "ollama_cloud" | "cloud" => { let key = state.ollama_cloud_key.as_deref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "OLLAMA_CLOUD_KEY not configured".to_string(), ))?; let r = ollama_cloud::chat(key, &*req_for_adapter) .await .map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama cloud: {e}")))?; (r, "ollama_cloud".to_string()) } "openrouter" | "openrouter_free" => { // Free-tier rescue rung. Added 2026-04-24 after iter 5 // repeated Ollama Cloud 502s on kimi-k2:1t — OpenRouter // gives a different provider backbone as fallback. let key = state.openrouter_key.as_deref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "OPENROUTER_API_KEY not configured".to_string(), ))?; let r = openrouter::chat(key, &*req_for_adapter) .await .map_err(|e| (StatusCode::BAD_GATEWAY, format!("openrouter: {e}")))?; (r, "openrouter".to_string()) } "gemini" => { // Phase 40 provider adapter. Google Generative Language // API via query-string key auth (not bearer). let key = state.gemini_key.as_deref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "GEMINI_API_KEY not configured".to_string(), ))?; let r = gemini::chat(key, &*req_for_adapter) .await .map_err(|e| (StatusCode::BAD_GATEWAY, format!("gemini: {e}")))?; (r, "gemini".to_string()) } "claude" | "anthropic" => { // Phase 40 provider adapter. Anthropic Messages API via // x-api-key header + anthropic-version:2023-06-01. let key = state.claude_key.as_deref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "ANTHROPIC_API_KEY not configured".to_string(), ))?; let r = claude::chat(key, &*req_for_adapter) .await .map_err(|e| (StatusCode::BAD_GATEWAY, format!("claude: {e}")))?; (r, "claude".to_string()) } "kimi" => { // Direct Kimi For Coding provider — bypasses Ollama Cloud's // upstream-broken kimi-k2:1t and OpenRouter's rate-limited // moonshotai/kimi-k2.6. Uses sk-kimi-* keys from the Kimi // membership console. let key = state.kimi_key.as_deref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "KIMI_API_KEY not configured".to_string(), ))?; let r = kimi::chat(key, &*req_for_adapter) .await .map_err(|e| (StatusCode::BAD_GATEWAY, format!("kimi: {e}")))?; (r, "kimi".to_string()) } "opencode" => { // OpenCode GO multi-vendor gateway — Claude Opus 4.7, // GPT-5.5-pro, Gemini 3.1-pro, Kimi K2.6, DeepSeek, GLM, // Qwen, free-tier. OpenAI-compat at opencode.ai/zen/go/v1. let key = state.opencode_key.as_deref().ok_or(( StatusCode::SERVICE_UNAVAILABLE, "OPENCODE_API_KEY not configured".to_string(), ))?; let r = opencode::chat(key, &*req_for_adapter) .await .map_err(|e| (StatusCode::BAD_GATEWAY, format!("opencode: {e}")))?; (r, "opencode".to_string()) } other => { return Err(( StatusCode::BAD_REQUEST, format!("unknown provider '{other}' — supported: ollama, ollama_cloud, openrouter, gemini, claude, kimi, opencode"), )); } }; let end_time = chrono::Utc::now(); let latency_ms = start_instant.elapsed().as_millis() as u64; // Phase 40 — emit Langfuse trace. Fire-and-forget: the clone is // cheap (Arc inside), the tokio::spawn never blocks us, a dead // Langfuse just logs a warn. Client-visible response latency is // untouched. if let Some(lf) = &state.langfuse { let output = resp.choices.first() .map(|c| c.message.text()) .unwrap_or_default(); lf.emit_chat(langfuse_trace::ChatTrace { provider: used_provider.clone(), model: resp.model.clone(), input: req.messages.clone(), output, prompt_tokens: resp.usage.prompt_tokens, completion_tokens: resp.usage.completion_tokens, temperature: req.temperature, max_tokens: req.max_tokens, think: req.think, start_time: start_time.to_rfc3339(), end_time: end_time.to_rfc3339(), latency_ms, }); } // Phase 40 part 2 — fire-and-forget /event to observer at :3800. // Same ring-buffer that scrum + scenario events land in, so any // tool-routed-through-our-gateway (Pi, Archon, openai SDK clients) // shows up alongside scrum_master events for KB consolidation + // pathway-memory + bug-fingerprint compounding. Best-effort: // observer being down doesn't block the chat response. { let provider = used_provider.clone(); let model = resp.model.clone(); let prompt_tokens = resp.usage.prompt_tokens; let completion_tokens = resp.usage.completion_tokens; let success = true; tokio::spawn(async move { let body = serde_json::json!({ "endpoint": "/v1/chat", "source": "v1.chat", "event_kind": "chat_completion", "input_summary": format!( "{} {} prompt={}t", provider, model, prompt_tokens ), "output_summary": format!( "completion={}t {}ms", completion_tokens, latency_ms ), "success": success, "duration_ms": latency_ms, }); let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(2)) .build() .unwrap_or_else(|_| reqwest::Client::new()); let _ = client .post("http://localhost:3800/event") .json(&body) .send() .await; }); } // Phase 40: per-provider usage tracking { let mut u = state.usage.write().await; u.requests += 1; u.prompt_tokens += resp.usage.prompt_tokens as u64; u.completion_tokens += resp.usage.completion_tokens as u64; u.total_tokens += resp.usage.total_tokens as u64; let provider_usage = u.by_provider.entry(used_provider).or_default(); provider_usage.requests += 1; provider_usage.prompt_tokens += resp.usage.prompt_tokens as u64; provider_usage.completion_tokens += resp.usage.completion_tokens as u64; provider_usage.total_tokens += resp.usage.total_tokens as u64; } Ok(Json(resp)) } async fn usage(State(state): State) -> impl IntoResponse { let snapshot = state.usage.read().await.clone(); Json(snapshot) } /// Production operational health endpoint. /// /// `/v1/health` reports per-subsystem status as a JSON object so an /// operator (or the lakehouse-auditor service, or a load balancer) /// can verify the gateway is fully booted, has its provider keys /// loaded, the worker roster is hot, and Langfuse is reachable. /// Returns 200 always — fields are observed-state, not pass/fail /// gates. A monitoring tool should evaluate the booleans + counts /// against its own thresholds. async fn health(State(state): State) -> impl IntoResponse { // Honest worker count via WorkerLookup::len. Production switchover // verification: after swapping workers_500k.parquet → real Chicago // data and restarting, this number should match the row count of // the new file. 0 means the file was missing / unreadable / had a // schema mismatch and the gateway booted with the empty fallback. let workers_count = state.validate_workers.len(); let providers_configured = serde_json::json!({ "ollama_cloud": state.ollama_cloud_key.is_some(), "openrouter": state.openrouter_key.is_some(), "kimi": state.kimi_key.is_some(), "opencode": state.opencode_key.is_some(), "gemini": state.gemini_key.is_some(), "claude": state.claude_key.is_some(), }); let langfuse_configured = state.langfuse.is_some(); let usage_snapshot = state.usage.read().await.clone(); Json(serde_json::json!({ "status": "ok", "workers_count": workers_count, "workers_loaded": workers_count > 0, "providers_configured": providers_configured, "langfuse_configured": langfuse_configured, "usage_total_requests": usage_snapshot.requests, "usage_by_provider": usage_snapshot.by_provider.keys().collect::>(), })) } // Phase 38 is stateless — no session persistence yet. Return an empty // list in OpenAI-ish shape so clients that probe this endpoint don't // 404. Real session state lands in Phase 41 with the profile-system // expansion. async fn sessions() -> impl IntoResponse { Json(serde_json::json!({ "data": [], "object": "list", "note": "Phase 38: stateless. Session state lands in Phase 41.", })) } #[cfg(test)] mod tests { use super::*; #[test] fn chat_request_parses_openai_shape() { let raw = r#"{ "model": "qwen3.5:latest", "messages": [ {"role": "system", "content": "You are helpful."}, {"role": "user", "content": "Hi"} ], "temperature": 0.2, "max_tokens": 100 }"#; let r: ChatRequest = serde_json::from_str(raw).unwrap(); assert_eq!(r.model, "qwen3.5:latest"); assert_eq!(r.messages.len(), 2); assert_eq!(r.messages[0].role, "system"); assert_eq!(r.messages[1].text(), "Hi"); assert_eq!(r.temperature, Some(0.2)); assert_eq!(r.max_tokens, Some(100)); } #[test] fn chat_request_accepts_minimal() { let raw = r#"{ "model": "any", "messages": [{"role": "user", "content": "hi"}] }"#; let r: ChatRequest = serde_json::from_str(raw).unwrap(); assert_eq!(r.temperature, None); assert_eq!(r.max_tokens, None); assert_eq!(r.stream, None); } #[test] fn usage_counter_default_is_zero() { let u = Usage::default(); assert_eq!(u.requests, 0); assert_eq!(u.total_tokens, 0); } }