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}