Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.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).
Expand Down
1 change: 1 addition & 0 deletions rust/examples/dev-support/tests/serve_smoke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions rust/smooth-operator-lambda/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,8 @@ async fn send_message(
conversation_id: &session.conversation_id,
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
Expand Down
12 changes: 12 additions & 0 deletions rust/smooth-operator-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,21 @@ 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"] }
# `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"
Expand Down
247 changes: 247 additions & 0 deletions rust/smooth-operator-server/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ pub fn router() -> Router<AppState> {
)
.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.
Expand All @@ -87,6 +92,37 @@ pub fn router() -> Router<AppState> {
// 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.
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)
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE])
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -415,6 +451,137 @@ 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
/// { "<modelId>": { "inputCostPerToken": <number|null>,
/// "outputCostPerToken": <number|null>,
/// "tier": "<model_tier|null>",
/// "useCases": [<string>] } }
/// ```
///
/// 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<AppState>) -> Json<Value> {
// 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<Value> {
// `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))
}

/// 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<u32> {
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
/// `/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();
// 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!({
"inputCostPerToken": input,
"outputCostPerToken": output,
"tier": tier,
"useCases": use_cases,
"maxOutputTokens": max_output,
}),
);
}
Value::Object(out)
}

// ---------------------------------------------------------------------------
// Connector config CRUD (Phase 12, increment 3)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -931,3 +1098,83 @@ 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"],
"max_output_tokens": 65536
}
},
{
"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"]));
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]
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!({})
);
}
}
14 changes: 10 additions & 4 deletions rust/smooth-operator-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading