Skip to main content

smooth_operator_server/
server.rs

1//! The axum WebSocket server: one `/ws` endpoint, one task per connection.
2//!
3//! Per connection we split the socket and run two tasks joined by an
4//! `UnboundedSender<serde_json::Value>` outbound sink:
5//!
6//! - a **writer** that drains the sink and writes each event as a JSON text
7//!   frame, and
8//! - a **reader** that reads inbound frames and dispatches them via
9//!   [`crate::handler::handle_frame`], passing the sink so handlers (including
10//!   the streaming `send_message`) can emit events as they happen.
11//!
12//! Using a sink channel (instead of writing directly from the handler) is what
13//! lets a streaming turn fire many `stream_token` events from inside the agent
14//! loop while the connection is still reading.
15
16use std::net::SocketAddr;
17use std::sync::Arc;
18
19use anyhow::{Context, Result};
20use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
21use axum::extract::{Query, State};
22use axum::response::Response;
23use axum::routing::get;
24use axum::Router;
25
26use futures_util::{SinkExt, StreamExt};
27use smooth_operator::access_control::AccessContext;
28use tokio::net::TcpListener;
29
30use smooth_operator_adapter_memory::InMemoryStorageAdapter;
31use smooth_operator_core::{Document, DocumentType};
32
33use crate::config::ServerConfig;
34use crate::handler;
35use crate::state::AppState;
36
37/// Build the axum [`Router`] for the given application state. Exposed so tests
38/// can boot the server in-process. Serves the WebSocket `/ws` endpoint plus the
39/// auth-gated admin HTTP API under `/admin` (see [`crate::admin`]).
40pub fn router(state: AppState) -> Router {
41    Router::new()
42        .route("/ws", get(ws_upgrade))
43        .merge(crate::admin::router())
44        .with_state(state)
45}
46
47/// The document set the seeded demo docs are tagged into, so
48/// `GET /admin/document-sets` has something to report in a seeded server.
49const SEED_DOCUMENT_SET: &str = "policies";
50
51/// The org the seeded demo docs + their document-set registry entries belong to.
52/// Mirrors the org `handler::handle_create_session` stamps onto reference
53/// conversations, so the seeded sets show up for the reference org's admin
54/// caller (and ONLY that org — cross-org scoping).
55pub const SEED_ORG_ID: &str = "reference-org";
56
57/// Build an [`AppState`] over a fresh in-memory adapter, seeding the knowledge
58/// base when `config.seed_kb` is set. Exposed for tests + the binary.
59///
60/// The auth verifier defaults to [`NoAuthVerifier`](smooth_operator::auth::NoAuthVerifier)
61/// here (the protocol-only path needs no auth); the **binary** path
62/// ([`build_state_from_env`]) installs the env-configured, secure-by-default
63/// verifier instead.
64#[must_use]
65pub fn build_state(config: ServerConfig) -> AppState {
66    let seed = config.seed_kb;
67    let storage = Arc::new(InMemoryStorageAdapter::new());
68    let state = AppState::new(storage.clone(), config);
69    if seed {
70        seed_knowledge(storage.as_ref());
71        // Record the seeded docs' document-set membership for the admin API
72        // (the in-memory backend drops document metadata, so the registry is the
73        // source of truth for set names + counts).
74        state.record_document_set(SEED_ORG_ID, SEED_DOCUMENT_SET);
75        state.record_document_set(SEED_ORG_ID, SEED_DOCUMENT_SET);
76    }
77    state
78}
79
80/// Build an [`AppState`] with the **env-configured** auth verifier (secure by
81/// default — see [`smooth_operator::auth::AuthConfig`]). Used by the binary.
82///
83/// # Errors
84/// Returns an error if the auth configuration is invalid (e.g. `AUTH_MODE=jwt`
85/// with no key) — the server refuses to start rather than fall back to no-auth.
86pub fn build_state_from_env(config: ServerConfig) -> Result<AppState> {
87    let verifier = smooth_operator::auth::AuthConfig::from_env()
88        .map_err(|e| anyhow::anyhow!("auth configuration error: {e}"))?;
89    let state = install_widget_auth_from_env(build_state(config));
90    Ok(state.with_auth(Arc::from(verifier)))
91}
92
93/// Install an [`HttpWidgetAuth`](smooth_operator::widget_auth::HttpWidgetAuth)
94/// provider from `WIDGET_AUTH_URL` (optionally `WIDGET_AUTH_BEARER` +
95/// `WIDGET_AUTH_TTL_SECS`); otherwise leave the permissive default. This lets a
96/// host enforce embeddable-widget auth against its own policy service by setting
97/// env vars — no custom binary needed. (A host wanting bespoke logic still
98/// installs its own provider via [`AppState::with_widget_auth`].)
99fn install_widget_auth_from_env(state: AppState) -> AppState {
100    let Ok(url) = std::env::var("WIDGET_AUTH_URL") else {
101        return state;
102    };
103    let url = url.trim();
104    if url.is_empty() {
105        return state;
106    }
107    let mut provider = smooth_operator::widget_auth::HttpWidgetAuth::new(url);
108    if let Ok(bearer) = std::env::var("WIDGET_AUTH_BEARER") {
109        let bearer = bearer.trim();
110        if !bearer.is_empty() {
111            provider = provider.with_bearer(bearer);
112        }
113    }
114    if let Some(secs) = std::env::var("WIDGET_AUTH_TTL_SECS")
115        .ok()
116        .and_then(|s| s.trim().parse::<u64>().ok())
117    {
118        provider = provider.with_ttl(std::time::Duration::from_secs(secs));
119    }
120    state.with_widget_auth(Arc::new(provider))
121}
122
123/// Build an [`AppState`] selecting the **storage backend** (and the matching
124/// durable **admin stores**) from `config.storage`, then installing the
125/// env-configured auth verifier.
126///
127/// - [`StorageBackend::Memory`](crate::config::StorageBackend::Memory) — the
128///   in-memory adapter + in-memory admin stores (the [`build_state`] path; lost
129///   on restart). The default.
130/// - [`StorageBackend::Postgres`](crate::config::StorageBackend::Postgres) —
131///   the Postgres + pgvector adapter; the admin stores persist to the **same
132///   database** (`connector_configs` / `agent_settings` / `indexing_runs`).
133///   Connection string from `SMOOTH_AGENT_DATABASE_URL` / `DATABASE_URL`.
134/// - [`StorageBackend::Dynamodb`](crate::config::StorageBackend::Dynamodb) — the
135///   DynamoDB single-table adapter; the admin stores persist to the **same
136///   table**. Table from `SMOOTH_AGENT_DDB_TABLE`; the table is created if
137///   absent.
138///
139/// The admin store backend always matches the storage backend so a connector
140/// config / settings / indexing run survives a restart wherever the
141/// conversations and knowledge live.
142///
143/// # Errors
144/// Returns an error if the auth configuration is invalid, or if the selected
145/// persistent backend fails to connect / migrate.
146pub async fn build_state_from_env_async(config: ServerConfig) -> Result<AppState> {
147    use crate::config::StorageBackend;
148    use smooth_operator::adapter::StorageAdapter;
149
150    let verifier = smooth_operator::auth::AuthConfig::from_env()
151        .map_err(|e| anyhow::anyhow!("auth configuration error: {e}"))?;
152
153    let state = match config.storage {
154        // The in-memory path is unchanged (synchronous, no external services).
155        StorageBackend::Memory => build_state(config),
156
157        StorageBackend::Postgres => {
158            use smooth_operator_adapter_postgres::PostgresAdapter;
159            // The pgvector column width MUST match the embedder the `/index`
160            // path uses (1536 keyed / 1024 offline). Build the embedder from
161            // config and create the adapter with it so document vectors (at
162            // ingest) and query vectors agree — no silent 1024/1536 mismatch.
163            let embedder = crate::embedder::build_embedder(
164                &crate::embedder::EmbedderConfig::from_server_config(&config),
165            );
166            let conn_str = std::env::var("SMOOTH_AGENT_DATABASE_URL")
167                .or_else(|_| std::env::var("DATABASE_URL"))
168                .map_err(|_| {
169                    anyhow::anyhow!(
170                        "Postgres backend selected but neither SMOOTH_AGENT_DATABASE_URL \
171                             nor DATABASE_URL is set"
172                    )
173                })?;
174            let adapter = Arc::new(
175                PostgresAdapter::connect_with_embedder(&conn_str, embedder)
176                    .await
177                    .map_err(|e| anyhow::anyhow!("connecting Postgres storage backend: {e}"))?,
178            );
179            // Admin stores against the SAME database — durable.
180            let connectors = Arc::new(adapter.connector_config_store());
181            let settings = Arc::new(adapter.settings_store());
182            let indexing = Arc::new(adapter.indexing_store());
183            let storage: Arc<dyn StorageAdapter> = adapter;
184            AppState::new(storage, config)
185                .with_connector_configs(connectors)
186                .with_settings(settings)
187                .with_indexing(indexing)
188        }
189
190        StorageBackend::Dynamodb => {
191            use smooth_operator_adapter_dynamodb::DynamoDbAdapter;
192            let adapter = Arc::new(
193                DynamoDbAdapter::from_env(None)
194                    .await
195                    .map_err(|e| anyhow::anyhow!("connecting DynamoDB storage backend: {e}"))?,
196            );
197            adapter
198                .create_table()
199                .await
200                .map_err(|e| anyhow::anyhow!("creating DynamoDB table: {e}"))?;
201            // Admin stores against the SAME table — durable.
202            let connectors = Arc::new(adapter.connector_config_store());
203            let settings = Arc::new(adapter.settings_store());
204            let indexing = Arc::new(adapter.indexing_store());
205            let storage: Arc<dyn StorageAdapter> = adapter;
206            AppState::new(storage, config)
207                .with_connector_configs(connectors)
208                .with_settings(settings)
209                .with_indexing(indexing)
210        }
211    };
212
213    let state = install_backplane_from_env(state).await?;
214    let state = install_widget_auth_from_env(state);
215
216    Ok(state.with_auth(Arc::from(verifier)))
217}
218
219/// Select the connection [`Backplane`](smooth_operator::backplane::Backplane)
220/// from `SMOOTH_AGENT_BACKPLANE`, installing it via
221/// [`AppState::with_backplane`](crate::state::AppState::with_backplane).
222///
223/// | value | backend | url env |
224/// |---|---|---|
225/// | unset / `memory` / `inmemory` | single-process (default) | — |
226/// | `redis` / `valkey` | [`RedisBackplane`] cross-pod fan-out | `SMOOTH_AGENT_BACKPLANE_URL` \| `SMOOTH_AGENT_REDIS_URL` |
227/// | `nats` | [`NatsBackplane`] cross-pod fan-out | `SMOOTH_AGENT_BACKPLANE_URL` \| `SMOOTH_AGENT_NATS_URL` |
228///
229/// A distributed backend is required for >1 replica (otherwise an event produced
230/// on one pod can't reach a socket on another) and to let non-AI publishers push
231/// realtime events via `Backplane::publish`.
232///
233/// # Errors
234/// Returns an error for an unknown backend value, a missing url, or a failed
235/// connection — fail loud at boot rather than silently run single-process.
236async fn install_backplane_from_env(state: AppState) -> Result<AppState> {
237    let kind = std::env::var("SMOOTH_AGENT_BACKPLANE")
238        .unwrap_or_default()
239        .trim()
240        .to_lowercase();
241
242    let url = |specific: &str| -> Result<String> {
243        std::env::var("SMOOTH_AGENT_BACKPLANE_URL")
244            .or_else(|_| std::env::var(specific))
245            .map_err(|_| {
246                anyhow::anyhow!(
247                    "{kind} backplane selected but neither SMOOTH_AGENT_BACKPLANE_URL nor {specific} is set"
248                )
249            })
250    };
251
252    match kind.as_str() {
253        "" | "memory" | "inmemory" => Ok(state), // default InMemoryBackplane already installed
254        "redis" | "valkey" => {
255            use smooth_operator_adapter_backplane_redis::RedisBackplane;
256            let backplane = RedisBackplane::connect(&url("SMOOTH_AGENT_REDIS_URL")?)
257                .await
258                .map_err(|e| anyhow::anyhow!("connecting Redis backplane: {e}"))?;
259            Ok(state.with_backplane(Arc::new(backplane)))
260        }
261        "nats" => {
262            use smooth_operator_adapter_backplane_nats::NatsBackplane;
263            let backplane = NatsBackplane::connect(&url("SMOOTH_AGENT_NATS_URL")?)
264                .await
265                .map_err(|e| anyhow::anyhow!("connecting NATS backplane: {e}"))?;
266            Ok(state.with_backplane(Arc::new(backplane)))
267        }
268        other => Err(anyhow::anyhow!(
269            "unknown SMOOTH_AGENT_BACKPLANE '{other}' (expected: memory | redis | valkey | nats)"
270        )),
271    }
272}
273
274/// Seed a couple of distinctive demo docs so knowledge-grounded E2E is
275/// deterministic. The 17-day return window is deliberately unusual so an
276/// ungrounded answer can't accidentally match it. Both docs are tagged into the
277/// `policies` document set so the admin API can report it.
278pub fn seed_knowledge(storage: &InMemoryStorageAdapter) {
279    let kb = smooth_operator::adapter::StorageAdapter::knowledge(storage);
280    let _ = kb.ingest(smooth_operator::with_document_set(
281        Document::new(
282            "SmooAI's return window is exactly 17 days from delivery. Returns after 17 days are not accepted.",
283            "policies/returns.md",
284            DocumentType::Documentation,
285        ),
286        [SEED_DOCUMENT_SET],
287    ));
288    let _ = kb.ingest(smooth_operator::with_document_set(
289        Document::new(
290            "SmooAI standard shipping takes 5 to 7 business days. Expedited shipping takes 2 business days.",
291            "policies/shipping.md",
292            DocumentType::Documentation,
293        ),
294        [SEED_DOCUMENT_SET],
295    ));
296}
297
298/// Bind on `<SMOOTH_AGENT_BIND>:<port>` (default loopback) and serve until the
299/// process is killed. Returns the bound [`TcpListener`] + the router, used by
300/// both the binary and tests (tests bind port 0 for an ephemeral port).
301///
302/// Uses the **env-configured, secure-by-default** auth verifier
303/// ([`build_state_from_env`]) — the binary refuses to start if auth is
304/// misconfigured rather than silently serving the admin API unauthenticated.
305///
306/// # Errors
307/// Returns an error if the auth configuration is invalid or the TCP bind fails.
308pub async fn bind(config: ServerConfig) -> Result<(TcpListener, Router)> {
309    let ip: std::net::IpAddr = config
310        .bind
311        .parse()
312        .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
313    let addr = SocketAddr::new(ip, config.port);
314    // Async so a Postgres / DynamoDB storage backend (and its matching durable
315    // admin stores) can be wired; in-memory stays synchronous inside.
316    let state = build_state_from_env_async(config).await?;
317    let app = router(state);
318    let listener = TcpListener::bind(addr)
319        .await
320        .with_context(|| format!("binding WebSocket server on {addr}"))?;
321    Ok((listener, app))
322}
323
324/// Serve a **pre-built** [`AppState`] to completion (blocks), binding on
325/// `state.config.bind:state.config.port`.
326///
327/// This is the library entry point for callers that assemble their own
328/// `AppState` — e.g. the `dev-support` example, which ingests a GitHub repo into
329/// a storage adapter, wires the env-configured [`AuthVerifier`], and then serves
330/// that exact state so the chat-widget queries the ingested knowledge. It does
331/// **not** rebuild the state or touch the ACL/auth/embedder/reranker selection —
332/// those are baked into the `state` the caller passes in. The WS loop, router,
333/// and listening log are identical to [`run`] (which builds its state from env);
334/// `run` is unchanged.
335///
336/// # Errors
337/// Returns an error if the TCP bind fails or serving fails.
338pub async fn serve_state(state: AppState) -> Result<()> {
339    let ip: std::net::IpAddr = state
340        .config
341        .bind
342        .parse()
343        .unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
344    let addr = SocketAddr::new(ip, state.config.port);
345    let listener = TcpListener::bind(addr)
346        .await
347        .with_context(|| format!("binding WebSocket server on {addr}"))?;
348    serve_state_on(state, listener).await
349}
350
351/// Serve a pre-built [`AppState`] on an already-bound [`TcpListener`] (blocks).
352///
353/// Splitting the bind from the serve lets a caller (or a test) bind an ephemeral
354/// port, read [`TcpListener::local_addr`] for the real port, then hand the
355/// listener here. Logs the same "listening" line [`run`] does.
356///
357/// # Errors
358/// Returns an error if serving fails.
359pub async fn serve_state_on(state: AppState, listener: TcpListener) -> Result<()> {
360    let has_llm = state.config.has_llm();
361    let model = state.config.model.clone();
362    let gateway = state.config.gateway_url.clone();
363    let local = listener.local_addr().context("local addr")?;
364    let app = router(state);
365
366    tracing::info!(
367        %local,
368        endpoint = "/ws",
369        %model,
370        %gateway,
371        llm_enabled = has_llm,
372        "smooth-operator-server listening"
373    );
374    println!(
375        "smooth-operator-server listening on ws://{local}/ws (model={model}, llm_enabled={has_llm})"
376    );
377
378    axum::serve(listener, app)
379        .await
380        .context("serving WebSocket connections")?;
381    Ok(())
382}
383
384/// Run the server to completion (blocks). Logs a single listening line.
385///
386/// # Errors
387/// Returns an error if binding or serving fails.
388pub async fn run(config: ServerConfig) -> Result<()> {
389    let has_llm = config.has_llm();
390    let model = config.model.clone();
391    let gateway = config.gateway_url.clone();
392    let (listener, app) = bind(config).await?;
393    let local = listener.local_addr().context("local addr")?;
394
395    tracing::info!(
396        %local,
397        endpoint = "/ws",
398        %model,
399        %gateway,
400        llm_enabled = has_llm,
401        "smooth-operator-server listening"
402    );
403    // Also print to stdout so the run-confirmation check is unambiguous without
404    // a tracing subscriber filter.
405    println!(
406        "smooth-operator-server listening on ws://{local}/ws (model={model}, llm_enabled={has_llm})"
407    );
408
409    axum::serve(listener, app)
410        .await
411        .context("serving WebSocket connections")?;
412    Ok(())
413}
414
415/// Query parameters accepted on the `/ws` upgrade. `token` carries the bearer
416/// JWT used to authenticate the connection (browsers can't set custom headers on
417/// a WebSocket handshake, so the token rides on the query string — the standard
418/// pattern for WS auth).
419#[derive(Debug, serde::Deserialize, Default)]
420struct WsQuery {
421    /// The bearer token (raw JWT, no `Bearer ` prefix), if provided.
422    #[serde(default)]
423    token: Option<String>,
424}
425
426/// Resolve the connection's [`AccessContext`] from the `?token=` query param.
427///
428/// **Fail closed for ACL'd content**: when no token is presented, or the auth
429/// verifier is the no-key [`AdminDisabledVerifier`] (admin/auth unconfigured —
430/// dev/no-auth), or the token fails to verify, the connection runs as
431/// [`AccessContext::anonymous`] — which sees **only org-public** knowledge, not
432/// every document. A valid token yields the principal's full
433/// [`AccessContext`](smooth_operator::auth::Principal::access_context) (user id +
434/// groups), so it can read documents scoped to it. Verification failures are
435/// logged (never the token) and degrade to anonymous rather than dropping the
436/// connection, so the dev/no-auth case still serves org-public knowledge.
437fn resolve_ws_access(state: &AppState, query: &WsQuery) -> AccessContext {
438    let Some(token) = query
439        .token
440        .as_deref()
441        .map(str::trim)
442        .filter(|t| !t.is_empty())
443    else {
444        // No token → anonymous (org-public only). Keeps the dev/no-auth `/ws`
445        // path working while failing closed for ACL'd content.
446        return AccessContext::anonymous();
447    };
448    match state.auth.verify(token) {
449        Ok(principal) => principal.access_context(),
450        Err(e) => {
451            // Don't leak the token; log only the mode + a generic reason.
452            tracing::warn!(
453                auth_mode = state.auth.mode(),
454                error = %e,
455                "ws token failed verification; serving org-public knowledge only (anonymous)"
456            );
457            AccessContext::anonymous()
458        }
459    }
460}
461
462/// Axum handler: upgrade an HTTP request on `/ws` to a WebSocket. The bearer
463/// token (if any) is taken from the `?token=` query param, resolved to an
464/// [`AccessContext`] at connect time, and threaded into every turn so retrieval
465/// is access-controlled per connection.
466async fn ws_upgrade(
467    ws: WebSocketUpgrade,
468    State(state): State<AppState>,
469    Query(query): Query<WsQuery>,
470    headers: axum::http::HeaderMap,
471) -> Response {
472    let access = resolve_ws_access(&state, &query);
473    // Capture the browser's `Origin` at the handshake (browsers always send it,
474    // and can't be made to forge another site's). It's enforced per-agent at
475    // session creation against the agent's embed allowlist (widget_auth).
476    let origin = headers
477        .get(axum::http::header::ORIGIN)
478        .and_then(|v| v.to_str().ok())
479        .map(str::to_string);
480    ws.on_upgrade(move |socket| connection_loop(socket, state, access, origin))
481}
482
483/// Drive one WebSocket connection: split into reader + writer, joined by an
484/// outbound event sink. `access` is the connection's resolved document-level
485/// entitlement, threaded into every `send_message` turn. `origin` is the
486/// handshake `Origin` header, enforced against an agent's embed allowlist.
487async fn connection_loop(
488    socket: WebSocket,
489    state: AppState,
490    access: AccessContext,
491    origin: Option<String>,
492) {
493    let (mut ws_tx, mut ws_rx) = socket.split();
494    let (sink_tx, mut sink_rx) = tokio::sync::mpsc::unbounded_channel::<serde_json::Value>();
495
496    // Register this connection's outbound sink with the backplane so events
497    // published from anywhere (this pod or, with a Redis/NATS impl, another) can
498    // reach it. `conn_id` is associated with its session at create-session time.
499    let conn_id = uuid::Uuid::new_v4().to_string();
500    let sink_for_backplane = sink_tx.clone();
501    state
502        .backplane
503        .attach(
504            &conn_id,
505            std::sync::Arc::new(move |event| {
506                let _ = sink_for_backplane.send(event);
507            }),
508        )
509        .await;
510
511    // Writer: drain the sink and write each event as a JSON text frame.
512    let writer = tokio::spawn(async move {
513        while let Some(event) = sink_rx.recv().await {
514            let text = match serde_json::to_string(&event) {
515                Ok(t) => t,
516                Err(_) => continue,
517            };
518            if ws_tx.send(Message::Text(text.into())).await.is_err() {
519                break;
520            }
521        }
522    });
523
524    // Reader: dispatch inbound frames. Handlers emit events via `sink_tx`.
525    while let Some(frame) = ws_rx.next().await {
526        match frame {
527            Ok(Message::Text(text)) => {
528                handler::handle_frame(
529                    &state,
530                    &access,
531                    &conn_id,
532                    origin.as_deref(),
533                    text.as_str(),
534                    &sink_tx,
535                )
536                .await;
537            }
538            Ok(Message::Binary(_)) => {
539                let _ = sink_tx.send(crate::protocol::error(
540                    None,
541                    "VALIDATION_ERROR",
542                    "binary frames are not supported; send JSON text frames",
543                ));
544            }
545            Ok(Message::Close(_)) => break,
546            // Ping/Pong control frames are handled by axum automatically.
547            Ok(_) => {}
548            Err(_) => break,
549        }
550    }
551
552    // Reader finished → detach from the backplane, then drop the sink so the
553    // writer task exits.
554    state.backplane.detach(&conn_id).await;
555    drop(sink_tx);
556    let _ = writer.await;
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use smooth_operator::adapter::StorageAdapter;
563
564    #[test]
565    fn seeded_kb_returns_17_day_fact() {
566        let storage = InMemoryStorageAdapter::new();
567        seed_knowledge(&storage);
568        let results = storage
569            .knowledge()
570            .query("return window policy", 3)
571            .expect("query");
572        assert!(
573            results.iter().any(|r| r.chunk.contains("17")),
574            "expected seeded 17-day fact, got: {results:?}"
575        );
576    }
577
578    #[tokio::test]
579    async fn build_state_without_key_has_no_llm() {
580        let cfg = ServerConfig {
581            bind: "127.0.0.1".into(),
582            port: 0,
583            gateway_url: "https://example.test/v1".into(),
584            gateway_key: None,
585            model: "m".into(),
586            seed_kb: true,
587            max_iterations: 4,
588            max_tokens: 128,
589            storage: crate::config::StorageBackend::Memory,
590            widget_auth_strict: false,
591        };
592        let state = build_state(cfg);
593        assert!(!state.config.has_llm());
594    }
595}