diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs
index fb4d80a7..6964e98b 100644
--- a/core/archipelago/src/api/handler/mod.rs
+++ b/core/archipelago/src/api/handler/mod.rs
@@ -6,6 +6,7 @@ mod node_message;
mod proxy;
mod remote_input;
mod remote_relay;
+mod routstr_proxy;
mod websocket;
use crate::api::rpc::RpcHandler;
@@ -449,6 +450,14 @@ impl ApiHandler {
self.handle_model_proxy(req_with_bytes, p).await
}
+ // AIUI Routstr proxy — the explicit, user-selected Routstr
+ // provider (model catalog + Cashu-paid completions), same
+ // session-gate discipline as the model proxy above. D-05: paid
+ // requests are refused unless the operator has armed a budget.
+ (_, p) if p.starts_with("/aiui/api/routstr/") => {
+ self.handle_routstr_proxy(req_with_bytes, p).await
+ }
+
// Health — unauthenticated, returns JSON with service status
(Method::GET, "/health") => {
let recovery_complete = crate::crash_recovery::is_recovery_complete();
diff --git a/core/archipelago/src/api/handler/model_proxy.rs b/core/archipelago/src/api/handler/model_proxy.rs
index 81f157b7..e149a75f 100644
--- a/core/archipelago/src/api/handler/model_proxy.rs
+++ b/core/archipelago/src/api/handler/model_proxy.rs
@@ -84,14 +84,14 @@ async fn route_model_proxy(
/// call back into `ApiHandler::is_authenticated` — keeping this small and
/// dependency-free is what makes the 401 behaviour unit-testable without
/// paying for a full `ApiHandler` in every test.
-async fn is_authenticated(session_store: &SessionStore, headers: &HeaderMap) -> bool {
+pub(super) async fn is_authenticated(session_store: &SessionStore, headers: &HeaderMap) -> bool {
match session::extract_session_cookie(headers) {
Some(token) => session_store.validate(&token).await,
None => false,
}
}
-fn unauthorized() -> Response
{
+pub(super) fn unauthorized() -> Response {
let body = serde_json::json!({ "error": "Unauthorized" });
Response::builder()
.status(StatusCode::UNAUTHORIZED)
@@ -119,7 +119,7 @@ fn key_not_configured() -> Response {
/// backends' egress screen never sees the forwarder path — the standalone
/// frontend posts FULL history and images straight here — so the forwarder
/// screens for itself. 400, plain-language, never naming what matched.
-fn blocked_secret_shaped() -> Response {
+pub(super) fn blocked_secret_shaped() -> Response {
let body = serde_json::json!({
"error": "Blocked: this request contained secret-shaped content (e.g. a seed phrase, key, or token). It was not sent anywhere."
});
@@ -134,12 +134,12 @@ fn blocked_secret_shaped() -> Response {
/// the assistant's secret-shape rules (G-B1) with this node's own secrets
/// as the deny corpus. Returns Some(kind) — kind only, never the value —
/// when the content must not leave.
-async fn forward_screen(text: &str, data_dir: &Path) -> Option<&'static str> {
+pub(super) async fn forward_screen(text: &str, data_dir: &Path) -> Option<&'static str> {
let secrets = crate::assistant::egress::load_known_secrets(&data_dir.join("secrets")).await;
crate::assistant::egress::scan_secret_shapes(text, &secrets)
}
-fn bad_gateway(msg: &str) -> Response {
+pub(super) fn bad_gateway(msg: &str) -> Response {
let body = serde_json::json!({ "error": msg });
Response::builder()
.status(StatusCode::BAD_GATEWAY)
@@ -331,7 +331,7 @@ async fn forward(
/// same shape as `proxy.rs`'s peer-content Range streamer — so a
/// token-by-token reply doesn't wait for the full response before the first
/// byte reaches the browser.
-fn stream_response(resp: reqwest::Response) -> Result> {
+pub(super) fn stream_response(resp: reqwest::Response) -> Result> {
let status = resp.status().as_u16();
let headers = resp.headers().clone();
let mut builder = Response::builder().status(status);
diff --git a/core/archipelago/src/api/handler/routstr_proxy.rs b/core/archipelago/src/api/handler/routstr_proxy.rs
new file mode 100644
index 00000000..7b0e1e98
--- /dev/null
+++ b/core/archipelago/src/api/handler/routstr_proxy.rs
@@ -0,0 +1,535 @@
+//! Session-gated forwarder for `/aiui/api/routstr/*` — the explicit,
+//! user-selected Routstr path (as opposed to `assistant/backends/routstr.rs`,
+//! which is the D-04 fallback leg the operator never chooses directly).
+//!
+//! AIUI's model picker lists Routstr as a first-class provider; selecting one
+//! of its models routes chat completions through here. Same discipline as
+//! `model_proxy.rs`: auth is re-derived from the request's own session cookie
+//! (never trusted to nginx), inbound `authorization`/`cookie` headers are
+//! never forwarded, and every outbound body is egress-screened (S3) before it
+//! leaves the node.
+//!
+//! Payment is Cashu, D-05-gated end to end: a request is refused unless the
+//! operator has set a non-zero Routstr allowance (Settings → System), the
+//! quoted price fits the remaining allowance, and `auto_pay_token` (the ONE
+//! budget-capped payment primitive, T-13-89) agrees to build the token. The
+//! provider's change (`X-Cashu` / `X-Cashu-Refund` response headers, per
+//! docs.routstr.com) is redeemed back into the node wallet and only the net
+//! is recorded against the allowance.
+//!
+//! Upstream is the public Routstr aggregator instance routstr.com itself
+//! ships against (verified live 2026-08-14: `/v1/models` serves the full
+//! catalog with `sats_pricing`; the canonical `api.routstr.com` host 404s).
+//! Making the instance operator-configurable — or sourcing it from the Nostr
+//! provider announcements once those carry real endpoint/pricing content —
+//! is the planned follow-up, not this file's job.
+
+use super::ApiHandler;
+use crate::session::SessionStore;
+use anyhow::Result;
+use hyper::{Body, Method, Request, Response, StatusCode};
+use serde_json::{json, Value};
+use std::path::Path;
+use std::sync::Mutex as StdMutex;
+use std::sync::OnceLock;
+use std::time::{Duration, Instant};
+
+use super::model_proxy::{
+ bad_gateway, blocked_secret_shaped, forward_screen, is_authenticated, unauthorized,
+};
+
+/// The live public Routstr aggregator (the same instance routstr.com's own
+/// frontend queries for `/v1/providers` and `/v1/models`).
+const ROUTSTR_INSTANCE: &str = "https://routstr.otrta.me";
+/// Generation cap forced onto every forwarded completion — never unbounded
+/// (T-13-88), and the completion half of the price quote is arithmetic over
+/// exactly this figure.
+const MAX_COMPLETION_TOKENS: u64 = 1024;
+/// Same round-trip ceiling as `model_proxy.rs`'s Claude/Ollama forwarders.
+const FORWARD_TIMEOUT_SECS: u64 = 180;
+/// Models-catalog cache TTL — mirrors `backends/routstr.rs`'s provider
+/// discovery TTL. The catalog prices every chat request, so it cannot be
+/// fetched per-message without doubling latency.
+const MODELS_CACHE_TTL: Duration = Duration::from_secs(300);
+
+/// One model's sats-denominated pricing, parsed from the aggregator's
+/// `/v1/models` entries (`sats_pricing`). Rates are sats PER TOKEN (fractional
+/// floats); `request` is a flat per-request fee in sats. Untrusted input — a
+/// missing/garbled field parses as 0.0 and simply prices low, which the
+/// remaining-allowance ceiling still caps.
+#[derive(Debug, Clone, Default, serde::Deserialize)]
+struct SatsPricing {
+ #[serde(default)]
+ prompt: f64,
+ #[serde(default)]
+ completion: f64,
+ #[serde(default)]
+ request: f64,
+}
+
+/// Quote a price in whole sats for one completion call: flat request fee +
+/// prompt rate × (payload chars / 4, the usual chars-per-token rule of thumb)
+/// + completion rate × the forced `MAX_COMPLETION_TOKENS` cap, +20% margin,
+/// rounded up, never below 1. Deliberately a pure function so the arithmetic
+/// is unit-testable; deliberately conservative because the provider's change
+/// comes back as a Cashu refund and is redeemed — overquoting costs nothing
+/// but float, underquoting gets the request rejected upstream.
+fn estimate_price_sats(pricing: &SatsPricing, prompt_chars: usize, completion_tokens: u64) -> u64 {
+ let prompt_tokens = (prompt_chars as f64) / 4.0;
+ let raw = pricing.request
+ + pricing.prompt * prompt_tokens
+ + pricing.completion * (completion_tokens as f64);
+ let with_margin = raw * 1.2;
+ (with_margin.ceil() as u64).max(1)
+}
+
+/// Process-lifetime cache of the aggregator's models catalog (same pattern as
+/// `backends/routstr.rs`'s `PROVIDER_CACHE`).
+static MODELS_CACHE: OnceLock>> = OnceLock::new();
+
+fn cached_models() -> Option {
+ let cache = MODELS_CACHE.get_or_init(|| StdMutex::new(None));
+ let guard = cache.lock().expect("routstr models cache poisoned");
+ guard.as_ref().and_then(|(at, models)| {
+ if at.elapsed() < MODELS_CACHE_TTL {
+ Some(models.clone())
+ } else {
+ None
+ }
+ })
+}
+
+fn set_cached_models(models: Value) {
+ let cache = MODELS_CACHE.get_or_init(|| StdMutex::new(None));
+ *cache.lock().expect("routstr models cache poisoned") = Some((Instant::now(), models));
+}
+
+/// Fetch (or serve cached) the aggregator's `/v1/models` catalog.
+async fn fetch_models() -> Result {
+ if let Some(cached) = cached_models() {
+ return Ok(cached);
+ }
+ let client = reqwest::Client::builder()
+ .timeout(Duration::from_secs(20))
+ .build()?;
+ let url = format!("{ROUTSTR_INSTANCE}/v1/models");
+ let resp = client.get(&url).send().await?;
+ if !resp.status().is_success() {
+ anyhow::bail!("routstr models upstream returned HTTP {}", resp.status());
+ }
+ let models: Value = resp.json().await?;
+ set_cached_models(models.clone());
+ Ok(models)
+}
+
+/// Find one model's `sats_pricing` in the catalog by exact id.
+fn pricing_for_model(models: &Value, model_id: &str) -> Option {
+ models
+ .get("data")?
+ .as_array()?
+ .iter()
+ .find(|m| m.get("id").and_then(|v| v.as_str()) == Some(model_id))
+ .and_then(|m| m.get("sats_pricing"))
+ .and_then(|sp| serde_json::from_value(sp.clone()).ok())
+}
+
+fn json_response(status: StatusCode, body: Value) -> Response {
+ Response::builder()
+ .status(status)
+ .header("Content-Type", "application/json")
+ .body(Body::from(serde_json::to_vec(&body).unwrap_or_default()))
+ .unwrap_or_else(|_| Response::new(Body::from("{}")))
+}
+
+/// Plain-language refusal naming the UI path that fixes it — never a bare
+/// status (RULE: every action needs a UI path).
+fn budget_refusal(msg: String) -> Response {
+ json_response(
+ StatusCode::SERVICE_UNAVAILABLE,
+ json!({ "error": { "message": msg } }),
+ )
+}
+
+impl ApiHandler {
+ /// Entry point wired into the `/aiui/api/routstr/` arm in `mod.rs` —
+ /// thin, like `handle_model_proxy`, so the routing/budget logic below is
+ /// testable without a full `ApiHandler`.
+ pub(super) async fn handle_routstr_proxy(
+ &self,
+ req: Request,
+ path: &str,
+ ) -> Result> {
+ route_routstr_proxy(&self.session_store, &self.config.data_dir, req, path).await
+ }
+}
+
+async fn route_routstr_proxy(
+ session_store: &SessionStore,
+ data_dir: &Path,
+ req: Request,
+ path: &str,
+) -> Result> {
+ if !is_authenticated(session_store, req.headers()).await {
+ tracing::warn!("401 routstr proxy {} — session invalid or missing", path);
+ return Ok(unauthorized());
+ }
+ match path.strip_prefix("/aiui/api/routstr/") {
+ Some("models") if req.method() == Method::GET => forward_models().await,
+ Some("chat/completions") if req.method() == Method::POST => {
+ forward_chat(req, data_dir).await
+ }
+ _ => Ok(unauthorized()),
+ }
+}
+
+/// GET /aiui/api/routstr/models — the full catalog, passed through so AIUI
+/// can render ids/names and show sats pricing. Read-only and unpaid.
+async fn forward_models() -> Result> {
+ match fetch_models().await {
+ Ok(models) => Ok(json_response(StatusCode::OK, models)),
+ Err(e) => {
+ tracing::warn!("routstr proxy: models upstream failed: {}", e);
+ Ok(bad_gateway("Routstr model catalog is unreachable"))
+ }
+ }
+}
+
+/// POST /aiui/api/routstr/chat/completions — one paid, non-streaming,
+/// OpenAI-shaped completion. Order matters: screen (S3) → budget gate (D-05,
+/// offline) → price quote → pay → forward → redeem change → record net.
+async fn forward_chat(req: Request, data_dir: &Path) -> Result> {
+ let payload = hyper::body::to_bytes(req.into_body())
+ .await
+ .map_err(|e| anyhow::anyhow!("read request payload: {e}"))?;
+ let payload_str = String::from_utf8_lossy(&payload).to_string();
+
+ // S3: the standalone frontend posts full history straight here with no
+ // assistant loop (and no egress screen) behind it.
+ if let Some(kind) = forward_screen(&payload_str, data_dir).await {
+ tracing::error!(
+ kind,
+ "routstr proxy: blocked chat forward — secret-shaped content"
+ );
+ return Ok(blocked_secret_shaped());
+ }
+
+ let mut body: Value = match serde_json::from_str(&payload_str) {
+ Ok(v) => v,
+ Err(_) => {
+ return Ok(json_response(
+ StatusCode::BAD_REQUEST,
+ json!({ "error": { "message": "request body is not valid JSON" } }),
+ ));
+ }
+ };
+ let Some(model_id) = body.get("model").and_then(|v| v.as_str()).map(String::from) else {
+ return Ok(json_response(
+ StatusCode::BAD_REQUEST,
+ json!({ "error": { "message": "request is missing a model id" } }),
+ ));
+ };
+
+ // D-05 gate, checked before any network I/O: a zero allowance means
+ // Routstr is refused outright, with the UI path that arms it.
+ let mut budget = crate::assistant::AssistantBudget::load(data_dir).await;
+ if budget.allowance_sats == 0 {
+ return Ok(budget_refusal(
+ "Routstr is disabled on this node — set a sats budget in Settings → System → \
+ Routstr AI budget to enable it."
+ .to_string(),
+ ));
+ }
+ let remaining = budget.remaining_sats();
+ if remaining == 0 {
+ return Ok(budget_refusal(format!(
+ "This period's Routstr budget is spent ({} of {} sats). Raise the allowance in \
+ Settings → System → Routstr AI budget to continue.",
+ budget.spent_sats, budget.allowance_sats
+ )));
+ }
+
+ // Price the request from the catalog. An unknown model is a caller bug
+ // (the dropdown only offers catalog models), not a reason to guess a
+ // price.
+ let models = match fetch_models().await {
+ Ok(m) => m,
+ Err(e) => {
+ tracing::warn!("routstr proxy: cannot price request, models fetch failed: {e}");
+ return Ok(bad_gateway(
+ "Routstr model catalog is unreachable — cannot price this request",
+ ));
+ }
+ };
+ let Some(pricing) = pricing_for_model(&models, &model_id) else {
+ return Ok(json_response(
+ StatusCode::BAD_REQUEST,
+ json!({ "error": { "message": format!("unknown Routstr model: {model_id}") } }),
+ ));
+ };
+
+ // Force the shape this forwarder actually supports: non-streaming, with
+ // an explicit, capped generation limit (T-13-88).
+ let max_tokens = body
+ .get("max_tokens")
+ .and_then(|v| v.as_u64())
+ .unwrap_or(MAX_COMPLETION_TOKENS)
+ .min(MAX_COMPLETION_TOKENS);
+ body["stream"] = json!(false);
+ body["max_tokens"] = json!(max_tokens);
+
+ let price_sats = estimate_price_sats(&pricing, payload_str.len(), max_tokens);
+ if price_sats > remaining {
+ return Ok(budget_refusal(format!(
+ "This request quotes ~{price_sats} sats but only {remaining} sats remain in this \
+ period's Routstr budget (Settings → System → Routstr AI budget)."
+ )));
+ }
+
+ // Pay via the ONE budget-capped primitive (T-13-89) — same call, same
+ // mint list as the fallback leg in backends/routstr.rs.
+ let accepted_mints = crate::wallet::ecash::load_accepted_mints(data_dir)
+ .await
+ .map(|m| m.mints)
+ .unwrap_or_default();
+ let token = match crate::swarm::payment::auto_pay_token(
+ data_dir,
+ &budget.payment_policy(),
+ &accepted_mints,
+ price_sats,
+ )
+ .await?
+ {
+ Some(t) => t,
+ None => {
+ return Ok(budget_refusal(format!(
+ "The node wallet could not fund this request (~{price_sats} sats) — check the \
+ ecash balance and accepted mints in Settings → Wallet."
+ )));
+ }
+ };
+
+ let client = reqwest::Client::builder()
+ .timeout(Duration::from_secs(FORWARD_TIMEOUT_SECS))
+ .build()?;
+ let url = format!("{ROUTSTR_INSTANCE}/v1/chat/completions");
+ let resp = match client
+ .post(&url)
+ .header("Authorization", format!("Bearer {token}"))
+ .header("Content-Type", "application/json")
+ .json(&body)
+ .send()
+ .await
+ {
+ Ok(r) => r,
+ Err(e) => {
+ // The token never reached the provider — reclaim it into our own
+ // wallet so the sats aren't stranded, and record nothing.
+ match crate::wallet::ecash::receive_token(data_dir, &token).await {
+ Ok(_) => tracing::info!(
+ "routstr proxy: upstream send failed ({e}); unsent payment token reclaimed"
+ ),
+ Err(re) => tracing::warn!(
+ "routstr proxy: upstream send failed ({e}) AND reclaiming the unsent token \
+ failed ({re}) — {price_sats} sats may be stranded in the token"
+ ),
+ }
+ return Ok(bad_gateway("Routstr provider is unreachable"));
+ }
+ };
+
+ let status = resp.status();
+ // Change comes back as a Cashu token header (docs.routstr.com names both
+ // spellings across versions); redeem it so only the net leaves the
+ // allowance.
+ let refund_token = ["x-cashu-refund", "x-cashu"]
+ .iter()
+ .find_map(|h| resp.headers().get(*h))
+ .and_then(|v| v.to_str().ok())
+ .map(String::from);
+ let resp_body = resp.bytes().await.unwrap_or_default();
+
+ let mut reclaimed = 0u64;
+ if let Some(refund) = refund_token {
+ match crate::wallet::ecash::receive_token(data_dir, &refund).await {
+ Ok(sats) => reclaimed = sats,
+ Err(e) => tracing::warn!("routstr proxy: redeeming the change token failed: {e}"),
+ }
+ } else if !status.is_success() {
+ // The provider refused the request (e.g. "mint unreachable") and
+ // sent no change — if it never actually redeemed our token, the
+ // proofs are still ours to take back. If it DID redeem and then
+ // failed, this reclaim fails harmlessly and the spend stands.
+ match crate::wallet::ecash::receive_token(data_dir, &token).await {
+ Ok(sats) => {
+ reclaimed = sats;
+ tracing::info!(
+ "routstr proxy: upstream refused (HTTP {status}); unredeemed payment token \
+ reclaimed ({sats} sats)"
+ );
+ }
+ Err(e) => tracing::warn!(
+ "routstr proxy: upstream refused (HTTP {status}) and the payment token could \
+ not be reclaimed ({e}) — treating the {price_sats} sats as spent"
+ ),
+ }
+ }
+ let net_sats = price_sats.saturating_sub(reclaimed);
+ if net_sats > 0 {
+ if let Err(e) = budget.record_spend(data_dir, net_sats).await {
+ tracing::warn!(
+ error = %e,
+ "routstr proxy: failed to persist the budget spend (the payment itself already happened)"
+ );
+ }
+ }
+ tracing::info!(
+ model = %model_id,
+ quoted = price_sats,
+ reclaimed,
+ net = net_sats,
+ status = %status,
+ "routstr proxy: forwarded paid chat completion"
+ );
+
+ Ok(Response::builder()
+ .status(status.as_u16())
+ .header("Content-Type", "application/json")
+ .body(Body::from(resp_body))
+ .unwrap_or_else(|_| Response::new(Body::from("{}"))))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ async fn test_store() -> SessionStore {
+ let path = std::env::temp_dir().join(format!(
+ "archy-routstr-proxy-test-sessions-{}.json",
+ rand::RngCore::next_u64(&mut rand::rngs::OsRng)
+ ));
+ SessionStore::new_for_tests(path)
+ }
+
+ fn req(method: &str, path: &str, cookie: Option<&str>, body: &'static str) -> Request {
+ let mut builder = Request::builder().method(method).uri(path);
+ if let Some(c) = cookie {
+ builder = builder.header("cookie", format!("session={c}"));
+ }
+ builder.body(Body::from(body)).unwrap()
+ }
+
+ #[tokio::test]
+ async fn models_without_session_is_401() {
+ let store = test_store().await;
+ let data_dir = tempfile::tempdir().unwrap();
+ let r = req("GET", "/aiui/api/routstr/models", None, "");
+ let resp = route_routstr_proxy(&store, data_dir.path(), r, "/aiui/api/routstr/models")
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+ }
+
+ #[tokio::test]
+ async fn chat_without_session_is_401() {
+ let store = test_store().await;
+ let data_dir = tempfile::tempdir().unwrap();
+ let r = req("POST", "/aiui/api/routstr/chat/completions", None, "{}");
+ let resp = route_routstr_proxy(
+ &store,
+ data_dir.path(),
+ r,
+ "/aiui/api/routstr/chat/completions",
+ )
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+ }
+
+ /// D-05: a fresh node (no budget file → zero allowance) refuses the paid
+ /// path BEFORE any pricing/network I/O — this test runs fully offline.
+ #[tokio::test]
+ async fn chat_with_zero_allowance_is_refused_offline() {
+ let store = test_store().await;
+ let token = store.create().await;
+ let data_dir = tempfile::tempdir().unwrap();
+ let r = req(
+ "POST",
+ "/aiui/api/routstr/chat/completions",
+ Some(&token),
+ r#"{"model":"some-model","messages":[{"role":"user","content":"hi"}]}"#,
+ );
+ let resp = route_routstr_proxy(
+ &store,
+ data_dir.path(),
+ r,
+ "/aiui/api/routstr/chat/completions",
+ )
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
+ let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
+ let v: Value = serde_json::from_slice(&body).unwrap();
+ let msg = v["error"]["message"].as_str().unwrap();
+ assert!(msg.contains("Settings"), "refusal must name the UI path");
+ }
+
+ #[tokio::test]
+ async fn chat_body_carrying_bip39_is_blocked() {
+ let store = test_store().await;
+ let token = store.create().await;
+ let data_dir = tempfile::tempdir().unwrap();
+ std::fs::create_dir_all(data_dir.path().join("secrets")).unwrap();
+ let r = req(
+ "POST",
+ "/aiui/api/routstr/chat/completions",
+ Some(&token),
+ r#"{"model":"m","messages":[{"role":"user","content":"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"}]}"#,
+ );
+ let resp = route_routstr_proxy(
+ &store,
+ data_dir.path(),
+ r,
+ "/aiui/api/routstr/chat/completions",
+ )
+ .await
+ .unwrap();
+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
+ }
+
+ #[test]
+ fn price_estimate_is_conservative_and_never_zero() {
+ // A free/garbled pricing entry still quotes at least 1 sat.
+ assert_eq!(estimate_price_sats(&SatsPricing::default(), 100, 1024), 1);
+
+ // Live-catalog-shaped numbers (deepseek-v4-flash, 2026-08-14):
+ // request 0.001, prompt ~0.000178/tok, completion ~0.000267/tok.
+ let p = SatsPricing {
+ prompt: 0.000178,
+ completion: 0.000267,
+ request: 0.001,
+ };
+ let quote = estimate_price_sats(&p, 4000, 1024);
+ // ~0.18 + ~0.27 + flat, with margin → rounds up to 1 sat.
+ assert_eq!(quote, 1);
+
+ // A pricier model scales with the prompt.
+ let expensive = SatsPricing {
+ prompt: 0.05,
+ completion: 0.1,
+ request: 1.0,
+ };
+ let quote = estimate_price_sats(&expensive, 40_000, 1024);
+ assert!(quote >= 600, "quote {quote} should reflect real rates");
+ }
+
+ #[test]
+ fn pricing_lookup_finds_exact_model_id() {
+ let models = json!({ "data": [
+ { "id": "a-model", "sats_pricing": { "prompt": 0.1, "completion": 0.2, "request": 1.0 } },
+ { "id": "other", "sats_pricing": { "prompt": 0.3 } }
+ ]});
+ let p = pricing_for_model(&models, "a-model").unwrap();
+ assert_eq!(p.request, 1.0);
+ assert!(pricing_for_model(&models, "missing").is_none());
+ }
+}
diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf
index 375ad0ef..7b9b8bb6 100644
--- a/image-recipe/configs/nginx-archipelago.conf
+++ b/image-recipe/configs/nginx-archipelago.conf
@@ -107,6 +107,24 @@ server {
proxy_send_timeout 120s;
}
+ # AIUI Routstr proxy — the explicit, user-selected Routstr provider in
+ # AIUI's model picker. Same daemon, same session-gate discipline as the
+ # Claude block above; the daemon additionally refuses paid requests
+ # unless the operator has armed a Routstr budget (D-05), so this is
+ # never an open relay even though completions cost sats.
+ location /aiui/api/routstr/ {
+ proxy_pass http://127.0.0.1:5678;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header Cookie $http_cookie;
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_connect_timeout 120s;
+ proxy_read_timeout 300s;
+ proxy_send_timeout 120s;
+ }
+
# AIUI Ollama (local AI) proxy — same daemon, same session gate as above.
# The standalone AIUI OpenRouter relay that used to live here is deleted
# outright: the node holds no key for that backend, it is not in the
@@ -1041,6 +1059,20 @@ server {
proxy_read_timeout 300s;
proxy_send_timeout 120s;
}
+ # Session-gated Routstr provider path — same rationale and shape as the
+ # HTTP server block above; both blocks must carry it (T-13-15).
+ location /aiui/api/routstr/ {
+ proxy_pass http://127.0.0.1:5678;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header Cookie $http_cookie;
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_connect_timeout 120s;
+ proxy_read_timeout 300s;
+ proxy_send_timeout 120s;
+ }
location /aiui/api/ollama/ {
proxy_pass http://127.0.0.1:5678;
proxy_http_version 1.1;