Skip to main content

smooth_operator_server/
config.rs

1//! Server configuration, read entirely from the environment.
2//!
3//! No secret is ever hardcoded. The gateway key is optional at *startup* — the
4//! server still binds and answers protocol-only actions (`ping`,
5//! `create_conversation_session`) without it — but `send_message` returns a
6//! clean `error` event when the key is absent, so protocol conformance can be
7//! tested with zero credentials.
8//!
9//! ## Environment variables (the contract every language E2E harness reuses)
10//!
11//! | var | default | meaning |
12//! | --- | --- | --- |
13//! | `SMOOTH_AGENT_BIND` | `127.0.0.1` | IP address to bind. Set `0.0.0.0` in k8s/containers so the Service/Ingress can reach the pod. |
14//! | `SMOOTH_AGENT_PORT` | `8787` | TCP port to bind. |
15//! | `SMOOAI_GATEWAY_URL` | `https://llm.smoo.ai/v1` | OpenAI-compatible LLM gateway base URL. |
16//! | `SMOOAI_GATEWAY_KEY` | *(unset)* | Gateway API key. When unset, `send_message` errors cleanly. |
17//! | `SMOOTH_AGENT_MODEL` | `claude-haiku-4-5` | Model id requested from the gateway. |
18//! | `SMOOTH_AGENT_SEED_KB` | *(unset)* | When `1`, seed a couple of distinctive demo docs on startup. |
19//! | `SMOOTH_AGENT_MAX_ITERATIONS` | `6` | Agent-loop iteration cap per turn. |
20//! | `SMOOTH_AGENT_MAX_TOKENS` | `512` | `max_tokens` sent to the gateway (kept low — paid endpoint). |
21//! | `SMOOTH_AGENT_STORAGE` | `memory` | Storage backend: `memory` \| `postgres` \| `dynamodb`. |
22//! | `SMOOTH_AGENT_BACKPLANE` | `memory` | Connection backplane: `memory` (single-process) \| `redis`/`valkey` \| `nats`. A distributed backend is required for >1 replica and to let non-AI publishers push events via `Backplane::publish`. |
23//! | `SMOOTH_AGENT_BACKPLANE_URL` | *(unset)* | Bus URL for `redis`/`nats` (e.g. `redis://valkey:6379`, `nats://nats:4222`); falls back to `SMOOTH_AGENT_REDIS_URL` / `SMOOTH_AGENT_NATS_URL`. |
24//! | `WIDGET_AUTH_STRICT` | *(unset → `false`)* | Fail-closed embeddable-widget auth: when `1`/`true`, a session for an agent the [`WidgetAuthProvider`](smooth_operator::widget_auth::WidgetAuthProvider) has no policy for is rejected. Origin + `authContext` are always enforced for policied agents. |
25//! | `WIDGET_AUTH_URL` | *(unset → permissive)* | When set, install an [`HttpWidgetAuth`](smooth_operator::widget_auth::HttpWidgetAuth) provider resolving each agent's embed policy from `{url}/{agentId}` — enforce widget auth against a host policy service with no custom binary. |
26//! | `WIDGET_AUTH_BEARER` | *(unset)* | Optional bearer token sent to `WIDGET_AUTH_URL` (e.g. an M2M token). |
27//! | `WIDGET_AUTH_TTL_SECS` | `60` | Policy cache TTL for `WIDGET_AUTH_URL` (incl. cached 404 no-policy results). |
28//!
29//! ### Auth (load-bearing — the admin API's `require_role` reads these)
30//!
31//! Parsed by [`smooth_operator::auth::AuthConfig::from_env`], not [`ServerConfig`],
32//! but documented here because they gate `/admin` and the binary refuses to start
33//! when they're misconfigured. See [`smooth_operator::auth`] for the full contract.
34//!
35//! | var | default | meaning |
36//! | --- | --- | --- |
37//! | `AUTH_MODE` | *(unset → admin disabled, 401)* | `jwt` (BYO) \| `smoo` (hosted) \| `none` (dev only). Unset boots `/ws` but `/admin` returns 401 until configured. |
38//! | `AUTH_JWT_HS256_SECRET` | — | HS256 shared secret (for `jwt`/`smoo`). |
39//! | `AUTH_JWT_RS256_PUBLIC_KEY` | — | RS256 PEM public key (takes precedence over HS256). |
40//! | `AUTH_JWT_ISSUER` | — | Required `iss` claim (required for `smoo`; optional for `jwt`). |
41//! | `AUTH_JWT_AUDIENCE` | — | Required `aud` claim (optional). |
42//!
43//! ### Embedding (the retrieval/index path)
44//!
45//! The `/index` path (and the `dev-support` example) select the embedder from the
46//! gateway config above: with `SMOOAI_GATEWAY_KEY` set, the real **`GatewayEmbedder`**
47//! (`text-embedding-3-small`, 1536-d) is used for semantic retrieval; without it,
48//! the network-free **`DeterministicEmbedder`** (FNV-1a hash, 1024-d) is used and a
49//! warning is logged. See [`crate::embedder`].
50
51use smooth_operator_core::llm::{ApiFormat, RetryPolicy};
52use smooth_operator_core::LlmConfig;
53
54/// Default bind address (loopback; override with `0.0.0.0` in containers).
55pub const DEFAULT_BIND: &str = "127.0.0.1";
56/// Default WebSocket bind port.
57pub const DEFAULT_PORT: u16 = 8787;
58/// Default OpenAI-compatible LLM gateway.
59pub const DEFAULT_GATEWAY_URL: &str = "https://llm.smoo.ai/v1";
60/// Default (cheap) model.
61pub const DEFAULT_MODEL: &str = "claude-haiku-4-5";
62/// Default agent-loop iteration cap.
63pub const DEFAULT_MAX_ITERATIONS: u32 = 6;
64/// Default `max_tokens` per LLM call.
65pub const DEFAULT_MAX_TOKENS: u32 = 512;
66
67/// Which storage backend the server runs on. Selected via `SMOOTH_AGENT_STORAGE`
68/// (`memory` / `postgres` / `dynamodb`); the **admin stores** (connector configs,
69/// settings, indexing runs) follow the same backend so they're durable wherever
70/// the conversations / knowledge live.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum StorageBackend {
73    /// Process-local in-memory (the default — local dev / tests). Admin stores
74    /// are the in-memory impls (lost on restart).
75    Memory,
76    /// Postgres + pgvector. Admin stores persist to the same database.
77    Postgres,
78    /// DynamoDB single-table (AWS-serverless). Admin stores persist to the same
79    /// table.
80    Dynamodb,
81}
82
83impl StorageBackend {
84    /// Parse from the `SMOOTH_AGENT_STORAGE` wire value (case-insensitive).
85    /// Unknown / empty falls back to [`StorageBackend::Memory`].
86    #[must_use]
87    pub fn parse(value: &str) -> Self {
88        match value.trim().to_ascii_lowercase().as_str() {
89            "postgres" | "pg" | "postgresql" => Self::Postgres,
90            "dynamodb" | "ddb" | "dynamo" => Self::Dynamodb,
91            _ => Self::Memory,
92        }
93    }
94}
95
96/// Fully-resolved server configuration.
97#[derive(Debug, Clone)]
98pub struct ServerConfig {
99    /// IP address to bind (`127.0.0.1` for local dev, `0.0.0.0` in containers).
100    pub bind: String,
101    /// Port to bind.
102    pub port: u16,
103    /// LLM gateway base URL.
104    pub gateway_url: String,
105    /// Optional gateway API key. `None` means LLM turns are unavailable and
106    /// `send_message` returns a clean error.
107    pub gateway_key: Option<String>,
108    /// Model id.
109    pub model: String,
110    /// Whether to seed the knowledge base with demo docs on startup.
111    pub seed_kb: bool,
112    /// Agent-loop iteration cap per turn.
113    pub max_iterations: u32,
114    /// `max_tokens` per LLM call.
115    pub max_tokens: u32,
116    /// Storage backend (drives both the storage adapter and the matching durable
117    /// admin stores). Defaults to [`StorageBackend::Memory`].
118    pub storage: StorageBackend,
119    /// Fail-closed embeddable-widget auth: when `true`, a session for an agent
120    /// the [`WidgetAuthProvider`](smooth_operator::widget_auth::WidgetAuthProvider)
121    /// has **no** policy for is **rejected** (unknown/unregistered agents can't be
122    /// embedded). When `false` (default), an absent policy is allowed — so the
123    /// permissive default provider leaves `/ws` open. Set `WIDGET_AUTH_STRICT=1`
124    /// in front of a real provider. Origin + `authContext` are always enforced
125    /// for agents that *do* have a policy, regardless of this flag.
126    pub widget_auth_strict: bool,
127}
128
129impl ServerConfig {
130    /// Read configuration from the environment, applying documented defaults.
131    #[must_use]
132    pub fn from_env() -> Self {
133        let bind = std::env::var("SMOOTH_AGENT_BIND")
134            .ok()
135            .map(|s| s.trim().to_string())
136            .filter(|s| !s.is_empty())
137            .unwrap_or_else(|| DEFAULT_BIND.to_string());
138
139        let port = std::env::var("SMOOTH_AGENT_PORT")
140            .ok()
141            .and_then(|s| s.trim().parse::<u16>().ok())
142            .unwrap_or(DEFAULT_PORT);
143
144        let gateway_url = std::env::var("SMOOAI_GATEWAY_URL")
145            .ok()
146            .map(|s| s.trim().to_string())
147            .filter(|s| !s.is_empty())
148            .unwrap_or_else(|| DEFAULT_GATEWAY_URL.to_string());
149
150        let gateway_key = std::env::var("SMOOAI_GATEWAY_KEY")
151            .ok()
152            .map(|s| s.trim().to_string())
153            .filter(|s| !s.is_empty());
154
155        let model = std::env::var("SMOOTH_AGENT_MODEL")
156            .ok()
157            .map(|s| s.trim().to_string())
158            .filter(|s| !s.is_empty())
159            .unwrap_or_else(|| DEFAULT_MODEL.to_string());
160
161        let seed_kb = std::env::var("SMOOTH_AGENT_SEED_KB").as_deref() == Ok("1");
162
163        let max_iterations = std::env::var("SMOOTH_AGENT_MAX_ITERATIONS")
164            .ok()
165            .and_then(|s| s.trim().parse::<u32>().ok())
166            .filter(|n| *n > 0)
167            .unwrap_or(DEFAULT_MAX_ITERATIONS);
168
169        let max_tokens = std::env::var("SMOOTH_AGENT_MAX_TOKENS")
170            .ok()
171            .and_then(|s| s.trim().parse::<u32>().ok())
172            .filter(|n| *n > 0)
173            .unwrap_or(DEFAULT_MAX_TOKENS);
174
175        let storage = std::env::var("SMOOTH_AGENT_STORAGE")
176            .ok()
177            .map(|s| StorageBackend::parse(&s))
178            .unwrap_or(StorageBackend::Memory);
179
180        let widget_auth_strict = std::env::var("WIDGET_AUTH_STRICT")
181            .ok()
182            .map(|s| {
183                let s = s.trim().to_ascii_lowercase();
184                s == "1" || s == "true" || s == "yes"
185            })
186            .unwrap_or(false);
187
188        Self {
189            bind,
190            port,
191            gateway_url,
192            gateway_key,
193            model,
194            seed_kb,
195            max_iterations,
196            max_tokens,
197            storage,
198            widget_auth_strict,
199        }
200    }
201
202    /// `true` when a gateway key is present, so LLM turns can actually run.
203    #[must_use]
204    pub fn has_llm(&self) -> bool {
205        self.gateway_key.is_some()
206    }
207
208    /// Build the smooth-operator [`LlmConfig`] for live turns.
209    ///
210    /// Returns `None` when no gateway key is configured (callers should emit a
211    /// clean protocol `error` rather than attempting a turn).
212    #[must_use]
213    pub fn llm_config(&self) -> Option<LlmConfig> {
214        let key = self.gateway_key.clone()?;
215        Some(LlmConfig {
216            api_url: self.gateway_url.clone(),
217            api_key: key,
218            model: self.model.clone(),
219            max_tokens: self.max_tokens,
220            temperature: 0.0,
221            retry_policy: RetryPolicy::default(),
222            api_format: ApiFormat::OpenAiCompat,
223        })
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn defaults_apply_when_env_absent() {
233        // Build a config directly (env-independent) to assert default constants
234        // line up with the documented contract.
235        let cfg = ServerConfig {
236            bind: DEFAULT_BIND.to_string(),
237            port: DEFAULT_PORT,
238            gateway_url: DEFAULT_GATEWAY_URL.to_string(),
239            gateway_key: None,
240            model: DEFAULT_MODEL.to_string(),
241            seed_kb: false,
242            max_iterations: DEFAULT_MAX_ITERATIONS,
243            max_tokens: DEFAULT_MAX_TOKENS,
244            storage: StorageBackend::Memory,
245            widget_auth_strict: false,
246        };
247        assert_eq!(cfg.port, 8787);
248        assert_eq!(cfg.storage, StorageBackend::Memory);
249        assert_eq!(cfg.gateway_url, "https://llm.smoo.ai/v1");
250        assert_eq!(cfg.model, "claude-haiku-4-5");
251        assert!(!cfg.has_llm());
252        assert!(cfg.llm_config().is_none());
253    }
254
255    #[test]
256    fn llm_config_built_when_key_present() {
257        let cfg = ServerConfig {
258            bind: DEFAULT_BIND.to_string(),
259            port: 1,
260            gateway_url: "https://example.test/v1".into(),
261            gateway_key: Some("sk-test".into()),
262            model: "m".into(),
263            seed_kb: false,
264            max_iterations: 4,
265            max_tokens: 128,
266            storage: StorageBackend::Memory,
267            widget_auth_strict: false,
268        };
269        assert!(cfg.has_llm());
270        let llm = cfg.llm_config().expect("llm config");
271        assert_eq!(llm.api_url, "https://example.test/v1");
272        assert_eq!(llm.model, "m");
273        assert_eq!(llm.max_tokens, 128);
274        assert!(matches!(llm.api_format, ApiFormat::OpenAiCompat));
275    }
276
277    #[test]
278    fn storage_backend_parse_maps_aliases_and_defaults_memory() {
279        assert_eq!(StorageBackend::parse("postgres"), StorageBackend::Postgres);
280        assert_eq!(StorageBackend::parse("  PG "), StorageBackend::Postgres);
281        assert_eq!(
282            StorageBackend::parse("PostgreSQL"),
283            StorageBackend::Postgres
284        );
285        assert_eq!(StorageBackend::parse("dynamodb"), StorageBackend::Dynamodb);
286        assert_eq!(StorageBackend::parse("ddb"), StorageBackend::Dynamodb);
287        assert_eq!(StorageBackend::parse("Dynamo"), StorageBackend::Dynamodb);
288        // Memory is the default for the explicit value, unknown values, and empty.
289        assert_eq!(StorageBackend::parse("memory"), StorageBackend::Memory);
290        assert_eq!(StorageBackend::parse("sqlite"), StorageBackend::Memory);
291        assert_eq!(StorageBackend::parse(""), StorageBackend::Memory);
292    }
293}