From 9b6e54f71093bd6d43cfc12cdca7300a0729595a Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Fri, 26 Jun 2026 17:59:57 -0400 Subject: [PATCH 01/10] local flavor: strict-auth mode (reject invalid/missing token, not anonymous) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator's resolve_ws_access degrades a missing/invalid ?token= to an ANONYMOUS connection rather than rejecting it, so a token verifier gates only ACL scope, not connections — a real gap for single-tenant local/tailnet deployments (a tokenless peer can drive the agent). Surfaced by the smooth daemon's live e2e. - AppState.strict_auth + with_strict_auth (default false — K8s/widget anonymous flows unchanged). - resolve_ws_access -> Result: in strict mode a missing/invalid token returns Err; ws_upgrade then responds 401 instead of upgrading. Lenient (default) is byte-for-byte unchanged. - LocalServerBuilder::strict_auth(bool) threads it; the smooth daemon's local flavor opts in. Tested via the daemon's live e2e (smooth repo): tokenless /ws REJECTED, valid-token accepted + a real LLM turn still runs. Unit test: strict off by default, opt-in threads to AppState. (Couldn't run the operator's own suite in this worktree — a corrupt rustls-pki-types 1.14.1 in the local cargo cache; validated through the daemon build, which pins the working 1.14.0.) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1 --- rust/smooth-operator-server/src/local.rs | 31 +++++++++++++++++ rust/smooth-operator-server/src/server.rs | 42 +++++++++++++++++------ rust/smooth-operator-server/src/state.rs | 17 +++++++++ 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/rust/smooth-operator-server/src/local.rs b/rust/smooth-operator-server/src/local.rs index b6de70b..f359aa3 100644 --- a/rust/smooth-operator-server/src/local.rs +++ b/rust/smooth-operator-server/src/local.rs @@ -91,6 +91,7 @@ pub struct LocalServerBuilder { tool_provider: Option>, serve_widget: bool, widget_token: Option, + strict_auth: bool, } impl std::fmt::Debug for LocalServerBuilder { @@ -119,6 +120,7 @@ impl Default for LocalServerBuilder { tool_provider: None, serve_widget: false, widget_token: None, + strict_auth: false, } } } @@ -178,6 +180,17 @@ impl LocalServerBuilder { self } + /// Enable **strict auth**: reject `/ws` connections with a missing/invalid + /// token (HTTP 401) instead of degrading to an anonymous connection. Pair + /// with [`auth`](Self::auth) — recommended whenever the server is reachable + /// beyond loopback (e.g. a tailnet), so a tokenless peer can't drive the + /// agent. Off by default. + #[must_use] + pub fn strict_auth(mut self, strict: bool) -> Self { + self.strict_auth = strict; + self + } + /// Override the full [`ServerConfig`] (e.g. to point at a gateway / model). /// /// The local flavor still **forces** in-memory storage and the caller's bind @@ -219,6 +232,9 @@ impl LocalServerBuilder { if self.serve_widget { state = state.with_widget(self.widget_token.clone()); } + if self.strict_auth { + state = state.with_strict_auth(true); + } state } @@ -452,4 +468,19 @@ mod tests { ); assert_eq!(state.widget_token, None); } + + #[test] + fn strict_auth_off_by_default_and_opt_in() { + assert!( + !LocalServerBuilder::default().build().strict_auth, + "lenient/anonymous by default" + ); + assert!( + LocalServerBuilder::default() + .strict_auth(true) + .build() + .strict_auth, + "opt-in threads to AppState" + ); + } } diff --git a/rust/smooth-operator-server/src/server.rs b/rust/smooth-operator-server/src/server.rs index f74a697..ca8f57e 100644 --- a/rust/smooth-operator-server/src/server.rs +++ b/rust/smooth-operator-server/src/server.rs @@ -596,36 +596,52 @@ struct ConnectionAuth { org_id: Option, } -fn resolve_ws_access(state: &AppState, query: &WsQuery) -> ConnectionAuth { +/// Resolve the connection's access from `?token=`. +/// +/// **Lenient (default):** a missing/invalid token degrades to +/// [`AccessContext::anonymous`] (org-public only) — keeps the dev/no-auth `/ws` +/// path and the embeddable widget's anonymous flow working while failing closed +/// for ACL'd content. **Strict ([`AppState::strict_auth`]):** a missing/invalid +/// token is **rejected** (the connection is refused, not degraded) — what a +/// single-tenant local/tailnet deployment wants so a tokenless peer can't drive +/// the agent. Returns `Err(())` to signal "reject the upgrade". +fn resolve_ws_access(state: &AppState, query: &WsQuery) -> Result { let Some(token) = query .token .as_deref() .map(str::trim) .filter(|t| !t.is_empty()) else { - // No token → anonymous (org-public only). Keeps the dev/no-auth `/ws` - // path working while failing closed for ACL'd content. - return ConnectionAuth { + if state.strict_auth { + tracing::warn!("strict auth: rejecting tokenless /ws connection"); + return Err(()); + } + // No token → anonymous (org-public only). + return Ok(ConnectionAuth { access: AccessContext::anonymous(), org_id: None, - }; + }); }; match state.auth.verify(token) { - Ok(principal) => ConnectionAuth { + Ok(principal) => Ok(ConnectionAuth { access: principal.access_context(), org_id: Some(principal.org_id), - }, + }), Err(e) => { // Don't leak the token; log only the mode + a generic reason. tracing::warn!( auth_mode = state.auth.mode(), error = %e, - "ws token failed verification; serving org-public knowledge only (anonymous)" + strict = state.strict_auth, + "ws token failed verification" ); - ConnectionAuth { + if state.strict_auth { + return Err(()); + } + Ok(ConnectionAuth { access: AccessContext::anonymous(), org_id: None, - } + }) } } } @@ -640,7 +656,11 @@ async fn ws_upgrade( Query(query): Query, headers: axum::http::HeaderMap, ) -> Response { - let ConnectionAuth { access, org_id } = resolve_ws_access(&state, &query); + let ConnectionAuth { access, org_id } = match resolve_ws_access(&state, &query) { + Ok(auth) => auth, + // Strict auth refused the connection (missing/invalid token). + Err(()) => return (axum::http::StatusCode::UNAUTHORIZED, "unauthorized").into_response(), + }; // Capture the browser's `Origin` at the handshake (browsers always send it, // and can't be made to forge another site's). It's enforced per-agent at // session creation against the agent's embed allowlist (widget_auth). diff --git a/rust/smooth-operator-server/src/state.rs b/rust/smooth-operator-server/src/state.rs index bba74ba..cd89a0f 100644 --- a/rust/smooth-operator-server/src/state.rs +++ b/rust/smooth-operator-server/src/state.rs @@ -128,6 +128,13 @@ pub struct AppState { /// the embedded widget connects to this server's `/ws?token=…`. `None` ⇒ no /// token injected (a no-auth local server). pub widget_token: Option, + /// **Strict auth.** When `true`, the `/ws` connect path **rejects** a + /// missing/invalid token (HTTP 401) instead of degrading to an anonymous + /// connection. Off by default (K8s/widget anonymous flows unchanged); a + /// single-tenant local/tailnet deployment opts in via + /// [`with_strict_auth`](Self::with_strict_auth) so a tokenless peer can't + /// drive the agent. + pub strict_auth: bool, } /// Namespace a connector name by org for the [`IndexingStore`] key, so two orgs @@ -176,6 +183,7 @@ impl AppState { pending_confirmations: Arc::new(RwLock::new(HashMap::new())), serve_widget: false, widget_token: None, + strict_auth: false, } } @@ -217,6 +225,15 @@ impl AppState { self } + /// Enable **strict auth** (builder): reject `/ws` connections with a + /// missing/invalid token (HTTP 401) instead of degrading to anonymous. Pair + /// with a real [`with_auth`](Self::with_auth) verifier. Off by default. + #[must_use] + pub fn with_strict_auth(mut self, strict: bool) -> Self { + self.strict_auth = strict; + self + } + /// Serve the embedded official widget (host page at `/`, bundle at /// `/chat-widget.iife.js`), injecting `token` into the page so the widget /// connects to this server's `/ws?token=…` (builder). The local deployment From c74b4171898884c75098afd5a360a64b669267d0 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sun, 28 Jun 2026 20:14:25 -0400 Subject: [PATCH 02/10] local flavor: storage-adapter injection seam (durable self-hosted persistence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local flavor hardcoded InMemoryStorageAdapter, so an always-on, self-hosted daemon lost all conversations/sessions/checkpoints on restart — and the only durable backend was Postgres (the cloud flavor). Add a seam so an embedder can inject ANY StorageAdapter (e.g. a local sqlite/dolt one) without standing up Postgres: - AppState::with_storage(Arc) — builder, mirrors with_auth. - LocalServerBuilder::storage(...) — when set, build() installs it in place of the in-memory default; unset → in-memory (unchanged). This is the keystone for collapsing the smooth daemon onto a single operator runtime: with durable local storage the operator path can absorb what the bespoke serve_persistent path provided, and the bespoke path retires. Tested: storage_seam_installs_a_durable_adapter (injected adapter installed; default still in-memory). 33 lib tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016wBikCFJyoowRokiWK5rX1 --- rust/smooth-operator-server/src/local.rs | 32 ++++++++++++++++++++++++ rust/smooth-operator-server/src/state.rs | 12 +++++++++ 2 files changed, 44 insertions(+) diff --git a/rust/smooth-operator-server/src/local.rs b/rust/smooth-operator-server/src/local.rs index f359aa3..82ef6d1 100644 --- a/rust/smooth-operator-server/src/local.rs +++ b/rust/smooth-operator-server/src/local.rs @@ -63,6 +63,7 @@ use anyhow::{Context, Result}; use tokio::net::TcpListener; use tokio::task::JoinHandle; +use smooth_operator::adapter::StorageAdapter; use smooth_operator::auth::{AuthVerifier, NoAuthVerifier}; use smooth_operator::tool_provider::ToolProvider; @@ -92,6 +93,7 @@ pub struct LocalServerBuilder { serve_widget: bool, widget_token: Option, strict_auth: bool, + storage: Option>, } impl std::fmt::Debug for LocalServerBuilder { @@ -121,6 +123,7 @@ impl Default for LocalServerBuilder { serve_widget: false, widget_token: None, strict_auth: false, + storage: None, } } } @@ -191,6 +194,17 @@ impl LocalServerBuilder { self } + /// Install a **durable** storage adapter, replacing the default in-memory + /// store. This is the seam an always-on, self-hosted deployment (the local + /// daemon) uses to persist conversations/sessions/checkpoints across + /// restarts without standing up Postgres — the embedder supplies any + /// [`StorageAdapter`] (e.g. a local sqlite/dolt one). Unset → in-memory. + #[must_use] + pub fn storage(mut self, storage: Arc) -> Self { + self.storage = Some(storage); + self + } + /// Override the full [`ServerConfig`] (e.g. to point at a gateway / model). /// /// The local flavor still **forces** in-memory storage and the caller's bind @@ -226,6 +240,11 @@ impl LocalServerBuilder { .clone() .unwrap_or_else(|| Arc::new(NoAuthVerifier::default()) as Arc); let mut state = build_state(config).with_auth(auth); + // A durable adapter, when supplied, replaces the in-memory default — the + // local flavor stays "no external services" but can now persist. + if let Some(storage) = &self.storage { + state = state.with_storage(Arc::clone(storage)); + } if let Some(provider) = &self.tool_provider { state = state.with_tools(Arc::clone(provider)); } @@ -416,6 +435,19 @@ mod tests { assert_eq!(state.auth.mode(), "none"); } + #[test] + fn storage_seam_installs_a_durable_adapter() { + use smooth_operator_adapter_memory::InMemoryStorageAdapter; + // Any StorageAdapter stands in for a durable one; assert the builder + // installs the *injected* adapter, not the hardcoded in-memory default. + let injected: Arc = Arc::new(InMemoryStorageAdapter::new()); + let state = LocalServerBuilder::default().storage(Arc::clone(&injected)).build(); + assert!(Arc::ptr_eq(&state.storage, &injected), "the injected storage adapter must be installed"); + // Default (no override) → a distinct in-memory adapter. + let default_state = LocalServerBuilder::default().build(); + assert!(!Arc::ptr_eq(&default_state.storage, &injected)); + } + #[test] fn auth_seam_installs_a_custom_verifier() { use smooth_operator::auth::LocalTokenVerifier; diff --git a/rust/smooth-operator-server/src/state.rs b/rust/smooth-operator-server/src/state.rs index cd89a0f..c2946b7 100644 --- a/rust/smooth-operator-server/src/state.rs +++ b/rust/smooth-operator-server/src/state.rs @@ -194,6 +194,18 @@ impl AppState { self } + /// Replace the storage adapter (builder). + /// + /// Lets an embedder (e.g. the local-flavor daemon) swap the default + /// in-memory store for a **durable local adapter** — the seam an always-on, + /// self-hosted deployment needs so conversations/sessions/checkpoints + /// survive a restart without standing up Postgres. + #[must_use] + pub fn with_storage(mut self, storage: Arc) -> Self { + self.storage = storage; + self + } + /// Install the indexing store (builder). #[must_use] pub fn with_indexing(mut self, indexing: Arc) -> Self { From 762614d25a6a1ba5a6c9b90944f1f3dc614c3a2d Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Mon, 29 Jun 2026 17:45:48 -0400 Subject: [PATCH 03/10] =?UTF-8?q?smooth-operator-server:=20host=20seams=20?= =?UTF-8?q?for=20the=20daemon=20=E2=80=94=20persona,=20CORS,=20serve=5Fspa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additive, behavior-preserving seams the smooth-daemon (EPIC th-c89c2a) needs; all default to the prior behavior when unused: - default_persona (AppState + LocalServer.persona()): a host-installed default system prompt the WS chat turn falls back to when no per-org persona is set. Unset → the const customer-support prompt, byte-for-byte unchanged. - CORS on /admin (admin_cors): allow cross-origin GET/POST with the auth header so a same-origin-or-dev SPA can read /admin/me. /ws (no preflight) untouched; auth unchanged. - serve_spa(Router): mount a host-supplied SPA as the router fallback (so /ws, /health, /admin still win), token-injection left to the host (SPA-agnostic, no cross-repo dep). Tests added for each seam (state default/trim, admin CORS header + preflight, SPA-vs-operator routing). Full operator-server suite green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RuPU8KGE7exadVU6Gi9hBs --- rust/Cargo.lock | 1 + rust/smooth-operator-server/Cargo.toml | 7 + rust/smooth-operator-server/src/admin.rs | 31 ++++ rust/smooth-operator-server/src/handler.rs | 14 +- rust/smooth-operator-server/src/local.rs | 136 +++++++++++++++++- rust/smooth-operator-server/src/state.rs | 49 +++++++ .../smooth-operator-server/tests/admin_api.rs | 76 ++++++++++ 7 files changed, 307 insertions(+), 7 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 619b7ea..2661bf5 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -4229,6 +4229,7 @@ dependencies = [ "tokio-tungstenite 0.26.2", "tokio-util", "tower 0.5.3", + "tower-http", "tracing", "tracing-subscriber", "uuid", diff --git a/rust/smooth-operator-server/Cargo.toml b/rust/smooth-operator-server/Cargo.toml index 79a8ac1..fe81ec8 100644 --- a/rust/smooth-operator-server/Cargo.toml +++ b/rust/smooth-operator-server/Cargo.toml @@ -66,6 +66,13 @@ tokio = { workspace = true } tokio-util = "0.7" axum = { version = "0.8", features = ["ws"] } +# CORS for the `/admin` HTTP API: the local-flavor daemon's smooth-web SPA does a +# best-effort cross-origin `GET /admin/me` from the Vite dev origin +# (http://localhost:3100) to show the live model/identity in its header. The local +# flavor is loopback/tailnet-only + token-authed, so a permissive CORS on `/admin` +# is acceptable. (`/ws` needs no CORS — a WebSocket handshake isn't subject to the +# CORS preflight.) +tower-http = { version = "0.6", features = ["cors"] } futures-util = "0.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/rust/smooth-operator-server/src/admin.rs b/rust/smooth-operator-server/src/admin.rs index a9ee9d2..ba2d3f7 100644 --- a/rust/smooth-operator-server/src/admin.rs +++ b/rust/smooth-operator-server/src/admin.rs @@ -87,6 +87,37 @@ pub fn router() -> Router { // target over the WebSocket fleet. The plug point for non-AI publishers // (job status, ingestion progress, notifications). Admin-gated. .route("/admin/publish", post(publish_event)) + // CORS for the `/admin` surface only (NOT `/ws` — a WebSocket handshake + // isn't subject to the CORS preflight). The local-flavor daemon serves its + // smooth-web SPA same-origin, but in dev the SPA runs on the Vite origin + // (http://localhost:3100) and does a best-effort cross-origin + // `GET /admin/me` to populate the live model/identity in its header — which + // the browser blocks without these headers. The local flavor is + // loopback/tailnet-only and every `/admin` route is token-authed + // ([`require_role`]), so a permissive CORS here doesn't widen the trust + // boundary: a cross-origin caller still needs a valid bearer token. + .layer(admin_cors()) +} + +/// The permissive [`CorsLayer`](tower_http::cors::CorsLayer) applied to `/admin`. +/// +/// Allows any origin with the verbs the admin API exposes (GET/POST/PUT/DELETE, +/// plus the implicit `OPTIONS` preflight) and the only request headers the SPA +/// sends (`authorization` for the bearer token, `content-type` for JSON bodies). +/// Auth is unaffected — every route still runs [`require_role`], so this only +/// relaxes the browser's same-origin policy, not the server's authorization. +fn admin_cors() -> tower_http::cors::CorsLayer { + use axum::http::{header, Method}; + tower_http::cors::CorsLayer::new() + .allow_origin(tower_http::cors::Any) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) } // --------------------------------------------------------------------------- diff --git a/rust/smooth-operator-server/src/handler.rs b/rust/smooth-operator-server/src/handler.rs index 1eaee8e..d2ef9f5 100644 --- a/rust/smooth-operator-server/src/handler.rs +++ b/rust/smooth-operator-server/src/handler.rs @@ -638,10 +638,16 @@ async fn handle_send_message( // auth. Used to (a) resolve the org's persona override (SEAM 2) and (b) // scope the host's tool provider (SEAM 1). let org_id = crate::server::SEED_ORG_ID.to_string(); - // SEAM 2 — resolve the per-org persona. With the default in-memory settings - // store the override is `None`, so the runner stays on its const prompt and - // behavior is unchanged. - let system_prompt = state.settings.get(&org_id).persona; + // SEAM 2 — resolve the per-org persona, falling back to the host's installed + // default persona ([`AppState::default_persona`], e.g. the local daemon's + // "Big Smooth" personal-assistant prompt). With the default in-memory settings + // store AND no default persona installed, both are `None`, so the runner stays + // on its const prompt and behavior is byte-for-byte unchanged. + let system_prompt = state + .settings + .get(&org_id) + .persona + .or_else(|| state.default_persona.clone()); // SEAM 1 — host tool provider (None by default ⇒ built-ins only). let tool_provider = state.tool_provider.clone(); diff --git a/rust/smooth-operator-server/src/local.rs b/rust/smooth-operator-server/src/local.rs index 82ef6d1..d450d8e 100644 --- a/rust/smooth-operator-server/src/local.rs +++ b/rust/smooth-operator-server/src/local.rs @@ -94,6 +94,8 @@ pub struct LocalServerBuilder { widget_token: Option, strict_auth: bool, storage: Option>, + persona: Option, + spa_router: Option, } impl std::fmt::Debug for LocalServerBuilder { @@ -124,6 +126,8 @@ impl Default for LocalServerBuilder { widget_token: None, strict_auth: false, storage: None, + persona: None, + spa_router: None, } } } @@ -183,6 +187,31 @@ impl LocalServerBuilder { self } + /// Set the **default agent persona** (system prompt) for every turn that has + /// no per-org override. A single-tenant host (the local daemon) uses this to + /// give the agent its own personality instead of the built-in customer-support + /// prompt. Threads to [`AppState::default_persona`](crate::state::AppState::default_persona). + /// Unset → the built-in const prompt (unchanged). + #[must_use] + pub fn persona(mut self, persona: impl Into) -> Self { + self.persona = Some(persona.into()); + self + } + + /// Serve a **host-supplied SPA** (e.g. the smooth-web dashboard) at this + /// server's own origin, as the router fallback. The operator's explicit routes + /// (`/ws`, `/health`, `/admin/*`) still win; everything else (`/`, hashed + /// asset paths, SPA client routes) is served by `spa`. Use this INSTEAD of + /// [`serve_widget`](Self::serve_widget) when the host wants its own UI at `/` + /// — the endpoint is then simply `http:///` with no `?api`/`?token` + /// query string (the host injects the token into the SPA's `index.html` + /// itself, so the operator-server stays agnostic to the SPA's auth wiring). + #[must_use] + pub fn serve_spa(mut self, spa: axum::Router) -> Self { + self.spa_router = Some(spa); + self + } + /// Enable **strict auth**: reject `/ws` connections with a missing/invalid /// token (HTTP 401) instead of degrading to an anonymous connection. Pair /// with [`auth`](Self::auth) — recommended whenever the server is reachable @@ -254,9 +283,29 @@ impl LocalServerBuilder { if self.strict_auth { state = state.with_strict_auth(true); } + if let Some(persona) = &self.persona { + state = state.with_default_persona(persona.clone()); + } state } + /// Assemble the full axum [`Router`](axum::Router): the operator's routes + /// (`/ws`, `/health`, `/admin/*`, and optionally the widget) plus, when a host + /// SPA was installed via [`serve_spa`](Self::serve_spa), that SPA as the + /// router fallback (so the explicit operator routes still win). Factored out of + /// [`spawn`](Self::spawn) so a test can drive it with `tower::ServiceExt::oneshot`. + fn build_app(&self) -> axum::Router { + let mut app = router(self.build()); + // A host SPA (smooth-web) is mounted as the router fallback so the + // operator's explicit routes (`/ws`, `/health`, `/admin/*`) still win and + // everything else — `/`, hashed assets, SPA client routes — is served by + // the SPA at this server's own origin. + if let Some(spa) = self.spa_router.clone() { + app = app.fallback_service(spa); + } + app + } + /// Bind and spawn the server in a background task, returning a [`LocalServer`] /// handle carrying the **real** bound address (resolved even for port `0`) /// and a graceful-shutdown switch. @@ -271,7 +320,7 @@ impl LocalServerBuilder { .with_context(|| format!("binding local smooth-operator server on {}", self.addr))?; let addr = listener.local_addr().context("local addr")?; - let app = router(self.build()); + let app = self.build_app(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let join = tokio::spawn(async move { @@ -441,8 +490,13 @@ mod tests { // Any StorageAdapter stands in for a durable one; assert the builder // installs the *injected* adapter, not the hardcoded in-memory default. let injected: Arc = Arc::new(InMemoryStorageAdapter::new()); - let state = LocalServerBuilder::default().storage(Arc::clone(&injected)).build(); - assert!(Arc::ptr_eq(&state.storage, &injected), "the injected storage adapter must be installed"); + let state = LocalServerBuilder::default() + .storage(Arc::clone(&injected)) + .build(); + assert!( + Arc::ptr_eq(&state.storage, &injected), + "the injected storage adapter must be installed" + ); // Default (no override) → a distinct in-memory adapter. let default_state = LocalServerBuilder::default().build(); assert!(!Arc::ptr_eq(&default_state.storage, &injected)); @@ -501,6 +555,82 @@ mod tests { assert_eq!(state.widget_token, None); } + #[test] + fn persona_seam_installs_default_persona() { + // No persona → no default (built-in const prompt, unchanged behavior). + assert_eq!( + LocalServerBuilder::default().build().default_persona, + None, + "no default persona unless set" + ); + // `.persona(..)` threads through to AppState::default_persona. + let state = LocalServerBuilder::default() + .persona("You are Big Smooth.") + .build(); + assert_eq!( + state.default_persona.as_deref(), + Some("You are Big Smooth.") + ); + } + + #[tokio::test] + async fn serve_spa_mounts_host_router_as_fallback() { + use http_body_util::BodyExt; + use tower::ServiceExt; + + // A trivial host SPA: any unmatched path returns a sentinel. The + // operator's `/health` must still win (an explicit route beats the SPA + // fallback). + let spa = axum::Router::new().fallback(axum::routing::get(|| async { "SPA-ROOT" })); + let app = LocalServerBuilder::default().serve_spa(spa).build_app(); + + // `/` (and any non-operator path) routes to the SPA. + let res = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri("/") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), axum::http::StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + assert_eq!( + &body[..], + b"SPA-ROOT", + "the host SPA is served as the fallback" + ); + + // The operator's explicit `/health` route still wins over the SPA fallback. + let res = app + .oneshot( + axum::http::Request::builder() + .uri("/health") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), axum::http::StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + assert_eq!( + &body[..], + b"ok", + "explicit operator routes win over the SPA" + ); + } + + #[test] + fn no_spa_by_default() { + // Without `serve_spa`, an unknown path is a 404 (no fallback service). + assert!( + LocalServerBuilder::default().spa_router.is_none(), + "no SPA mounted unless the host installs one" + ); + } + #[test] fn strict_auth_off_by_default_and_opt_in() { assert!( diff --git a/rust/smooth-operator-server/src/state.rs b/rust/smooth-operator-server/src/state.rs index c2946b7..894e587 100644 --- a/rust/smooth-operator-server/src/state.rs +++ b/rust/smooth-operator-server/src/state.rs @@ -135,6 +135,15 @@ pub struct AppState { /// [`with_strict_auth`](Self::with_strict_auth) so a tokenless peer can't /// drive the agent. pub strict_auth: bool, + /// **Default agent persona / system prompt.** When `Some`, it is used as the + /// turn's system prompt whenever the per-org [`AgentSettings::persona`] is + /// `None` — i.e. a host-supplied default that replaces the built-in + /// customer-support [`KNOWLEDGE_CHAT_SYSTEM_PROMPT`](crate::runner) when no + /// per-org override exists. The single-tenant local daemon installs its + /// "Big Smooth" personal-assistant persona here via + /// [`with_default_persona`](Self::with_default_persona). `None` (the default) + /// keeps the const prompt, so the cloud flavor is byte-for-byte unchanged. + pub default_persona: Option, } /// Namespace a connector name by org for the [`IndexingStore`] key, so two orgs @@ -184,6 +193,7 @@ impl AppState { serve_widget: false, widget_token: None, strict_auth: false, + default_persona: None, } } @@ -246,6 +256,23 @@ impl AppState { self } + /// Install a **default agent persona** (builder): the system prompt used for + /// a turn when the per-org [`AgentSettings::persona`] is unset. A single-tenant + /// host (the local daemon) installs its own personality here so every turn + /// runs as that agent rather than the built-in customer-support prompt. `None` + /// (the default) keeps the const prompt, so the cloud flavor is unchanged. An + /// empty/whitespace-only string is treated as no default. + #[must_use] + pub fn with_default_persona(mut self, persona: impl Into) -> Self { + let persona = persona.into(); + self.default_persona = if persona.trim().is_empty() { + None + } else { + Some(persona) + }; + self + } + /// Serve the embedded official widget (host page at `/`, bundle at /// `/chat-widget.iife.js`), injecting `token` into the page so the widget /// connects to this server's `/ws?token=…` (builder). The local deployment @@ -441,6 +468,28 @@ mod tests { AppState::new(Arc::new(InMemoryStorageAdapter::new()), config) } + #[test] + fn default_persona_unset_by_default() { + let state = state_with(config_with_env_key(None)); + assert_eq!( + state.default_persona, None, + "no default persona unless a host installs one" + ); + } + + #[test] + fn with_default_persona_installs_and_trims_empty() { + let state = + state_with(config_with_env_key(None)).with_default_persona("You are Big Smooth."); + assert_eq!( + state.default_persona.as_deref(), + Some("You are Big Smooth.") + ); + // An empty / whitespace-only persona is treated as "no default". + let blank = state_with(config_with_env_key(None)).with_default_persona(" "); + assert_eq!(blank.default_persona, None, "blank persona is ignored"); + } + /// Per-org resolver covering exactly one org; `None` (→ env fallback) for any /// other org. Mirrors what a multi-tenant host installs. struct OneOrgResolver { diff --git a/rust/smooth-operator-server/tests/admin_api.rs b/rust/smooth-operator-server/tests/admin_api.rs index ef924b9..ecf43ef 100644 --- a/rust/smooth-operator-server/tests/admin_api.rs +++ b/rust/smooth-operator-server/tests/admin_api.rs @@ -150,6 +150,82 @@ async fn health_is_unauthenticated() { assert_eq!(body["status"], "ok"); } +#[tokio::test] +async fn admin_response_carries_cors_header() { + // A real cross-origin GET /admin/me must carry `access-control-allow-origin` + // so the daemon's smooth-web SPA (running on the Vite dev origin) can read the + // model/identity. Auth is unchanged — the request still needs a valid token. + let state = state_with_auth(false, Arc::new(InMemoryIndexingStore::new())); + let app = router(state); + let resp = app + .oneshot( + Request::builder() + .method("GET") + .uri("/admin/me") + .header("Origin", "http://localhost:3100") + .header( + "Authorization", + format!("Bearer {}", token("alice", "admin")), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("oneshot"); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers() + .get("access-control-allow-origin") + .map(|v| v.to_str().unwrap()), + Some("*"), + "the /admin response must carry a permissive CORS allow-origin header" + ); +} + +#[tokio::test] +async fn admin_cors_preflight_allows_authorization_header() { + // The browser preflights GET /admin/me (it carries an Authorization header) + // with an OPTIONS request; the response must allow the `authorization` request + // header, else the real request is blocked. + let state = state_with_auth(false, Arc::new(InMemoryIndexingStore::new())); + let app = router(state); + let resp = app + .oneshot( + Request::builder() + .method("OPTIONS") + .uri("/admin/me") + .header("Origin", "http://localhost:3100") + .header("Access-Control-Request-Method", "GET") + .header("Access-Control-Request-Headers", "authorization") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("oneshot"); + // tower-http answers the preflight directly (200/204) with the allow headers. + assert!( + resp.status().is_success(), + "CORS preflight should succeed, got {}", + resp.status() + ); + let allow_headers = resp + .headers() + .get("access-control-allow-headers") + .map(|v| v.to_str().unwrap().to_ascii_lowercase()) + .unwrap_or_default(); + assert!( + allow_headers.contains("authorization"), + "preflight must allow the authorization header, got: {allow_headers:?}" + ); + assert_eq!( + resp.headers() + .get("access-control-allow-origin") + .map(|v| v.to_str().unwrap()), + Some("*"), + "preflight must echo a permissive allow-origin" + ); +} + #[tokio::test] async fn me_returns_principal() { let state = state_with_auth(false, Arc::new(InMemoryIndexingStore::new())); From 5ca1d15081885f99bf3da035fc57af9e8d792674 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Mon, 29 Jun 2026 19:03:17 -0400 Subject: [PATCH 04/10] operator-server: map AgentEvent::ReasoningDelta -> stream_reasoning protocol msg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reasoning tokens now arrive on AgentEvent::ReasoningDelta (smooth-operator-core th-4d8682). Route them to a new stream_reasoning protocol message — shaped like stream_token but on a distinct type — so clients render reasoning as 'thinking' and never as the answer. Test added. Pearl th-4d8682. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RuPU8KGE7exadVU6Gi9hBs --- rust/smooth-operator-server/src/protocol.rs | 27 +++++++++++++++++++++ rust/smooth-operator-server/src/runner.rs | 10 ++++++++ 2 files changed, 37 insertions(+) diff --git a/rust/smooth-operator-server/src/protocol.rs b/rust/smooth-operator-server/src/protocol.rs index f1e873e..4032919 100644 --- a/rust/smooth-operator-server/src/protocol.rs +++ b/rust/smooth-operator-server/src/protocol.rs @@ -64,6 +64,22 @@ pub fn stream_token(request_id: &str, token: &str) -> Value { }) } +/// `stream_reasoning` — a single streamed *reasoning* token from a reasoning +/// model's separate thinking channel. Shaped exactly like `stream_token`, but +/// on a distinct `type` so clients render it as "thinking" and never fold it +/// into the answer. Clients that don't know the type simply ignore it (the +/// answer still streams via `stream_token`). +#[must_use] +pub fn stream_reasoning(request_id: &str, token: &str) -> Value { + json!({ + "type": "stream_reasoning", + "requestId": request_id, + "token": token, + "data": { "requestId": request_id, "token": token }, + "timestamp": now_ms(), + }) +} + /// `stream_chunk` — a per-node state snapshot. `node` is mirrored at the /// envelope level and inside `data` (per `stream-chunk.schema.json`). `state` /// only carries safe-to-expose fields. @@ -198,6 +214,17 @@ mod tests { assert_eq!(ev["data"]["requestId"], "r1"); } + #[test] + fn stream_reasoning_is_distinct_type_but_mirrors_token() { + let ev = stream_reasoning("r1", "let me think"); + // Distinct type so clients never fold it into the answer… + assert_eq!(ev["type"], "stream_reasoning"); + // …but shaped exactly like stream_token so they render it the same way. + assert_eq!(ev["token"], "let me think"); + assert_eq!(ev["data"]["token"], "let me think"); + assert_eq!(ev["data"]["requestId"], "r1"); + } + #[test] fn stream_chunk_mirrors_node() { let ev = stream_chunk("r1", "knowledge_search", json!({ "rawResponse": "x" })); diff --git a/rust/smooth-operator-server/src/runner.rs b/rust/smooth-operator-server/src/runner.rs index 4e19f6e..c349621 100644 --- a/rust/smooth-operator-server/src/runner.rs +++ b/rust/smooth-operator-server/src/runner.rs @@ -365,6 +365,16 @@ pub async fn run_streaming_turn( .send(crate::protocol::stream_token(&request_id_owned, &content)); } } + AgentEvent::ReasoningDelta { content } => { + // Reasoning rides its own protocol message so the client shows + // it as "thinking", never as the answer (th-4d8682). + if !content.is_empty() { + let _ = sink_clone.send(crate::protocol::stream_reasoning( + &request_id_owned, + &content, + )); + } + } AgentEvent::ToolCallStart { tool_name, arguments, From d14e3010a85edbf2d5f4f3e776e57b465962c39e Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Tue, 30 Jun 2026 06:51:26 -0400 Subject: [PATCH 05/10] operator-server: per-turn model override + cost on eventual_response + /admin/model-costs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for Smooth Modes (th-f512b1, th-2a6330), all additive + back-compat: - send_message gains optional 'model' → that turn runs on the chosen model (sets llm.model for the turn only; absent = server default). - eventual_response carries data.data.usage.{costUsd,promptTokens,completionTokens} (from AgentEvent::Completed) so clients accumulate live session cost. - GET /admin/model-costs (ungated) returns model->{inputCostPerToken,outputCostPerToken, tier,useCases} from the gateway /v1/model/info, cached; {} (200) on gateway error. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RuPU8KGE7exadVU6Gi9hBs --- rust/Cargo.lock | 1 + rust/smooth-operator-server/Cargo.toml | 5 + rust/smooth-operator-server/src/admin.rs | 182 ++++++++++++++++++++ rust/smooth-operator-server/src/handler.rs | 79 +++++++++ rust/smooth-operator-server/src/protocol.rs | 78 +++++++++ rust/smooth-operator-server/src/runner.rs | 31 +++- rust/smooth-operator-server/src/state.rs | 8 + 7 files changed, 381 insertions(+), 3 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2661bf5..9b79d6a 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -4215,6 +4215,7 @@ dependencies = [ "futures-util", "http-body-util", "jsonwebtoken", + "reqwest", "serde", "serde_json", "smooai-smooth-operator", diff --git a/rust/smooth-operator-server/Cargo.toml b/rust/smooth-operator-server/Cargo.toml index fe81ec8..909e18d 100644 --- a/rust/smooth-operator-server/Cargo.toml +++ b/rust/smooth-operator-server/Cargo.toml @@ -76,6 +76,11 @@ tower-http = { version = "0.6", features = ["cors"] } futures-util = "0.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +# `GET /admin/model-costs` fetches the LLM gateway's `/v1/model/info` (the same +# gateway base url + key the turns use) to surface per-model pricing for cost +# badges. Workspace reqwest is rustls-only (no OpenSSL); `json` adds response +# deserialization. reqwest is already in the dependency graph via the core crate. +reqwest = { workspace = true, features = ["json"] } [dev-dependencies] tokio-tungstenite = "0.26" diff --git a/rust/smooth-operator-server/src/admin.rs b/rust/smooth-operator-server/src/admin.rs index ba2d3f7..ca7975b 100644 --- a/rust/smooth-operator-server/src/admin.rs +++ b/rust/smooth-operator-server/src/admin.rs @@ -68,6 +68,11 @@ pub fn router() -> Router { ) .route("/admin/indexing/runs", get(indexing_runs)) .route("/admin/document-sets", get(document_sets)) + // Per-model pricing for cost badges (Smooth Modes). Intentionally + // UNGATED (like `/admin/health`): gateway pricing is not org-sensitive, + // and the UI must be able to render badges even on a tokenless local + // connection. Degrades to an empty object on any gateway error. + .route("/admin/model-costs", get(model_costs)) // Write API (Phase 12, increment 3) — connector CRUD, index trigger, // settings. RBAC: list/get are Curator; create/update/delete are Admin; // index trigger is Curator; settings read is Curator, write is Admin. @@ -446,6 +451,107 @@ async fn document_sets( Json(serde_json::json!({ "documentSets": sets })) } +// --------------------------------------------------------------------------- +// Model costs — `GET /admin/model-costs` +// --------------------------------------------------------------------------- + +/// `GET /admin/model-costs` — per-model gateway pricing, keyed by gateway model +/// id, for the UI's per-mode cost badges (Smooth Modes). Shape: +/// +/// ```json +/// { "": { "inputCostPerToken": , +/// "outputCostPerToken": , +/// "tier": "", +/// "useCases": [] } } +/// ``` +/// +/// Ungated (see the route registration): pricing isn't org-sensitive and the +/// badge must render even on a tokenless local connection. The gateway's +/// `/v1/model/info` is fetched at most once per process (cached in +/// [`AppState::model_costs_cache`]); on **any** gateway error this returns an +/// empty object with status 200 — never a 500 — so the UI degrades to no-badge. +async fn model_costs(State(state): State) -> Json { + // Reuse the cached pricing if a prior request already fetched it. + if let Some(cached) = state.model_costs_cache.get() { + return Json(cached.clone()); + } + match fetch_model_costs(&state.config).await { + Ok(map) => { + // Cache the first success; a lost race (another request set it first) + // is harmless — both computed the same stable pricing. + let _ = state.model_costs_cache.set(map.clone()); + Json(map) + } + // Degrade to no-badge on any gateway/transport error — never surface a + // 500. NOT cached, so the next request retries. + Err(_) => Json(serde_json::json!({})), + } +} + +/// Fetch the gateway's `/v1/model/info` (using the server's configured gateway +/// base url + key — the same creds the turns use) and map it to the +/// `/admin/model-costs` response shape via [`map_model_info`]. +/// +/// # Errors +/// Returns an error on any transport / non-2xx / decode failure; the caller maps +/// that to an empty object (UI degrades gracefully). +async fn fetch_model_costs(config: &crate::config::ServerConfig) -> anyhow::Result { + // `gateway_url` already ends in `/v1` (e.g. `https://llm.smoo.ai/v1`), so the + // model-info endpoint is `{gateway_url}/model/info`. + let url = format!("{}/model/info", config.gateway_url.trim_end_matches('/')); + let client = reqwest::Client::new(); + let mut req = client.get(&url); + if let Some(key) = config.gateway_key.as_deref() { + req = req.bearer_auth(key); + } + let payload: Value = req.send().await?.error_for_status()?.json().await?; + Ok(map_model_info(&payload)) +} + +/// Map the gateway's `/v1/model/info` payload +/// (`{ data: [{ model_name, model_info: { input_cost_per_token, +/// output_cost_per_token, model_tier, use_cases } }] }`) to the +/// `/admin/model-costs` response object, keyed by `model_name`. Missing numeric +/// fields become `null`, a missing tier becomes `null`, and a missing +/// `use_cases` becomes `[]`. Entries without a `model_name` are skipped. Pure + +/// network-free so it's unit-testable on a sample payload. +fn map_model_info(payload: &Value) -> Value { + let mut out = serde_json::Map::new(); + let Some(entries) = payload.get("data").and_then(Value::as_array) else { + return Value::Object(out); + }; + for entry in entries { + let Some(name) = entry.get("model_name").and_then(Value::as_str) else { + continue; + }; + let info = entry.get("model_info"); + let input = info + .and_then(|i| i.get("input_cost_per_token")) + .and_then(Value::as_f64); + let output = info + .and_then(|i| i.get("output_cost_per_token")) + .and_then(Value::as_f64); + let tier = info + .and_then(|i| i.get("model_tier")) + .and_then(Value::as_str); + let use_cases = info + .and_then(|i| i.get("use_cases")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + out.insert( + name.to_string(), + serde_json::json!({ + "inputCostPerToken": input, + "outputCostPerToken": output, + "tier": tier, + "useCases": use_cases, + }), + ); + } + Value::Object(out) +} + // --------------------------------------------------------------------------- // Connector config CRUD (Phase 12, increment 3) // --------------------------------------------------------------------------- @@ -962,3 +1068,79 @@ async fn publish_event( .await; Json(PublishResponse { delivered }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn map_model_info_maps_sample_payload() { + // A representative `/v1/model/info` payload from the LiteLLM gateway. + let payload = serde_json::json!({ + "data": [ + { + "model_name": "claude-opus-4-8", + "model_info": { + "input_cost_per_token": 0.000015, + "output_cost_per_token": 0.000075, + "model_tier": "frontier", + "use_cases": ["reasoning", "coding"] + } + }, + { + "model_name": "claude-haiku-4-5", + "model_info": { + "input_cost_per_token": 0.0000008, + "output_cost_per_token": 0.000004, + "model_tier": "fast", + "use_cases": ["chat"] + } + } + ] + }); + + let out = map_model_info(&payload); + let opus = &out["claude-opus-4-8"]; + assert!((opus["inputCostPerToken"].as_f64().unwrap() - 0.000015).abs() < 1e-12); + assert!((opus["outputCostPerToken"].as_f64().unwrap() - 0.000075).abs() < 1e-12); + assert_eq!(opus["tier"], "frontier"); + assert_eq!(opus["useCases"], serde_json::json!(["reasoning", "coding"])); + + let haiku = &out["claude-haiku-4-5"]; + assert_eq!(haiku["tier"], "fast"); + assert_eq!(haiku["useCases"], serde_json::json!(["chat"])); + } + + #[test] + fn map_model_info_tolerates_missing_fields() { + // Missing model_info / cost / tier / use_cases → nulls + empty array, + // and an entry with no model_name is skipped. + let payload = serde_json::json!({ + "data": [ + { "model_name": "bare", "model_info": {} }, + { "model_info": { "model_tier": "x" } } + ] + }); + let out = map_model_info(&payload); + let obj = out.as_object().unwrap(); + assert_eq!(obj.len(), 1, "the model_name-less entry is skipped"); + let bare = &out["bare"]; + assert!(bare["inputCostPerToken"].is_null()); + assert!(bare["outputCostPerToken"].is_null()); + assert!(bare["tier"].is_null()); + assert_eq!(bare["useCases"], serde_json::json!([])); + } + + #[test] + fn map_model_info_empty_on_missing_data() { + // A payload with no `data` array maps to an empty object (UI no-badge). + assert_eq!( + map_model_info(&serde_json::json!({})), + serde_json::json!({}) + ); + assert_eq!( + map_model_info(&serde_json::json!({ "data": "nope" })), + serde_json::json!({}) + ); + } +} diff --git a/rust/smooth-operator-server/src/handler.rs b/rust/smooth-operator-server/src/handler.rs index d2ef9f5..7b9f153 100644 --- a/rust/smooth-operator-server/src/handler.rs +++ b/rust/smooth-operator-server/src/handler.rs @@ -16,6 +16,7 @@ use smooth_operator::access_control::AccessContext; use smooth_operator::domain::{ Conversation, Participant, ParticipantType, Platform, Session, SessionStatus, }; +use smooth_operator_core::LlmConfig; use crate::protocol; use crate::runner; @@ -604,6 +605,12 @@ async fn handle_send_message( } }; + // Per-turn model override (Smooth Modes / `/smooth-mode` preset): when the + // send_message body carries a non-empty `model`, run THIS turn on it, + // overriding the server's configured default model. Absent or blank ⇒ the + // config default is kept, so behavior is unchanged when the field is unused. + let llm = apply_model_override(llm, parsed); + // Ack: processing started. let _ = sink.send(protocol::immediate_response( Some(request_id), @@ -716,6 +723,7 @@ async fn handle_send_message( response, false, &turn.citations, + turn.usage, )); } Err(e) => { @@ -809,3 +817,74 @@ fn handle_confirm_tool_action( json!({ "sessionId": session_id, "approved": approved }), )); } + +/// Apply an optional per-turn `model` override (from a `send_message` body) to a +/// resolved [`LlmConfig`]. When the body carries a non-empty `model` string, this +/// turn runs on that gateway model id (a Smooth Modes / `/smooth-mode` preset), +/// overriding the server's configured default; an absent, non-string, or +/// blank/whitespace-only `model` leaves the config's default model unchanged +/// (byte-for-byte the prior behavior). Every other field (url, key, limits) +/// stays as resolved — only the model id changes. +fn apply_model_override(mut llm: LlmConfig, body: &Value) -> LlmConfig { + if let Some(model) = body.get("model").and_then(Value::as_str) { + let model = model.trim(); + if !model.is_empty() { + llm.model = model.to_string(); + } + } + llm +} + +#[cfg(test)] +mod tests { + use super::*; + use smooth_operator_core::llm::{ApiFormat, RetryPolicy}; + + /// A baseline config whose `model` is the server default, so each override + /// test asserts against a known starting model. + fn base_llm() -> LlmConfig { + LlmConfig { + api_url: "https://llm.smoo.ai/v1".to_string(), + api_key: "sk-test".to_string(), + model: "claude-haiku-4-5".to_string(), + max_tokens: 512, + temperature: 0.0, + retry_policy: RetryPolicy::default(), + api_format: ApiFormat::OpenAiCompat, + } + } + + #[test] + fn model_override_present_replaces_model() { + let body = json!({ "action": "send_message", "model": "claude-opus-4-8" }); + let llm = apply_model_override(base_llm(), &body); + assert_eq!(llm.model, "claude-opus-4-8"); + // Only the model id changes — every other field is preserved. + assert_eq!(llm.api_url, "https://llm.smoo.ai/v1"); + assert_eq!(llm.api_key, "sk-test"); + assert_eq!(llm.max_tokens, 512); + } + + #[test] + fn model_override_absent_keeps_default() { + let body = json!({ "action": "send_message", "message": "hi" }); + let llm = apply_model_override(base_llm(), &body); + assert_eq!(llm.model, "claude-haiku-4-5"); + } + + #[test] + fn model_override_blank_or_non_string_keeps_default() { + // Whitespace-only is treated as absent. + let blank = json!({ "model": " " }); + assert_eq!( + apply_model_override(base_llm(), &blank).model, + "claude-haiku-4-5" + ); + // A non-string `model` is ignored (no panic, default kept). + let wrong_type = json!({ "model": 42 }); + assert_eq!( + apply_model_override(base_llm(), &wrong_type).model, + "claude-haiku-4-5" + ); + } +} diff --git a/rust/smooth-operator-server/src/protocol.rs b/rust/smooth-operator-server/src/protocol.rs index 4032919..8dbd7f4 100644 --- a/rust/smooth-operator-server/src/protocol.rs +++ b/rust/smooth-operator-server/src/protocol.rs @@ -94,12 +94,33 @@ pub fn stream_chunk(request_id: &str, node: &str, state: Value) -> Value { }) } +/// Per-turn token-accounting + cost, captured from the engine's terminal +/// [`AgentEvent::Completed`](smooth_operator_core::AgentEvent::Completed) and +/// surfaced on the `eventual_response` so clients can accumulate a live session +/// cost. All fields are accumulated across every LLM call in the turn. `Copy` so +/// it threads through the runner → handler → protocol by value. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct TurnUsage { + /// Accumulated cost in USD for this turn (gateway-priced). + pub cost_usd: f64, + /// Accumulated prompt (input) tokens for this turn. + pub prompt_tokens: u64, + /// Accumulated completion (output) tokens for this turn. + pub completion_tokens: u64, +} + /// `eventual_response` — the terminal event of a streaming turn. The payload is /// double-nested (`data.data`) per `eventual-response.schema.json`. /// /// `citations` are the sources that grounded the answer. They're attached to /// the inner `data.data.citations` array only when non-empty — absent otherwise, /// keeping the event back-compatible with clients that predate citations. +/// +/// `usage`, when `Some`, attaches the turn's token-accounting + cost as a sibling +/// `data.data.usage` object (`{ costUsd, promptTokens, completionTokens }`) so a +/// client can accumulate live session cost. Absent when the engine reported no +/// usage (e.g. an offline mock turn), keeping the event back-compatible with +/// clients that predate cost reporting. #[must_use] pub fn eventual_response( request_id: &str, @@ -108,6 +129,7 @@ pub fn eventual_response( response: Value, needs_escalation: bool, citations: &[smooth_operator::domain::Citation], + usage: Option, ) -> Value { let mut inner = json!({ "messageId": message_id, @@ -118,6 +140,14 @@ pub fn eventual_response( if !citations.is_empty() { inner["citations"] = serde_json::to_value(citations).unwrap_or(Value::Null); } + // Optional + back-compat: only emit `usage` when the engine reported it. + if let Some(usage) = usage { + inner["usage"] = json!({ + "costUsd": usage.cost_usd, + "promptTokens": usage.prompt_tokens, + "completionTokens": usage.completion_tokens, + }); + } json!({ "type": "eventual_response", "requestId": request_id, @@ -243,6 +273,7 @@ mod tests { json!({"responseParts": ["hi"]}), false, &[], + None, ); assert_eq!(ev["type"], "eventual_response"); assert_eq!(ev["status"], 200); @@ -261,6 +292,7 @@ mod tests { json!({"responseParts": ["hi"]}), false, &[], + None, ); assert!( ev["data"]["data"].get("citations").is_none(), @@ -268,6 +300,51 @@ mod tests { ); } + #[test] + fn eventual_response_omits_usage_when_none() { + // Back-compat: no `usage` key at all when the engine reported no cost. + let ev = eventual_response( + "r1", + 200, + "m1", + json!({"responseParts": ["hi"]}), + false, + &[], + None, + ); + assert!( + ev["data"]["data"].get("usage").is_none(), + "usage must be absent when None for back-compat" + ); + } + + #[test] + fn eventual_response_attaches_usage_when_present() { + let usage = TurnUsage { + cost_usd: 0.0123, + prompt_tokens: 1500, + completion_tokens: 42, + }; + let ev = eventual_response( + "r1", + 200, + "m1", + json!({"responseParts": ["hi"]}), + false, + &[], + Some(usage), + ); + let u = &ev["data"]["data"]["usage"]; + assert!( + u.is_object(), + "usage should be a sibling object under data.data" + ); + let cost = u["costUsd"].as_f64().expect("costUsd is a number"); + assert!((cost - 0.0123).abs() < 1e-9, "costUsd should round-trip"); + assert_eq!(u["promptTokens"], 1500); + assert_eq!(u["completionTokens"], 42); + } + #[test] fn eventual_response_attaches_citations_when_present() { let citations = vec![ @@ -293,6 +370,7 @@ mod tests { json!({"responseParts": ["hi"]}), false, &citations, + None, ); let cites = &ev["data"]["data"]["citations"]; assert!(cites.is_array(), "citations should be an array"); diff --git a/rust/smooth-operator-server/src/runner.rs b/rust/smooth-operator-server/src/runner.rs index c349621..646cc2f 100644 --- a/rust/smooth-operator-server/src/runner.rs +++ b/rust/smooth-operator-server/src/runner.rs @@ -120,6 +120,11 @@ pub struct TurnResult { /// `knowledge_search` result), deduped by id and capped. Carried onto the /// `eventual_response`'s `citations`. Empty when nothing was retrieved. pub citations: Vec, + /// The turn's token-accounting + cost, captured from the engine's terminal + /// [`AgentEvent::Completed`]. Carried onto the `eventual_response`'s `usage` + /// object so clients accumulate live session cost. `None` when the engine + /// reported no `Completed` event (e.g. an offline mock turn). + pub usage: Option, } /// Everything one streaming turn needs. Bundled into a struct so the call sites @@ -357,6 +362,9 @@ pub async fn run_streaming_turn( // time while the agent loop runs concurrently. let translator = tokio::spawn(async move { let mut invoked_knowledge_search = false; + // The terminal `Completed` event carries the turn's accumulated cost + + // token counts; capture them to surface on the `eventual_response`. + let mut usage: Option = None; while let Some(event) = rx.recv().await { match event { AgentEvent::TokenDelta { content } => { @@ -414,13 +422,29 @@ pub async fn run_streaming_turn( json!({}), )); } - // Started / Completed / token-accounting events are terminal or + // The terminal `Completed` event is NOT re-emitted as a stream + // event (the protocol carries the turn outcome on the + // `eventual_response`), but we capture its accumulated cost + + // token counts to attach to that terminal event's `usage`. + AgentEvent::Completed { + cost_usd, + prompt_tokens, + completion_tokens, + .. + } => { + usage = Some(crate::protocol::TurnUsage { + cost_usd, + prompt_tokens, + completion_tokens, + }); + } + // Other Started / token-accounting events are terminal or // structural; the protocol carries those via immediate/eventual // responses, so they're intentionally not re-emitted here. _ => {} } } - invoked_knowledge_search + (invoked_knowledge_search, usage) }); // Drive the agent loop. `run_with_channel` consumes `tx`; when it returns, @@ -439,7 +463,7 @@ pub async fn run_streaming_turn( (cfg.clear)(&cfg.session_id); } - let invoked_knowledge_search = translator.await.unwrap_or(false); + let (invoked_knowledge_search, usage) = translator.await.unwrap_or((false, None)); let reply = conversation .last_assistant_content() @@ -476,6 +500,7 @@ pub async fn run_streaming_turn( message_id, invoked_knowledge_search, citations, + usage, }) } diff --git a/rust/smooth-operator-server/src/state.rs b/rust/smooth-operator-server/src/state.rs index 894e587..b1e56f7 100644 --- a/rust/smooth-operator-server/src/state.rs +++ b/rust/smooth-operator-server/src/state.rs @@ -144,6 +144,13 @@ pub struct AppState { /// [`with_default_persona`](Self::with_default_persona). `None` (the default) /// keeps the const prompt, so the cloud flavor is byte-for-byte unchanged. pub default_persona: Option, + /// **Model-pricing cache** for `GET /admin/model-costs`. The gateway's + /// `/v1/model/info` pricing is stable, so it's fetched at most once per + /// process and reused for every subsequent request (the admin handler sets + /// this on the first successful fetch; a gateway error is NOT cached, so a + /// transient failure is retried on the next request). Shared across clones so + /// every connection/request sees the same cached map. + pub model_costs_cache: Arc>, } /// Namespace a connector name by org for the [`IndexingStore`] key, so two orgs @@ -194,6 +201,7 @@ impl AppState { widget_token: None, strict_auth: false, default_persona: None, + model_costs_cache: Arc::new(tokio::sync::OnceCell::new()), } } From 806b11bc4d06155d8cd60ff1af5b560dbec23c36 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Tue, 30 Jun 2026 07:52:34 -0400 Subject: [PATCH 06/10] =?UTF-8?q?operator-server:=20LocalServer.serve=5Fro?= =?UTF-8?q?utes()=20seam=20=E2=80=94=20merge=20host=20routes=20alongside?= =?UTF-8?q?=20/ws,/admin,/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a host (the smooth-daemon) mount its own routes (e.g. /search for @ mentions) into the operator router as real merged routes, CORS-matched to /admin. admin_cors made pub(crate) for reuse. Additive; default behavior unchanged. (th-58b5fe) Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RuPU8KGE7exadVU6Gi9hBs --- rust/smooth-operator-server/src/admin.rs | 2 +- rust/smooth-operator-server/src/local.rs | 78 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/rust/smooth-operator-server/src/admin.rs b/rust/smooth-operator-server/src/admin.rs index ca7975b..6d69bbb 100644 --- a/rust/smooth-operator-server/src/admin.rs +++ b/rust/smooth-operator-server/src/admin.rs @@ -111,7 +111,7 @@ pub fn router() -> Router { /// sends (`authorization` for the bearer token, `content-type` for JSON bodies). /// Auth is unaffected — every route still runs [`require_role`], so this only /// relaxes the browser's same-origin policy, not the server's authorization. -fn admin_cors() -> tower_http::cors::CorsLayer { +pub(crate) fn admin_cors() -> tower_http::cors::CorsLayer { use axum::http::{header, Method}; tower_http::cors::CorsLayer::new() .allow_origin(tower_http::cors::Any) diff --git a/rust/smooth-operator-server/src/local.rs b/rust/smooth-operator-server/src/local.rs index d450d8e..b4c0363 100644 --- a/rust/smooth-operator-server/src/local.rs +++ b/rust/smooth-operator-server/src/local.rs @@ -96,6 +96,7 @@ pub struct LocalServerBuilder { storage: Option>, persona: Option, spa_router: Option, + extra_routes: Option, } impl std::fmt::Debug for LocalServerBuilder { @@ -128,6 +129,7 @@ impl Default for LocalServerBuilder { storage: None, persona: None, spa_router: None, + extra_routes: None, } } } @@ -212,6 +214,25 @@ impl LocalServerBuilder { self } + /// Merge **host-supplied real routes** into the operator's own router, so they + /// sit alongside `/ws`, `/health`, and `/admin/*` as first-class routes (NOT a + /// fallback like [`serve_spa`](Self::serve_spa)). The daemon uses this to add + /// its own endpoints — e.g. the `@`-mention `GET /search` the web composer + /// calls — to the operator origin without the operator-server knowing about + /// them. + /// + /// The supplied routes get the **same permissive CORS as `/admin`** so the + /// cross-origin dev SPA (the Vite origin `http://localhost:3100`) can call them + /// in the browser. The host is responsible for any auth on these routes; the + /// operator merges them verbatim. A route here MUST NOT collide with an + /// existing operator path (`/ws`, `/health`, `/admin/*`) — axum panics on a + /// duplicate route at merge time. + #[must_use] + pub fn serve_routes(mut self, routes: axum::Router) -> Self { + self.extra_routes = Some(routes); + self + } + /// Enable **strict auth**: reject `/ws` connections with a missing/invalid /// token (HTTP 401) instead of degrading to an anonymous connection. Pair /// with [`auth`](Self::auth) — recommended whenever the server is reachable @@ -296,6 +317,12 @@ impl LocalServerBuilder { /// [`spawn`](Self::spawn) so a test can drive it with `tower::ServiceExt::oneshot`. fn build_app(&self) -> axum::Router { let mut app = router(self.build()); + // Host-supplied real routes (e.g. the daemon's `/search`) are merged so + // they sit alongside the operator's own routes. They carry the same + // permissive CORS as `/admin` so the cross-origin dev SPA can call them. + if let Some(routes) = self.extra_routes.clone() { + app = app.merge(routes.layer(crate::admin::admin_cors())); + } // A host SPA (smooth-web) is mounted as the router fallback so the // operator's explicit routes (`/ws`, `/health`, `/admin/*`) still win and // everything else — `/`, hashed assets, SPA client routes — is served by @@ -631,6 +658,57 @@ mod tests { ); } + #[tokio::test] + async fn serve_routes_merges_host_routes_alongside_operator_routes() { + use http_body_util::BodyExt; + use tower::ServiceExt; + + // A host route that must respond as a real route (not a fallback), while + // the operator's own `/health` keeps working. + let routes = + axum::Router::new().route("/search", axum::routing::get(|| async { "SEARCH-OK" })); + let app = LocalServerBuilder::default() + .serve_routes(routes) + .build_app(); + + // The merged host route responds. + let res = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri("/search?q=foo") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), axum::http::StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"SEARCH-OK", "merged host route responds"); + + // The operator's own `/health` still works alongside the merged routes. + let res = app + .oneshot( + axum::http::Request::builder() + .uri("/health") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(res.status(), axum::http::StatusCode::OK); + let body = res.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"ok", "operator routes survive the merge"); + } + + #[test] + fn no_extra_routes_by_default() { + assert!( + LocalServerBuilder::default().extra_routes.is_none(), + "no host routes merged unless the host installs them" + ); + } + #[test] fn strict_auth_off_by_default_and_opt_in() { assert!( From 61390999ea920f3644987363d844b8222ed60688 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Sat, 4 Jul 2026 23:37:04 -0400 Subject: [PATCH 07/10] =?UTF-8?q?th-3be564:=20multimodal=20chat=20?= =?UTF-8?q?=E2=80=94=20accept=20image/PDF=20media=20in=20send=5Fmessage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smooth-operator-server: send_message accepts an optional 'images' array of data-URL media; runner threads them via TurnRequest.user_images into AgentConfig::with_user_images so the engine emits OpenAI image_url content parts. Image/PDF turns auto-route to gemini-2.5-flash-lite (explicit model still wins). Bump core path-dep 0.14->0.15 (multimodal API landed in core 0.15). Fill Message.images:vec![] at the two EngineMessage build sites and the TurnRequest call/test sites. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/Cargo.toml | 2 +- .../examples/dev-support/tests/serve_smoke.rs | 1 + rust/smooth-operator-lambda/src/dispatch.rs | 1 + rust/smooth-operator-server/src/handler.rs | 36 ++++++++++++++++++- rust/smooth-operator-server/src/runner.rs | 10 +++++- .../tests/acl_chat_leak.rs | 1 + .../tests/acl_trusted_mode.rs | 1 + .../tests/confirm_tool_action.rs | 1 + .../tests/injection_seams.rs | 1 + .../tests/knowledge_org_scoping.rs | 1 + rust/smooth-operator/src/runtime.rs | 1 + 11 files changed, 53 insertions(+), 3 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 313ef3c..b9f3931 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -25,7 +25,7 @@ repository = "https://github.com/SmooAI/smooth-operator" # (sibling checkout); `version` is what the PUBLISHED crates reference, so these # crates can ship to crates.io (roadmap Phase 0). When the sibling checkout is # absent, cargo resolves the version from crates.io. -smooai-smooth-operator-core = { path = "../../smooth-operator-core/rust/smooth-operator-core", version = "0.14" } +smooai-smooth-operator-core = { path = "../../smooth-operator-core/rust/smooth-operator-core", version = "0.15" } # Intra-workspace dep on the reference lib carries its version so the adapters / # ingestion / server that depend on it are publishable (path = local dev, # version = the crates.io requirement). diff --git a/rust/examples/dev-support/tests/serve_smoke.rs b/rust/examples/dev-support/tests/serve_smoke.rs index d372282..08f4f18 100644 --- a/rust/examples/dev-support/tests/serve_smoke.rs +++ b/rust/examples/dev-support/tests/serve_smoke.rs @@ -361,6 +361,7 @@ async fn grounded_turn_over_served_storage_answers_from_the_ingested_repo() { conversation_id: &conversation_id, request_id: "sm-grounded", user_message: "How big is the Frobnicator's ring buffer?", + user_images: Vec::new(), access: AccessContext::anonymous(), llm_provider: Some(Arc::new(mock.clone())), reranker: None, diff --git a/rust/smooth-operator-lambda/src/dispatch.rs b/rust/smooth-operator-lambda/src/dispatch.rs index be7c68a..f6ed527 100644 --- a/rust/smooth-operator-lambda/src/dispatch.rs +++ b/rust/smooth-operator-lambda/src/dispatch.rs @@ -531,6 +531,7 @@ async fn send_message( conversation_id: &session.conversation_id, request_id, user_message: &message, + user_images: Vec::new(), access, llm_provider: None, // Opt-in rerank stage (feature gap G8): `None` unless the operator diff --git a/rust/smooth-operator-server/src/handler.rs b/rust/smooth-operator-server/src/handler.rs index 7b9f153..803edb2 100644 --- a/rust/smooth-operator-server/src/handler.rs +++ b/rust/smooth-operator-server/src/handler.rs @@ -505,6 +505,11 @@ async fn handle_get_conversation_messages( /// knowledge-grounded turn, emit `stream_token` / `stream_chunk` as it goes, and /// finish with `eventual_response` (200). Errors (no gateway key, unknown /// session, agent failure) surface as clean `error` events. +/// Vision model an image/PDF turn auto-routes to when the client didn't pick +/// a model. `gemini-2.5-flash-lite` reads images AND PDFs natively and is +/// cheap. Pearl th-3be564. +const VISION_MODEL: &str = "gemini-2.5-flash-lite"; + async fn handle_send_message( state: &AppState, access: &AccessContext, @@ -543,6 +548,22 @@ async fn handle_send_message( } }; + // Multimodal: optional `images` array of `data:;base64,…` URLs the SPA + // built from picked/pasted/dropped image or PDF files. The engine emits them + // as OpenAI `image_url` content parts on this turn's user message. Absent or + // empty ⇒ a plain text turn (unchanged). Pearl th-3be564. + let user_images: Vec = parsed + .get("images") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .filter(|s| !s.is_empty()) + .map(smooth_operator_core::conversation::ImageContent::new) + .collect() + }) + .unwrap_or_default(); + let Some(session) = state.get_session(session_id) else { let _ = sink.send(protocol::error( Some(request_id), @@ -609,7 +630,18 @@ async fn handle_send_message( // send_message body carries a non-empty `model`, run THIS turn on it, // overriding the server's configured default model. Absent or blank ⇒ the // config default is kept, so behavior is unchanged when the field is unused. - let llm = apply_model_override(llm, parsed); + let mut llm = apply_model_override(llm, parsed); + + // Vision auto-route: a turn carrying image/PDF media runs on a vision model + // UNLESS the client explicitly picked one (Smooth Modes). gemini-flash reads + // images and PDFs natively. Precedence: explicit model > vision > default. + let has_explicit_model = parsed + .get("model") + .and_then(Value::as_str) + .is_some_and(|m| !m.trim().is_empty()); + if !user_images.is_empty() && !has_explicit_model { + llm.model = VISION_MODEL.to_string(); + } // Ack: processing started. let _ = sink.send(protocol::immediate_response( @@ -686,6 +718,8 @@ async fn handle_send_message( conversation_id: &conversation_id, request_id: &request_id_owned, user_message: &message, + // Multimodal media for this turn (empty ⇒ text-only). Pearl th-3be564. + user_images, // The connection's resolved document-level entitlement: retrieval is // filtered to what this requester may read (org-public only when the // connection is anonymous). diff --git a/rust/smooth-operator-server/src/runner.rs b/rust/smooth-operator-server/src/runner.rs index 646cc2f..33bf9b0 100644 --- a/rust/smooth-operator-server/src/runner.rs +++ b/rust/smooth-operator-server/src/runner.rs @@ -144,6 +144,10 @@ pub struct TurnRequest<'a> { pub request_id: &'a str, /// The inbound user message. pub user_message: &'a str, + /// Image/PDF media attached to THIS turn (multimodal). Emitted as OpenAI + /// `image_url` content parts on the user message via the engine's + /// `with_user_images`. Empty ⇒ a text-only turn (unchanged). Pearl th-3be564. + pub user_images: Vec, /// **The requester's document-level entitlements.** Retrieval (the /// auto-injected `[Relevant knowledge]` context AND the `knowledge_search` /// tool) reads through `storage.knowledge_for_access(&access)`, so a @@ -225,6 +229,7 @@ pub async fn run_streaming_turn( conversation_id, request_id, user_message, + user_images, access, llm_provider, reranker, @@ -279,7 +284,9 @@ pub async fn run_streaming_turn( let config = AgentConfig::new("smooth-agent-chat", resolved_prompt, llm) .with_max_iterations(max_iterations) .with_knowledge(Arc::clone(&knowledge)) - .with_prior_messages(prior); + .with_prior_messages(prior) + // Attach this turn's image/PDF media (empty ⇒ no-op, text-only turn). + .with_user_images(user_images); let mut tools = ToolRegistry::new(); // Build the knowledge_search tool over the SAME ACL-filtered handle, with the @@ -609,6 +616,7 @@ async fn load_prior_messages( tool_name: None, tool_calls: vec![], reasoning_content: None, + images: vec![], timestamp: m.created_at, }); } diff --git a/rust/smooth-operator-server/tests/acl_chat_leak.rs b/rust/smooth-operator-server/tests/acl_chat_leak.rs index aaa5b1c..059b92f 100644 --- a/rust/smooth-operator-server/tests/acl_chat_leak.rs +++ b/rust/smooth-operator-server/tests/acl_chat_leak.rs @@ -123,6 +123,7 @@ async fn run_turn_as( conversation_id: "conv-acl-leak", request_id: "req-1", user_message: "Tell me about alpha", + user_images: Vec::new(), access, llm_provider: Some(Arc::new(mock.clone())), reranker: None, diff --git a/rust/smooth-operator-server/tests/acl_trusted_mode.rs b/rust/smooth-operator-server/tests/acl_trusted_mode.rs index 30b7f7a..65a51e4 100644 --- a/rust/smooth-operator-server/tests/acl_trusted_mode.rs +++ b/rust/smooth-operator-server/tests/acl_trusted_mode.rs @@ -128,6 +128,7 @@ async fn run_turn_as( conversation_id: "conv-trusted-acl", request_id: "req-1", user_message: "Tell me about alpha", + user_images: Vec::new(), access, llm_provider: Some(Arc::new(mock.clone())), reranker: None, diff --git a/rust/smooth-operator-server/tests/confirm_tool_action.rs b/rust/smooth-operator-server/tests/confirm_tool_action.rs index 565ced4..aec68ff 100644 --- a/rust/smooth-operator-server/tests/confirm_tool_action.rs +++ b/rust/smooth-operator-server/tests/confirm_tool_action.rs @@ -139,6 +139,7 @@ fn spawn_turn( conversation_id: CONVERSATION_ID, request_id: REQUEST_ID, user_message: "Tell me about alpha", + user_images: Vec::new(), access: AccessContext::anonymous(), llm_provider: Some(Arc::new(mock)), reranker: None, diff --git a/rust/smooth-operator-server/tests/injection_seams.rs b/rust/smooth-operator-server/tests/injection_seams.rs index 6c0488e..f65e791 100644 --- a/rust/smooth-operator-server/tests/injection_seams.rs +++ b/rust/smooth-operator-server/tests/injection_seams.rs @@ -123,6 +123,7 @@ async fn run_turn_with_key( conversation_id: "conv-seam", request_id: "req-1", user_message: "hello", + user_images: Vec::new(), access: AccessContext::anonymous(), llm_provider: Some(Arc::new(mock.clone())), reranker: None, diff --git a/rust/smooth-operator-server/tests/knowledge_org_scoping.rs b/rust/smooth-operator-server/tests/knowledge_org_scoping.rs index c025b66..24cd144 100644 --- a/rust/smooth-operator-server/tests/knowledge_org_scoping.rs +++ b/rust/smooth-operator-server/tests/knowledge_org_scoping.rs @@ -206,6 +206,7 @@ async fn run_turn_as(storage: Arc, access: AccessContext) { conversation_id: "conv-org-scope", request_id: "req-1", user_message: "Tell me about alpha", + user_images: Vec::new(), access, llm_provider: Some(Arc::new(mock.clone())), reranker: None, diff --git a/rust/smooth-operator/src/runtime.rs b/rust/smooth-operator/src/runtime.rs index b876f47..96f90ef 100644 --- a/rust/smooth-operator/src/runtime.rs +++ b/rust/smooth-operator/src/runtime.rs @@ -699,6 +699,7 @@ impl KnowledgeChatRuntime { tool_name: None, tool_calls: vec![], reasoning_content: None, + images: vec![], timestamp: m.created_at, }); } From 023b41812006bdee4380944a379148595343974c Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Wed, 8 Jul 2026 11:11:38 -0400 Subject: [PATCH 08/10] th-562b6d: clamp chat max_tokens to model /model/info ceiling Chat path now clamps max_tokens to the resolved model's output ceiling: - map_model_info carries maxOutputTokens from /model/info - model_output_ceiling(state, model) reuses the cached /model/info fetch (one fetch/process), best-effort -> None on any gateway error - handler threads the resolved model's ceiling into TurnRequest.model_max_output - runner applies AgentConfig::with_model_ceiling Groq-compound (8192 out) is the load-bearing case; high-ceiling models are no-ops at the current 32768 budget. EPIC th-1cc9fa. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/Cargo.lock | 5 ++- rust/smooth-operator-lambda/src/dispatch.rs | 1 + rust/smooth-operator-server/src/admin.rs | 36 ++++++++++++++++++- rust/smooth-operator-server/src/handler.rs | 6 ++++ rust/smooth-operator-server/src/runner.rs | 9 ++++- .../tests/acl_chat_leak.rs | 1 + .../tests/acl_trusted_mode.rs | 1 + .../tests/confirm_tool_action.rs | 1 + .../tests/injection_seams.rs | 1 + .../tests/knowledge_org_scoping.rs | 1 + 10 files changed, 59 insertions(+), 3 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9b79d6a..e3df52f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -4093,7 +4093,7 @@ dependencies = [ [[package]] name = "smooai-smooth-operator-core" -version = "0.14.1" +version = "0.15.0" dependencies = [ "anyhow", "async-trait", @@ -4105,14 +4105,17 @@ dependencies = [ "postgres", "r2d2", "r2d2_postgres", + "regex", "reqwest", "serde", "serde_json", "serde_yml", + "sha2 0.10.9", "thiserror 2.0.18", "tokio", "tokio-stream", "tokio-tungstenite 0.26.2", + "toml", "tracing", "uuid", ] diff --git a/rust/smooth-operator-lambda/src/dispatch.rs b/rust/smooth-operator-lambda/src/dispatch.rs index f6ed527..3ee842a 100644 --- a/rust/smooth-operator-lambda/src/dispatch.rs +++ b/rust/smooth-operator-lambda/src/dispatch.rs @@ -532,6 +532,7 @@ async fn send_message( request_id, user_message: &message, user_images: Vec::new(), + model_max_output: None, access, llm_provider: None, // Opt-in rerank stage (feature gap G8): `None` unless the operator diff --git a/rust/smooth-operator-server/src/admin.rs b/rust/smooth-operator-server/src/admin.rs index 6d69bbb..f0f5a24 100644 --- a/rust/smooth-operator-server/src/admin.rs +++ b/rust/smooth-operator-server/src/admin.rs @@ -508,6 +508,30 @@ async fn fetch_model_costs(config: &crate::config::ServerConfig) -> anyhow::Resu Ok(map_model_info(&payload)) } +/// The `model`'s hard output ceiling (`max_output_tokens`) from the gateway's +/// `/model/info`, for clamping `max_tokens` on the chat path (EPIC th-1cc9fa). +/// +/// Reuses the same process-wide [`AppState::model_costs_cache`] the +/// `/admin/model-costs` route fills, so this costs at most one `/model/info` +/// fetch per process. **Best-effort**: any gateway error, an unknown model, or a +/// model whose gateway entry has no ceiling ⇒ `None` ⇒ the engine leaves +/// `max_tokens` unclamped (graceful, no behaviour change). +pub(crate) async fn model_output_ceiling(state: &AppState, model: &str) -> Option { + let map = match state.model_costs_cache.get() { + Some(cached) => cached.clone(), + None => { + let fetched = fetch_model_costs(&state.config).await.ok()?; + let _ = state.model_costs_cache.set(fetched.clone()); + fetched + } + }; + map.get(model) + .and_then(|m| m.get("maxOutputTokens")) + .and_then(Value::as_u64) + .and_then(|n| u32::try_from(n).ok()) + .filter(|&n| n > 0) +} + /// Map the gateway's `/v1/model/info` payload /// (`{ data: [{ model_name, model_info: { input_cost_per_token, /// output_cost_per_token, model_tier, use_cases } }] }`) to the @@ -539,6 +563,11 @@ fn map_model_info(payload: &Value) -> Value { .and_then(Value::as_array) .cloned() .unwrap_or_default(); + // The model's hard output ceiling — used by the chat path to clamp + // `max_tokens` to what the model can physically emit (EPIC th-1cc9fa). + let max_output = info + .and_then(|i| i.get("max_output_tokens")) + .and_then(Value::as_u64); out.insert( name.to_string(), serde_json::json!({ @@ -546,6 +575,7 @@ fn map_model_info(payload: &Value) -> Value { "outputCostPerToken": output, "tier": tier, "useCases": use_cases, + "maxOutputTokens": max_output, }), ); } @@ -1084,7 +1114,8 @@ mod tests { "input_cost_per_token": 0.000015, "output_cost_per_token": 0.000075, "model_tier": "frontier", - "use_cases": ["reasoning", "coding"] + "use_cases": ["reasoning", "coding"], + "max_output_tokens": 65536 } }, { @@ -1105,10 +1136,13 @@ mod tests { assert!((opus["outputCostPerToken"].as_f64().unwrap() - 0.000075).abs() < 1e-12); assert_eq!(opus["tier"], "frontier"); assert_eq!(opus["useCases"], serde_json::json!(["reasoning", "coding"])); + assert_eq!(opus["maxOutputTokens"], 65536); let haiku = &out["claude-haiku-4-5"]; assert_eq!(haiku["tier"], "fast"); assert_eq!(haiku["useCases"], serde_json::json!(["chat"])); + // No max_output_tokens in the payload -> null (engine leaves it unclamped). + assert_eq!(haiku["maxOutputTokens"], serde_json::Value::Null); } #[test] diff --git a/rust/smooth-operator-server/src/handler.rs b/rust/smooth-operator-server/src/handler.rs index 803edb2..5a2f838 100644 --- a/rust/smooth-operator-server/src/handler.rs +++ b/rust/smooth-operator-server/src/handler.rs @@ -710,6 +710,10 @@ async fn handle_send_message( let conversation_id = session.conversation_id.clone(); tokio::spawn(async move { + // Clamp max_tokens to the resolved model's output ceiling (best-effort; + // None ⇒ unclamped). Reuses the cached /model/info fetch. EPIC th-1cc9fa. + let model_max_output = + crate::admin::model_output_ceiling(&state_for_turn, &llm.model).await; let result = runner::run_streaming_turn( TurnRequest { storage: state_for_turn.storage.clone(), @@ -720,6 +724,8 @@ async fn handle_send_message( user_message: &message, // Multimodal media for this turn (empty ⇒ text-only). Pearl th-3be564. user_images, + // The resolved model's output ceiling (clamps max_tokens; None ⇒ unclamped). + model_max_output, // The connection's resolved document-level entitlement: retrieval is // filtered to what this requester may read (org-public only when the // connection is anonymous). diff --git a/rust/smooth-operator-server/src/runner.rs b/rust/smooth-operator-server/src/runner.rs index 33bf9b0..29ba023 100644 --- a/rust/smooth-operator-server/src/runner.rs +++ b/rust/smooth-operator-server/src/runner.rs @@ -148,6 +148,10 @@ pub struct TurnRequest<'a> { /// `image_url` content parts on the user message via the engine's /// `with_user_images`. Empty ⇒ a text-only turn (unchanged). Pearl th-3be564. pub user_images: Vec, + /// The resolved model's hard output ceiling (`max_output_tokens`) from the + /// gateway, or `None` when unknown. Clamps `max_tokens` to what the model can + /// emit via the engine's `with_model_ceiling`. `None` ⇒ unclamped (EPIC th-1cc9fa). + pub model_max_output: Option, /// **The requester's document-level entitlements.** Retrieval (the /// auto-injected `[Relevant knowledge]` context AND the `knowledge_search` /// tool) reads through `storage.knowledge_for_access(&access)`, so a @@ -230,6 +234,7 @@ pub async fn run_streaming_turn( request_id, user_message, user_images, + model_max_output, access, llm_provider, reranker, @@ -286,7 +291,9 @@ pub async fn run_streaming_turn( .with_knowledge(Arc::clone(&knowledge)) .with_prior_messages(prior) // Attach this turn's image/PDF media (empty ⇒ no-op, text-only turn). - .with_user_images(user_images); + .with_user_images(user_images) + // Clamp max_tokens to the model's output ceiling (None ⇒ unclamped). + .with_model_ceiling(model_max_output); let mut tools = ToolRegistry::new(); // Build the knowledge_search tool over the SAME ACL-filtered handle, with the diff --git a/rust/smooth-operator-server/tests/acl_chat_leak.rs b/rust/smooth-operator-server/tests/acl_chat_leak.rs index 059b92f..6c6cdb8 100644 --- a/rust/smooth-operator-server/tests/acl_chat_leak.rs +++ b/rust/smooth-operator-server/tests/acl_chat_leak.rs @@ -124,6 +124,7 @@ async fn run_turn_as( request_id: "req-1", user_message: "Tell me about alpha", user_images: Vec::new(), + model_max_output: None, access, llm_provider: Some(Arc::new(mock.clone())), reranker: None, diff --git a/rust/smooth-operator-server/tests/acl_trusted_mode.rs b/rust/smooth-operator-server/tests/acl_trusted_mode.rs index 65a51e4..1280688 100644 --- a/rust/smooth-operator-server/tests/acl_trusted_mode.rs +++ b/rust/smooth-operator-server/tests/acl_trusted_mode.rs @@ -129,6 +129,7 @@ async fn run_turn_as( request_id: "req-1", user_message: "Tell me about alpha", user_images: Vec::new(), + model_max_output: None, access, llm_provider: Some(Arc::new(mock.clone())), reranker: None, diff --git a/rust/smooth-operator-server/tests/confirm_tool_action.rs b/rust/smooth-operator-server/tests/confirm_tool_action.rs index aec68ff..55778a6 100644 --- a/rust/smooth-operator-server/tests/confirm_tool_action.rs +++ b/rust/smooth-operator-server/tests/confirm_tool_action.rs @@ -140,6 +140,7 @@ fn spawn_turn( request_id: REQUEST_ID, user_message: "Tell me about alpha", user_images: Vec::new(), + model_max_output: None, access: AccessContext::anonymous(), llm_provider: Some(Arc::new(mock)), reranker: None, diff --git a/rust/smooth-operator-server/tests/injection_seams.rs b/rust/smooth-operator-server/tests/injection_seams.rs index f65e791..01bb1cb 100644 --- a/rust/smooth-operator-server/tests/injection_seams.rs +++ b/rust/smooth-operator-server/tests/injection_seams.rs @@ -124,6 +124,7 @@ async fn run_turn_with_key( request_id: "req-1", user_message: "hello", user_images: Vec::new(), + model_max_output: None, access: AccessContext::anonymous(), llm_provider: Some(Arc::new(mock.clone())), reranker: None, diff --git a/rust/smooth-operator-server/tests/knowledge_org_scoping.rs b/rust/smooth-operator-server/tests/knowledge_org_scoping.rs index 24cd144..435947d 100644 --- a/rust/smooth-operator-server/tests/knowledge_org_scoping.rs +++ b/rust/smooth-operator-server/tests/knowledge_org_scoping.rs @@ -207,6 +207,7 @@ async fn run_turn_as(storage: Arc, access: AccessContext) { request_id: "req-1", user_message: "Tell me about alpha", user_images: Vec::new(), + model_max_output: None, access, llm_provider: Some(Arc::new(mock.clone())), reranker: None, From d46e6339c2ef31c1e261f30031bce82c52f444aa Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Wed, 8 Jul 2026 13:53:59 -0400 Subject: [PATCH 09/10] th-562b6d: bump core dep pin 0.15 -> 0.16 (engine #69 merged) Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b9f3931..b396419 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -25,7 +25,7 @@ repository = "https://github.com/SmooAI/smooth-operator" # (sibling checkout); `version` is what the PUBLISHED crates reference, so these # crates can ship to crates.io (roadmap Phase 0). When the sibling checkout is # absent, cargo resolves the version from crates.io. -smooai-smooth-operator-core = { path = "../../smooth-operator-core/rust/smooth-operator-core", version = "0.15" } +smooai-smooth-operator-core = { path = "../../smooth-operator-core/rust/smooth-operator-core", version = "0.16" } # Intra-workspace dep on the reference lib carries its version so the adapters / # ingestion / server that depend on it are publishable (path = local dev, # version = the crates.io requirement). From 39b3015c88ed845cf35a84542b799dfd4cbfc847 Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Wed, 8 Jul 2026 15:34:07 -0400 Subject: [PATCH 10/10] th-1cc9fa: raise starvation-prone server defaults (max_tokens 512->8192, iterations 6->20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 512/6 defaults are chat-widget sizing that starve reasoning models (they burn the budget on reasoning_content and return empty). Safe to raise now that the per-model output-ceiling clamp caps max_tokens to what each model can emit — a higher cap only bounds runaway output, concise answers stay concise. OSS default benefits every consumer. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/smooth-operator-server/src/config.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/rust/smooth-operator-server/src/config.rs b/rust/smooth-operator-server/src/config.rs index b31f10a..a3e3678 100644 --- a/rust/smooth-operator-server/src/config.rs +++ b/rust/smooth-operator-server/src/config.rs @@ -60,10 +60,16 @@ pub const DEFAULT_PORT: u16 = 8787; pub const DEFAULT_GATEWAY_URL: &str = "https://llm.smoo.ai/v1"; /// Default (cheap) model. pub const DEFAULT_MODEL: &str = "claude-haiku-4-5"; -/// Default agent-loop iteration cap. -pub const DEFAULT_MAX_ITERATIONS: u32 = 6; -/// Default `max_tokens` per LLM call. -pub const DEFAULT_MAX_TOKENS: u32 = 512; +/// Default agent-loop iteration cap. Was 6 (chat-widget sizing) — too tight for +/// any multi-step turn. Raised to 20 for agentic use (EPIC th-1cc9fa). +pub const DEFAULT_MAX_ITERATIONS: u32 = 20; +/// Default `max_tokens` per LLM call. Was 512 (chat-widget sizing), which +/// STARVES reasoning models — they spend it all on `reasoning_content` and +/// return empty `content`. Raised to 8192 (EPIC th-1cc9fa). A cap only bounds +/// runaway output; concise answers stay concise, and the per-model output +/// ceiling clamp (`AgentConfig::with_model_ceiling`) keeps it under whatever the +/// model can physically emit. +pub const DEFAULT_MAX_TOKENS: u32 = 8192; /// Which storage backend the server runs on. Selected via `SMOOTH_AGENT_STORAGE` /// (`memory` / `postgres` / `dynamodb`); the **admin stores** (connector configs,