1 line
1.1 MiB
Plaintext
1 line
1.1 MiB
Plaintext
{"repo": "arcatva/agentic-dev", "files": {"server-rs/src/api/misc.rs": {"8955886b155171a056af332bdd4a25001a17c87d": {"t": 1785746234.5654092, "n": 85320, "h": "75e010f903eefba3", "c": "use crate::api::state::AppState;\nuse crate::util::now_ms;\nuse axum::{\n extract::State,\n http::{HeaderMap, StatusCode},\n response::{IntoResponse, Response},\n Json,\n};\nuse serde_json::json;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c\n .last_error\n .clone()\n .unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n {\n let mut c = st.usage_cache.lock();\n c.last_attempt_at = now_ms();\n }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n {\n let mut c = st.usage_cache.lock();\n c.at = now_ms();\n c.data = Some(data.clone());\n c.last_error = None;\n }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (\n StatusCode::SERVICE_UNAVAILABLE,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(\n &st.config.claude_config_base,\n ))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(\n State(st): State<AppState>,\n) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" | \"mcp\" => {\n let components =\n crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components\n .iter()\n .any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)})),\n )\n .into_response();\n }\n }\n other => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown kind: {other}\")})),\n )\n .into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // MCP: move the definition between mcpServers and the disabled parking key in\n // .claude.json. list_components enumerates both sides, so the id is known-valid\n // here; a concurrent external edit could still make it vanish → surface as an error.\n \"mcp\" => crate::engine::user_config::set_mcp_server_enabled(base, &req.id, req.enabled)\n .and_then(|found| {\n if found {\n Ok(())\n } else {\n Err(std::io::Error::new(\n std::io::ErrorKind::NotFound,\n format!(\"unknown mcp id: {}\", req.id),\n ))\n }\n }),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(\n base,\n &st.config.skills_dir,\n ))\n .into_response(),\n // NotFound = the id vanished between the known-check and the mutate (external edit\n // race) — same logical class as the pre-check's \"unknown id\", so same 400, not a 500.\n Err(e) if e.kind() == std::io::ErrorKind::NotFound => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let b: UpdateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n match st\n .store\n .update_group(&id, b.name.as_deref(), b.icon.as_deref())\n .await\n {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"group not found\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn groups_delete(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(\n &st.config.templates_path,\n ))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"array of templates required\"})),\n )\n .into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"template '{name}' not found\")})),\n )\n .into_response();\n };\n let prompt =\n crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st\n .engine\n .submit_session(\n tpl.repos,\n tpl.skills,\n prompt,\n std::collections::HashMap::new(),\n meta,\n )\n .await\n {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody {\n pub token: Option<String>,\n}\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"token required\"})),\n )\n .into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n enabled: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n enabled: p.enabled,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> = crate::engine::providers::load_list()\n .iter()\n .map(provider_view)\n .collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid provider: {e}\")})),\n )\n .into_response()\n }\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"capability cannot be NaN\"})),\n )\n .into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"priority cannot be NaN\"})),\n )\n .into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"cost cannot be NaN\"})),\n )\n .into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name, base_url, model are required\"})),\n )\n .into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"router validation failed: {e}\")})),\n )\n .into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"no such provider\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── native Claude per-family routing overrides ──\n\n#[derive(serde::Serialize)]\nstruct NativeModelRef {\n id: String,\n display_name: String,\n}\n\n#[derive(serde::Serialize)]\nstruct NativeFamilyView {\n family: String,\n label: String,\n models: Vec<NativeModelRef>,\n capability: f32,\n priority: f32,\n cost: f32,\n description: String,\n enabled: bool,\n customized: bool,\n editable: bool,\n}\n\nfn default_true() -> bool {\n true\n}\n\n#[derive(serde::Deserialize)]\nstruct NativeOverrideReq {\n capability: f32,\n priority: f32,\n cost: f32,\n #[serde(default)]\n description: String,\n /// Whether this native family participates in routing. Defaults true (old clients omit it).\n #[serde(default = \"default_true\")]\n enabled: bool,\n}\n\nfn is_editable_family(family: &str) -> bool {\n matches!(family, \"opus\" | \"sonnet\" | \"haiku\" | \"fable\")\n}\n\nfn family_label(family: &str) -> &'static str {\n match family {\n \"opus\" => \"Opus\",\n \"sonnet\" => \"Sonnet\",\n \"haiku\" => \"Haiku\",\n \"fable\" => \"Fable\",\n _ => \"Other\",\n }\n}\n\n/// GET /api/native-models — native Claude families with effective routing metrics + override state.\npub async fn native_models_get() -> Response {\n use crate::engine::providers::{\n family_default_metrics, family_of, native_claude_models, DEFAULT_NATIVE_PRIORITY,\n };\n let overrides = crate::engine::native_overrides::load_map();\n\n // Group discovered models by family, first-seen (newest-first) order.\n let mut order: Vec<&'static str> = Vec::new();\n let mut groups: std::collections::HashMap<&'static str, Vec<NativeModelRef>> =\n std::collections::HashMap::new();\n for m in native_claude_models() {\n let fam = family_of(&m.id);\n groups.entry(fam).or_default().push(NativeModelRef {\n id: m.id.clone(),\n display_name: m.display_name.clone(),\n });\n if !order.contains(&fam) {\n order.push(fam);\n }\n }\n\n let mut views: Vec<NativeFamilyView> = order\n .into_iter()\n .map(|fam| {\n let (dc, dk) = family_default_metrics(fam);\n let (capability, priority, cost, description, enabled, customized) =\n match overrides.get(fam) {\n Some(o) => (\n o.capability,\n o.priority,\n o.cost,\n o.description.clone(),\n o.enabled,\n true,\n ),\n None => (dc, DEFAULT_NATIVE_PRIORITY, dk, String::new(), true, false),\n };\n NativeFamilyView {\n family: fam.to_string(),\n label: family_label(fam).to_string(),\n models: groups.remove(fam).unwrap_or_default(),\n capability,\n priority,\n cost,\n description,\n enabled,\n customized,\n editable: is_editable_family(fam),\n }\n })\n .collect();\n // cheap → capable, family-name tiebreak (matches the /api/models ordering contract).\n views.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.family.cmp(&b.family))\n });\n\n Json(json!({ \"families\": views })).into_response()\n}\n\n/// POST /api/native-models/{family} — set a family's routing override.\npub async fn native_models_post(\n axum::extract::Path(family): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n let req: NativeOverrideReq = match serde_json::from_slice(&body) {\n Ok(r) => r,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid override: {e}\")})),\n )\n .into_response()\n }\n };\n for (name, v) in [\n (\"capability\", req.capability),\n (\"priority\", req.priority),\n (\"cost\", req.cost),\n ] {\n if v.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"{name} cannot be NaN\")})),\n )\n .into_response();\n }\n }\n let ov = crate::engine::native_overrides::NativeOverride {\n capability: req.capability.clamp(0.0, 1.0),\n priority: req.priority.clamp(0.0, 1.0),\n cost: req.cost.clamp(0.0, 1.0),\n description: req.description,\n enabled: req.enabled,\n };\n match crate::engine::native_overrides::upsert(&family, ov) {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/native-models/{family} — reset a family to defaults (idempotent).\npub async fn native_models_delete(\n axum::extract::Path(family): axum::extract::Path<String>,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n match crate::engine::native_overrides::remove(&family) {\n Ok(_) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// GET /api/routing — the global cost⇄quality tradeoff knob (0=cheapest .. 1=strongest).\npub async fn routing_get() -> Response {\n Json(json!({ \"tradeoff\": crate::engine::routing_config::load().tradeoff })).into_response()\n}\n\n#[derive(serde::Deserialize)]\nstruct RoutingReq {\n tradeoff: f32,\n}\n\n/// POST /api/routing — set the global tradeoff. Rejects NaN; `save` clamps to [0,1]. Responds with\n/// the effective (clamped) value so the client can reflect it.\npub async fn routing_post(body: axum::body::Bytes) -> Response {\n let req: RoutingReq = match serde_json::from_slice(&body) {\n Ok(r) => r,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid routing config: {e}\")})),\n )\n .into_response()\n }\n };\n if req.tradeoff.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"tradeoff cannot be NaN\"})),\n )\n .into_response();\n }\n let cfg = crate::engine::routing_config::RoutingConfig {\n tradeoff: req.tradeoff,\n };\n match crate::engine::routing_config::save(&cfg) {\n Ok(()) => Json(json!({\n \"ok\": true,\n \"tradeoff\": crate::engine::routing_config::load().tradeoff\n }))\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)})),\n )\n .into_response();\n }\n if let Err(e) = crate::api::validation::validate_mcp_def(&def) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"MCP server '{name}' not found\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody {\n pub name: String,\n pub description: String,\n /// The skill's markdown body — the actual instructions the agent loads. Optional for\n /// back-compat; without it the created skill is an empty shell (frontmatter only).\n #[serde(default)]\n pub instructions: String,\n}\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description, &b.instructions) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CatalogQuery {\n #[serde(default)]\n pub refresh: bool,\n}\n\n/// GET /api/skills/catalog[?refresh=true] — the aggregated external skill store across every\n/// configured source. A broken source shows up in `errors` instead of failing the whole store.\npub async fn skills_catalog_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<CatalogQuery>,\n) -> Response {\n let (skills, errors) =\n crate::engine::skill_install::fetch_catalog(&st.config.claude_config_base, q.refresh).await;\n // Per-request (never cached): compare each entry's content fingerprint against the\n // installed copy's provenance metadata so the app can show Update only when one exists.\n // Blocking file reads → spawn_blocking, keeping the async executor free.\n let skills_dir = st.config.skills_dir.clone();\n let skills = tokio::task::spawn_blocking(move || {\n let mut skills = skills;\n crate::engine::skill_install::annotate_update_available(&mut skills, &skills_dir);\n skills\n })\n .await\n // JoinError = the annotation task panicked (it can't, absent fs pathology). An empty\n // list beats a 500 for a read-only catalog.\n .unwrap_or_default();\n Json(json!({ \"skills\": skills, \"errors\": errors })).into_response()\n}\n\n/// GET /api/skills/sources — the configured store sources.\npub async fn skills_sources_route(State(st): State<AppState>) -> Response {\n Json(json!({ \"sources\": crate::engine::skill_install::read_sources(&st.config.claude_config_base) })).into_response()\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceBody {\n pub source: String,\n}\n\n/// POST /api/skills/sources — add a store source (owner/repo[/path] or github.com URL).\npub async fn skills_sources_add_route(\n State(st): State<AppState>,\n body: axum::body::Bytes,\n) -> Response {\n let b: SkillSourceBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Caller-fault (bad syntax) → 400 up front; anything add_source itself fails on afterwards\n // is a server-side write problem → 500 (consistent with the DELETE route).\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n match crate::engine::skill_install::add_source(&st.config.claude_config_base, &b.source) {\n Ok(sources) => Json(json!({ \"sources\": sources })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceQuery {\n pub source: String,\n}\n\n/// DELETE /api/skills/sources?source=… — remove a store source (query param: sources contain\n/// slashes, which a path segment would mangle).\npub async fn skills_sources_delete_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<SkillSourceQuery>,\n) -> Response {\n match crate::engine::skill_install::remove_source(&st.config.claude_config_base, &q.source) {\n Ok((sources, true)) => Json(json!({ \"sources\": sources })).into_response(),\n Ok((_, false)) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"unknown source: {}\", q.source)})),\n )\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct InstallSkillBody {\n pub source: String,\n /// true = update: replace an existing install of the same name (atomic swap).\n #[serde(default)]\n pub update: bool,\n}\n\n/// POST /api/skills/install — download a skill (SKILL.md + companion files) from a GitHub\n/// source (`owner/repo[/path]` or a github.com URL) into the skills dir.\npub async fn skills_install_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: InstallSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Parse errors are the caller's fault (400) BEFORE any network is touched.\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skill_install::install_from_source(&skills, &b.source, b.update).await {\n Ok(_name) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n // Everything else mixes remote and local causes; BAD_GATEWAY for remote-ish messages\n // would be guesswork — a 400 with the human-readable reason serves the app either way.\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"skill '{name}' not found\")})),\n )\n .into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill path: {e}\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody {\n pub id: String,\n}\n\n/// CLI-reported failures (nonzero exit: unknown plugin/marketplace…) are the caller's\n/// error → 400; spawn/timeout/join failures are ours → 500. Prefixes match the exact\n/// error formats of `plugin_cli::run_plugin_command` plus this file's spawn_blocking wrapper.\nfn plugin_error_status(e: &str) -> StatusCode {\n if e.starts_with(\"failed to spawn\")\n || e.starts_with(\"process error\")\n || e.starts_with(\"task error\")\n || e.starts_with(\"plugin command timed out\")\n {\n StatusCode::INTERNAL_SERVER_ERROR\n } else {\n StatusCode::BAD_REQUEST\n }\n}\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result =\n tokio::task::spawn_blocking(move || crate::engine::plugin_cli::install_plugin(&base, &id))\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (plugin_error_status(&e), Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n })\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (plugin_error_status(&e), Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n// ── GET /api/commands — available slash commands, DYNAMIC (never hardcoded) ──\n// The list is the SDK's own `supportedCommands()` for the current enabled-plugin set, so it\n// reflects exactly what a session would expand (plugins added/removed change it). Fetched by\n// spawning the bridge in one-shot `commands` mode; cached briefly since it only changes when\n// plugins change. The Android composer's `/` palette reads this.\n\n#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)]\npub struct SlashCommandInfo {\n pub name: String,\n #[serde(default)]\n pub description: String,\n #[serde(rename = \"argumentHint\", default)]\n pub argument_hint: String,\n}\n\nstatic COMMANDS_CACHE: std::sync::Mutex<Option<(std::time::Instant, Vec<SlashCommandInfo>)>> =\n std::sync::Mutex::new(None);\nconst COMMANDS_TTL: std::time::Duration = std::time::Duration::from_secs(60);\n\npub async fn commands_get(State(st): State<AppState>) -> Response {\n if let Ok(guard) = COMMANDS_CACHE.lock() {\n if let Some((at, cmds)) = guard.as_ref() {\n if at.elapsed() < COMMANDS_TTL {\n return Json(cmds.clone()).into_response();\n }\n }\n }\n let cmds = fetch_slash_commands(&st).await.unwrap_or_default();\n if let Ok(mut guard) = COMMANDS_CACHE.lock() {\n *guard = Some((std::time::Instant::now(), cmds.clone()));\n }\n Json(cmds).into_response()\n}\n\nasync fn fetch_slash_commands(st: &AppState) -> Option<Vec<SlashCommandInfo>> {\n let bridge = crate::engine::sdk_runner::default_bridge_path();\n let node = std::env::var(\"AGENTIC_NODE_BIN\").unwrap_or_else(|_| \"node\".to_string());\n // Global default plugin set (no per-session hide/force) — matches an ordinary new session.\n let enabled =\n crate::engine::plugins::resolve_enabled_plugins(&st.config.claude_config_base, &[], &[]);\n let enabled_json = serde_json::to_string(&enabled).ok()?;\n let output = tokio::time::timeout(\n std::time::Duration::from_secs(30),\n tokio::process::Command::new(&node)\n .arg(&bridge)\n .env(\"SDK_BRIDGE_MODE\", \"commands\")\n .env(\"SDK_BRIDGE_CWD\", &st.config.src_root)\n .env(\"SDK_BRIDGE_ENABLED_PLUGINS\", enabled_json)\n .env(\"CLAUDE_CONFIG_DIR\", &st.config.claude_config_base)\n .stdin(std::process::Stdio::null())\n .stderr(std::process::Stdio::null())\n .output(),\n )\n .await\n .ok()?\n .ok()?;\n serde_json::from_slice::<Vec<SlashCommandInfo>>(&output.stdout).ok()\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{auth, oneshot_req, test_state};\n use axum::body::Body;\n use axum::http::Request;\n use parking_lot::Mutex;\n\n #[test]\n fn slash_command_info_parses_bridge_json_shape() {\n // exactly what sdk-bridge `commands` mode emits (argumentHint is camelCase on the wire).\n let wire = r#\"[{\"name\":\"lfg\",\"description\":\"Run the pipeline\",\"argumentHint\":\"[feature]\"},\n {\"name\":\"ce-code-review\",\"description\":\"\",\"argumentHint\":\"\"}]\"#;\n let cmds: Vec<SlashCommandInfo> = serde_json::from_str(wire).unwrap();\n assert_eq!(cmds.len(), 2);\n assert_eq!(cmds[0].name, \"lfg\");\n assert_eq!(cmds[0].argument_hint, \"[feature]\");\n // missing fields default to empty (bridge always sends them, but be forgiving)\n let sparse: Vec<SlashCommandInfo> = serde_json::from_str(r#\"[{\"name\":\"x\"}]\"#).unwrap();\n assert_eq!(sparse[0].description, \"\");\n assert_eq!(sparse[0].argument_hint, \"\");\n }\n use std::sync::Arc;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://x\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n enabled: true,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(\n !json.contains(\"SECRET\"),\n \"key must never appear in the view: {json}\"\n );\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider {\n api_key: String::new(),\n ..p\n };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n enabled: true,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n enabled: true,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(\n !models.is_empty(),\n \"native Claude candidates should be present\"\n );\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"]\n .as_str()\n .unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}}))\n })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| {\n Box::pin(async { Err(crate::engine::usage::UsageError::Status(429)) })\n }));\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req(\n (*st2).clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles)\n .await\n .into_iter()\n .map(|r| r.unwrap())\n .collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(\n *s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\",\n s\n );\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(\n err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\"\n );\n // Delete\n let (s3, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert!(\n !arr2\n .as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(\n st.clone(),\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\"\n );\n // Delete\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n async fn post_json(\n st: &crate::api::state::AppState,\n path: &str,\n body: &str,\n ) -> (StatusCode, serde_json::Value) {\n oneshot_req(\n st.clone(),\n Request::post(path)\n .header(\"authorization\", auth(st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(body.to_string()))\n .unwrap(),\n )\n .await\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_incomplete_defs() {\n let st = test_state().await;\n for (body, want) in [\n (r#\"{\"name\":\"x\"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"command\":\" \"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"type\":\"stdio\"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"ftp://e.com\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"sse\",\"url\":\"https://\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"https:///mcp\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"http://?x=1\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"ws\",\"url\":\"https://e.com\"}\"#, \"type\"),\n (\n r#\"{\"name\":\"x\",\"command\":\"c\",\"url\":\"https://e.com\"}\"#,\n \"both\",\n ),\n (\n r#\"{\"name\":\"x\",\"type\":\"http\",\"command\":\"c\",\"url\":\"https://e.com\"}\"#,\n \"command\",\n ),\n ] {\n let (s, b) = post_json(&st, \"/api/mcp-servers\", body).await;\n assert_eq!(s, StatusCode::BAD_REQUEST, \"body: {body}\");\n assert!(b[\"error\"].as_str().unwrap().contains(want), \"{body} -> {b}\");\n }\n }\n\n #[tokio::test]\n async fn mcp_add_http_round_trip() {\n let st = test_state().await;\n let (s, _) = post_json(\n &st,\n \"/api/mcp-servers\",\n r#\"{\"name\":\"e2e-http\",\"type\":\"http\",\"url\":\"https://example.com/mcp\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let cj = st\n .config\n .claude_config_base\n .parent()\n .unwrap()\n .join(\".claude.json\");\n let saved: serde_json::Value =\n serde_json::from_str(&std::fs::read_to_string(&cj).unwrap()).unwrap();\n assert_eq!(saved[\"mcpServers\"][\"e2e-http\"][\"type\"], \"http\");\n assert_eq!(\n saved[\"mcpServers\"][\"e2e-http\"][\"url\"],\n \"https://example.com/mcp\"\n );\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/e2e-http\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n // url without \"type\" is accepted and persists with the default type \"http\".\n let (s3, _) = post_json(\n &st,\n \"/api/mcp-servers\",\n r#\"{\"name\":\"e2e-default\",\"url\":\"https://example.com/mcp\"}\"#,\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n let saved: serde_json::Value =\n serde_json::from_str(&std::fs::read_to_string(&cj).unwrap()).unwrap();\n assert_eq!(saved[\"mcpServers\"][\"e2e-default\"][\"type\"], \"http\");\n }\n\n #[tokio::test]\n async fn skill_sources_crud_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Default seed present.\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/skills/sources\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"], serde_json::json!([\"anthropics/skills\"]));\n // Add.\n let (s, b) = post_json(\n &st,\n \"/api/skills/sources\",\n r#\"{\"source\":\"octocat/Hello-World\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"].as_array().unwrap().len(), 2);\n // Duplicate (trailing-slash variant) is deduped, not appended.\n let (s, b) = post_json(\n &st,\n \"/api/skills/sources\",\n r#\"{\"source\":\"octocat/Hello-World/\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"].as_array().unwrap().len(), 2);\n // Bad syntax → 400.\n let (s, _) = post_json(&st, \"/api/skills/sources\", r#\"{\"source\":\"not a source\"}\"#).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n // Remove.\n let (s, b) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/sources?source=octocat%2FHello-World\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"], serde_json::json!([\"anthropics/skills\"]));\n // Remove unknown → 404.\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/sources?source=never%2Fadded\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n }\n\n #[tokio::test]\n async fn skills_install_rejects_bad_source_before_network() {\n let st = test_state().await;\n for body in [\n r#\"{\"source\":\"not a source\"}\"#,\n r#\"{\"source\":\"\"}\"#,\n r#\"{\"source\":\"https://gitlab.com/x/y\"}\"#,\n ] {\n let (s, b) = post_json(&st, \"/api/skills/install\", body).await;\n assert_eq!(s, StatusCode::BAD_REQUEST, \"body: {body}\");\n assert!(b[\"error\"].as_str().is_some());\n }\n }\n\n struct NativeOvGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for NativeOvGuard {\n fn drop(&mut self) {\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_native_overrides_file() -> NativeOvGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"native-overrides.json\"));\n NativeOvGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n struct RoutingGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for RoutingGuard {\n fn drop(&mut self) {\n *crate::engine::routing_config::ROUTING_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_routing_file() -> RoutingGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::routing_config::ROUTING_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"routing.json\"));\n RoutingGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn routing_get_post_roundtrip_and_clamp() {\n let _rc = isolated_routing_file();\n let st = test_state().await;\n let get = |st: AppState| async move {\n oneshot_req(\n st.clone(),\n Request::get(\"/api/routing\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n };\n let post = |st: AppState, body: &'static str| async move {\n oneshot_req(\n st.clone(),\n Request::post(\"/api/routing\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(body))\n .unwrap(),\n )\n .await\n };\n // Default when no file exists.\n let (s, b) = get(st.clone()).await;\n assert_eq!(s, StatusCode::OK);\n assert!((b[\"tradeoff\"].as_f64().unwrap() - 0.5).abs() < 1e-6);\n // In-range round-trips and GET reflects it.\n let (s, b) = post(st.clone(), r#\"{\"tradeoff\":0.2}\"#).await;\n assert_eq!(s, StatusCode::OK);\n assert!((b[\"tradeoff\"].as_f64().unwrap() - 0.2).abs() < 1e-6);\n let (_s, b) = get(st.clone()).await;\n assert!((b[\"tradeoff\"].as_f64().unwrap() - 0.2).abs() < 1e-6);\n // Out-of-range is clamped by `save`, and the response echoes the effective value.\n let (_s, b) = post(st.clone(), r#\"{\"tradeoff\":1.7}\"#).await;\n assert!((b[\"tradeoff\"].as_f64().unwrap() - 1.0).abs() < 1e-6);\n // A non-numeric tradeoff (JSON can't carry NaN) fails to parse → 400.\n let (s, _b) = post(st.clone(), r#\"{\"tradeoff\": null}\"#).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n }\n\n #[tokio::test]\n async fn native_models_get_groups_then_post_marks_customized() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n // GET: families present, opus editable and not customized\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let fams = b[\"families\"].as_array().unwrap();\n // Seed families: fable/opus/opus/sonnet/haiku → 4 groups (opus collapses in routing, but GET\n // groups by discovered family). Ordering contract: cheap → capable (family-name tiebreak).\n assert_eq!(fams.len(), 4);\n let order: Vec<&str> = fams.iter().map(|f| f[\"family\"].as_str().unwrap()).collect();\n assert_eq!(order, vec![\"haiku\", \"sonnet\", \"opus\", \"fable\"]);\n let opus = fams.iter().find(|f| f[\"family\"] == \"opus\").unwrap();\n assert_eq!(opus[\"editable\"], true);\n assert_eq!(opus[\"customized\"], false);\n // A non-overridden family returns an empty description verbatim (the generated per-model\n // fallback happens only at routing time, not in this view).\n assert_eq!(\n fams.iter().find(|f| f[\"family\"] == \"sonnet\").unwrap()[\"description\"],\n \"\"\n );\n\n // POST with mixed-case family normalizes and applies\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/Opus\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.9,\"priority\":0.85,\"cost\":0.2,\"description\":\"hard only\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // GET again: opus is now customized with the new priority\n let (_, b3) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n let opus3 = b3[\"families\"]\n .as_array()\n .unwrap()\n .iter()\n .find(|f| f[\"family\"] == \"opus\")\n .unwrap()\n .clone();\n assert_eq!(opus3[\"customized\"], true);\n // f32→JSON widens to f64, so compare with a tolerance rather than `== 0.85` (which would fail).\n assert!((opus3[\"priority\"].as_f64().unwrap() - 0.85).abs() < 1e-6);\n assert_eq!(opus3[\"description\"], \"hard only\");\n }\n\n #[tokio::test]\n async fn native_models_post_rejects_other_and_bad_family_and_clamps() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // `other` is read-only\n let (s_other, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/other\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_other, StatusCode::BAD_REQUEST);\n\n // unknown family\n let (s_bad, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/nope\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_bad, StatusCode::BAD_REQUEST);\n\n // out-of-range clamps (stored value is 1.0, not 5.0)\n let (s_ok, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/sonnet\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":5.0,\"priority\":-1.0,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_ok, StatusCode::OK);\n let m = crate::engine::native_overrides::load_map();\n assert_eq!(m[\"sonnet\"].capability, 1.0);\n assert_eq!(m[\"sonnet\"].priority, 0.0);\n }\n\n #[tokio::test]\n async fn native_models_delete_resets_and_validates_family() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // seed an override, then reset it\n crate::engine::native_overrides::upsert(\n \"opus\",\n crate::engine::native_overrides::NativeOverride {\n capability: 0.9,\n priority: 0.8,\n cost: 0.2,\n description: String::new(),\n enabled: true,\n },\n )\n .unwrap();\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(crate::engine::native_overrides::load_map()\n .get(\"opus\")\n .is_none());\n\n // idempotent: deleting again is still OK\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // invalid family (`other` catch-all, and an unknown name) → 400\n for bad in [\"other\", \"nope\"] {\n let (sb, _) = oneshot_req(\n st.clone(),\n Request::delete(format!(\"/api/native-models/{bad}\"))\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(sb, StatusCode::BAD_REQUEST, \"DELETE {bad} must be 400\");\n }\n }\n}\n"}, "59b354516051eab841ce93898d7f16d89fa59b40": {"t": 1785760888.1500716, "n": 85320, "h": "75e010f903eefba3", "c": "use crate::api::state::AppState;\nuse crate::util::now_ms;\nuse axum::{\n extract::State,\n http::{HeaderMap, StatusCode},\n response::{IntoResponse, Response},\n Json,\n};\nuse serde_json::json;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c\n .last_error\n .clone()\n .unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n {\n let mut c = st.usage_cache.lock();\n c.last_attempt_at = now_ms();\n }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n {\n let mut c = st.usage_cache.lock();\n c.at = now_ms();\n c.data = Some(data.clone());\n c.last_error = None;\n }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (\n StatusCode::SERVICE_UNAVAILABLE,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(\n &st.config.claude_config_base,\n ))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(\n State(st): State<AppState>,\n) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" | \"mcp\" => {\n let components =\n crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components\n .iter()\n .any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)})),\n )\n .into_response();\n }\n }\n other => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown kind: {other}\")})),\n )\n .into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // MCP: move the definition between mcpServers and the disabled parking key in\n // .claude.json. list_components enumerates both sides, so the id is known-valid\n // here; a concurrent external edit could still make it vanish → surface as an error.\n \"mcp\" => crate::engine::user_config::set_mcp_server_enabled(base, &req.id, req.enabled)\n .and_then(|found| {\n if found {\n Ok(())\n } else {\n Err(std::io::Error::new(\n std::io::ErrorKind::NotFound,\n format!(\"unknown mcp id: {}\", req.id),\n ))\n }\n }),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(\n base,\n &st.config.skills_dir,\n ))\n .into_response(),\n // NotFound = the id vanished between the known-check and the mutate (external edit\n // race) — same logical class as the pre-check's \"unknown id\", so same 400, not a 500.\n Err(e) if e.kind() == std::io::ErrorKind::NotFound => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let b: UpdateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n match st\n .store\n .update_group(&id, b.name.as_deref(), b.icon.as_deref())\n .await\n {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"group not found\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn groups_delete(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(\n &st.config.templates_path,\n ))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"array of templates required\"})),\n )\n .into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"template '{name}' not found\")})),\n )\n .into_response();\n };\n let prompt =\n crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st\n .engine\n .submit_session(\n tpl.repos,\n tpl.skills,\n prompt,\n std::collections::HashMap::new(),\n meta,\n )\n .await\n {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody {\n pub token: Option<String>,\n}\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"token required\"})),\n )\n .into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n enabled: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n enabled: p.enabled,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> = crate::engine::providers::load_list()\n .iter()\n .map(provider_view)\n .collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid provider: {e}\")})),\n )\n .into_response()\n }\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"capability cannot be NaN\"})),\n )\n .into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"priority cannot be NaN\"})),\n )\n .into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"cost cannot be NaN\"})),\n )\n .into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name, base_url, model are required\"})),\n )\n .into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"router validation failed: {e}\")})),\n )\n .into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"no such provider\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── native Claude per-family routing overrides ──\n\n#[derive(serde::Serialize)]\nstruct NativeModelRef {\n id: String,\n display_name: String,\n}\n\n#[derive(serde::Serialize)]\nstruct NativeFamilyView {\n family: String,\n label: String,\n models: Vec<NativeModelRef>,\n capability: f32,\n priority: f32,\n cost: f32,\n description: String,\n enabled: bool,\n customized: bool,\n editable: bool,\n}\n\nfn default_true() -> bool {\n true\n}\n\n#[derive(serde::Deserialize)]\nstruct NativeOverrideReq {\n capability: f32,\n priority: f32,\n cost: f32,\n #[serde(default)]\n description: String,\n /// Whether this native family participates in routing. Defaults true (old clients omit it).\n #[serde(default = \"default_true\")]\n enabled: bool,\n}\n\nfn is_editable_family(family: &str) -> bool {\n matches!(family, \"opus\" | \"sonnet\" | \"haiku\" | \"fable\")\n}\n\nfn family_label(family: &str) -> &'static str {\n match family {\n \"opus\" => \"Opus\",\n \"sonnet\" => \"Sonnet\",\n \"haiku\" => \"Haiku\",\n \"fable\" => \"Fable\",\n _ => \"Other\",\n }\n}\n\n/// GET /api/native-models — native Claude families with effective routing metrics + override state.\npub async fn native_models_get() -> Response {\n use crate::engine::providers::{\n family_default_metrics, family_of, native_claude_models, DEFAULT_NATIVE_PRIORITY,\n };\n let overrides = crate::engine::native_overrides::load_map();\n\n // Group discovered models by family, first-seen (newest-first) order.\n let mut order: Vec<&'static str> = Vec::new();\n let mut groups: std::collections::HashMap<&'static str, Vec<NativeModelRef>> =\n std::collections::HashMap::new();\n for m in native_claude_models() {\n let fam = family_of(&m.id);\n groups.entry(fam).or_default().push(NativeModelRef {\n id: m.id.clone(),\n display_name: m.display_name.clone(),\n });\n if !order.contains(&fam) {\n order.push(fam);\n }\n }\n\n let mut views: Vec<NativeFamilyView> = order\n .into_iter()\n .map(|fam| {\n let (dc, dk) = family_default_metrics(fam);\n let (capability, priority, cost, description, enabled, customized) =\n match overrides.get(fam) {\n Some(o) => (\n o.capability,\n o.priority,\n o.cost,\n o.description.clone(),\n o.enabled,\n true,\n ),\n None => (dc, DEFAULT_NATIVE_PRIORITY, dk, String::new(), true, false),\n };\n NativeFamilyView {\n family: fam.to_string(),\n label: family_label(fam).to_string(),\n models: groups.remove(fam).unwrap_or_default(),\n capability,\n priority,\n cost,\n description,\n enabled,\n customized,\n editable: is_editable_family(fam),\n }\n })\n .collect();\n // cheap → capable, family-name tiebreak (matches the /api/models ordering contract).\n views.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.family.cmp(&b.family))\n });\n\n Json(json!({ \"families\": views })).into_response()\n}\n\n/// POST /api/native-models/{family} — set a family's routing override.\npub async fn native_models_post(\n axum::extract::Path(family): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n let req: NativeOverrideReq = match serde_json::from_slice(&body) {\n Ok(r) => r,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid override: {e}\")})),\n )\n .into_response()\n }\n };\n for (name, v) in [\n (\"capability\", req.capability),\n (\"priority\", req.priority),\n (\"cost\", req.cost),\n ] {\n if v.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"{name} cannot be NaN\")})),\n )\n .into_response();\n }\n }\n let ov = crate::engine::native_overrides::NativeOverride {\n capability: req.capability.clamp(0.0, 1.0),\n priority: req.priority.clamp(0.0, 1.0),\n cost: req.cost.clamp(0.0, 1.0),\n description: req.description,\n enabled: req.enabled,\n };\n match crate::engine::native_overrides::upsert(&family, ov) {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/native-models/{family} — reset a family to defaults (idempotent).\npub async fn native_models_delete(\n axum::extract::Path(family): axum::extract::Path<String>,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n match crate::engine::native_overrides::remove(&family) {\n Ok(_) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// GET /api/routing — the global cost⇄quality tradeoff knob (0=cheapest .. 1=strongest).\npub async fn routing_get() -> Response {\n Json(json!({ \"tradeoff\": crate::engine::routing_config::load().tradeoff })).into_response()\n}\n\n#[derive(serde::Deserialize)]\nstruct RoutingReq {\n tradeoff: f32,\n}\n\n/// POST /api/routing — set the global tradeoff. Rejects NaN; `save` clamps to [0,1]. Responds with\n/// the effective (clamped) value so the client can reflect it.\npub async fn routing_post(body: axum::body::Bytes) -> Response {\n let req: RoutingReq = match serde_json::from_slice(&body) {\n Ok(r) => r,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid routing config: {e}\")})),\n )\n .into_response()\n }\n };\n if req.tradeoff.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"tradeoff cannot be NaN\"})),\n )\n .into_response();\n }\n let cfg = crate::engine::routing_config::RoutingConfig {\n tradeoff: req.tradeoff,\n };\n match crate::engine::routing_config::save(&cfg) {\n Ok(()) => Json(json!({\n \"ok\": true,\n \"tradeoff\": crate::engine::routing_config::load().tradeoff\n }))\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)})),\n )\n .into_response();\n }\n if let Err(e) = crate::api::validation::validate_mcp_def(&def) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"MCP server '{name}' not found\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody {\n pub name: String,\n pub description: String,\n /// The skill's markdown body — the actual instructions the agent loads. Optional for\n /// back-compat; without it the created skill is an empty shell (frontmatter only).\n #[serde(default)]\n pub instructions: String,\n}\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description, &b.instructions) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CatalogQuery {\n #[serde(default)]\n pub refresh: bool,\n}\n\n/// GET /api/skills/catalog[?refresh=true] — the aggregated external skill store across every\n/// configured source. A broken source shows up in `errors` instead of failing the whole store.\npub async fn skills_catalog_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<CatalogQuery>,\n) -> Response {\n let (skills, errors) =\n crate::engine::skill_install::fetch_catalog(&st.config.claude_config_base, q.refresh).await;\n // Per-request (never cached): compare each entry's content fingerprint against the\n // installed copy's provenance metadata so the app can show Update only when one exists.\n // Blocking file reads → spawn_blocking, keeping the async executor free.\n let skills_dir = st.config.skills_dir.clone();\n let skills = tokio::task::spawn_blocking(move || {\n let mut skills = skills;\n crate::engine::skill_install::annotate_update_available(&mut skills, &skills_dir);\n skills\n })\n .await\n // JoinError = the annotation task panicked (it can't, absent fs pathology). An empty\n // list beats a 500 for a read-only catalog.\n .unwrap_or_default();\n Json(json!({ \"skills\": skills, \"errors\": errors })).into_response()\n}\n\n/// GET /api/skills/sources — the configured store sources.\npub async fn skills_sources_route(State(st): State<AppState>) -> Response {\n Json(json!({ \"sources\": crate::engine::skill_install::read_sources(&st.config.claude_config_base) })).into_response()\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceBody {\n pub source: String,\n}\n\n/// POST /api/skills/sources — add a store source (owner/repo[/path] or github.com URL).\npub async fn skills_sources_add_route(\n State(st): State<AppState>,\n body: axum::body::Bytes,\n) -> Response {\n let b: SkillSourceBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Caller-fault (bad syntax) → 400 up front; anything add_source itself fails on afterwards\n // is a server-side write problem → 500 (consistent with the DELETE route).\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n match crate::engine::skill_install::add_source(&st.config.claude_config_base, &b.source) {\n Ok(sources) => Json(json!({ \"sources\": sources })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceQuery {\n pub source: String,\n}\n\n/// DELETE /api/skills/sources?source=… — remove a store source (query param: sources contain\n/// slashes, which a path segment would mangle).\npub async fn skills_sources_delete_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<SkillSourceQuery>,\n) -> Response {\n match crate::engine::skill_install::remove_source(&st.config.claude_config_base, &q.source) {\n Ok((sources, true)) => Json(json!({ \"sources\": sources })).into_response(),\n Ok((_, false)) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"unknown source: {}\", q.source)})),\n )\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct InstallSkillBody {\n pub source: String,\n /// true = update: replace an existing install of the same name (atomic swap).\n #[serde(default)]\n pub update: bool,\n}\n\n/// POST /api/skills/install — download a skill (SKILL.md + companion files) from a GitHub\n/// source (`owner/repo[/path]` or a github.com URL) into the skills dir.\npub async fn skills_install_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: InstallSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Parse errors are the caller's fault (400) BEFORE any network is touched.\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skill_install::install_from_source(&skills, &b.source, b.update).await {\n Ok(_name) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n // Everything else mixes remote and local causes; BAD_GATEWAY for remote-ish messages\n // would be guesswork — a 400 with the human-readable reason serves the app either way.\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"skill '{name}' not found\")})),\n )\n .into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill path: {e}\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody {\n pub id: String,\n}\n\n/// CLI-reported failures (nonzero exit: unknown plugin/marketplace…) are the caller's\n/// error → 400; spawn/timeout/join failures are ours → 500. Prefixes match the exact\n/// error formats of `plugin_cli::run_plugin_command` plus this file's spawn_blocking wrapper.\nfn plugin_error_status(e: &str) -> StatusCode {\n if e.starts_with(\"failed to spawn\")\n || e.starts_with(\"process error\")\n || e.starts_with(\"task error\")\n || e.starts_with(\"plugin command timed out\")\n {\n StatusCode::INTERNAL_SERVER_ERROR\n } else {\n StatusCode::BAD_REQUEST\n }\n}\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result =\n tokio::task::spawn_blocking(move || crate::engine::plugin_cli::install_plugin(&base, &id))\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (plugin_error_status(&e), Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n })\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (plugin_error_status(&e), Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n// ── GET /api/commands — available slash commands, DYNAMIC (never hardcoded) ──\n// The list is the SDK's own `supportedCommands()` for the current enabled-plugin set, so it\n// reflects exactly what a session would expand (plugins added/removed change it). Fetched by\n// spawning the bridge in one-shot `commands` mode; cached briefly since it only changes when\n// plugins change. The Android composer's `/` palette reads this.\n\n#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)]\npub struct SlashCommandInfo {\n pub name: String,\n #[serde(default)]\n pub description: String,\n #[serde(rename = \"argumentHint\", default)]\n pub argument_hint: String,\n}\n\nstatic COMMANDS_CACHE: std::sync::Mutex<Option<(std::time::Instant, Vec<SlashCommandInfo>)>> =\n std::sync::Mutex::new(None);\nconst COMMANDS_TTL: std::time::Duration = std::time::Duration::from_secs(60);\n\npub async fn commands_get(State(st): State<AppState>) -> Response {\n if let Ok(guard) = COMMANDS_CACHE.lock() {\n if let Some((at, cmds)) = guard.as_ref() {\n if at.elapsed() < COMMANDS_TTL {\n return Json(cmds.clone()).into_response();\n }\n }\n }\n let cmds = fetch_slash_commands(&st).await.unwrap_or_default();\n if let Ok(mut guard) = COMMANDS_CACHE.lock() {\n *guard = Some((std::time::Instant::now(), cmds.clone()));\n }\n Json(cmds).into_response()\n}\n\nasync fn fetch_slash_commands(st: &AppState) -> Option<Vec<SlashCommandInfo>> {\n let bridge = crate::engine::sdk_runner::default_bridge_path();\n let node = std::env::var(\"AGENTIC_NODE_BIN\").unwrap_or_else(|_| \"node\".to_string());\n // Global default plugin set (no per-session hide/force) — matches an ordinary new session.\n let enabled =\n crate::engine::plugins::resolve_enabled_plugins(&st.config.claude_config_base, &[], &[]);\n let enabled_json = serde_json::to_string(&enabled).ok()?;\n let output = tokio::time::timeout(\n std::time::Duration::from_secs(30),\n tokio::process::Command::new(&node)\n .arg(&bridge)\n .env(\"SDK_BRIDGE_MODE\", \"commands\")\n .env(\"SDK_BRIDGE_CWD\", &st.config.src_root)\n .env(\"SDK_BRIDGE_ENABLED_PLUGINS\", enabled_json)\n .env(\"CLAUDE_CONFIG_DIR\", &st.config.claude_config_base)\n .stdin(std::process::Stdio::null())\n .stderr(std::process::Stdio::null())\n .output(),\n )\n .await\n .ok()?\n .ok()?;\n serde_json::from_slice::<Vec<SlashCommandInfo>>(&output.stdout).ok()\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{auth, oneshot_req, test_state};\n use axum::body::Body;\n use axum::http::Request;\n use parking_lot::Mutex;\n\n #[test]\n fn slash_command_info_parses_bridge_json_shape() {\n // exactly what sdk-bridge `commands` mode emits (argumentHint is camelCase on the wire).\n let wire = r#\"[{\"name\":\"lfg\",\"description\":\"Run the pipeline\",\"argumentHint\":\"[feature]\"},\n {\"name\":\"ce-code-review\",\"description\":\"\",\"argumentHint\":\"\"}]\"#;\n let cmds: Vec<SlashCommandInfo> = serde_json::from_str(wire).unwrap();\n assert_eq!(cmds.len(), 2);\n assert_eq!(cmds[0].name, \"lfg\");\n assert_eq!(cmds[0].argument_hint, \"[feature]\");\n // missing fields default to empty (bridge always sends them, but be forgiving)\n let sparse: Vec<SlashCommandInfo> = serde_json::from_str(r#\"[{\"name\":\"x\"}]\"#).unwrap();\n assert_eq!(sparse[0].description, \"\");\n assert_eq!(sparse[0].argument_hint, \"\");\n }\n use std::sync::Arc;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://x\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n enabled: true,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(\n !json.contains(\"SECRET\"),\n \"key must never appear in the view: {json}\"\n );\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider {\n api_key: String::new(),\n ..p\n };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n enabled: true,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n enabled: true,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(\n !models.is_empty(),\n \"native Claude candidates should be present\"\n );\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"]\n .as_str()\n .unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}}))\n })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| {\n Box::pin(async { Err(crate::engine::usage::UsageError::Status(429)) })\n }));\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req(\n (*st2).clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles)\n .await\n .into_iter()\n .map(|r| r.unwrap())\n .collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(\n *s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\",\n s\n );\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(\n err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\"\n );\n // Delete\n let (s3, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert!(\n !arr2\n .as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(\n st.clone(),\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\"\n );\n // Delete\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n async fn post_json(\n st: &crate::api::state::AppState,\n path: &str,\n body: &str,\n ) -> (StatusCode, serde_json::Value) {\n oneshot_req(\n st.clone(),\n Request::post(path)\n .header(\"authorization\", auth(st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(body.to_string()))\n .unwrap(),\n )\n .await\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_incomplete_defs() {\n let st = test_state().await;\n for (body, want) in [\n (r#\"{\"name\":\"x\"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"command\":\" \"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"type\":\"stdio\"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"ftp://e.com\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"sse\",\"url\":\"https://\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"https:///mcp\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"http://?x=1\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"ws\",\"url\":\"https://e.com\"}\"#, \"type\"),\n (\n r#\"{\"name\":\"x\",\"command\":\"c\",\"url\":\"https://e.com\"}\"#,\n \"both\",\n ),\n (\n r#\"{\"name\":\"x\",\"type\":\"http\",\"command\":\"c\",\"url\":\"https://e.com\"}\"#,\n \"command\",\n ),\n ] {\n let (s, b) = post_json(&st, \"/api/mcp-servers\", body).await;\n assert_eq!(s, StatusCode::BAD_REQUEST, \"body: {body}\");\n assert!(b[\"error\"].as_str().unwrap().contains(want), \"{body} -> {b}\");\n }\n }\n\n #[tokio::test]\n async fn mcp_add_http_round_trip() {\n let st = test_state().await;\n let (s, _) = post_json(\n &st,\n \"/api/mcp-servers\",\n r#\"{\"name\":\"e2e-http\",\"type\":\"http\",\"url\":\"https://example.com/mcp\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let cj = st\n .config\n .claude_config_base\n .parent()\n .unwrap()\n .join(\".claude.json\");\n let saved: serde_json::Value =\n serde_json::from_str(&std::fs::read_to_string(&cj).unwrap()).unwrap();\n assert_eq!(saved[\"mcpServers\"][\"e2e-http\"][\"type\"], \"http\");\n assert_eq!(\n saved[\"mcpServers\"][\"e2e-http\"][\"url\"],\n \"https://example.com/mcp\"\n );\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/e2e-http\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n // url without \"type\" is accepted and persists with the default type \"http\".\n let (s3, _) = post_json(\n &st,\n \"/api/mcp-servers\",\n r#\"{\"name\":\"e2e-default\",\"url\":\"https://example.com/mcp\"}\"#,\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n let saved: serde_json::Value =\n serde_json::from_str(&std::fs::read_to_string(&cj).unwrap()).unwrap();\n assert_eq!(saved[\"mcpServers\"][\"e2e-default\"][\"type\"], \"http\");\n }\n\n #[tokio::test]\n async fn skill_sources_crud_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Default seed present.\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/skills/sources\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"], serde_json::json!([\"anthropics/skills\"]));\n // Add.\n let (s, b) = post_json(\n &st,\n \"/api/skills/sources\",\n r#\"{\"source\":\"octocat/Hello-World\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"].as_array().unwrap().len(), 2);\n // Duplicate (trailing-slash variant) is deduped, not appended.\n let (s, b) = post_json(\n &st,\n \"/api/skills/sources\",\n r#\"{\"source\":\"octocat/Hello-World/\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"].as_array().unwrap().len(), 2);\n // Bad syntax → 400.\n let (s, _) = post_json(&st, \"/api/skills/sources\", r#\"{\"source\":\"not a source\"}\"#).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n // Remove.\n let (s, b) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/sources?source=octocat%2FHello-World\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"], serde_json::json!([\"anthropics/skills\"]));\n // Remove unknown → 404.\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/sources?source=never%2Fadded\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n }\n\n #[tokio::test]\n async fn skills_install_rejects_bad_source_before_network() {\n let st = test_state().await;\n for body in [\n r#\"{\"source\":\"not a source\"}\"#,\n r#\"{\"source\":\"\"}\"#,\n r#\"{\"source\":\"https://gitlab.com/x/y\"}\"#,\n ] {\n let (s, b) = post_json(&st, \"/api/skills/install\", body).await;\n assert_eq!(s, StatusCode::BAD_REQUEST, \"body: {body}\");\n assert!(b[\"error\"].as_str().is_some());\n }\n }\n\n struct NativeOvGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for NativeOvGuard {\n fn drop(&mut self) {\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_native_overrides_file() -> NativeOvGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"native-overrides.json\"));\n NativeOvGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n struct RoutingGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for RoutingGuard {\n fn drop(&mut self) {\n *crate::engine::routing_config::ROUTING_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_routing_file() -> RoutingGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::routing_config::ROUTING_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"routing.json\"));\n RoutingGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn routing_get_post_roundtrip_and_clamp() {\n let _rc = isolated_routing_file();\n let st = test_state().await;\n let get = |st: AppState| async move {\n oneshot_req(\n st.clone(),\n Request::get(\"/api/routing\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n };\n let post = |st: AppState, body: &'static str| async move {\n oneshot_req(\n st.clone(),\n Request::post(\"/api/routing\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(body))\n .unwrap(),\n )\n .await\n };\n // Default when no file exists.\n let (s, b) = get(st.clone()).await;\n assert_eq!(s, StatusCode::OK);\n assert!((b[\"tradeoff\"].as_f64().unwrap() - 0.5).abs() < 1e-6);\n // In-range round-trips and GET reflects it.\n let (s, b) = post(st.clone(), r#\"{\"tradeoff\":0.2}\"#).await;\n assert_eq!(s, StatusCode::OK);\n assert!((b[\"tradeoff\"].as_f64().unwrap() - 0.2).abs() < 1e-6);\n let (_s, b) = get(st.clone()).await;\n assert!((b[\"tradeoff\"].as_f64().unwrap() - 0.2).abs() < 1e-6);\n // Out-of-range is clamped by `save`, and the response echoes the effective value.\n let (_s, b) = post(st.clone(), r#\"{\"tradeoff\":1.7}\"#).await;\n assert!((b[\"tradeoff\"].as_f64().unwrap() - 1.0).abs() < 1e-6);\n // A non-numeric tradeoff (JSON can't carry NaN) fails to parse → 400.\n let (s, _b) = post(st.clone(), r#\"{\"tradeoff\": null}\"#).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n }\n\n #[tokio::test]\n async fn native_models_get_groups_then_post_marks_customized() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n // GET: families present, opus editable and not customized\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let fams = b[\"families\"].as_array().unwrap();\n // Seed families: fable/opus/opus/sonnet/haiku → 4 groups (opus collapses in routing, but GET\n // groups by discovered family). Ordering contract: cheap → capable (family-name tiebreak).\n assert_eq!(fams.len(), 4);\n let order: Vec<&str> = fams.iter().map(|f| f[\"family\"].as_str().unwrap()).collect();\n assert_eq!(order, vec![\"haiku\", \"sonnet\", \"opus\", \"fable\"]);\n let opus = fams.iter().find(|f| f[\"family\"] == \"opus\").unwrap();\n assert_eq!(opus[\"editable\"], true);\n assert_eq!(opus[\"customized\"], false);\n // A non-overridden family returns an empty description verbatim (the generated per-model\n // fallback happens only at routing time, not in this view).\n assert_eq!(\n fams.iter().find(|f| f[\"family\"] == \"sonnet\").unwrap()[\"description\"],\n \"\"\n );\n\n // POST with mixed-case family normalizes and applies\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/Opus\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.9,\"priority\":0.85,\"cost\":0.2,\"description\":\"hard only\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // GET again: opus is now customized with the new priority\n let (_, b3) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n let opus3 = b3[\"families\"]\n .as_array()\n .unwrap()\n .iter()\n .find(|f| f[\"family\"] == \"opus\")\n .unwrap()\n .clone();\n assert_eq!(opus3[\"customized\"], true);\n // f32→JSON widens to f64, so compare with a tolerance rather than `== 0.85` (which would fail).\n assert!((opus3[\"priority\"].as_f64().unwrap() - 0.85).abs() < 1e-6);\n assert_eq!(opus3[\"description\"], \"hard only\");\n }\n\n #[tokio::test]\n async fn native_models_post_rejects_other_and_bad_family_and_clamps() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // `other` is read-only\n let (s_other, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/other\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_other, StatusCode::BAD_REQUEST);\n\n // unknown family\n let (s_bad, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/nope\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_bad, StatusCode::BAD_REQUEST);\n\n // out-of-range clamps (stored value is 1.0, not 5.0)\n let (s_ok, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/sonnet\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":5.0,\"priority\":-1.0,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_ok, StatusCode::OK);\n let m = crate::engine::native_overrides::load_map();\n assert_eq!(m[\"sonnet\"].capability, 1.0);\n assert_eq!(m[\"sonnet\"].priority, 0.0);\n }\n\n #[tokio::test]\n async fn native_models_delete_resets_and_validates_family() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // seed an override, then reset it\n crate::engine::native_overrides::upsert(\n \"opus\",\n crate::engine::native_overrides::NativeOverride {\n capability: 0.9,\n priority: 0.8,\n cost: 0.2,\n description: String::new(),\n enabled: true,\n },\n )\n .unwrap();\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(crate::engine::native_overrides::load_map()\n .get(\"opus\")\n .is_none());\n\n // idempotent: deleting again is still OK\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // invalid family (`other` catch-all, and an unknown name) → 400\n for bad in [\"other\", \"nope\"] {\n let (sb, _) = oneshot_req(\n st.clone(),\n Request::delete(format!(\"/api/native-models/{bad}\"))\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(sb, StatusCode::BAD_REQUEST, \"DELETE {bad} must be 400\");\n }\n }\n}\n"}, "b2662d1e5b4b87e360cc59e96014288500dfbe57": {"t": 1785760889.8061543, "n": 81919, "h": "c20e5dd73ed16979", "c": "use crate::api::state::AppState;\nuse crate::util::now_ms;\nuse axum::{\n extract::State,\n http::{HeaderMap, StatusCode},\n response::{IntoResponse, Response},\n Json,\n};\nuse serde_json::json;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c\n .last_error\n .clone()\n .unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n {\n let mut c = st.usage_cache.lock();\n c.last_attempt_at = now_ms();\n }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n {\n let mut c = st.usage_cache.lock();\n c.at = now_ms();\n c.data = Some(data.clone());\n c.last_error = None;\n }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (\n StatusCode::SERVICE_UNAVAILABLE,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(\n &st.config.claude_config_base,\n ))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(\n State(st): State<AppState>,\n) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" | \"mcp\" => {\n let components =\n crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components\n .iter()\n .any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)})),\n )\n .into_response();\n }\n }\n other => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown kind: {other}\")})),\n )\n .into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // MCP: move the definition between mcpServers and the disabled parking key in\n // .claude.json. list_components enumerates both sides, so the id is known-valid\n // here; a concurrent external edit could still make it vanish → surface as an error.\n \"mcp\" => crate::engine::user_config::set_mcp_server_enabled(base, &req.id, req.enabled)\n .and_then(|found| {\n if found {\n Ok(())\n } else {\n Err(std::io::Error::new(\n std::io::ErrorKind::NotFound,\n format!(\"unknown mcp id: {}\", req.id),\n ))\n }\n }),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(\n base,\n &st.config.skills_dir,\n ))\n .into_response(),\n // NotFound = the id vanished between the known-check and the mutate (external edit\n // race) — same logical class as the pre-check's \"unknown id\", so same 400, not a 500.\n Err(e) if e.kind() == std::io::ErrorKind::NotFound => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let b: UpdateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n match st\n .store\n .update_group(&id, b.name.as_deref(), b.icon.as_deref())\n .await\n {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"group not found\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn groups_delete(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(\n &st.config.templates_path,\n ))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"array of templates required\"})),\n )\n .into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"template '{name}' not found\")})),\n )\n .into_response();\n };\n let prompt =\n crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st\n .engine\n .submit_session(\n tpl.repos,\n tpl.skills,\n prompt,\n std::collections::HashMap::new(),\n meta,\n )\n .await\n {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody {\n pub token: Option<String>,\n}\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"token required\"})),\n )\n .into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n enabled: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n enabled: p.enabled,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> = crate::engine::providers::load_list()\n .iter()\n .map(provider_view)\n .collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid provider: {e}\")})),\n )\n .into_response()\n }\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"capability cannot be NaN\"})),\n )\n .into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"priority cannot be NaN\"})),\n )\n .into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"cost cannot be NaN\"})),\n )\n .into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name, base_url, model are required\"})),\n )\n .into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"router validation failed: {e}\")})),\n )\n .into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"no such provider\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── native Claude per-family routing overrides ──\n\n#[derive(serde::Serialize)]\nstruct NativeModelRef {\n id: String,\n display_name: String,\n}\n\n#[derive(serde::Serialize)]\nstruct NativeFamilyView {\n family: String,\n label: String,\n models: Vec<NativeModelRef>,\n capability: f32,\n priority: f32,\n cost: f32,\n description: String,\n enabled: bool,\n customized: bool,\n editable: bool,\n}\n\nfn default_true() -> bool {\n true\n}\n\n#[derive(serde::Deserialize)]\nstruct NativeOverrideReq {\n capability: f32,\n priority: f32,\n cost: f32,\n #[serde(default)]\n description: String,\n /// Whether this native family participates in routing. Defaults true (old clients omit it).\n #[serde(default = \"default_true\")]\n enabled: bool,\n}\n\nfn is_editable_family(family: &str) -> bool {\n matches!(family, \"opus\" | \"sonnet\" | \"haiku\" | \"fable\")\n}\n\nfn family_label(family: &str) -> &'static str {\n match family {\n \"opus\" => \"Opus\",\n \"sonnet\" => \"Sonnet\",\n \"haiku\" => \"Haiku\",\n \"fable\" => \"Fable\",\n _ => \"Other\",\n }\n}\n\n/// GET /api/native-models — native Claude families with effective routing metrics + override state.\npub async fn native_models_get() -> Response {\n use crate::engine::providers::{\n family_default_metrics, family_of, native_claude_models, DEFAULT_NATIVE_PRIORITY,\n };\n let overrides = crate::engine::native_overrides::load_map();\n\n // Group discovered models by family, first-seen (newest-first) order.\n let mut order: Vec<&'static str> = Vec::new();\n let mut groups: std::collections::HashMap<&'static str, Vec<NativeModelRef>> =\n std::collections::HashMap::new();\n for m in native_claude_models() {\n let fam = family_of(&m.id);\n groups.entry(fam).or_default().push(NativeModelRef {\n id: m.id.clone(),\n display_name: m.display_name.clone(),\n });\n if !order.contains(&fam) {\n order.push(fam);\n }\n }\n\n let mut views: Vec<NativeFamilyView> = order\n .into_iter()\n .map(|fam| {\n let (dc, dk) = family_default_metrics(fam);\n let (capability, priority, cost, description, enabled, customized) =\n match overrides.get(fam) {\n Some(o) => (\n o.capability,\n o.priority,\n o.cost,\n o.description.clone(),\n o.enabled,\n true,\n ),\n None => (dc, DEFAULT_NATIVE_PRIORITY, dk, String::new(), true, false),\n };\n NativeFamilyView {\n family: fam.to_string(),\n label: family_label(fam).to_string(),\n models: groups.remove(fam).unwrap_or_default(),\n capability,\n priority,\n cost,\n description,\n enabled,\n customized,\n editable: is_editable_family(fam),\n }\n })\n .collect();\n // cheap → capable, family-name tiebreak (matches the /api/models ordering contract).\n views.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.family.cmp(&b.family))\n });\n\n Json(json!({ \"families\": views })).into_response()\n}\n\n/// POST /api/native-models/{family} — set a family's routing override.\npub async fn native_models_post(\n axum::extract::Path(family): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n let req: NativeOverrideReq = match serde_json::from_slice(&body) {\n Ok(r) => r,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid override: {e}\")})),\n )\n .into_response()\n }\n };\n for (name, v) in [\n (\"capability\", req.capability),\n (\"priority\", req.priority),\n (\"cost\", req.cost),\n ] {\n if v.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"{name} cannot be NaN\")})),\n )\n .into_response();\n }\n }\n let ov = crate::engine::native_overrides::NativeOverride {\n capability: req.capability.clamp(0.0, 1.0),\n priority: req.priority.clamp(0.0, 1.0),\n cost: req.cost.clamp(0.0, 1.0),\n description: req.description,\n enabled: req.enabled,\n };\n match crate::engine::native_overrides::upsert(&family, ov) {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/native-models/{family} — reset a family to defaults (idempotent).\npub async fn native_models_delete(\n axum::extract::Path(family): axum::extract::Path<String>,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n match crate::engine::native_overrides::remove(&family) {\n Ok(_) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// GET /api/routing — the global cost⇄quality tradeoff knob (0=cheapest .. 1=strongest).\npub async fn routing_get() -> Response {\n Json(json!({ \"tradeoff\": crate::engine::routing_config::load().tradeoff })).into_response()\n}\n\n#[derive(serde::Deserialize)]\nstruct RoutingReq {\n tradeoff: f32,\n}\n\n/// POST /api/routing — set the global tradeoff. Rejects NaN; `save` clamps to [0,1]. Responds with\n/// the effective (clamped) value so the client can reflect it.\npub async fn routing_post(body: axum::body::Bytes) -> Response {\n let req: RoutingReq = match serde_json::from_slice(&body) {\n Ok(r) => r,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid routing config: {e}\")})),\n )\n .into_response()\n }\n };\n if req.tradeoff.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"tradeoff cannot be NaN\"})),\n )\n .into_response();\n }\n let cfg = crate::engine::routing_config::RoutingConfig {\n tradeoff: req.tradeoff,\n };\n match crate::engine::routing_config::save(&cfg) {\n Ok(()) => Json(json!({\n \"ok\": true,\n \"tradeoff\": crate::engine::routing_config::load().tradeoff\n }))\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)})),\n )\n .into_response();\n }\n if let Err(e) = crate::api::validation::validate_mcp_def(&def) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"MCP server '{name}' not found\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody {\n pub name: String,\n pub description: String,\n /// The skill's markdown body — the actual instructions the agent loads. Optional for\n /// back-compat; without it the created skill is an empty shell (frontmatter only).\n #[serde(default)]\n pub instructions: String,\n}\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description, &b.instructions) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CatalogQuery {\n #[serde(default)]\n pub refresh: bool,\n}\n\n/// GET /api/skills/catalog[?refresh=true] — the aggregated external skill store across every\n/// configured source. A broken source shows up in `errors` instead of failing the whole store.\npub async fn skills_catalog_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<CatalogQuery>,\n) -> Response {\n let (skills, errors) =\n crate::engine::skill_install::fetch_catalog(&st.config.claude_config_base, q.refresh).await;\n // Per-request (never cached): compare each entry's content fingerprint against the\n // installed copy's provenance metadata so the app can show Update only when one exists.\n // Blocking file reads → spawn_blocking, keeping the async executor free.\n let skills_dir = st.config.skills_dir.clone();\n let skills = tokio::task::spawn_blocking(move || {\n let mut skills = skills;\n crate::engine::skill_install::annotate_update_available(&mut skills, &skills_dir);\n skills\n })\n .await\n // JoinError = the annotation task panicked (it can't, absent fs pathology). An empty\n // list beats a 500 for a read-only catalog.\n .unwrap_or_default();\n Json(json!({ \"skills\": skills, \"errors\": errors })).into_response()\n}\n\n/// GET /api/skills/sources — the configured store sources.\npub async fn skills_sources_route(State(st): State<AppState>) -> Response {\n Json(json!({ \"sources\": crate::engine::skill_install::read_sources(&st.config.claude_config_base) })).into_response()\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceBody {\n pub source: String,\n}\n\n/// POST /api/skills/sources — add a store source (owner/repo[/path] or github.com URL).\npub async fn skills_sources_add_route(\n State(st): State<AppState>,\n body: axum::body::Bytes,\n) -> Response {\n let b: SkillSourceBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Caller-fault (bad syntax) → 400 up front; anything add_source itself fails on afterwards\n // is a server-side write problem → 500 (consistent with the DELETE route).\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n match crate::engine::skill_install::add_source(&st.config.claude_config_base, &b.source) {\n Ok(sources) => Json(json!({ \"sources\": sources })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceQuery {\n pub source: String,\n}\n\n/// DELETE /api/skills/sources?source=… — remove a store source (query param: sources contain\n/// slashes, which a path segment would mangle).\npub async fn skills_sources_delete_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<SkillSourceQuery>,\n) -> Response {\n match crate::engine::skill_install::remove_source(&st.config.claude_config_base, &q.source) {\n Ok((sources, true)) => Json(json!({ \"sources\": sources })).into_response(),\n Ok((_, false)) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"unknown source: {}\", q.source)})),\n )\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct InstallSkillBody {\n pub source: String,\n /// true = update: replace an existing install of the same name (atomic swap).\n #[serde(default)]\n pub update: bool,\n}\n\n/// POST /api/skills/install — download a skill (SKILL.md + companion files) from a GitHub\n/// source (`owner/repo[/path]` or a github.com URL) into the skills dir.\npub async fn skills_install_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: InstallSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Parse errors are the caller's fault (400) BEFORE any network is touched.\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skill_install::install_from_source(&skills, &b.source, b.update).await {\n Ok(_name) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n // Everything else mixes remote and local causes; BAD_GATEWAY for remote-ish messages\n // would be guesswork — a 400 with the human-readable reason serves the app either way.\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"skill '{name}' not found\")})),\n )\n .into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill path: {e}\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody {\n pub id: String,\n}\n\n/// CLI-reported failures (nonzero exit: unknown plugin/marketplace…) are the caller's\n/// error → 400; spawn/timeout/join failures are ours → 500. Prefixes match the exact\n/// error formats of `plugin_cli::run_plugin_command` plus this file's spawn_blocking wrapper.\nfn plugin_error_status(e: &str) -> StatusCode {\n if e.starts_with(\"failed to spawn\")\n || e.starts_with(\"process error\")\n || e.starts_with(\"task error\")\n || e.starts_with(\"plugin command timed out\")\n {\n StatusCode::INTERNAL_SERVER_ERROR\n } else {\n StatusCode::BAD_REQUEST\n }\n}\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result =\n tokio::task::spawn_blocking(move || crate::engine::plugin_cli::install_plugin(&base, &id))\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (plugin_error_status(&e), Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n })\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (plugin_error_status(&e), Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{auth, oneshot_req, test_state};\n use axum::body::Body;\n use axum::http::Request;\n use parking_lot::Mutex;\n use std::sync::Arc;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://x\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n enabled: true,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(\n !json.contains(\"SECRET\"),\n \"key must never appear in the view: {json}\"\n );\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider {\n api_key: String::new(),\n ..p\n };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n enabled: true,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n enabled: true,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(\n !models.is_empty(),\n \"native Claude candidates should be present\"\n );\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"]\n .as_str()\n .unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}}))\n })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| {\n Box::pin(async { Err(crate::engine::usage::UsageError::Status(429)) })\n }));\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req(\n (*st2).clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles)\n .await\n .into_iter()\n .map(|r| r.unwrap())\n .collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(\n *s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\",\n s\n );\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(\n err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\"\n );\n // Delete\n let (s3, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert!(\n !arr2\n .as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(\n st.clone(),\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\"\n );\n // Delete\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n async fn post_json(\n st: &crate::api::state::AppState,\n path: &str,\n body: &str,\n ) -> (StatusCode, serde_json::Value) {\n oneshot_req(\n st.clone(),\n Request::post(path)\n .header(\"authorization\", auth(st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(body.to_string()))\n .unwrap(),\n )\n .await\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_incomplete_defs() {\n let st = test_state().await;\n for (body, want) in [\n (r#\"{\"name\":\"x\"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"command\":\" \"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"type\":\"stdio\"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"ftp://e.com\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"sse\",\"url\":\"https://\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"https:///mcp\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"http://?x=1\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"ws\",\"url\":\"https://e.com\"}\"#, \"type\"),\n (\n r#\"{\"name\":\"x\",\"command\":\"c\",\"url\":\"https://e.com\"}\"#,\n \"both\",\n ),\n (\n r#\"{\"name\":\"x\",\"type\":\"http\",\"command\":\"c\",\"url\":\"https://e.com\"}\"#,\n \"command\",\n ),\n ] {\n let (s, b) = post_json(&st, \"/api/mcp-servers\", body).await;\n assert_eq!(s, StatusCode::BAD_REQUEST, \"body: {body}\");\n assert!(b[\"error\"].as_str().unwrap().contains(want), \"{body} -> {b}\");\n }\n }\n\n #[tokio::test]\n async fn mcp_add_http_round_trip() {\n let st = test_state().await;\n let (s, _) = post_json(\n &st,\n \"/api/mcp-servers\",\n r#\"{\"name\":\"e2e-http\",\"type\":\"http\",\"url\":\"https://example.com/mcp\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let cj = st\n .config\n .claude_config_base\n .parent()\n .unwrap()\n .join(\".claude.json\");\n let saved: serde_json::Value =\n serde_json::from_str(&std::fs::read_to_string(&cj).unwrap()).unwrap();\n assert_eq!(saved[\"mcpServers\"][\"e2e-http\"][\"type\"], \"http\");\n assert_eq!(\n saved[\"mcpServers\"][\"e2e-http\"][\"url\"],\n \"https://example.com/mcp\"\n );\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/e2e-http\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n // url without \"type\" is accepted and persists with the default type \"http\".\n let (s3, _) = post_json(\n &st,\n \"/api/mcp-servers\",\n r#\"{\"name\":\"e2e-default\",\"url\":\"https://example.com/mcp\"}\"#,\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n let saved: serde_json::Value =\n serde_json::from_str(&std::fs::read_to_string(&cj).unwrap()).unwrap();\n assert_eq!(saved[\"mcpServers\"][\"e2e-default\"][\"type\"], \"http\");\n }\n\n #[tokio::test]\n async fn skill_sources_crud_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Default seed present.\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/skills/sources\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"], serde_json::json!([\"anthropics/skills\"]));\n // Add.\n let (s, b) = post_json(\n &st,\n \"/api/skills/sources\",\n r#\"{\"source\":\"octocat/Hello-World\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"].as_array().unwrap().len(), 2);\n // Duplicate (trailing-slash variant) is deduped, not appended.\n let (s, b) = post_json(\n &st,\n \"/api/skills/sources\",\n r#\"{\"source\":\"octocat/Hello-World/\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"].as_array().unwrap().len(), 2);\n // Bad syntax → 400.\n let (s, _) = post_json(&st, \"/api/skills/sources\", r#\"{\"source\":\"not a source\"}\"#).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n // Remove.\n let (s, b) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/sources?source=octocat%2FHello-World\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"], serde_json::json!([\"anthropics/skills\"]));\n // Remove unknown → 404.\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/sources?source=never%2Fadded\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n }\n\n #[tokio::test]\n async fn skills_install_rejects_bad_source_before_network() {\n let st = test_state().await;\n for body in [\n r#\"{\"source\":\"not a source\"}\"#,\n r#\"{\"source\":\"\"}\"#,\n r#\"{\"source\":\"https://gitlab.com/x/y\"}\"#,\n ] {\n let (s, b) = post_json(&st, \"/api/skills/install\", body).await;\n assert_eq!(s, StatusCode::BAD_REQUEST, \"body: {body}\");\n assert!(b[\"error\"].as_str().is_some());\n }\n }\n\n struct NativeOvGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for NativeOvGuard {\n fn drop(&mut self) {\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_native_overrides_file() -> NativeOvGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"native-overrides.json\"));\n NativeOvGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n struct RoutingGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for RoutingGuard {\n fn drop(&mut self) {\n *crate::engine::routing_config::ROUTING_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_routing_file() -> RoutingGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::routing_config::ROUTING_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"routing.json\"));\n RoutingGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn routing_get_post_roundtrip_and_clamp() {\n let _rc = isolated_routing_file();\n let st = test_state().await;\n let get = |st: AppState| async move {\n oneshot_req(\n st.clone(),\n Request::get(\"/api/routing\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n };\n let post = |st: AppState, body: &'static str| async move {\n oneshot_req(\n st.clone(),\n Request::post(\"/api/routing\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(body))\n .unwrap(),\n )\n .await\n };\n // Default when no file exists.\n let (s, b) = get(st.clone()).await;\n assert_eq!(s, StatusCode::OK);\n assert!((b[\"tradeoff\"].as_f64().unwrap() - 0.5).abs() < 1e-6);\n // In-range round-trips and GET reflects it.\n let (s, b) = post(st.clone(), r#\"{\"tradeoff\":0.2}\"#).await;\n assert_eq!(s, StatusCode::OK);\n assert!((b[\"tradeoff\"].as_f64().unwrap() - 0.2).abs() < 1e-6);\n let (_s, b) = get(st.clone()).await;\n assert!((b[\"tradeoff\"].as_f64().unwrap() - 0.2).abs() < 1e-6);\n // Out-of-range is clamped by `save`, and the response echoes the effective value.\n let (_s, b) = post(st.clone(), r#\"{\"tradeoff\":1.7}\"#).await;\n assert!((b[\"tradeoff\"].as_f64().unwrap() - 1.0).abs() < 1e-6);\n // A non-numeric tradeoff (JSON can't carry NaN) fails to parse → 400.\n let (s, _b) = post(st.clone(), r#\"{\"tradeoff\": null}\"#).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n }\n\n #[tokio::test]\n async fn native_models_get_groups_then_post_marks_customized() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n // GET: families present, opus editable and not customized\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let fams = b[\"families\"].as_array().unwrap();\n // Seed families: fable/opus/opus/sonnet/haiku → 4 groups (opus collapses in routing, but GET\n // groups by discovered family). Ordering contract: cheap → capable (family-name tiebreak).\n assert_eq!(fams.len(), 4);\n let order: Vec<&str> = fams.iter().map(|f| f[\"family\"].as_str().unwrap()).collect();\n assert_eq!(order, vec![\"haiku\", \"sonnet\", \"opus\", \"fable\"]);\n let opus = fams.iter().find(|f| f[\"family\"] == \"opus\").unwrap();\n assert_eq!(opus[\"editable\"], true);\n assert_eq!(opus[\"customized\"], false);\n // A non-overridden family returns an empty description verbatim (the generated per-model\n // fallback happens only at routing time, not in this view).\n assert_eq!(\n fams.iter().find(|f| f[\"family\"] == \"sonnet\").unwrap()[\"description\"],\n \"\"\n );\n\n // POST with mixed-case family normalizes and applies\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/Opus\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.9,\"priority\":0.85,\"cost\":0.2,\"description\":\"hard only\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // GET again: opus is now customized with the new priority\n let (_, b3) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n let opus3 = b3[\"families\"]\n .as_array()\n .unwrap()\n .iter()\n .find(|f| f[\"family\"] == \"opus\")\n .unwrap()\n .clone();\n assert_eq!(opus3[\"customized\"], true);\n // f32→JSON widens to f64, so compare with a tolerance rather than `== 0.85` (which would fail).\n assert!((opus3[\"priority\"].as_f64().unwrap() - 0.85).abs() < 1e-6);\n assert_eq!(opus3[\"description\"], \"hard only\");\n }\n\n #[tokio::test]\n async fn native_models_post_rejects_other_and_bad_family_and_clamps() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // `other` is read-only\n let (s_other, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/other\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_other, StatusCode::BAD_REQUEST);\n\n // unknown family\n let (s_bad, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/nope\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_bad, StatusCode::BAD_REQUEST);\n\n // out-of-range clamps (stored value is 1.0, not 5.0)\n let (s_ok, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/sonnet\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":5.0,\"priority\":-1.0,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_ok, StatusCode::OK);\n let m = crate::engine::native_overrides::load_map();\n assert_eq!(m[\"sonnet\"].capability, 1.0);\n assert_eq!(m[\"sonnet\"].priority, 0.0);\n }\n\n #[tokio::test]\n async fn native_models_delete_resets_and_validates_family() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // seed an override, then reset it\n crate::engine::native_overrides::upsert(\n \"opus\",\n crate::engine::native_overrides::NativeOverride {\n capability: 0.9,\n priority: 0.8,\n cost: 0.2,\n description: String::new(),\n enabled: true,\n },\n )\n .unwrap();\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(crate::engine::native_overrides::load_map()\n .get(\"opus\")\n .is_none());\n\n // idempotent: deleting again is still OK\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // invalid family (`other` catch-all, and an unknown name) → 400\n for bad in [\"other\", \"nope\"] {\n let (sb, _) = oneshot_req(\n st.clone(),\n Request::delete(format!(\"/api/native-models/{bad}\"))\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(sb, StatusCode::BAD_REQUEST, \"DELETE {bad} must be 400\");\n }\n }\n}\n"}, "9a4738bcb8713113e39a34b1f19fdf871d238bd9": {"t": 1785760891.3868108, "n": 77637, "h": "2cf95f442eb55095", "c": "use crate::api::state::AppState;\nuse crate::util::now_ms;\nuse axum::{\n extract::State,\n http::{HeaderMap, StatusCode},\n response::{IntoResponse, Response},\n Json,\n};\nuse serde_json::json;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c\n .last_error\n .clone()\n .unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n {\n let mut c = st.usage_cache.lock();\n c.last_attempt_at = now_ms();\n }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n {\n let mut c = st.usage_cache.lock();\n c.at = now_ms();\n c.data = Some(data.clone());\n c.last_error = None;\n }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (\n StatusCode::SERVICE_UNAVAILABLE,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(\n &st.config.claude_config_base,\n ))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(\n State(st): State<AppState>,\n) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" | \"mcp\" => {\n let components =\n crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components\n .iter()\n .any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)})),\n )\n .into_response();\n }\n }\n other => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown kind: {other}\")})),\n )\n .into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // MCP: move the definition between mcpServers and the disabled parking key in\n // .claude.json. list_components enumerates both sides, so the id is known-valid\n // here; a concurrent external edit could still make it vanish → surface as an error.\n \"mcp\" => crate::engine::user_config::set_mcp_server_enabled(base, &req.id, req.enabled)\n .and_then(|found| {\n if found {\n Ok(())\n } else {\n Err(std::io::Error::new(\n std::io::ErrorKind::NotFound,\n format!(\"unknown mcp id: {}\", req.id),\n ))\n }\n }),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(\n base,\n &st.config.skills_dir,\n ))\n .into_response(),\n // NotFound = the id vanished between the known-check and the mutate (external edit\n // race) — same logical class as the pre-check's \"unknown id\", so same 400, not a 500.\n Err(e) if e.kind() == std::io::ErrorKind::NotFound => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let b: UpdateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n match st\n .store\n .update_group(&id, b.name.as_deref(), b.icon.as_deref())\n .await\n {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"group not found\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn groups_delete(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(\n &st.config.templates_path,\n ))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"array of templates required\"})),\n )\n .into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"template '{name}' not found\")})),\n )\n .into_response();\n };\n let prompt =\n crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st\n .engine\n .submit_session(\n tpl.repos,\n tpl.skills,\n prompt,\n std::collections::HashMap::new(),\n meta,\n )\n .await\n {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody {\n pub token: Option<String>,\n}\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"token required\"})),\n )\n .into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> = crate::engine::providers::load_list()\n .iter()\n .map(provider_view)\n .collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid provider: {e}\")})),\n )\n .into_response()\n }\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"capability cannot be NaN\"})),\n )\n .into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"priority cannot be NaN\"})),\n )\n .into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"cost cannot be NaN\"})),\n )\n .into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name, base_url, model are required\"})),\n )\n .into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"router validation failed: {e}\")})),\n )\n .into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"no such provider\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── native Claude per-family routing overrides ──\n\n#[derive(serde::Serialize)]\nstruct NativeModelRef {\n id: String,\n display_name: String,\n}\n\n#[derive(serde::Serialize)]\nstruct NativeFamilyView {\n family: String,\n label: String,\n models: Vec<NativeModelRef>,\n capability: f32,\n priority: f32,\n cost: f32,\n description: String,\n customized: bool,\n editable: bool,\n}\n\nfn default_true() -> bool {\n true\n}\n\n#[derive(serde::Deserialize)]\nstruct NativeOverrideReq {\n capability: f32,\n priority: f32,\n cost: f32,\n #[serde(default)]\n description: String,\n /// Whether this native family participates in routing. Defaults true (old clients omit it).\n #[serde(default = \"default_true\")]\n enabled: bool,\n}\n\nfn is_editable_family(family: &str) -> bool {\n matches!(family, \"opus\" | \"sonnet\" | \"haiku\" | \"fable\")\n}\n\nfn family_label(family: &str) -> &'static str {\n match family {\n \"opus\" => \"Opus\",\n \"sonnet\" => \"Sonnet\",\n \"haiku\" => \"Haiku\",\n \"fable\" => \"Fable\",\n _ => \"Other\",\n }\n}\n\n/// GET /api/native-models — native Claude families with effective routing metrics + override state.\npub async fn native_models_get() -> Response {\n use crate::engine::providers::{\n family_default_metrics, family_of, native_claude_models, DEFAULT_NATIVE_PRIORITY,\n };\n let overrides = crate::engine::native_overrides::load_map();\n\n // Group discovered models by family, first-seen (newest-first) order.\n let mut order: Vec<&'static str> = Vec::new();\n let mut groups: std::collections::HashMap<&'static str, Vec<NativeModelRef>> =\n std::collections::HashMap::new();\n for m in native_claude_models() {\n let fam = family_of(&m.id);\n groups.entry(fam).or_default().push(NativeModelRef {\n id: m.id.clone(),\n display_name: m.display_name.clone(),\n });\n if !order.contains(&fam) {\n order.push(fam);\n }\n }\n\n let mut views: Vec<NativeFamilyView> = order\n .into_iter()\n .map(|fam| {\n let (dc, dk) = family_default_metrics(fam);\n let (capability, priority, cost, description, customized) = match overrides.get(fam) {\n Some(o) => (\n o.capability,\n o.priority,\n o.cost,\n o.description.clone(),\n true,\n ),\n None => (dc, DEFAULT_NATIVE_PRIORITY, dk, String::new(), false),\n };\n NativeFamilyView {\n family: fam.to_string(),\n label: family_label(fam).to_string(),\n models: groups.remove(fam).unwrap_or_default(),\n capability,\n priority,\n cost,\n description,\n customized,\n editable: is_editable_family(fam),\n }\n })\n .collect();\n // cheap → capable, family-name tiebreak (matches the /api/models ordering contract).\n views.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.family.cmp(&b.family))\n });\n\n Json(json!({ \"families\": views })).into_response()\n}\n\n/// POST /api/native-models/{family} — set a family's routing override.\npub async fn native_models_post(\n axum::extract::Path(family): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n let req: NativeOverrideReq = match serde_json::from_slice(&body) {\n Ok(r) => r,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid override: {e}\")})),\n )\n .into_response()\n }\n };\n for (name, v) in [\n (\"capability\", req.capability),\n (\"priority\", req.priority),\n (\"cost\", req.cost),\n ] {\n if v.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"{name} cannot be NaN\")})),\n )\n .into_response();\n }\n }\n let ov = crate::engine::native_overrides::NativeOverride {\n capability: req.capability.clamp(0.0, 1.0),\n priority: req.priority.clamp(0.0, 1.0),\n cost: req.cost.clamp(0.0, 1.0),\n description: req.description,\n enabled: req.enabled,\n };\n match crate::engine::native_overrides::upsert(&family, ov) {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/native-models/{family} — reset a family to defaults (idempotent).\npub async fn native_models_delete(\n axum::extract::Path(family): axum::extract::Path<String>,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n match crate::engine::native_overrides::remove(&family) {\n Ok(_) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)})),\n )\n .into_response();\n }\n if let Err(e) = crate::api::validation::validate_mcp_def(&def) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"MCP server '{name}' not found\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody {\n pub name: String,\n pub description: String,\n /// The skill's markdown body — the actual instructions the agent loads. Optional for\n /// back-compat; without it the created skill is an empty shell (frontmatter only).\n #[serde(default)]\n pub instructions: String,\n}\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description, &b.instructions) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CatalogQuery {\n #[serde(default)]\n pub refresh: bool,\n}\n\n/// GET /api/skills/catalog[?refresh=true] — the aggregated external skill store across every\n/// configured source. A broken source shows up in `errors` instead of failing the whole store.\npub async fn skills_catalog_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<CatalogQuery>,\n) -> Response {\n let (skills, errors) =\n crate::engine::skill_install::fetch_catalog(&st.config.claude_config_base, q.refresh).await;\n // Per-request (never cached): compare each entry's content fingerprint against the\n // installed copy's provenance metadata so the app can show Update only when one exists.\n // Blocking file reads → spawn_blocking, keeping the async executor free.\n let skills_dir = st.config.skills_dir.clone();\n let skills = tokio::task::spawn_blocking(move || {\n let mut skills = skills;\n crate::engine::skill_install::annotate_update_available(&mut skills, &skills_dir);\n skills\n })\n .await\n // JoinError = the annotation task panicked (it can't, absent fs pathology). An empty\n // list beats a 500 for a read-only catalog.\n .unwrap_or_default();\n Json(json!({ \"skills\": skills, \"errors\": errors })).into_response()\n}\n\n/// GET /api/skills/sources — the configured store sources.\npub async fn skills_sources_route(State(st): State<AppState>) -> Response {\n Json(json!({ \"sources\": crate::engine::skill_install::read_sources(&st.config.claude_config_base) })).into_response()\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceBody {\n pub source: String,\n}\n\n/// POST /api/skills/sources — add a store source (owner/repo[/path] or github.com URL).\npub async fn skills_sources_add_route(\n State(st): State<AppState>,\n body: axum::body::Bytes,\n) -> Response {\n let b: SkillSourceBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Caller-fault (bad syntax) → 400 up front; anything add_source itself fails on afterwards\n // is a server-side write problem → 500 (consistent with the DELETE route).\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n match crate::engine::skill_install::add_source(&st.config.claude_config_base, &b.source) {\n Ok(sources) => Json(json!({ \"sources\": sources })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceQuery {\n pub source: String,\n}\n\n/// DELETE /api/skills/sources?source=… — remove a store source (query param: sources contain\n/// slashes, which a path segment would mangle).\npub async fn skills_sources_delete_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<SkillSourceQuery>,\n) -> Response {\n match crate::engine::skill_install::remove_source(&st.config.claude_config_base, &q.source) {\n Ok((sources, true)) => Json(json!({ \"sources\": sources })).into_response(),\n Ok((_, false)) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"unknown source: {}\", q.source)})),\n )\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct InstallSkillBody {\n pub source: String,\n /// true = update: replace an existing install of the same name (atomic swap).\n #[serde(default)]\n pub update: bool,\n}\n\n/// POST /api/skills/install — download a skill (SKILL.md + companion files) from a GitHub\n/// source (`owner/repo[/path]` or a github.com URL) into the skills dir.\npub async fn skills_install_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: InstallSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Parse errors are the caller's fault (400) BEFORE any network is touched.\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skill_install::install_from_source(&skills, &b.source, b.update).await {\n Ok(_name) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n // Everything else mixes remote and local causes; BAD_GATEWAY for remote-ish messages\n // would be guesswork — a 400 with the human-readable reason serves the app either way.\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"skill '{name}' not found\")})),\n )\n .into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill path: {e}\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody {\n pub id: String,\n}\n\n/// CLI-reported failures (nonzero exit: unknown plugin/marketplace…) are the caller's\n/// error → 400; spawn/timeout/join failures are ours → 500. Prefixes match the exact\n/// error formats of `plugin_cli::run_plugin_command` plus this file's spawn_blocking wrapper.\nfn plugin_error_status(e: &str) -> StatusCode {\n if e.starts_with(\"failed to spawn\")\n || e.starts_with(\"process error\")\n || e.starts_with(\"task error\")\n || e.starts_with(\"plugin command timed out\")\n {\n StatusCode::INTERNAL_SERVER_ERROR\n } else {\n StatusCode::BAD_REQUEST\n }\n}\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result =\n tokio::task::spawn_blocking(move || crate::engine::plugin_cli::install_plugin(&base, &id))\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (plugin_error_status(&e), Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n })\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (plugin_error_status(&e), Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{auth, oneshot_req, test_state};\n use axum::body::Body;\n use axum::http::Request;\n use parking_lot::Mutex;\n use std::sync::Arc;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://x\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n enabled: true,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(\n !json.contains(\"SECRET\"),\n \"key must never appear in the view: {json}\"\n );\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider {\n api_key: String::new(),\n ..p\n };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n enabled: true,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n enabled: true,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(\n !models.is_empty(),\n \"native Claude candidates should be present\"\n );\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"]\n .as_str()\n .unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}}))\n })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| {\n Box::pin(async { Err(crate::engine::usage::UsageError::Status(429)) })\n }));\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req(\n (*st2).clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles)\n .await\n .into_iter()\n .map(|r| r.unwrap())\n .collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(\n *s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\",\n s\n );\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(\n err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\"\n );\n // Delete\n let (s3, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert!(\n !arr2\n .as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(\n st.clone(),\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\"\n );\n // Delete\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n async fn post_json(\n st: &crate::api::state::AppState,\n path: &str,\n body: &str,\n ) -> (StatusCode, serde_json::Value) {\n oneshot_req(\n st.clone(),\n Request::post(path)\n .header(\"authorization\", auth(st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(body.to_string()))\n .unwrap(),\n )\n .await\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_incomplete_defs() {\n let st = test_state().await;\n for (body, want) in [\n (r#\"{\"name\":\"x\"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"command\":\" \"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"type\":\"stdio\"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"ftp://e.com\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"sse\",\"url\":\"https://\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"https:///mcp\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"http://?x=1\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"ws\",\"url\":\"https://e.com\"}\"#, \"type\"),\n (\n r#\"{\"name\":\"x\",\"command\":\"c\",\"url\":\"https://e.com\"}\"#,\n \"both\",\n ),\n (\n r#\"{\"name\":\"x\",\"type\":\"http\",\"command\":\"c\",\"url\":\"https://e.com\"}\"#,\n \"command\",\n ),\n ] {\n let (s, b) = post_json(&st, \"/api/mcp-servers\", body).await;\n assert_eq!(s, StatusCode::BAD_REQUEST, \"body: {body}\");\n assert!(b[\"error\"].as_str().unwrap().contains(want), \"{body} -> {b}\");\n }\n }\n\n #[tokio::test]\n async fn mcp_add_http_round_trip() {\n let st = test_state().await;\n let (s, _) = post_json(\n &st,\n \"/api/mcp-servers\",\n r#\"{\"name\":\"e2e-http\",\"type\":\"http\",\"url\":\"https://example.com/mcp\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let cj = st\n .config\n .claude_config_base\n .parent()\n .unwrap()\n .join(\".claude.json\");\n let saved: serde_json::Value =\n serde_json::from_str(&std::fs::read_to_string(&cj).unwrap()).unwrap();\n assert_eq!(saved[\"mcpServers\"][\"e2e-http\"][\"type\"], \"http\");\n assert_eq!(\n saved[\"mcpServers\"][\"e2e-http\"][\"url\"],\n \"https://example.com/mcp\"\n );\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/e2e-http\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n // url without \"type\" is accepted and persists with the default type \"http\".\n let (s3, _) = post_json(\n &st,\n \"/api/mcp-servers\",\n r#\"{\"name\":\"e2e-default\",\"url\":\"https://example.com/mcp\"}\"#,\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n let saved: serde_json::Value =\n serde_json::from_str(&std::fs::read_to_string(&cj).unwrap()).unwrap();\n assert_eq!(saved[\"mcpServers\"][\"e2e-default\"][\"type\"], \"http\");\n }\n\n #[tokio::test]\n async fn skill_sources_crud_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Default seed present.\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/skills/sources\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"], serde_json::json!([\"anthropics/skills\"]));\n // Add.\n let (s, b) = post_json(\n &st,\n \"/api/skills/sources\",\n r#\"{\"source\":\"octocat/Hello-World\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"].as_array().unwrap().len(), 2);\n // Duplicate (trailing-slash variant) is deduped, not appended.\n let (s, b) = post_json(\n &st,\n \"/api/skills/sources\",\n r#\"{\"source\":\"octocat/Hello-World/\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"].as_array().unwrap().len(), 2);\n // Bad syntax → 400.\n let (s, _) = post_json(&st, \"/api/skills/sources\", r#\"{\"source\":\"not a source\"}\"#).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n // Remove.\n let (s, b) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/sources?source=octocat%2FHello-World\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"], serde_json::json!([\"anthropics/skills\"]));\n // Remove unknown → 404.\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/sources?source=never%2Fadded\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n }\n\n #[tokio::test]\n async fn skills_install_rejects_bad_source_before_network() {\n let st = test_state().await;\n for body in [\n r#\"{\"source\":\"not a source\"}\"#,\n r#\"{\"source\":\"\"}\"#,\n r#\"{\"source\":\"https://gitlab.com/x/y\"}\"#,\n ] {\n let (s, b) = post_json(&st, \"/api/skills/install\", body).await;\n assert_eq!(s, StatusCode::BAD_REQUEST, \"body: {body}\");\n assert!(b[\"error\"].as_str().is_some());\n }\n }\n\n struct NativeOvGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for NativeOvGuard {\n fn drop(&mut self) {\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_native_overrides_file() -> NativeOvGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"native-overrides.json\"));\n NativeOvGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn native_models_get_groups_then_post_marks_customized() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n // GET: families present, opus editable and not customized\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let fams = b[\"families\"].as_array().unwrap();\n // Seed families: fable/opus/opus/sonnet/haiku → 4 groups (opus collapses in routing, but GET\n // groups by discovered family). Ordering contract: cheap → capable (family-name tiebreak).\n assert_eq!(fams.len(), 4);\n let order: Vec<&str> = fams.iter().map(|f| f[\"family\"].as_str().unwrap()).collect();\n assert_eq!(order, vec![\"haiku\", \"sonnet\", \"opus\", \"fable\"]);\n let opus = fams.iter().find(|f| f[\"family\"] == \"opus\").unwrap();\n assert_eq!(opus[\"editable\"], true);\n assert_eq!(opus[\"customized\"], false);\n // A non-overridden family returns an empty description verbatim (the generated per-model\n // fallback happens only at routing time, not in this view).\n assert_eq!(\n fams.iter().find(|f| f[\"family\"] == \"sonnet\").unwrap()[\"description\"],\n \"\"\n );\n\n // POST with mixed-case family normalizes and applies\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/Opus\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.9,\"priority\":0.85,\"cost\":0.2,\"description\":\"hard only\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // GET again: opus is now customized with the new priority\n let (_, b3) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n let opus3 = b3[\"families\"]\n .as_array()\n .unwrap()\n .iter()\n .find(|f| f[\"family\"] == \"opus\")\n .unwrap()\n .clone();\n assert_eq!(opus3[\"customized\"], true);\n // f32→JSON widens to f64, so compare with a tolerance rather than `== 0.85` (which would fail).\n assert!((opus3[\"priority\"].as_f64().unwrap() - 0.85).abs() < 1e-6);\n assert_eq!(opus3[\"description\"], \"hard only\");\n }\n\n #[tokio::test]\n async fn native_models_post_rejects_other_and_bad_family_and_clamps() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // `other` is read-only\n let (s_other, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/other\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_other, StatusCode::BAD_REQUEST);\n\n // unknown family\n let (s_bad, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/nope\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_bad, StatusCode::BAD_REQUEST);\n\n // out-of-range clamps (stored value is 1.0, not 5.0)\n let (s_ok, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/sonnet\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":5.0,\"priority\":-1.0,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_ok, StatusCode::OK);\n let m = crate::engine::native_overrides::load_map();\n assert_eq!(m[\"sonnet\"].capability, 1.0);\n assert_eq!(m[\"sonnet\"].priority, 0.0);\n }\n\n #[tokio::test]\n async fn native_models_delete_resets_and_validates_family() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // seed an override, then reset it\n crate::engine::native_overrides::upsert(\n \"opus\",\n crate::engine::native_overrides::NativeOverride {\n capability: 0.9,\n priority: 0.8,\n cost: 0.2,\n description: String::new(),\n enabled: true,\n },\n )\n .unwrap();\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(crate::engine::native_overrides::load_map()\n .get(\"opus\")\n .is_none());\n\n // idempotent: deleting again is still OK\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // invalid family (`other` catch-all, and an unknown name) → 400\n for bad in [\"other\", \"nope\"] {\n let (sb, _) = oneshot_req(\n st.clone(),\n Request::delete(format!(\"/api/native-models/{bad}\"))\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(sb, StatusCode::BAD_REQUEST, \"DELETE {bad} must be 400\");\n }\n }\n}\n"}, "c0fae44f48afad5112c951b2d143c33ce19a5243": {"t": 1785760893.3766987, "n": 77292, "h": "0d9aff2d331df4e2", "c": "use crate::api::state::AppState;\nuse crate::util::now_ms;\nuse axum::{\n extract::State,\n http::{HeaderMap, StatusCode},\n response::{IntoResponse, Response},\n Json,\n};\nuse serde_json::json;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c\n .last_error\n .clone()\n .unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n {\n let mut c = st.usage_cache.lock();\n c.last_attempt_at = now_ms();\n }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n {\n let mut c = st.usage_cache.lock();\n c.at = now_ms();\n c.data = Some(data.clone());\n c.last_error = None;\n }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (\n StatusCode::SERVICE_UNAVAILABLE,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(\n &st.config.claude_config_base,\n ))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(\n State(st): State<AppState>,\n) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" | \"mcp\" => {\n let components =\n crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components\n .iter()\n .any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)})),\n )\n .into_response();\n }\n }\n other => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown kind: {other}\")})),\n )\n .into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // MCP: move the definition between mcpServers and the disabled parking key in\n // .claude.json. list_components enumerates both sides, so the id is known-valid\n // here; a concurrent external edit could still make it vanish → surface as an error.\n \"mcp\" => crate::engine::user_config::set_mcp_server_enabled(base, &req.id, req.enabled)\n .and_then(|found| {\n if found {\n Ok(())\n } else {\n Err(std::io::Error::new(\n std::io::ErrorKind::NotFound,\n format!(\"unknown mcp id: {}\", req.id),\n ))\n }\n }),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(\n base,\n &st.config.skills_dir,\n ))\n .into_response(),\n // NotFound = the id vanished between the known-check and the mutate (external edit\n // race) — same logical class as the pre-check's \"unknown id\", so same 400, not a 500.\n Err(e) if e.kind() == std::io::ErrorKind::NotFound => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let b: UpdateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n match st\n .store\n .update_group(&id, b.name.as_deref(), b.icon.as_deref())\n .await\n {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"group not found\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn groups_delete(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(\n &st.config.templates_path,\n ))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"array of templates required\"})),\n )\n .into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"template '{name}' not found\")})),\n )\n .into_response();\n };\n let prompt =\n crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st\n .engine\n .submit_session(\n tpl.repos,\n tpl.skills,\n prompt,\n std::collections::HashMap::new(),\n meta,\n )\n .await\n {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody {\n pub token: Option<String>,\n}\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"token required\"})),\n )\n .into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> = crate::engine::providers::load_list()\n .iter()\n .map(provider_view)\n .collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid provider: {e}\")})),\n )\n .into_response()\n }\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"capability cannot be NaN\"})),\n )\n .into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"priority cannot be NaN\"})),\n )\n .into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"cost cannot be NaN\"})),\n )\n .into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name, base_url, model are required\"})),\n )\n .into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"router validation failed: {e}\")})),\n )\n .into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"no such provider\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── native Claude per-family routing overrides ──\n\n#[derive(serde::Serialize)]\nstruct NativeModelRef {\n id: String,\n display_name: String,\n}\n\n#[derive(serde::Serialize)]\nstruct NativeFamilyView {\n family: String,\n label: String,\n models: Vec<NativeModelRef>,\n capability: f32,\n priority: f32,\n cost: f32,\n description: String,\n customized: bool,\n editable: bool,\n}\n\n#[derive(serde::Deserialize)]\nstruct NativeOverrideReq {\n capability: f32,\n priority: f32,\n cost: f32,\n #[serde(default)]\n description: String,\n}\n\nfn is_editable_family(family: &str) -> bool {\n matches!(family, \"opus\" | \"sonnet\" | \"haiku\" | \"fable\")\n}\n\nfn family_label(family: &str) -> &'static str {\n match family {\n \"opus\" => \"Opus\",\n \"sonnet\" => \"Sonnet\",\n \"haiku\" => \"Haiku\",\n \"fable\" => \"Fable\",\n _ => \"Other\",\n }\n}\n\n/// GET /api/native-models — native Claude families with effective routing metrics + override state.\npub async fn native_models_get() -> Response {\n use crate::engine::providers::{\n family_default_metrics, family_of, native_claude_models, DEFAULT_NATIVE_PRIORITY,\n };\n let overrides = crate::engine::native_overrides::load_map();\n\n // Group discovered models by family, first-seen (newest-first) order.\n let mut order: Vec<&'static str> = Vec::new();\n let mut groups: std::collections::HashMap<&'static str, Vec<NativeModelRef>> =\n std::collections::HashMap::new();\n for m in native_claude_models() {\n let fam = family_of(&m.id);\n groups.entry(fam).or_default().push(NativeModelRef {\n id: m.id.clone(),\n display_name: m.display_name.clone(),\n });\n if !order.contains(&fam) {\n order.push(fam);\n }\n }\n\n let mut views: Vec<NativeFamilyView> = order\n .into_iter()\n .map(|fam| {\n let (dc, dk) = family_default_metrics(fam);\n let (capability, priority, cost, description, customized) = match overrides.get(fam) {\n Some(o) => (\n o.capability,\n o.priority,\n o.cost,\n o.description.clone(),\n true,\n ),\n None => (dc, DEFAULT_NATIVE_PRIORITY, dk, String::new(), false),\n };\n NativeFamilyView {\n family: fam.to_string(),\n label: family_label(fam).to_string(),\n models: groups.remove(fam).unwrap_or_default(),\n capability,\n priority,\n cost,\n description,\n customized,\n editable: is_editable_family(fam),\n }\n })\n .collect();\n // cheap → capable, family-name tiebreak (matches the /api/models ordering contract).\n views.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.family.cmp(&b.family))\n });\n\n Json(json!({ \"families\": views })).into_response()\n}\n\n/// POST /api/native-models/{family} — set a family's routing override.\npub async fn native_models_post(\n axum::extract::Path(family): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n let req: NativeOverrideReq = match serde_json::from_slice(&body) {\n Ok(r) => r,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid override: {e}\")})),\n )\n .into_response()\n }\n };\n for (name, v) in [\n (\"capability\", req.capability),\n (\"priority\", req.priority),\n (\"cost\", req.cost),\n ] {\n if v.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"{name} cannot be NaN\")})),\n )\n .into_response();\n }\n }\n let ov = crate::engine::native_overrides::NativeOverride {\n capability: req.capability.clamp(0.0, 1.0),\n priority: req.priority.clamp(0.0, 1.0),\n cost: req.cost.clamp(0.0, 1.0),\n description: req.description,\n };\n match crate::engine::native_overrides::upsert(&family, ov) {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/native-models/{family} — reset a family to defaults (idempotent).\npub async fn native_models_delete(\n axum::extract::Path(family): axum::extract::Path<String>,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n match crate::engine::native_overrides::remove(&family) {\n Ok(_) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)})),\n )\n .into_response();\n }\n if let Err(e) = crate::api::validation::validate_mcp_def(&def) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"MCP server '{name}' not found\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody {\n pub name: String,\n pub description: String,\n /// The skill's markdown body — the actual instructions the agent loads. Optional for\n /// back-compat; without it the created skill is an empty shell (frontmatter only).\n #[serde(default)]\n pub instructions: String,\n}\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description, &b.instructions) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CatalogQuery {\n #[serde(default)]\n pub refresh: bool,\n}\n\n/// GET /api/skills/catalog[?refresh=true] — the aggregated external skill store across every\n/// configured source. A broken source shows up in `errors` instead of failing the whole store.\npub async fn skills_catalog_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<CatalogQuery>,\n) -> Response {\n let (skills, errors) =\n crate::engine::skill_install::fetch_catalog(&st.config.claude_config_base, q.refresh).await;\n // Per-request (never cached): compare each entry's content fingerprint against the\n // installed copy's provenance metadata so the app can show Update only when one exists.\n // Blocking file reads → spawn_blocking, keeping the async executor free.\n let skills_dir = st.config.skills_dir.clone();\n let skills = tokio::task::spawn_blocking(move || {\n let mut skills = skills;\n crate::engine::skill_install::annotate_update_available(&mut skills, &skills_dir);\n skills\n })\n .await\n // JoinError = the annotation task panicked (it can't, absent fs pathology). An empty\n // list beats a 500 for a read-only catalog.\n .unwrap_or_default();\n Json(json!({ \"skills\": skills, \"errors\": errors })).into_response()\n}\n\n/// GET /api/skills/sources — the configured store sources.\npub async fn skills_sources_route(State(st): State<AppState>) -> Response {\n Json(json!({ \"sources\": crate::engine::skill_install::read_sources(&st.config.claude_config_base) })).into_response()\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceBody {\n pub source: String,\n}\n\n/// POST /api/skills/sources — add a store source (owner/repo[/path] or github.com URL).\npub async fn skills_sources_add_route(\n State(st): State<AppState>,\n body: axum::body::Bytes,\n) -> Response {\n let b: SkillSourceBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Caller-fault (bad syntax) → 400 up front; anything add_source itself fails on afterwards\n // is a server-side write problem → 500 (consistent with the DELETE route).\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n match crate::engine::skill_install::add_source(&st.config.claude_config_base, &b.source) {\n Ok(sources) => Json(json!({ \"sources\": sources })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceQuery {\n pub source: String,\n}\n\n/// DELETE /api/skills/sources?source=… — remove a store source (query param: sources contain\n/// slashes, which a path segment would mangle).\npub async fn skills_sources_delete_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<SkillSourceQuery>,\n) -> Response {\n match crate::engine::skill_install::remove_source(&st.config.claude_config_base, &q.source) {\n Ok((sources, true)) => Json(json!({ \"sources\": sources })).into_response(),\n Ok((_, false)) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"unknown source: {}\", q.source)})),\n )\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct InstallSkillBody {\n pub source: String,\n /// true = update: replace an existing install of the same name (atomic swap).\n #[serde(default)]\n pub update: bool,\n}\n\n/// POST /api/skills/install — download a skill (SKILL.md + companion files) from a GitHub\n/// source (`owner/repo[/path]` or a github.com URL) into the skills dir.\npub async fn skills_install_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: InstallSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Parse errors are the caller's fault (400) BEFORE any network is touched.\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skill_install::install_from_source(&skills, &b.source, b.update).await {\n Ok(_name) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n // Everything else mixes remote and local causes; BAD_GATEWAY for remote-ish messages\n // would be guesswork — a 400 with the human-readable reason serves the app either way.\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"skill '{name}' not found\")})),\n )\n .into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill path: {e}\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody {\n pub id: String,\n}\n\n/// CLI-reported failures (nonzero exit: unknown plugin/marketplace…) are the caller's\n/// error → 400; spawn/timeout/join failures are ours → 500. Prefixes match the exact\n/// error formats of `plugin_cli::run_plugin_command` plus this file's spawn_blocking wrapper.\nfn plugin_error_status(e: &str) -> StatusCode {\n if e.starts_with(\"failed to spawn\")\n || e.starts_with(\"process error\")\n || e.starts_with(\"task error\")\n || e.starts_with(\"plugin command timed out\")\n {\n StatusCode::INTERNAL_SERVER_ERROR\n } else {\n StatusCode::BAD_REQUEST\n }\n}\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result =\n tokio::task::spawn_blocking(move || crate::engine::plugin_cli::install_plugin(&base, &id))\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (plugin_error_status(&e), Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n })\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (plugin_error_status(&e), Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{auth, oneshot_req, test_state};\n use axum::body::Body;\n use axum::http::Request;\n use parking_lot::Mutex;\n use std::sync::Arc;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://x\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(\n !json.contains(\"SECRET\"),\n \"key must never appear in the view: {json}\"\n );\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider {\n api_key: String::new(),\n ..p\n };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(\n !models.is_empty(),\n \"native Claude candidates should be present\"\n );\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"]\n .as_str()\n .unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}}))\n })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| {\n Box::pin(async { Err(crate::engine::usage::UsageError::Status(429)) })\n }));\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req(\n (*st2).clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles)\n .await\n .into_iter()\n .map(|r| r.unwrap())\n .collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(\n *s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\",\n s\n );\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(\n err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\"\n );\n // Delete\n let (s3, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert!(\n !arr2\n .as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(\n st.clone(),\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\"\n );\n // Delete\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n async fn post_json(\n st: &crate::api::state::AppState,\n path: &str,\n body: &str,\n ) -> (StatusCode, serde_json::Value) {\n oneshot_req(\n st.clone(),\n Request::post(path)\n .header(\"authorization\", auth(st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(body.to_string()))\n .unwrap(),\n )\n .await\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_incomplete_defs() {\n let st = test_state().await;\n for (body, want) in [\n (r#\"{\"name\":\"x\"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"command\":\" \"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"type\":\"stdio\"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"ftp://e.com\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"sse\",\"url\":\"https://\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"https:///mcp\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"http://?x=1\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"ws\",\"url\":\"https://e.com\"}\"#, \"type\"),\n (\n r#\"{\"name\":\"x\",\"command\":\"c\",\"url\":\"https://e.com\"}\"#,\n \"both\",\n ),\n (\n r#\"{\"name\":\"x\",\"type\":\"http\",\"command\":\"c\",\"url\":\"https://e.com\"}\"#,\n \"command\",\n ),\n ] {\n let (s, b) = post_json(&st, \"/api/mcp-servers\", body).await;\n assert_eq!(s, StatusCode::BAD_REQUEST, \"body: {body}\");\n assert!(b[\"error\"].as_str().unwrap().contains(want), \"{body} -> {b}\");\n }\n }\n\n #[tokio::test]\n async fn mcp_add_http_round_trip() {\n let st = test_state().await;\n let (s, _) = post_json(\n &st,\n \"/api/mcp-servers\",\n r#\"{\"name\":\"e2e-http\",\"type\":\"http\",\"url\":\"https://example.com/mcp\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let cj = st\n .config\n .claude_config_base\n .parent()\n .unwrap()\n .join(\".claude.json\");\n let saved: serde_json::Value =\n serde_json::from_str(&std::fs::read_to_string(&cj).unwrap()).unwrap();\n assert_eq!(saved[\"mcpServers\"][\"e2e-http\"][\"type\"], \"http\");\n assert_eq!(\n saved[\"mcpServers\"][\"e2e-http\"][\"url\"],\n \"https://example.com/mcp\"\n );\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/e2e-http\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n // url without \"type\" is accepted and persists with the default type \"http\".\n let (s3, _) = post_json(\n &st,\n \"/api/mcp-servers\",\n r#\"{\"name\":\"e2e-default\",\"url\":\"https://example.com/mcp\"}\"#,\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n let saved: serde_json::Value =\n serde_json::from_str(&std::fs::read_to_string(&cj).unwrap()).unwrap();\n assert_eq!(saved[\"mcpServers\"][\"e2e-default\"][\"type\"], \"http\");\n }\n\n #[tokio::test]\n async fn skill_sources_crud_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Default seed present.\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/skills/sources\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"], serde_json::json!([\"anthropics/skills\"]));\n // Add.\n let (s, b) = post_json(\n &st,\n \"/api/skills/sources\",\n r#\"{\"source\":\"octocat/Hello-World\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"].as_array().unwrap().len(), 2);\n // Duplicate (trailing-slash variant) is deduped, not appended.\n let (s, b) = post_json(\n &st,\n \"/api/skills/sources\",\n r#\"{\"source\":\"octocat/Hello-World/\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"].as_array().unwrap().len(), 2);\n // Bad syntax → 400.\n let (s, _) = post_json(&st, \"/api/skills/sources\", r#\"{\"source\":\"not a source\"}\"#).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n // Remove.\n let (s, b) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/sources?source=octocat%2FHello-World\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"], serde_json::json!([\"anthropics/skills\"]));\n // Remove unknown → 404.\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/sources?source=never%2Fadded\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n }\n\n #[tokio::test]\n async fn skills_install_rejects_bad_source_before_network() {\n let st = test_state().await;\n for body in [\n r#\"{\"source\":\"not a source\"}\"#,\n r#\"{\"source\":\"\"}\"#,\n r#\"{\"source\":\"https://gitlab.com/x/y\"}\"#,\n ] {\n let (s, b) = post_json(&st, \"/api/skills/install\", body).await;\n assert_eq!(s, StatusCode::BAD_REQUEST, \"body: {body}\");\n assert!(b[\"error\"].as_str().is_some());\n }\n }\n\n struct NativeOvGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for NativeOvGuard {\n fn drop(&mut self) {\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_native_overrides_file() -> NativeOvGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"native-overrides.json\"));\n NativeOvGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn native_models_get_groups_then_post_marks_customized() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n // GET: families present, opus editable and not customized\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let fams = b[\"families\"].as_array().unwrap();\n // Seed families: fable/opus/opus/sonnet/haiku → 4 groups (opus collapses in routing, but GET\n // groups by discovered family). Ordering contract: cheap → capable (family-name tiebreak).\n assert_eq!(fams.len(), 4);\n let order: Vec<&str> = fams.iter().map(|f| f[\"family\"].as_str().unwrap()).collect();\n assert_eq!(order, vec![\"haiku\", \"sonnet\", \"opus\", \"fable\"]);\n let opus = fams.iter().find(|f| f[\"family\"] == \"opus\").unwrap();\n assert_eq!(opus[\"editable\"], true);\n assert_eq!(opus[\"customized\"], false);\n // A non-overridden family returns an empty description verbatim (the generated per-model\n // fallback happens only at routing time, not in this view).\n assert_eq!(\n fams.iter().find(|f| f[\"family\"] == \"sonnet\").unwrap()[\"description\"],\n \"\"\n );\n\n // POST with mixed-case family normalizes and applies\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/Opus\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.9,\"priority\":0.85,\"cost\":0.2,\"description\":\"hard only\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // GET again: opus is now customized with the new priority\n let (_, b3) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n let opus3 = b3[\"families\"]\n .as_array()\n .unwrap()\n .iter()\n .find(|f| f[\"family\"] == \"opus\")\n .unwrap()\n .clone();\n assert_eq!(opus3[\"customized\"], true);\n // f32→JSON widens to f64, so compare with a tolerance rather than `== 0.85` (which would fail).\n assert!((opus3[\"priority\"].as_f64().unwrap() - 0.85).abs() < 1e-6);\n assert_eq!(opus3[\"description\"], \"hard only\");\n }\n\n #[tokio::test]\n async fn native_models_post_rejects_other_and_bad_family_and_clamps() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // `other` is read-only\n let (s_other, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/other\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_other, StatusCode::BAD_REQUEST);\n\n // unknown family\n let (s_bad, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/nope\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_bad, StatusCode::BAD_REQUEST);\n\n // out-of-range clamps (stored value is 1.0, not 5.0)\n let (s_ok, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/sonnet\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":5.0,\"priority\":-1.0,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_ok, StatusCode::OK);\n let m = crate::engine::native_overrides::load_map();\n assert_eq!(m[\"sonnet\"].capability, 1.0);\n assert_eq!(m[\"sonnet\"].priority, 0.0);\n }\n\n #[tokio::test]\n async fn native_models_delete_resets_and_validates_family() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // seed an override, then reset it\n crate::engine::native_overrides::upsert(\n \"opus\",\n crate::engine::native_overrides::NativeOverride {\n capability: 0.9,\n priority: 0.8,\n cost: 0.2,\n description: String::new(),\n },\n )\n .unwrap();\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(crate::engine::native_overrides::load_map()\n .get(\"opus\")\n .is_none());\n\n // idempotent: deleting again is still OK\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // invalid family (`other` catch-all, and an unknown name) → 400\n for bad in [\"other\", \"nope\"] {\n let (sb, _) = oneshot_req(\n st.clone(),\n Request::delete(format!(\"/api/native-models/{bad}\"))\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(sb, StatusCode::BAD_REQUEST, \"DELETE {bad} must be 400\");\n }\n }\n}\n"}, "c1fc56f460566e79b5d8c257db8597aea9dcfe5b": {"t": 1785760894.9801803, "n": 77030, "h": "2682a3c14de0ac8e", "c": "use crate::api::state::AppState;\nuse crate::util::now_ms;\nuse axum::{\n extract::State,\n http::{HeaderMap, StatusCode},\n response::{IntoResponse, Response},\n Json,\n};\nuse serde_json::json;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c\n .last_error\n .clone()\n .unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n {\n let mut c = st.usage_cache.lock();\n c.last_attempt_at = now_ms();\n }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n {\n let mut c = st.usage_cache.lock();\n c.at = now_ms();\n c.data = Some(data.clone());\n c.last_error = None;\n }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (\n StatusCode::SERVICE_UNAVAILABLE,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(\n &st.config.claude_config_base,\n ))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(\n State(st): State<AppState>,\n) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" | \"mcp\" => {\n let components =\n crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components\n .iter()\n .any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)})),\n )\n .into_response();\n }\n }\n other => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown kind: {other}\")})),\n )\n .into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // MCP: move the definition between mcpServers and the disabled parking key in\n // .claude.json. list_components enumerates both sides, so the id is known-valid\n // here; a concurrent external edit could still make it vanish → surface as an error.\n \"mcp\" => crate::engine::user_config::set_mcp_server_enabled(base, &req.id, req.enabled)\n .and_then(|found| {\n if found {\n Ok(())\n } else {\n Err(std::io::Error::new(\n std::io::ErrorKind::NotFound,\n format!(\"unknown mcp id: {}\", req.id),\n ))\n }\n }),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(\n base,\n &st.config.skills_dir,\n ))\n .into_response(),\n // NotFound = the id vanished between the known-check and the mutate (external edit\n // race) — same logical class as the pre-check's \"unknown id\", so same 400, not a 500.\n Err(e) if e.kind() == std::io::ErrorKind::NotFound => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let b: UpdateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n match st\n .store\n .update_group(&id, b.name.as_deref(), b.icon.as_deref())\n .await\n {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"group not found\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn groups_delete(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(\n &st.config.templates_path,\n ))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"array of templates required\"})),\n )\n .into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"template '{name}' not found\")})),\n )\n .into_response();\n };\n let prompt =\n crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st\n .engine\n .submit_session(\n tpl.repos,\n tpl.skills,\n prompt,\n std::collections::HashMap::new(),\n meta,\n )\n .await\n {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody {\n pub token: Option<String>,\n}\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"token required\"})),\n )\n .into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> = crate::engine::providers::load_list()\n .iter()\n .map(provider_view)\n .collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid provider: {e}\")})),\n )\n .into_response()\n }\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"capability cannot be NaN\"})),\n )\n .into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"priority cannot be NaN\"})),\n )\n .into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"cost cannot be NaN\"})),\n )\n .into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name, base_url, model are required\"})),\n )\n .into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"router validation failed: {e}\")})),\n )\n .into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"no such provider\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── native Claude per-family routing overrides ──\n\n#[derive(serde::Serialize)]\nstruct NativeModelRef {\n id: String,\n display_name: String,\n}\n\n#[derive(serde::Serialize)]\nstruct NativeFamilyView {\n family: String,\n label: String,\n models: Vec<NativeModelRef>,\n capability: f32,\n priority: f32,\n cost: f32,\n description: String,\n customized: bool,\n editable: bool,\n}\n\n#[derive(serde::Deserialize)]\nstruct NativeOverrideReq {\n capability: f32,\n priority: f32,\n cost: f32,\n #[serde(default)]\n description: String,\n}\n\nfn is_editable_family(family: &str) -> bool {\n matches!(family, \"opus\" | \"sonnet\" | \"haiku\" | \"fable\")\n}\n\nfn family_label(family: &str) -> &'static str {\n match family {\n \"opus\" => \"Opus\",\n \"sonnet\" => \"Sonnet\",\n \"haiku\" => \"Haiku\",\n \"fable\" => \"Fable\",\n _ => \"Other\",\n }\n}\n\n/// GET /api/native-models — native Claude families with effective routing metrics + override state.\npub async fn native_models_get() -> Response {\n use crate::engine::providers::{\n family_default_metrics, family_of, native_claude_models, DEFAULT_NATIVE_PRIORITY,\n };\n let overrides = crate::engine::native_overrides::load_map();\n\n // Group discovered models by family, first-seen (newest-first) order.\n let mut order: Vec<&'static str> = Vec::new();\n let mut groups: std::collections::HashMap<&'static str, Vec<NativeModelRef>> =\n std::collections::HashMap::new();\n for m in native_claude_models() {\n let fam = family_of(&m.id);\n groups.entry(fam).or_default().push(NativeModelRef {\n id: m.id.clone(),\n display_name: m.display_name.clone(),\n });\n if !order.contains(&fam) {\n order.push(fam);\n }\n }\n\n let mut views: Vec<NativeFamilyView> = order\n .into_iter()\n .map(|fam| {\n let (dc, dk) = family_default_metrics(fam);\n let (capability, priority, cost, description, customized) = match overrides.get(fam) {\n Some(o) => (\n o.capability,\n o.priority,\n o.cost,\n o.description.clone(),\n true,\n ),\n None => (dc, DEFAULT_NATIVE_PRIORITY, dk, String::new(), false),\n };\n NativeFamilyView {\n family: fam.to_string(),\n label: family_label(fam).to_string(),\n models: groups.remove(fam).unwrap_or_default(),\n capability,\n priority,\n cost,\n description,\n customized,\n editable: is_editable_family(fam),\n }\n })\n .collect();\n // cheap → capable, family-name tiebreak (matches the /api/models ordering contract).\n views.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.family.cmp(&b.family))\n });\n\n Json(json!({ \"families\": views })).into_response()\n}\n\n/// POST /api/native-models/{family} — set a family's routing override.\npub async fn native_models_post(\n axum::extract::Path(family): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n let req: NativeOverrideReq = match serde_json::from_slice(&body) {\n Ok(r) => r,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid override: {e}\")})),\n )\n .into_response()\n }\n };\n for (name, v) in [\n (\"capability\", req.capability),\n (\"priority\", req.priority),\n (\"cost\", req.cost),\n ] {\n if v.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"{name} cannot be NaN\")})),\n )\n .into_response();\n }\n }\n let ov = crate::engine::native_overrides::NativeOverride {\n capability: req.capability.clamp(0.0, 1.0),\n priority: req.priority.clamp(0.0, 1.0),\n cost: req.cost.clamp(0.0, 1.0),\n description: req.description,\n };\n match crate::engine::native_overrides::upsert(&family, ov) {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/native-models/{family} — reset a family to defaults (idempotent).\npub async fn native_models_delete(\n axum::extract::Path(family): axum::extract::Path<String>,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n match crate::engine::native_overrides::remove(&family) {\n Ok(_) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)})),\n )\n .into_response();\n }\n if let Err(e) = crate::api::validation::validate_mcp_def(&def) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"MCP server '{name}' not found\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody {\n pub name: String,\n pub description: String,\n /// The skill's markdown body — the actual instructions the agent loads. Optional for\n /// back-compat; without it the created skill is an empty shell (frontmatter only).\n #[serde(default)]\n pub instructions: String,\n}\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description, &b.instructions) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CatalogQuery {\n #[serde(default)]\n pub refresh: bool,\n}\n\n/// GET /api/skills/catalog[?refresh=true] — the aggregated external skill store across every\n/// configured source. A broken source shows up in `errors` instead of failing the whole store.\npub async fn skills_catalog_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<CatalogQuery>,\n) -> Response {\n let (skills, errors) =\n crate::engine::skill_install::fetch_catalog(&st.config.claude_config_base, q.refresh).await;\n // Per-request (never cached): compare each entry's content fingerprint against the\n // installed copy's provenance metadata so the app can show Update only when one exists.\n // Blocking file reads → spawn_blocking, keeping the async executor free.\n let skills_dir = st.config.skills_dir.clone();\n let skills = tokio::task::spawn_blocking(move || {\n let mut skills = skills;\n crate::engine::skill_install::annotate_update_available(&mut skills, &skills_dir);\n skills\n })\n .await\n // JoinError = the annotation task panicked (it can't, absent fs pathology). An empty\n // list beats a 500 for a read-only catalog.\n .unwrap_or_default();\n Json(json!({ \"skills\": skills, \"errors\": errors })).into_response()\n}\n\n/// GET /api/skills/sources — the configured store sources.\npub async fn skills_sources_route(State(st): State<AppState>) -> Response {\n Json(json!({ \"sources\": crate::engine::skill_install::read_sources(&st.config.claude_config_base) })).into_response()\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceBody {\n pub source: String,\n}\n\n/// POST /api/skills/sources — add a store source (owner/repo[/path] or github.com URL).\npub async fn skills_sources_add_route(\n State(st): State<AppState>,\n body: axum::body::Bytes,\n) -> Response {\n let b: SkillSourceBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Caller-fault (bad syntax) → 400 up front; anything add_source itself fails on afterwards\n // is a server-side write problem → 500 (consistent with the DELETE route).\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n match crate::engine::skill_install::add_source(&st.config.claude_config_base, &b.source) {\n Ok(sources) => Json(json!({ \"sources\": sources })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceQuery {\n pub source: String,\n}\n\n/// DELETE /api/skills/sources?source=… — remove a store source (query param: sources contain\n/// slashes, which a path segment would mangle).\npub async fn skills_sources_delete_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<SkillSourceQuery>,\n) -> Response {\n match crate::engine::skill_install::remove_source(&st.config.claude_config_base, &q.source) {\n Ok((sources, true)) => Json(json!({ \"sources\": sources })).into_response(),\n Ok((_, false)) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"unknown source: {}\", q.source)})),\n )\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct InstallSkillBody {\n pub source: String,\n /// true = update: replace an existing install of the same name (atomic swap).\n #[serde(default)]\n pub update: bool,\n}\n\n/// POST /api/skills/install — download a skill (SKILL.md + companion files) from a GitHub\n/// source (`owner/repo[/path]` or a github.com URL) into the skills dir.\npub async fn skills_install_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: InstallSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Parse errors are the caller's fault (400) BEFORE any network is touched.\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skill_install::install_from_source(&skills, &b.source, b.update).await {\n Ok(_name) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n // Everything else mixes remote and local causes; BAD_GATEWAY for remote-ish messages\n // would be guesswork — a 400 with the human-readable reason serves the app either way.\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"skill '{name}' not found\")})),\n )\n .into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill path: {e}\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody {\n pub id: String,\n}\n\n/// CLI-reported failures (nonzero exit: unknown plugin/marketplace…) are the caller's\n/// error → 400; spawn/timeout/join failures are ours → 500. Prefixes match the exact\n/// error formats of `plugin_cli::run_plugin_command` plus this file's spawn_blocking wrapper.\nfn plugin_error_status(e: &str) -> StatusCode {\n if e.starts_with(\"failed to spawn\")\n || e.starts_with(\"process error\")\n || e.starts_with(\"task error\")\n || e.starts_with(\"plugin command timed out\")\n {\n StatusCode::INTERNAL_SERVER_ERROR\n } else {\n StatusCode::BAD_REQUEST\n }\n}\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result =\n tokio::task::spawn_blocking(move || crate::engine::plugin_cli::install_plugin(&base, &id))\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (plugin_error_status(&e), Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n })\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (plugin_error_status(&e), Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{auth, oneshot_req, test_state};\n use axum::body::Body;\n use axum::http::Request;\n use parking_lot::Mutex;\n use std::sync::Arc;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://x\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(\n !json.contains(\"SECRET\"),\n \"key must never appear in the view: {json}\"\n );\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider {\n api_key: String::new(),\n ..p\n };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(\n !models.is_empty(),\n \"native Claude candidates should be present\"\n );\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"]\n .as_str()\n .unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}}))\n })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| {\n Box::pin(async { Err(crate::engine::usage::UsageError::Status(429)) })\n }));\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req(\n (*st2).clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles)\n .await\n .into_iter()\n .map(|r| r.unwrap())\n .collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(\n *s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\",\n s\n );\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(\n err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\"\n );\n // Delete\n let (s3, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert!(\n !arr2\n .as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(\n st.clone(),\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\"\n );\n // Delete\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n async fn post_json(\n st: &crate::api::state::AppState,\n path: &str,\n body: &str,\n ) -> (StatusCode, serde_json::Value) {\n oneshot_req(\n st.clone(),\n Request::post(path)\n .header(\"authorization\", auth(st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(body.to_string()))\n .unwrap(),\n )\n .await\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_incomplete_defs() {\n let st = test_state().await;\n for (body, want) in [\n (r#\"{\"name\":\"x\"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"command\":\" \"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"type\":\"stdio\"}\"#, \"command\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"http\",\"url\":\"ftp://e.com\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"sse\",\"url\":\"https://\"}\"#, \"url\"),\n (r#\"{\"name\":\"x\",\"type\":\"ws\",\"url\":\"https://e.com\"}\"#, \"type\"),\n (r#\"{\"name\":\"x\",\"command\":\"c\",\"url\":\"https://e.com\"}\"#, \"both\"),\n (\n r#\"{\"name\":\"x\",\"type\":\"http\",\"command\":\"c\",\"url\":\"https://e.com\"}\"#,\n \"command\",\n ),\n ] {\n let (s, b) = post_json(&st, \"/api/mcp-servers\", body).await;\n assert_eq!(s, StatusCode::BAD_REQUEST, \"body: {body}\");\n assert!(\n b[\"error\"].as_str().unwrap().contains(want),\n \"{body} -> {b}\"\n );\n }\n }\n\n #[tokio::test]\n async fn mcp_add_http_round_trip() {\n let st = test_state().await;\n let (s, _) = post_json(\n &st,\n \"/api/mcp-servers\",\n r#\"{\"name\":\"e2e-http\",\"type\":\"http\",\"url\":\"https://example.com/mcp\"}\"#,\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let cj = st\n .config\n .claude_config_base\n .parent()\n .unwrap()\n .join(\".claude.json\");\n let saved: serde_json::Value =\n serde_json::from_str(&std::fs::read_to_string(&cj).unwrap()).unwrap();\n assert_eq!(saved[\"mcpServers\"][\"e2e-http\"][\"type\"], \"http\");\n assert_eq!(\n saved[\"mcpServers\"][\"e2e-http\"][\"url\"],\n \"https://example.com/mcp\"\n );\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/e2e-http\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n // url without \"type\" is accepted and persists with the default type \"http\".\n let (s3, _) = post_json(\n &st,\n \"/api/mcp-servers\",\n r#\"{\"name\":\"e2e-default\",\"url\":\"https://example.com/mcp\"}\"#,\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n let saved: serde_json::Value =\n serde_json::from_str(&std::fs::read_to_string(&cj).unwrap()).unwrap();\n assert_eq!(saved[\"mcpServers\"][\"e2e-default\"][\"type\"], \"http\");\n }\n\n #[tokio::test]\n async fn skill_sources_crud_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Default seed present.\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/skills/sources\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"], serde_json::json!([\"anthropics/skills\"]));\n // Add.\n let (s, b) = post_json(&st, \"/api/skills/sources\", r#\"{\"source\":\"octocat/Hello-World\"}\"#).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"].as_array().unwrap().len(), 2);\n // Duplicate (trailing-slash variant) is deduped, not appended.\n let (s, b) = post_json(&st, \"/api/skills/sources\", r#\"{\"source\":\"octocat/Hello-World/\"}\"#).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"].as_array().unwrap().len(), 2);\n // Bad syntax → 400.\n let (s, _) = post_json(&st, \"/api/skills/sources\", r#\"{\"source\":\"not a source\"}\"#).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n // Remove.\n let (s, b) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/sources?source=octocat%2FHello-World\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"sources\"], serde_json::json!([\"anthropics/skills\"]));\n // Remove unknown → 404.\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/sources?source=never%2Fadded\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n }\n\n #[tokio::test]\n async fn skills_install_rejects_bad_source_before_network() {\n let st = test_state().await;\n for body in [\n r#\"{\"source\":\"not a source\"}\"#,\n r#\"{\"source\":\"\"}\"#,\n r#\"{\"source\":\"https://gitlab.com/x/y\"}\"#,\n ] {\n let (s, b) = post_json(&st, \"/api/skills/install\", body).await;\n assert_eq!(s, StatusCode::BAD_REQUEST, \"body: {body}\");\n assert!(b[\"error\"].as_str().is_some());\n }\n }\n\n struct NativeOvGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for NativeOvGuard {\n fn drop(&mut self) {\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_native_overrides_file() -> NativeOvGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"native-overrides.json\"));\n NativeOvGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn native_models_get_groups_then_post_marks_customized() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n // GET: families present, opus editable and not customized\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let fams = b[\"families\"].as_array().unwrap();\n // Seed families: fable/opus/opus/sonnet/haiku → 4 groups (opus collapses in routing, but GET\n // groups by discovered family). Ordering contract: cheap → capable (family-name tiebreak).\n assert_eq!(fams.len(), 4);\n let order: Vec<&str> = fams.iter().map(|f| f[\"family\"].as_str().unwrap()).collect();\n assert_eq!(order, vec![\"haiku\", \"sonnet\", \"opus\", \"fable\"]);\n let opus = fams.iter().find(|f| f[\"family\"] == \"opus\").unwrap();\n assert_eq!(opus[\"editable\"], true);\n assert_eq!(opus[\"customized\"], false);\n // A non-overridden family returns an empty description verbatim (the generated per-model\n // fallback happens only at routing time, not in this view).\n assert_eq!(\n fams.iter().find(|f| f[\"family\"] == \"sonnet\").unwrap()[\"description\"],\n \"\"\n );\n\n // POST with mixed-case family normalizes and applies\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/Opus\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.9,\"priority\":0.85,\"cost\":0.2,\"description\":\"hard only\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // GET again: opus is now customized with the new priority\n let (_, b3) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n let opus3 = b3[\"families\"]\n .as_array()\n .unwrap()\n .iter()\n .find(|f| f[\"family\"] == \"opus\")\n .unwrap()\n .clone();\n assert_eq!(opus3[\"customized\"], true);\n // f32→JSON widens to f64, so compare with a tolerance rather than `== 0.85` (which would fail).\n assert!((opus3[\"priority\"].as_f64().unwrap() - 0.85).abs() < 1e-6);\n assert_eq!(opus3[\"description\"], \"hard only\");\n }\n\n #[tokio::test]\n async fn native_models_post_rejects_other_and_bad_family_and_clamps() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // `other` is read-only\n let (s_other, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/other\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_other, StatusCode::BAD_REQUEST);\n\n // unknown family\n let (s_bad, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/nope\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_bad, StatusCode::BAD_REQUEST);\n\n // out-of-range clamps (stored value is 1.0, not 5.0)\n let (s_ok, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/sonnet\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":5.0,\"priority\":-1.0,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_ok, StatusCode::OK);\n let m = crate::engine::native_overrides::load_map();\n assert_eq!(m[\"sonnet\"].capability, 1.0);\n assert_eq!(m[\"sonnet\"].priority, 0.0);\n }\n\n #[tokio::test]\n async fn native_models_delete_resets_and_validates_family() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // seed an override, then reset it\n crate::engine::native_overrides::upsert(\n \"opus\",\n crate::engine::native_overrides::NativeOverride {\n capability: 0.9,\n priority: 0.8,\n cost: 0.2,\n description: String::new(),\n },\n )\n .unwrap();\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(crate::engine::native_overrides::load_map()\n .get(\"opus\")\n .is_none());\n\n // idempotent: deleting again is still OK\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // invalid family (`other` catch-all, and an unknown name) → 400\n for bad in [\"other\", \"nope\"] {\n let (sb, _) = oneshot_req(\n st.clone(),\n Request::delete(format!(\"/api/native-models/{bad}\"))\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(sb, StatusCode::BAD_REQUEST, \"DELETE {bad} must be 400\");\n }\n }\n}\n"}, "3404138f8e42719d8a60c4e8247109ec93e2c0b6": {"t": 1785760896.5019395, "n": 70693, "h": "ea250bbfd68dc068", "c": "use crate::api::state::AppState;\nuse crate::util::now_ms;\nuse axum::{\n extract::State,\n http::{HeaderMap, StatusCode},\n response::{IntoResponse, Response},\n Json,\n};\nuse serde_json::json;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c\n .last_error\n .clone()\n .unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n {\n let mut c = st.usage_cache.lock();\n c.last_attempt_at = now_ms();\n }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n {\n let mut c = st.usage_cache.lock();\n c.at = now_ms();\n c.data = Some(data.clone());\n c.last_error = None;\n }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (\n StatusCode::SERVICE_UNAVAILABLE,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(\n &st.config.claude_config_base,\n ))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(\n State(st): State<AppState>,\n) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" | \"mcp\" => {\n let components =\n crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components\n .iter()\n .any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)})),\n )\n .into_response();\n }\n }\n other => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown kind: {other}\")})),\n )\n .into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // MCP: move the definition between mcpServers and the disabled parking key in\n // .claude.json. list_components enumerates both sides, so the id is known-valid\n // here; a concurrent external edit could still make it vanish → surface as an error.\n \"mcp\" => crate::engine::user_config::set_mcp_server_enabled(base, &req.id, req.enabled)\n .and_then(|found| {\n if found {\n Ok(())\n } else {\n Err(std::io::Error::new(\n std::io::ErrorKind::NotFound,\n format!(\"unknown mcp id: {}\", req.id),\n ))\n }\n }),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(\n base,\n &st.config.skills_dir,\n ))\n .into_response(),\n // NotFound = the id vanished between the known-check and the mutate (external edit\n // race) — same logical class as the pre-check's \"unknown id\", so same 400, not a 500.\n Err(e) if e.kind() == std::io::ErrorKind::NotFound => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let b: UpdateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n match st\n .store\n .update_group(&id, b.name.as_deref(), b.icon.as_deref())\n .await\n {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"group not found\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn groups_delete(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(\n &st.config.templates_path,\n ))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"array of templates required\"})),\n )\n .into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"template '{name}' not found\")})),\n )\n .into_response();\n };\n let prompt =\n crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st\n .engine\n .submit_session(\n tpl.repos,\n tpl.skills,\n prompt,\n std::collections::HashMap::new(),\n meta,\n )\n .await\n {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody {\n pub token: Option<String>,\n}\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"token required\"})),\n )\n .into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> = crate::engine::providers::load_list()\n .iter()\n .map(provider_view)\n .collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid provider: {e}\")})),\n )\n .into_response()\n }\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"capability cannot be NaN\"})),\n )\n .into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"priority cannot be NaN\"})),\n )\n .into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"cost cannot be NaN\"})),\n )\n .into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name, base_url, model are required\"})),\n )\n .into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"router validation failed: {e}\")})),\n )\n .into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"no such provider\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── native Claude per-family routing overrides ──\n\n#[derive(serde::Serialize)]\nstruct NativeModelRef {\n id: String,\n display_name: String,\n}\n\n#[derive(serde::Serialize)]\nstruct NativeFamilyView {\n family: String,\n label: String,\n models: Vec<NativeModelRef>,\n capability: f32,\n priority: f32,\n cost: f32,\n description: String,\n customized: bool,\n editable: bool,\n}\n\n#[derive(serde::Deserialize)]\nstruct NativeOverrideReq {\n capability: f32,\n priority: f32,\n cost: f32,\n #[serde(default)]\n description: String,\n}\n\nfn is_editable_family(family: &str) -> bool {\n matches!(family, \"opus\" | \"sonnet\" | \"haiku\" | \"fable\")\n}\n\nfn family_label(family: &str) -> &'static str {\n match family {\n \"opus\" => \"Opus\",\n \"sonnet\" => \"Sonnet\",\n \"haiku\" => \"Haiku\",\n \"fable\" => \"Fable\",\n _ => \"Other\",\n }\n}\n\n/// GET /api/native-models — native Claude families with effective routing metrics + override state.\npub async fn native_models_get() -> Response {\n use crate::engine::providers::{\n family_default_metrics, family_of, native_claude_models, DEFAULT_NATIVE_PRIORITY,\n };\n let overrides = crate::engine::native_overrides::load_map();\n\n // Group discovered models by family, first-seen (newest-first) order.\n let mut order: Vec<&'static str> = Vec::new();\n let mut groups: std::collections::HashMap<&'static str, Vec<NativeModelRef>> =\n std::collections::HashMap::new();\n for m in native_claude_models() {\n let fam = family_of(&m.id);\n groups.entry(fam).or_default().push(NativeModelRef {\n id: m.id.clone(),\n display_name: m.display_name.clone(),\n });\n if !order.contains(&fam) {\n order.push(fam);\n }\n }\n\n let mut views: Vec<NativeFamilyView> = order\n .into_iter()\n .map(|fam| {\n let (dc, dk) = family_default_metrics(fam);\n let (capability, priority, cost, description, customized) = match overrides.get(fam) {\n Some(o) => (\n o.capability,\n o.priority,\n o.cost,\n o.description.clone(),\n true,\n ),\n None => (dc, DEFAULT_NATIVE_PRIORITY, dk, String::new(), false),\n };\n NativeFamilyView {\n family: fam.to_string(),\n label: family_label(fam).to_string(),\n models: groups.remove(fam).unwrap_or_default(),\n capability,\n priority,\n cost,\n description,\n customized,\n editable: is_editable_family(fam),\n }\n })\n .collect();\n // cheap → capable, family-name tiebreak (matches the /api/models ordering contract).\n views.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.family.cmp(&b.family))\n });\n\n Json(json!({ \"families\": views })).into_response()\n}\n\n/// POST /api/native-models/{family} — set a family's routing override.\npub async fn native_models_post(\n axum::extract::Path(family): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n let req: NativeOverrideReq = match serde_json::from_slice(&body) {\n Ok(r) => r,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid override: {e}\")})),\n )\n .into_response()\n }\n };\n for (name, v) in [\n (\"capability\", req.capability),\n (\"priority\", req.priority),\n (\"cost\", req.cost),\n ] {\n if v.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"{name} cannot be NaN\")})),\n )\n .into_response();\n }\n }\n let ov = crate::engine::native_overrides::NativeOverride {\n capability: req.capability.clamp(0.0, 1.0),\n priority: req.priority.clamp(0.0, 1.0),\n cost: req.cost.clamp(0.0, 1.0),\n description: req.description,\n };\n match crate::engine::native_overrides::upsert(&family, ov) {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/native-models/{family} — reset a family to defaults (idempotent).\npub async fn native_models_delete(\n axum::extract::Path(family): axum::extract::Path<String>,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n match crate::engine::native_overrides::remove(&family) {\n Ok(_) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"MCP server '{name}' not found\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody {\n pub name: String,\n pub description: String,\n /// The skill's markdown body — the actual instructions the agent loads. Optional for\n /// back-compat; without it the created skill is an empty shell (frontmatter only).\n #[serde(default)]\n pub instructions: String,\n}\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description, &b.instructions) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CatalogQuery {\n #[serde(default)]\n pub refresh: bool,\n}\n\n/// GET /api/skills/catalog[?refresh=true] — the aggregated external skill store across every\n/// configured source. A broken source shows up in `errors` instead of failing the whole store.\npub async fn skills_catalog_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<CatalogQuery>,\n) -> Response {\n let (skills, errors) =\n crate::engine::skill_install::fetch_catalog(&st.config.claude_config_base, q.refresh).await;\n // Per-request (never cached): compare each entry's content fingerprint against the\n // installed copy's provenance metadata so the app can show Update only when one exists.\n // Blocking file reads → spawn_blocking, keeping the async executor free.\n let skills_dir = st.config.skills_dir.clone();\n let skills = tokio::task::spawn_blocking(move || {\n let mut skills = skills;\n crate::engine::skill_install::annotate_update_available(&mut skills, &skills_dir);\n skills\n })\n .await\n // JoinError = the annotation task panicked (it can't, absent fs pathology). An empty\n // list beats a 500 for a read-only catalog.\n .unwrap_or_default();\n Json(json!({ \"skills\": skills, \"errors\": errors })).into_response()\n}\n\n/// GET /api/skills/sources — the configured store sources.\npub async fn skills_sources_route(State(st): State<AppState>) -> Response {\n Json(json!({ \"sources\": crate::engine::skill_install::read_sources(&st.config.claude_config_base) })).into_response()\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceBody {\n pub source: String,\n}\n\n/// POST /api/skills/sources — add a store source (owner/repo[/path] or github.com URL).\npub async fn skills_sources_add_route(\n State(st): State<AppState>,\n body: axum::body::Bytes,\n) -> Response {\n let b: SkillSourceBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Caller-fault (bad syntax) → 400 up front; anything add_source itself fails on afterwards\n // is a server-side write problem → 500 (consistent with the DELETE route).\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n match crate::engine::skill_install::add_source(&st.config.claude_config_base, &b.source) {\n Ok(sources) => Json(json!({ \"sources\": sources })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceQuery {\n pub source: String,\n}\n\n/// DELETE /api/skills/sources?source=… — remove a store source (query param: sources contain\n/// slashes, which a path segment would mangle).\npub async fn skills_sources_delete_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<SkillSourceQuery>,\n) -> Response {\n match crate::engine::skill_install::remove_source(&st.config.claude_config_base, &q.source) {\n Ok((sources, true)) => Json(json!({ \"sources\": sources })).into_response(),\n Ok((_, false)) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"unknown source: {}\", q.source)})),\n )\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct InstallSkillBody {\n pub source: String,\n /// true = update: replace an existing install of the same name (atomic swap).\n #[serde(default)]\n pub update: bool,\n}\n\n/// POST /api/skills/install — download a skill (SKILL.md + companion files) from a GitHub\n/// source (`owner/repo[/path]` or a github.com URL) into the skills dir.\npub async fn skills_install_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: InstallSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Parse errors are the caller's fault (400) BEFORE any network is touched.\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skill_install::install_from_source(&skills, &b.source, b.update).await {\n Ok(_name) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n // Everything else mixes remote and local causes; BAD_GATEWAY for remote-ish messages\n // would be guesswork — a 400 with the human-readable reason serves the app either way.\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"skill '{name}' not found\")})),\n )\n .into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill path: {e}\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody {\n pub id: String,\n}\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result =\n tokio::task::spawn_blocking(move || crate::engine::plugin_cli::install_plugin(&base, &id))\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n })\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{auth, oneshot_req, test_state};\n use axum::body::Body;\n use axum::http::Request;\n use parking_lot::Mutex;\n use std::sync::Arc;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://x\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(\n !json.contains(\"SECRET\"),\n \"key must never appear in the view: {json}\"\n );\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider {\n api_key: String::new(),\n ..p\n };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(\n !models.is_empty(),\n \"native Claude candidates should be present\"\n );\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"]\n .as_str()\n .unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}}))\n })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| {\n Box::pin(async { Err(crate::engine::usage::UsageError::Status(429)) })\n }));\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req(\n (*st2).clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles)\n .await\n .into_iter()\n .map(|r| r.unwrap())\n .collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(\n *s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\",\n s\n );\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(\n err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\"\n );\n // Delete\n let (s3, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert!(\n !arr2\n .as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(\n st.clone(),\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\"\n );\n // Delete\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n struct NativeOvGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for NativeOvGuard {\n fn drop(&mut self) {\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_native_overrides_file() -> NativeOvGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"native-overrides.json\"));\n NativeOvGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn native_models_get_groups_then_post_marks_customized() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n // GET: families present, opus editable and not customized\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let fams = b[\"families\"].as_array().unwrap();\n // Seed families: fable/opus/opus/sonnet/haiku → 4 groups (opus collapses in routing, but GET\n // groups by discovered family). Ordering contract: cheap → capable (family-name tiebreak).\n assert_eq!(fams.len(), 4);\n let order: Vec<&str> = fams.iter().map(|f| f[\"family\"].as_str().unwrap()).collect();\n assert_eq!(order, vec![\"haiku\", \"sonnet\", \"opus\", \"fable\"]);\n let opus = fams.iter().find(|f| f[\"family\"] == \"opus\").unwrap();\n assert_eq!(opus[\"editable\"], true);\n assert_eq!(opus[\"customized\"], false);\n // A non-overridden family returns an empty description verbatim (the generated per-model\n // fallback happens only at routing time, not in this view).\n assert_eq!(\n fams.iter().find(|f| f[\"family\"] == \"sonnet\").unwrap()[\"description\"],\n \"\"\n );\n\n // POST with mixed-case family normalizes and applies\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/Opus\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.9,\"priority\":0.85,\"cost\":0.2,\"description\":\"hard only\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // GET again: opus is now customized with the new priority\n let (_, b3) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n let opus3 = b3[\"families\"]\n .as_array()\n .unwrap()\n .iter()\n .find(|f| f[\"family\"] == \"opus\")\n .unwrap()\n .clone();\n assert_eq!(opus3[\"customized\"], true);\n // f32→JSON widens to f64, so compare with a tolerance rather than `== 0.85` (which would fail).\n assert!((opus3[\"priority\"].as_f64().unwrap() - 0.85).abs() < 1e-6);\n assert_eq!(opus3[\"description\"], \"hard only\");\n }\n\n #[tokio::test]\n async fn native_models_post_rejects_other_and_bad_family_and_clamps() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // `other` is read-only\n let (s_other, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/other\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_other, StatusCode::BAD_REQUEST);\n\n // unknown family\n let (s_bad, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/nope\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_bad, StatusCode::BAD_REQUEST);\n\n // out-of-range clamps (stored value is 1.0, not 5.0)\n let (s_ok, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/sonnet\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":5.0,\"priority\":-1.0,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_ok, StatusCode::OK);\n let m = crate::engine::native_overrides::load_map();\n assert_eq!(m[\"sonnet\"].capability, 1.0);\n assert_eq!(m[\"sonnet\"].priority, 0.0);\n }\n\n #[tokio::test]\n async fn native_models_delete_resets_and_validates_family() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // seed an override, then reset it\n crate::engine::native_overrides::upsert(\n \"opus\",\n crate::engine::native_overrides::NativeOverride {\n capability: 0.9,\n priority: 0.8,\n cost: 0.2,\n description: String::new(),\n },\n )\n .unwrap();\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(crate::engine::native_overrides::load_map()\n .get(\"opus\")\n .is_none());\n\n // idempotent: deleting again is still OK\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // invalid family (`other` catch-all, and an unknown name) → 400\n for bad in [\"other\", \"nope\"] {\n let (sb, _) = oneshot_req(\n st.clone(),\n Request::delete(format!(\"/api/native-models/{bad}\"))\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(sb, StatusCode::BAD_REQUEST, \"DELETE {bad} must be 400\");\n }\n }\n}\n"}, "fae6657ba722a4b9e6882a24acaf32a76922591d": {"t": 1785760898.1513455, "n": 70288, "h": "dc7cabc45641e33c", "c": "use crate::api::state::AppState;\nuse crate::util::now_ms;\nuse axum::{\n extract::State,\n http::{HeaderMap, StatusCode},\n response::{IntoResponse, Response},\n Json,\n};\nuse serde_json::json;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c\n .last_error\n .clone()\n .unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n {\n let mut c = st.usage_cache.lock();\n c.last_attempt_at = now_ms();\n }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n {\n let mut c = st.usage_cache.lock();\n c.at = now_ms();\n c.data = Some(data.clone());\n c.last_error = None;\n }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (\n StatusCode::SERVICE_UNAVAILABLE,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(\n &st.config.claude_config_base,\n ))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(\n State(st): State<AppState>,\n) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" | \"mcp\" => {\n let components =\n crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components\n .iter()\n .any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)})),\n )\n .into_response();\n }\n }\n other => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown kind: {other}\")})),\n )\n .into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // MCP: move the definition between mcpServers and the disabled parking key in\n // .claude.json. list_components enumerates both sides, so the id is known-valid\n // here; a concurrent external edit could still make it vanish → surface as an error.\n \"mcp\" => crate::engine::user_config::set_mcp_server_enabled(base, &req.id, req.enabled)\n .and_then(|found| {\n if found {\n Ok(())\n } else {\n Err(std::io::Error::new(\n std::io::ErrorKind::NotFound,\n format!(\"unknown mcp id: {}\", req.id),\n ))\n }\n }),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(\n base,\n &st.config.skills_dir,\n ))\n .into_response(),\n // NotFound = the id vanished between the known-check and the mutate (external edit\n // race) — same logical class as the pre-check's \"unknown id\", so same 400, not a 500.\n Err(e) if e.kind() == std::io::ErrorKind::NotFound => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let b: UpdateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n match st\n .store\n .update_group(&id, b.name.as_deref(), b.icon.as_deref())\n .await\n {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"group not found\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn groups_delete(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(\n &st.config.templates_path,\n ))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"array of templates required\"})),\n )\n .into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"template '{name}' not found\")})),\n )\n .into_response();\n };\n let prompt =\n crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st\n .engine\n .submit_session(\n tpl.repos,\n tpl.skills,\n prompt,\n std::collections::HashMap::new(),\n meta,\n )\n .await\n {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody {\n pub token: Option<String>,\n}\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"token required\"})),\n )\n .into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> = crate::engine::providers::load_list()\n .iter()\n .map(provider_view)\n .collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid provider: {e}\")})),\n )\n .into_response()\n }\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"capability cannot be NaN\"})),\n )\n .into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"priority cannot be NaN\"})),\n )\n .into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"cost cannot be NaN\"})),\n )\n .into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name, base_url, model are required\"})),\n )\n .into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"router validation failed: {e}\")})),\n )\n .into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"no such provider\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── native Claude per-family routing overrides ──\n\n#[derive(serde::Serialize)]\nstruct NativeModelRef {\n id: String,\n display_name: String,\n}\n\n#[derive(serde::Serialize)]\nstruct NativeFamilyView {\n family: String,\n label: String,\n models: Vec<NativeModelRef>,\n capability: f32,\n priority: f32,\n cost: f32,\n description: String,\n customized: bool,\n editable: bool,\n}\n\n#[derive(serde::Deserialize)]\nstruct NativeOverrideReq {\n capability: f32,\n priority: f32,\n cost: f32,\n #[serde(default)]\n description: String,\n}\n\nfn is_editable_family(family: &str) -> bool {\n matches!(family, \"opus\" | \"sonnet\" | \"haiku\" | \"fable\")\n}\n\nfn family_label(family: &str) -> &'static str {\n match family {\n \"opus\" => \"Opus\",\n \"sonnet\" => \"Sonnet\",\n \"haiku\" => \"Haiku\",\n \"fable\" => \"Fable\",\n _ => \"Other\",\n }\n}\n\n/// GET /api/native-models — native Claude families with effective routing metrics + override state.\npub async fn native_models_get() -> Response {\n use crate::engine::providers::{\n family_default_metrics, family_of, native_claude_models, DEFAULT_NATIVE_PRIORITY,\n };\n let overrides = crate::engine::native_overrides::load_map();\n\n // Group discovered models by family, first-seen (newest-first) order.\n let mut order: Vec<&'static str> = Vec::new();\n let mut groups: std::collections::HashMap<&'static str, Vec<NativeModelRef>> =\n std::collections::HashMap::new();\n for m in native_claude_models() {\n let fam = family_of(&m.id);\n groups.entry(fam).or_default().push(NativeModelRef {\n id: m.id.clone(),\n display_name: m.display_name.clone(),\n });\n if !order.contains(&fam) {\n order.push(fam);\n }\n }\n\n let mut views: Vec<NativeFamilyView> = order\n .into_iter()\n .map(|fam| {\n let (dc, dk) = family_default_metrics(fam);\n let (capability, priority, cost, description, customized) = match overrides.get(fam) {\n Some(o) => (\n o.capability,\n o.priority,\n o.cost,\n o.description.clone(),\n true,\n ),\n None => (dc, DEFAULT_NATIVE_PRIORITY, dk, String::new(), false),\n };\n NativeFamilyView {\n family: fam.to_string(),\n label: family_label(fam).to_string(),\n models: groups.remove(fam).unwrap_or_default(),\n capability,\n priority,\n cost,\n description,\n customized,\n editable: is_editable_family(fam),\n }\n })\n .collect();\n // cheap → capable, family-name tiebreak (matches the /api/models ordering contract).\n views.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.family.cmp(&b.family))\n });\n\n Json(json!({ \"families\": views })).into_response()\n}\n\n/// POST /api/native-models/{family} — set a family's routing override.\npub async fn native_models_post(\n axum::extract::Path(family): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n let req: NativeOverrideReq = match serde_json::from_slice(&body) {\n Ok(r) => r,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid override: {e}\")})),\n )\n .into_response()\n }\n };\n for (name, v) in [\n (\"capability\", req.capability),\n (\"priority\", req.priority),\n (\"cost\", req.cost),\n ] {\n if v.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"{name} cannot be NaN\")})),\n )\n .into_response();\n }\n }\n let ov = crate::engine::native_overrides::NativeOverride {\n capability: req.capability.clamp(0.0, 1.0),\n priority: req.priority.clamp(0.0, 1.0),\n cost: req.cost.clamp(0.0, 1.0),\n description: req.description,\n };\n match crate::engine::native_overrides::upsert(&family, ov) {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/native-models/{family} — reset a family to defaults (idempotent).\npub async fn native_models_delete(\n axum::extract::Path(family): axum::extract::Path<String>,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n match crate::engine::native_overrides::remove(&family) {\n Ok(_) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"MCP server '{name}' not found\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody {\n pub name: String,\n pub description: String,\n /// The skill's markdown body — the actual instructions the agent loads. Optional for\n /// back-compat; without it the created skill is an empty shell (frontmatter only).\n #[serde(default)]\n pub instructions: String,\n}\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description, &b.instructions) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CatalogQuery {\n #[serde(default)]\n pub refresh: bool,\n}\n\n/// GET /api/skills/catalog[?refresh=true] — the aggregated external skill store across every\n/// configured source. A broken source shows up in `errors` instead of failing the whole store.\npub async fn skills_catalog_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<CatalogQuery>,\n) -> Response {\n let (mut skills, errors) =\n crate::engine::skill_install::fetch_catalog(&st.config.claude_config_base, q.refresh).await;\n // Per-request (never cached): compare each entry's content fingerprint against the\n // installed copy's provenance metadata so the app can show Update only when one exists.\n crate::engine::skill_install::annotate_update_available(&mut skills, &st.config.skills_dir);\n Json(json!({ \"skills\": skills, \"errors\": errors })).into_response()\n}\n\n/// GET /api/skills/sources — the configured store sources.\npub async fn skills_sources_route(State(st): State<AppState>) -> Response {\n Json(json!({ \"sources\": crate::engine::skill_install::read_sources(&st.config.claude_config_base) })).into_response()\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceBody {\n pub source: String,\n}\n\n/// POST /api/skills/sources — add a store source (owner/repo[/path] or github.com URL).\npub async fn skills_sources_add_route(\n State(st): State<AppState>,\n body: axum::body::Bytes,\n) -> Response {\n let b: SkillSourceBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Caller-fault (bad syntax) → 400 up front; anything add_source itself fails on afterwards\n // is a server-side write problem → 500 (consistent with the DELETE route).\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n match crate::engine::skill_install::add_source(&st.config.claude_config_base, &b.source) {\n Ok(sources) => Json(json!({ \"sources\": sources })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceQuery {\n pub source: String,\n}\n\n/// DELETE /api/skills/sources?source=… — remove a store source (query param: sources contain\n/// slashes, which a path segment would mangle).\npub async fn skills_sources_delete_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<SkillSourceQuery>,\n) -> Response {\n match crate::engine::skill_install::remove_source(&st.config.claude_config_base, &q.source) {\n Ok((sources, true)) => Json(json!({ \"sources\": sources })).into_response(),\n Ok((_, false)) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"unknown source: {}\", q.source)})),\n )\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct InstallSkillBody {\n pub source: String,\n /// true = update: replace an existing install of the same name (atomic swap).\n #[serde(default)]\n pub update: bool,\n}\n\n/// POST /api/skills/install — download a skill (SKILL.md + companion files) from a GitHub\n/// source (`owner/repo[/path]` or a github.com URL) into the skills dir.\npub async fn skills_install_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: InstallSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Parse errors are the caller's fault (400) BEFORE any network is touched.\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skill_install::install_from_source(&skills, &b.source, b.update).await {\n Ok(_name) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n // Everything else mixes remote and local causes; BAD_GATEWAY for remote-ish messages\n // would be guesswork — a 400 with the human-readable reason serves the app either way.\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"skill '{name}' not found\")})),\n )\n .into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill path: {e}\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody {\n pub id: String,\n}\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result =\n tokio::task::spawn_blocking(move || crate::engine::plugin_cli::install_plugin(&base, &id))\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n })\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{auth, oneshot_req, test_state};\n use axum::body::Body;\n use axum::http::Request;\n use parking_lot::Mutex;\n use std::sync::Arc;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://x\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(\n !json.contains(\"SECRET\"),\n \"key must never appear in the view: {json}\"\n );\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider {\n api_key: String::new(),\n ..p\n };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(\n !models.is_empty(),\n \"native Claude candidates should be present\"\n );\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"]\n .as_str()\n .unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}}))\n })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| {\n Box::pin(async { Err(crate::engine::usage::UsageError::Status(429)) })\n }));\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req(\n (*st2).clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles)\n .await\n .into_iter()\n .map(|r| r.unwrap())\n .collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(\n *s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\",\n s\n );\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(\n err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\"\n );\n // Delete\n let (s3, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert!(\n !arr2\n .as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(\n st.clone(),\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\"\n );\n // Delete\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n struct NativeOvGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for NativeOvGuard {\n fn drop(&mut self) {\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_native_overrides_file() -> NativeOvGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"native-overrides.json\"));\n NativeOvGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn native_models_get_groups_then_post_marks_customized() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n // GET: families present, opus editable and not customized\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let fams = b[\"families\"].as_array().unwrap();\n // Seed families: fable/opus/opus/sonnet/haiku → 4 groups (opus collapses in routing, but GET\n // groups by discovered family). Ordering contract: cheap → capable (family-name tiebreak).\n assert_eq!(fams.len(), 4);\n let order: Vec<&str> = fams.iter().map(|f| f[\"family\"].as_str().unwrap()).collect();\n assert_eq!(order, vec![\"haiku\", \"sonnet\", \"opus\", \"fable\"]);\n let opus = fams.iter().find(|f| f[\"family\"] == \"opus\").unwrap();\n assert_eq!(opus[\"editable\"], true);\n assert_eq!(opus[\"customized\"], false);\n // A non-overridden family returns an empty description verbatim (the generated per-model\n // fallback happens only at routing time, not in this view).\n assert_eq!(\n fams.iter().find(|f| f[\"family\"] == \"sonnet\").unwrap()[\"description\"],\n \"\"\n );\n\n // POST with mixed-case family normalizes and applies\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/Opus\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.9,\"priority\":0.85,\"cost\":0.2,\"description\":\"hard only\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // GET again: opus is now customized with the new priority\n let (_, b3) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n let opus3 = b3[\"families\"]\n .as_array()\n .unwrap()\n .iter()\n .find(|f| f[\"family\"] == \"opus\")\n .unwrap()\n .clone();\n assert_eq!(opus3[\"customized\"], true);\n // f32→JSON widens to f64, so compare with a tolerance rather than `== 0.85` (which would fail).\n assert!((opus3[\"priority\"].as_f64().unwrap() - 0.85).abs() < 1e-6);\n assert_eq!(opus3[\"description\"], \"hard only\");\n }\n\n #[tokio::test]\n async fn native_models_post_rejects_other_and_bad_family_and_clamps() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // `other` is read-only\n let (s_other, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/other\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_other, StatusCode::BAD_REQUEST);\n\n // unknown family\n let (s_bad, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/nope\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_bad, StatusCode::BAD_REQUEST);\n\n // out-of-range clamps (stored value is 1.0, not 5.0)\n let (s_ok, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/sonnet\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":5.0,\"priority\":-1.0,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_ok, StatusCode::OK);\n let m = crate::engine::native_overrides::load_map();\n assert_eq!(m[\"sonnet\"].capability, 1.0);\n assert_eq!(m[\"sonnet\"].priority, 0.0);\n }\n\n #[tokio::test]\n async fn native_models_delete_resets_and_validates_family() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // seed an override, then reset it\n crate::engine::native_overrides::upsert(\n \"opus\",\n crate::engine::native_overrides::NativeOverride {\n capability: 0.9,\n priority: 0.8,\n cost: 0.2,\n description: String::new(),\n },\n )\n .unwrap();\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(crate::engine::native_overrides::load_map()\n .get(\"opus\")\n .is_none());\n\n // idempotent: deleting again is still OK\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // invalid family (`other` catch-all, and an unknown name) → 400\n for bad in [\"other\", \"nope\"] {\n let (sb, _) = oneshot_req(\n st.clone(),\n Request::delete(format!(\"/api/native-models/{bad}\"))\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(sb, StatusCode::BAD_REQUEST, \"DELETE {bad} must be 400\");\n }\n }\n}\n"}, "1d0a8606f70eeafb639fa789be45e46647ca91f2": {"t": 1785760900.1282108, "n": 70006, "h": "6c39a71c807d596a", "c": "use crate::api::state::AppState;\nuse crate::util::now_ms;\nuse axum::{\n extract::State,\n http::{HeaderMap, StatusCode},\n response::{IntoResponse, Response},\n Json,\n};\nuse serde_json::json;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS {\n return Json(data.clone()).into_response();\n }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c\n .last_error\n .clone()\n .unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n {\n let mut c = st.usage_cache.lock();\n c.last_attempt_at = now_ms();\n }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n {\n let mut c = st.usage_cache.lock();\n c.at = now_ms();\n c.data = Some(data.clone());\n c.last_error = None;\n }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (\n StatusCode::SERVICE_UNAVAILABLE,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(\n &st.config.claude_config_base,\n ))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(\n State(st): State<AppState>,\n) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" | \"mcp\" => {\n let components =\n crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components\n .iter()\n .any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)})),\n )\n .into_response();\n }\n }\n other => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"unknown kind: {other}\")})),\n )\n .into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // MCP: move the definition between mcpServers and the disabled parking key in\n // .claude.json. list_components enumerates both sides, so the id is known-valid\n // here; a concurrent external edit could still make it vanish → surface as an error.\n \"mcp\" => crate::engine::user_config::set_mcp_server_enabled(base, &req.id, req.enabled)\n .and_then(|found| {\n if found {\n Ok(())\n } else {\n Err(std::io::Error::new(\n std::io::ErrorKind::NotFound,\n format!(\"unknown mcp id: {}\", req.id),\n ))\n }\n }),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(\n base,\n &st.config.skills_dir,\n ))\n .into_response(),\n // NotFound = the id vanished between the known-check and the mutate (external edit\n // race) — same logical class as the pre-check's \"unknown id\", so same 400, not a 500.\n Err(e) if e.kind() == std::io::ErrorKind::NotFound => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let b: UpdateGroupBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n match st\n .store\n .update_group(&id, b.name.as_deref(), b.icon.as_deref())\n .await\n {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"group not found\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn groups_delete(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(\n &st.config.templates_path,\n ))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"array of templates required\"})),\n )\n .into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name required\"})),\n )\n .into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"template '{name}' not found\")})),\n )\n .into_response();\n };\n let prompt =\n crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st\n .engine\n .submit_session(\n tpl.repos,\n tpl.skills,\n prompt,\n std::collections::HashMap::new(),\n meta,\n )\n .await\n {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody {\n pub token: Option<String>,\n}\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() {\n Default::default()\n } else {\n serde_json::from_slice(&body).unwrap_or_default()\n };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"token required\"})),\n )\n .into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> = crate::engine::providers::load_list()\n .iter()\n .map(provider_view)\n .collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid provider: {e}\")})),\n )\n .into_response()\n }\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"capability cannot be NaN\"})),\n )\n .into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"priority cannot be NaN\"})),\n )\n .into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"cost cannot be NaN\"})),\n )\n .into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\":\"name, base_url, model are required\"})),\n )\n .into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"router validation failed: {e}\")})),\n )\n .into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\":\"no such provider\"})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── native Claude per-family routing overrides ──\n\n#[derive(serde::Serialize)]\nstruct NativeModelRef {\n id: String,\n display_name: String,\n}\n\n#[derive(serde::Serialize)]\nstruct NativeFamilyView {\n family: String,\n label: String,\n models: Vec<NativeModelRef>,\n capability: f32,\n priority: f32,\n cost: f32,\n description: String,\n customized: bool,\n editable: bool,\n}\n\n#[derive(serde::Deserialize)]\nstruct NativeOverrideReq {\n capability: f32,\n priority: f32,\n cost: f32,\n #[serde(default)]\n description: String,\n}\n\nfn is_editable_family(family: &str) -> bool {\n matches!(family, \"opus\" | \"sonnet\" | \"haiku\" | \"fable\")\n}\n\nfn family_label(family: &str) -> &'static str {\n match family {\n \"opus\" => \"Opus\",\n \"sonnet\" => \"Sonnet\",\n \"haiku\" => \"Haiku\",\n \"fable\" => \"Fable\",\n _ => \"Other\",\n }\n}\n\n/// GET /api/native-models — native Claude families with effective routing metrics + override state.\npub async fn native_models_get() -> Response {\n use crate::engine::providers::{\n family_default_metrics, family_of, native_claude_models, DEFAULT_NATIVE_PRIORITY,\n };\n let overrides = crate::engine::native_overrides::load_map();\n\n // Group discovered models by family, first-seen (newest-first) order.\n let mut order: Vec<&'static str> = Vec::new();\n let mut groups: std::collections::HashMap<&'static str, Vec<NativeModelRef>> =\n std::collections::HashMap::new();\n for m in native_claude_models() {\n let fam = family_of(&m.id);\n groups.entry(fam).or_default().push(NativeModelRef {\n id: m.id.clone(),\n display_name: m.display_name.clone(),\n });\n if !order.contains(&fam) {\n order.push(fam);\n }\n }\n\n let mut views: Vec<NativeFamilyView> = order\n .into_iter()\n .map(|fam| {\n let (dc, dk) = family_default_metrics(fam);\n let (capability, priority, cost, description, customized) = match overrides.get(fam) {\n Some(o) => (\n o.capability,\n o.priority,\n o.cost,\n o.description.clone(),\n true,\n ),\n None => (dc, DEFAULT_NATIVE_PRIORITY, dk, String::new(), false),\n };\n NativeFamilyView {\n family: fam.to_string(),\n label: family_label(fam).to_string(),\n models: groups.remove(fam).unwrap_or_default(),\n capability,\n priority,\n cost,\n description,\n customized,\n editable: is_editable_family(fam),\n }\n })\n .collect();\n // cheap → capable, family-name tiebreak (matches the /api/models ordering contract).\n views.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.family.cmp(&b.family))\n });\n\n Json(json!({ \"families\": views })).into_response()\n}\n\n/// POST /api/native-models/{family} — set a family's routing override.\npub async fn native_models_post(\n axum::extract::Path(family): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n let req: NativeOverrideReq = match serde_json::from_slice(&body) {\n Ok(r) => r,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid override: {e}\")})),\n )\n .into_response()\n }\n };\n for (name, v) in [\n (\"capability\", req.capability),\n (\"priority\", req.priority),\n (\"cost\", req.cost),\n ] {\n if v.is_nan() {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"{name} cannot be NaN\")})),\n )\n .into_response();\n }\n }\n let ov = crate::engine::native_overrides::NativeOverride {\n capability: req.capability.clamp(0.0, 1.0),\n priority: req.priority.clamp(0.0, 1.0),\n cost: req.cost.clamp(0.0, 1.0),\n description: req.description,\n };\n match crate::engine::native_overrides::upsert(&family, ov) {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n/// DELETE /api/native-models/{family} — reset a family to defaults (idempotent).\npub async fn native_models_delete(\n axum::extract::Path(family): axum::extract::Path<String>,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"not an editable family: {family}\")})),\n )\n .into_response();\n }\n match crate::engine::native_overrides::remove(&family) {\n Ok(_) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| {\n a.capability\n .total_cmp(&b.capability)\n .then_with(|| a.key.cmp(&b.key))\n });\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"MCP server '{name}' not found\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody {\n pub name: String,\n pub description: String,\n /// The skill's markdown body — the actual instructions the agent loads. Optional for\n /// back-compat; without it the created skill is an empty shell (frontmatter only).\n #[serde(default)]\n pub instructions: String,\n}\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description, &b.instructions) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CatalogQuery {\n #[serde(default)]\n pub refresh: bool,\n}\n\n/// GET /api/skills/catalog[?refresh=true] — the aggregated external skill store across every\n/// configured source. A broken source shows up in `errors` instead of failing the whole store.\npub async fn skills_catalog_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<CatalogQuery>,\n) -> Response {\n let (skills, errors) =\n crate::engine::skill_install::fetch_catalog(&st.config.claude_config_base, q.refresh).await;\n Json(json!({ \"skills\": skills, \"errors\": errors })).into_response()\n}\n\n/// GET /api/skills/sources — the configured store sources.\npub async fn skills_sources_route(State(st): State<AppState>) -> Response {\n Json(json!({ \"sources\": crate::engine::skill_install::read_sources(&st.config.claude_config_base) })).into_response()\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceBody {\n pub source: String,\n}\n\n/// POST /api/skills/sources — add a store source (owner/repo[/path] or github.com URL).\npub async fn skills_sources_add_route(\n State(st): State<AppState>,\n body: axum::body::Bytes,\n) -> Response {\n let b: SkillSourceBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Caller-fault (bad syntax) → 400 up front; anything add_source itself fails on afterwards\n // is a server-side write problem → 500 (consistent with the DELETE route).\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n match crate::engine::skill_install::add_source(&st.config.claude_config_base, &b.source) {\n Ok(sources) => Json(json!({ \"sources\": sources })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceQuery {\n pub source: String,\n}\n\n/// DELETE /api/skills/sources?source=… — remove a store source (query param: sources contain\n/// slashes, which a path segment would mangle).\npub async fn skills_sources_delete_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<SkillSourceQuery>,\n) -> Response {\n match crate::engine::skill_install::remove_source(&st.config.claude_config_base, &q.source) {\n Ok((sources, true)) => Json(json!({ \"sources\": sources })).into_response(),\n Ok((_, false)) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"unknown source: {}\", q.source)})),\n )\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct InstallSkillBody {\n pub source: String,\n /// true = update: replace an existing install of the same name (atomic swap).\n #[serde(default)]\n pub update: bool,\n}\n\n/// POST /api/skills/install — download a skill (SKILL.md + companion files) from a GitHub\n/// source (`owner/repo[/path]` or a github.com URL) into the skills dir.\npub async fn skills_install_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: InstallSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n // Parse errors are the caller's fault (400) BEFORE any network is touched.\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skill_install::install_from_source(&skills, &b.source, b.update).await {\n Ok(_name) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n // Everything else mixes remote and local causes; BAD_GATEWAY for remote-ish messages\n // would be guesswork — a 400 with the human-readable reason serves the app either way.\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")})),\n )\n .into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => {\n Json(crate::engine::components::list_components(&base, &skills)).into_response()\n }\n Ok(false) => (\n StatusCode::NOT_FOUND,\n Json(json!({\"error\": format!(\"skill '{name}' not found\")})),\n )\n .into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid skill path: {e}\")})),\n )\n .into_response(),\n Err(e) => (\n StatusCode::INTERNAL_SERVER_ERROR,\n Json(json!({\"error\": e.to_string()})),\n )\n .into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody {\n pub id: String,\n}\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid body: {e}\")})),\n )\n .into_response()\n }\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result =\n tokio::task::spawn_blocking(move || crate::engine::plugin_cli::install_plugin(&base, &id))\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")})),\n )\n .into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n })\n .await\n .map_err(|e| format!(\"task error: {e}\"))\n .and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &skills,\n ))\n .into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{auth, oneshot_req, test_state};\n use axum::body::Body;\n use axum::http::Request;\n use parking_lot::Mutex;\n use std::sync::Arc;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://x\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(\n !json.contains(\"SECRET\"),\n \"key must never appear in the view: {json}\"\n );\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider {\n api_key: String::new(),\n ..p\n };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(\n !models.is_empty(),\n \"native Claude candidates should be present\"\n );\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"]\n .as_str()\n .unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"]\n .as_str()\n .unwrap()\n .contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}}))\n })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| {\n Box::pin(async { Err(crate::engine::usage::UsageError::Status(429)) })\n }));\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req(\n (*st2).clone(),\n Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2))\n .body(Body::empty())\n .unwrap(),\n )\n .await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles)\n .await\n .into_iter()\n .map(|r| r.unwrap())\n .collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(\n *s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\",\n s\n );\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\"))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(\n st.clone(),\n Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(\n err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\"\n );\n // Delete\n let (s3, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone())\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(\n st.clone(),\n Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert!(\n !arr2\n .as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\"\n );\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(\n st.clone(),\n Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(\n arr.as_array()\n .unwrap()\n .iter()\n .any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\"\n );\n // Delete\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok)\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(\n st,\n Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n struct NativeOvGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for NativeOvGuard {\n fn drop(&mut self) {\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_native_overrides_file() -> NativeOvGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"native-overrides.json\"));\n NativeOvGuard {\n _dir: dir,\n _lock: lock,\n }\n }\n\n #[tokio::test]\n async fn native_models_get_groups_then_post_marks_customized() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n // GET: families present, opus editable and not customized\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let fams = b[\"families\"].as_array().unwrap();\n // Seed families: fable/opus/opus/sonnet/haiku → 4 groups (opus collapses in routing, but GET\n // groups by discovered family). Ordering contract: cheap → capable (family-name tiebreak).\n assert_eq!(fams.len(), 4);\n let order: Vec<&str> = fams.iter().map(|f| f[\"family\"].as_str().unwrap()).collect();\n assert_eq!(order, vec![\"haiku\", \"sonnet\", \"opus\", \"fable\"]);\n let opus = fams.iter().find(|f| f[\"family\"] == \"opus\").unwrap();\n assert_eq!(opus[\"editable\"], true);\n assert_eq!(opus[\"customized\"], false);\n // A non-overridden family returns an empty description verbatim (the generated per-model\n // fallback happens only at routing time, not in this view).\n assert_eq!(\n fams.iter().find(|f| f[\"family\"] == \"sonnet\").unwrap()[\"description\"],\n \"\"\n );\n\n // POST with mixed-case family normalizes and applies\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/Opus\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.9,\"priority\":0.85,\"cost\":0.2,\"description\":\"hard only\"}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // GET again: opus is now customized with the new priority\n let (_, b3) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n let opus3 = b3[\"families\"]\n .as_array()\n .unwrap()\n .iter()\n .find(|f| f[\"family\"] == \"opus\")\n .unwrap()\n .clone();\n assert_eq!(opus3[\"customized\"], true);\n // f32→JSON widens to f64, so compare with a tolerance rather than `== 0.85` (which would fail).\n assert!((opus3[\"priority\"].as_f64().unwrap() - 0.85).abs() < 1e-6);\n assert_eq!(opus3[\"description\"], \"hard only\");\n }\n\n #[tokio::test]\n async fn native_models_post_rejects_other_and_bad_family_and_clamps() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // `other` is read-only\n let (s_other, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/other\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_other, StatusCode::BAD_REQUEST);\n\n // unknown family\n let (s_bad, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/nope\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_bad, StatusCode::BAD_REQUEST);\n\n // out-of-range clamps (stored value is 1.0, not 5.0)\n let (s_ok, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/sonnet\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\n r#\"{\"capability\":5.0,\"priority\":-1.0,\"cost\":0.5}\"#,\n ))\n .unwrap(),\n )\n .await;\n assert_eq!(s_ok, StatusCode::OK);\n let m = crate::engine::native_overrides::load_map();\n assert_eq!(m[\"sonnet\"].capability, 1.0);\n assert_eq!(m[\"sonnet\"].priority, 0.0);\n }\n\n #[tokio::test]\n async fn native_models_delete_resets_and_validates_family() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // seed an override, then reset it\n crate::engine::native_overrides::upsert(\n \"opus\",\n crate::engine::native_overrides::NativeOverride {\n capability: 0.9,\n priority: 0.8,\n cost: 0.2,\n description: String::new(),\n },\n )\n .unwrap();\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(crate::engine::native_overrides::load_map()\n .get(\"opus\")\n .is_none());\n\n // idempotent: deleting again is still OK\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // invalid family (`other` catch-all, and an unknown name) → 400\n for bad in [\"other\", \"nope\"] {\n let (sb, _) = oneshot_req(\n st.clone(),\n Request::delete(format!(\"/api/native-models/{bad}\"))\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(sb, StatusCode::BAD_REQUEST, \"DELETE {bad} must be 400\");\n }\n }\n}\n"}, "2bfe6c5bbb8f35fd6da8e119b8348cc93eeb42ab": {"t": 1785760901.7885368, "n": 63148, "h": "aa658ca0d18106c7", "c": "use axum::{extract::State, http::{StatusCode, HeaderMap}, response::{IntoResponse, Response}, Json};\nuse serde_json::json;\nuse crate::api::state::AppState;\nuse crate::util::now_ms;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c.last_error.clone().unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n { let mut c = st.usage_cache.lock(); c.last_attempt_at = now_ms(); }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n { let mut c = st.usage_cache.lock(); c.at = now_ms(); c.data = Some(data.clone()); c.last_error = None; }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": e.to_string()}))).into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(&st.config.claude_config_base))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" | \"mcp\" => {\n let components = crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components.iter().any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)}))).into_response();\n }\n }\n other => {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown kind: {other}\")}))).into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // MCP: move the definition between mcpServers and the disabled parking key in\n // .claude.json. list_components enumerates both sides, so the id is known-valid\n // here; a concurrent external edit could still make it vanish → surface as an error.\n \"mcp\" => crate::engine::user_config::set_mcp_server_enabled(base, &req.id, req.enabled).and_then(|found| {\n if found { Ok(()) } else {\n Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!(\"unknown mcp id: {}\", req.id)))\n }\n }),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(base, &st.config.skills_dir)).into_response(),\n // NotFound = the id vanished between the known-check and the mutate (external edit\n // race) — same logical class as the pre-check's \"unknown id\", so same 400, not a 500.\n Err(e) if e.kind() == std::io::ErrorKind::NotFound =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>, body: axum::body::Bytes) -> Response {\n let b: UpdateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n match st.store.update_group(&id, b.name.as_deref(), b.icon.as_deref()).await {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"group not found\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn groups_delete(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(&st.config.templates_path))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"array of templates required\"}))).into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"template '{name}' not found\")}))).into_response();\n };\n let prompt = crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st.engine.submit_session(tpl.repos, tpl.skills, prompt, std::collections::HashMap::new(), meta).await {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody { pub token: Option<String> }\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"token required\"}))).into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> =\n crate::engine::providers::load_list().iter().map(provider_view).collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid provider: {e}\")}))).into_response(),\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"capability cannot be NaN\"}))).into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"priority cannot be NaN\"}))).into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"cost cannot be NaN\"}))).into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name, base_url, model are required\"}))).into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"router validation failed: {e}\")}))).into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"no such provider\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── native Claude per-family routing overrides ──\n\n#[derive(serde::Serialize)]\nstruct NativeModelRef {\n id: String,\n display_name: String,\n}\n\n#[derive(serde::Serialize)]\nstruct NativeFamilyView {\n family: String,\n label: String,\n models: Vec<NativeModelRef>,\n capability: f32,\n priority: f32,\n cost: f32,\n description: String,\n customized: bool,\n editable: bool,\n}\n\n#[derive(serde::Deserialize)]\nstruct NativeOverrideReq {\n capability: f32,\n priority: f32,\n cost: f32,\n #[serde(default)]\n description: String,\n}\n\nfn is_editable_family(family: &str) -> bool {\n matches!(family, \"opus\" | \"sonnet\" | \"haiku\" | \"fable\")\n}\n\nfn family_label(family: &str) -> &'static str {\n match family {\n \"opus\" => \"Opus\",\n \"sonnet\" => \"Sonnet\",\n \"haiku\" => \"Haiku\",\n \"fable\" => \"Fable\",\n _ => \"Other\",\n }\n}\n\n/// GET /api/native-models — native Claude families with effective routing metrics + override state.\npub async fn native_models_get() -> Response {\n use crate::engine::providers::{family_default_metrics, family_of, native_claude_models, DEFAULT_NATIVE_PRIORITY};\n let overrides = crate::engine::native_overrides::load_map();\n\n // Group discovered models by family, first-seen (newest-first) order.\n let mut order: Vec<&'static str> = Vec::new();\n let mut groups: std::collections::HashMap<&'static str, Vec<NativeModelRef>> = std::collections::HashMap::new();\n for m in native_claude_models() {\n let fam = family_of(&m.id);\n groups.entry(fam).or_default().push(NativeModelRef { id: m.id.clone(), display_name: m.display_name.clone() });\n if !order.contains(&fam) {\n order.push(fam);\n }\n }\n\n let mut views: Vec<NativeFamilyView> = order\n .into_iter()\n .map(|fam| {\n let (dc, dk) = family_default_metrics(fam);\n let (capability, priority, cost, description, customized) = match overrides.get(fam) {\n Some(o) => (o.capability, o.priority, o.cost, o.description.clone(), true),\n None => (dc, DEFAULT_NATIVE_PRIORITY, dk, String::new(), false),\n };\n NativeFamilyView {\n family: fam.to_string(),\n label: family_label(fam).to_string(),\n models: groups.remove(fam).unwrap_or_default(),\n capability,\n priority,\n cost,\n description,\n customized,\n editable: is_editable_family(fam),\n }\n })\n .collect();\n // cheap → capable, family-name tiebreak (matches the /api/models ordering contract).\n views.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.family.cmp(&b.family)));\n\n Json(json!({ \"families\": views })).into_response()\n}\n\n/// POST /api/native-models/{family} — set a family's routing override.\npub async fn native_models_post(\n axum::extract::Path(family): axum::extract::Path<String>,\n body: axum::body::Bytes,\n) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"not an editable family: {family}\")}))).into_response();\n }\n let req: NativeOverrideReq = match serde_json::from_slice(&body) {\n Ok(r) => r,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid override: {e}\")}))).into_response(),\n };\n for (name, v) in [(\"capability\", req.capability), (\"priority\", req.priority), (\"cost\", req.cost)] {\n if v.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"{name} cannot be NaN\")}))).into_response();\n }\n }\n let ov = crate::engine::native_overrides::NativeOverride {\n capability: req.capability.clamp(0.0, 1.0),\n priority: req.priority.clamp(0.0, 1.0),\n cost: req.cost.clamp(0.0, 1.0),\n description: req.description,\n };\n match crate::engine::native_overrides::upsert(&family, ov) {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n/// DELETE /api/native-models/{family} — reset a family to defaults (idempotent).\npub async fn native_models_delete(axum::extract::Path(family): axum::extract::Path<String>) -> Response {\n let family = family.trim().to_lowercase();\n if !is_editable_family(&family) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"not an editable family: {family}\")}))).into_response();\n }\n match crate::engine::native_overrides::remove(&family) {\n Ok(_) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"MCP server '{name}' not found\")}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody {\n pub name: String,\n pub description: String,\n /// The skill's markdown body — the actual instructions the agent loads. Optional for\n /// back-compat; without it the created skill is an empty shell (frontmatter only).\n #[serde(default)]\n pub instructions: String,\n}\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description, &b.instructions) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CatalogQuery {\n #[serde(default)]\n pub refresh: bool,\n}\n\n/// GET /api/skills/catalog[?refresh=true] — the aggregated external skill store across every\n/// configured source. A broken source shows up in `errors` instead of failing the whole store.\npub async fn skills_catalog_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<CatalogQuery>,\n) -> Response {\n let (skills, errors) =\n crate::engine::skill_install::fetch_catalog(&st.config.claude_config_base, q.refresh).await;\n Json(json!({ \"skills\": skills, \"errors\": errors })).into_response()\n}\n\n/// GET /api/skills/sources — the configured store sources.\npub async fn skills_sources_route(State(st): State<AppState>) -> Response {\n Json(json!({ \"sources\": crate::engine::skill_install::read_sources(&st.config.claude_config_base) })).into_response()\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceBody { pub source: String }\n\n/// POST /api/skills/sources — add a store source (owner/repo[/path] or github.com URL).\npub async fn skills_sources_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: SkillSourceBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n // Caller-fault (bad syntax) → 400 up front; anything add_source itself fails on afterwards\n // is a server-side write problem → 500 (consistent with the DELETE route).\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n match crate::engine::skill_install::add_source(&st.config.claude_config_base, &b.source) {\n Ok(sources) => Json(json!({ \"sources\": sources })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceQuery { pub source: String }\n\n/// DELETE /api/skills/sources?source=… — remove a store source (query param: sources contain\n/// slashes, which a path segment would mangle).\npub async fn skills_sources_delete_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<SkillSourceQuery>,\n) -> Response {\n match crate::engine::skill_install::remove_source(&st.config.claude_config_base, &q.source) {\n Ok((sources, true)) => Json(json!({ \"sources\": sources })).into_response(),\n Ok((_, false)) => (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"unknown source: {}\", q.source)}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct InstallSkillBody {\n pub source: String,\n /// true = update: replace an existing install of the same name (atomic swap).\n #[serde(default)]\n pub update: bool,\n}\n\n/// POST /api/skills/install — download a skill (SKILL.md + companion files) from a GitHub\n/// source (`owner/repo[/path]` or a github.com URL) into the skills dir.\npub async fn skills_install_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: InstallSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n // Parse errors are the caller's fault (400) BEFORE any network is touched.\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skill_install::install_from_source(&skills, &b.source, b.update).await {\n Ok(_name) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n // Everything else mixes remote and local causes; BAD_GATEWAY for remote-ish messages\n // would be guesswork — a 400 with the human-readable reason serves the app either way.\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"skill '{name}' not found\")}))).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill path: {e}\")}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody { pub id: String }\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::install_plugin(&base, &id)\n }).await.map_err(|e| format!(\"task error: {e}\")).and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(&st.config.claude_config_base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n }).await.map_err(|e| format!(\"task error: {e}\")).and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(&st.config.claude_config_base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{test_state, auth, oneshot_req};\n use axum::body::Body;\n use axum::http::Request;\n use std::sync::Arc;\n use parking_lot::Mutex;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(), base_url: \"https://x\".into(), api_key: \"SECRET\".into(),\n api_key_env: None, model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic, capability: 0.5,\n description: None, priority: 0.5, cost: 0.3, router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(!json.contains(\"SECRET\"), \"key must never appear in the view: {json}\");\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider { api_key: String::new(), ..p };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard { _dir: dir, _lock: lock }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(!models.is_empty(), \"native Claude candidates should be present\");\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"].as_str().unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move { *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}})) })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| Box::pin(async {\n Err(crate::engine::usage::UsageError::Status(429)) })));\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req((*st2).clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2)).body(Body::empty()).unwrap()).await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles).await\n .into_iter().map(|r| r.unwrap()).collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(*s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\", s);\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\");\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(st.clone(), Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(st.clone(), Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone()).body(Body::empty()).unwrap()).await;\n assert_eq!(s2, StatusCode::OK);\n assert!(arr.as_array().unwrap().iter().any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\");\n // Delete\n let (s3, _) = oneshot_req(st.clone(), Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone()).body(Body::empty()).unwrap()).await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(st.clone(), Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert!(!arr2.as_array().unwrap().iter().any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\");\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(st.clone(), Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert!(arr.as_array().unwrap().iter().any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\");\n // Delete\n let (s2, _) = oneshot_req(st.clone(), Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n struct NativeOvGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for NativeOvGuard {\n fn drop(&mut self) {\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_native_overrides_file() -> NativeOvGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::native_overrides::NATIVE_OVERRIDES_FILE_OVERRIDE.lock() =\n Some(dir.path().join(\"native-overrides.json\"));\n NativeOvGuard { _dir: dir, _lock: lock }\n }\n\n #[tokio::test]\n async fn native_models_get_groups_then_post_marks_customized() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n // GET: families present, opus editable and not customized\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n let fams = b[\"families\"].as_array().unwrap();\n // Seed families: fable/opus/opus/sonnet/haiku → 4 groups (opus collapses in routing, but GET\n // groups by discovered family). Ordering contract: cheap → capable (family-name tiebreak).\n assert_eq!(fams.len(), 4);\n let order: Vec<&str> = fams.iter().map(|f| f[\"family\"].as_str().unwrap()).collect();\n assert_eq!(order, vec![\"haiku\", \"sonnet\", \"opus\", \"fable\"]);\n let opus = fams.iter().find(|f| f[\"family\"] == \"opus\").unwrap();\n assert_eq!(opus[\"editable\"], true);\n assert_eq!(opus[\"customized\"], false);\n // A non-overridden family returns an empty description verbatim (the generated per-model\n // fallback happens only at routing time, not in this view).\n assert_eq!(fams.iter().find(|f| f[\"family\"] == \"sonnet\").unwrap()[\"description\"], \"\");\n\n // POST with mixed-case family normalizes and applies\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/Opus\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"capability\":0.9,\"priority\":0.85,\"cost\":0.2,\"description\":\"hard only\"}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // GET again: opus is now customized with the new priority\n let (_, b3) = oneshot_req(\n st.clone(),\n Request::get(\"/api/native-models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n let opus3 = b3[\"families\"].as_array().unwrap().iter().find(|f| f[\"family\"] == \"opus\").unwrap().clone();\n assert_eq!(opus3[\"customized\"], true);\n // f32→JSON widens to f64, so compare with a tolerance rather than `== 0.85` (which would fail).\n assert!((opus3[\"priority\"].as_f64().unwrap() - 0.85).abs() < 1e-6);\n assert_eq!(opus3[\"description\"], \"hard only\");\n }\n\n #[tokio::test]\n async fn native_models_post_rejects_other_and_bad_family_and_clamps() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // `other` is read-only\n let (s_other, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/other\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s_other, StatusCode::BAD_REQUEST);\n\n // unknown family\n let (s_bad, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/nope\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"capability\":0.5,\"priority\":0.5,\"cost\":0.5}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s_bad, StatusCode::BAD_REQUEST);\n\n // out-of-range clamps (stored value is 1.0, not 5.0)\n let (s_ok, _) = oneshot_req(\n st.clone(),\n Request::post(\"/api/native-models/sonnet\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"capability\":5.0,\"priority\":-1.0,\"cost\":0.5}\"#))\n .unwrap(),\n )\n .await;\n assert_eq!(s_ok, StatusCode::OK);\n let m = crate::engine::native_overrides::load_map();\n assert_eq!(m[\"sonnet\"].capability, 1.0);\n assert_eq!(m[\"sonnet\"].priority, 0.0);\n }\n\n #[tokio::test]\n async fn native_models_delete_resets_and_validates_family() {\n let _ov = isolated_native_overrides_file();\n let st = test_state().await;\n\n // seed an override, then reset it\n crate::engine::native_overrides::upsert(\n \"opus\",\n crate::engine::native_overrides::NativeOverride { capability: 0.9, priority: 0.8, cost: 0.2, description: String::new() },\n )\n .unwrap();\n let (s, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s, StatusCode::OK);\n assert!(crate::engine::native_overrides::load_map().get(\"opus\").is_none());\n\n // idempotent: deleting again is still OK\n let (s2, _) = oneshot_req(\n st.clone(),\n Request::delete(\"/api/native-models/opus\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(s2, StatusCode::OK);\n\n // invalid family (`other` catch-all, and an unknown name) → 400\n for bad in [\"other\", \"nope\"] {\n let (sb, _) = oneshot_req(\n st.clone(),\n Request::delete(format!(\"/api/native-models/{bad}\"))\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n assert_eq!(sb, StatusCode::BAD_REQUEST, \"DELETE {bad} must be 400\");\n }\n }\n}\n"}, "293ba5e497bad7e309684d7950db8eacd98c6a39": {"t": 1785760903.5616345, "n": 51434, "h": "bf3a96ab0834db4a", "c": "use axum::{extract::State, http::{StatusCode, HeaderMap}, response::{IntoResponse, Response}, Json};\nuse serde_json::json;\nuse crate::api::state::AppState;\nuse crate::util::now_ms;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c.last_error.clone().unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n { let mut c = st.usage_cache.lock(); c.last_attempt_at = now_ms(); }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n { let mut c = st.usage_cache.lock(); c.at = now_ms(); c.data = Some(data.clone()); c.last_error = None; }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": e.to_string()}))).into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(&st.config.claude_config_base))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" | \"mcp\" => {\n let components = crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components.iter().any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)}))).into_response();\n }\n }\n other => {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown kind: {other}\")}))).into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // MCP: move the definition between mcpServers and the disabled parking key in\n // .claude.json. list_components enumerates both sides, so the id is known-valid\n // here; a concurrent external edit could still make it vanish → surface as an error.\n \"mcp\" => crate::engine::user_config::set_mcp_server_enabled(base, &req.id, req.enabled).and_then(|found| {\n if found { Ok(()) } else {\n Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!(\"unknown mcp id: {}\", req.id)))\n }\n }),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(base, &st.config.skills_dir)).into_response(),\n // NotFound = the id vanished between the known-check and the mutate (external edit\n // race) — same logical class as the pre-check's \"unknown id\", so same 400, not a 500.\n Err(e) if e.kind() == std::io::ErrorKind::NotFound =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>, body: axum::body::Bytes) -> Response {\n let b: UpdateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n match st.store.update_group(&id, b.name.as_deref(), b.icon.as_deref()).await {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"group not found\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn groups_delete(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(&st.config.templates_path))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"array of templates required\"}))).into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"template '{name}' not found\")}))).into_response();\n };\n let prompt = crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st.engine.submit_session(tpl.repos, tpl.skills, prompt, std::collections::HashMap::new(), meta).await {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody { pub token: Option<String> }\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"token required\"}))).into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> =\n crate::engine::providers::load_list().iter().map(provider_view).collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid provider: {e}\")}))).into_response(),\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"capability cannot be NaN\"}))).into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"priority cannot be NaN\"}))).into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"cost cannot be NaN\"}))).into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name, base_url, model are required\"}))).into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"router validation failed: {e}\")}))).into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"no such provider\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"MCP server '{name}' not found\")}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody {\n pub name: String,\n pub description: String,\n /// The skill's markdown body — the actual instructions the agent loads. Optional for\n /// back-compat; without it the created skill is an empty shell (frontmatter only).\n #[serde(default)]\n pub instructions: String,\n}\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description, &b.instructions) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CatalogQuery {\n #[serde(default)]\n pub refresh: bool,\n}\n\n/// GET /api/skills/catalog[?refresh=true] — the aggregated external skill store across every\n/// configured source. A broken source shows up in `errors` instead of failing the whole store.\npub async fn skills_catalog_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<CatalogQuery>,\n) -> Response {\n let (skills, errors) =\n crate::engine::skill_install::fetch_catalog(&st.config.claude_config_base, q.refresh).await;\n Json(json!({ \"skills\": skills, \"errors\": errors })).into_response()\n}\n\n/// GET /api/skills/sources — the configured store sources.\npub async fn skills_sources_route(State(st): State<AppState>) -> Response {\n Json(json!({ \"sources\": crate::engine::skill_install::read_sources(&st.config.claude_config_base) })).into_response()\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceBody { pub source: String }\n\n/// POST /api/skills/sources — add a store source (owner/repo[/path] or github.com URL).\npub async fn skills_sources_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: SkillSourceBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n // Caller-fault (bad syntax) → 400 up front; anything add_source itself fails on afterwards\n // is a server-side write problem → 500 (consistent with the DELETE route).\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n match crate::engine::skill_install::add_source(&st.config.claude_config_base, &b.source) {\n Ok(sources) => Json(json!({ \"sources\": sources })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct SkillSourceQuery { pub source: String }\n\n/// DELETE /api/skills/sources?source=… — remove a store source (query param: sources contain\n/// slashes, which a path segment would mangle).\npub async fn skills_sources_delete_route(\n State(st): State<AppState>,\n axum::extract::Query(q): axum::extract::Query<SkillSourceQuery>,\n) -> Response {\n match crate::engine::skill_install::remove_source(&st.config.claude_config_base, &q.source) {\n Ok((sources, true)) => Json(json!({ \"sources\": sources })).into_response(),\n Ok((_, false)) => (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"unknown source: {}\", q.source)}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct InstallSkillBody {\n pub source: String,\n /// true = update: replace an existing install of the same name (atomic swap).\n #[serde(default)]\n pub update: bool,\n}\n\n/// POST /api/skills/install — download a skill (SKILL.md + companion files) from a GitHub\n/// source (`owner/repo[/path]` or a github.com URL) into the skills dir.\npub async fn skills_install_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: InstallSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n // Parse errors are the caller's fault (400) BEFORE any network is touched.\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skill_install::install_from_source(&skills, &b.source, b.update).await {\n Ok(_name) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n // Everything else mixes remote and local causes; BAD_GATEWAY for remote-ish messages\n // would be guesswork — a 400 with the human-readable reason serves the app either way.\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"skill '{name}' not found\")}))).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill path: {e}\")}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody { pub id: String }\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::install_plugin(&base, &id)\n }).await.map_err(|e| format!(\"task error: {e}\")).and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(&st.config.claude_config_base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n }).await.map_err(|e| format!(\"task error: {e}\")).and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(&st.config.claude_config_base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{test_state, auth, oneshot_req};\n use axum::body::Body;\n use axum::http::Request;\n use std::sync::Arc;\n use parking_lot::Mutex;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(), base_url: \"https://x\".into(), api_key: \"SECRET\".into(),\n api_key_env: None, model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic, capability: 0.5,\n description: None, priority: 0.5, cost: 0.3, router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(!json.contains(\"SECRET\"), \"key must never appear in the view: {json}\");\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider { api_key: String::new(), ..p };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard { _dir: dir, _lock: lock }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(!models.is_empty(), \"native Claude candidates should be present\");\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"].as_str().unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move { *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}})) })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| Box::pin(async {\n Err(crate::engine::usage::UsageError::Status(429)) })));\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req((*st2).clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2)).body(Body::empty()).unwrap()).await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles).await\n .into_iter().map(|r| r.unwrap()).collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(*s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\", s);\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\");\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(st.clone(), Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(st.clone(), Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone()).body(Body::empty()).unwrap()).await;\n assert_eq!(s2, StatusCode::OK);\n assert!(arr.as_array().unwrap().iter().any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\");\n // Delete\n let (s3, _) = oneshot_req(st.clone(), Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone()).body(Body::empty()).unwrap()).await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(st.clone(), Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert!(!arr2.as_array().unwrap().iter().any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\");\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(st.clone(), Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert!(arr.as_array().unwrap().iter().any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\");\n // Delete\n let (s2, _) = oneshot_req(st.clone(), Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n}\n"}, "1f922092f564b5326063036fda84241b3faf956b": {"t": 1785760904.9680138, "n": 48829, "h": "4d69a3cbdd78525d", "c": "use axum::{extract::State, http::{StatusCode, HeaderMap}, response::{IntoResponse, Response}, Json};\nuse serde_json::json;\nuse crate::api::state::AppState;\nuse crate::util::now_ms;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c.last_error.clone().unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n { let mut c = st.usage_cache.lock(); c.last_attempt_at = now_ms(); }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n { let mut c = st.usage_cache.lock(); c.at = now_ms(); c.data = Some(data.clone()); c.last_error = None; }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": e.to_string()}))).into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(&st.config.claude_config_base))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" | \"mcp\" => {\n let components = crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components.iter().any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)}))).into_response();\n }\n }\n other => {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown kind: {other}\")}))).into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // MCP: move the definition between mcpServers and the disabled parking key in\n // .claude.json. list_components enumerates both sides, so the id is known-valid\n // here; a concurrent external edit could still make it vanish → surface as an error.\n \"mcp\" => crate::engine::user_config::set_mcp_server_enabled(base, &req.id, req.enabled).and_then(|found| {\n if found { Ok(()) } else {\n Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!(\"unknown mcp id: {}\", req.id)))\n }\n }),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(base, &st.config.skills_dir)).into_response(),\n // NotFound = the id vanished between the known-check and the mutate (external edit\n // race) — same logical class as the pre-check's \"unknown id\", so same 400, not a 500.\n Err(e) if e.kind() == std::io::ErrorKind::NotFound =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>, body: axum::body::Bytes) -> Response {\n let b: UpdateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n match st.store.update_group(&id, b.name.as_deref(), b.icon.as_deref()).await {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"group not found\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn groups_delete(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(&st.config.templates_path))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"array of templates required\"}))).into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"template '{name}' not found\")}))).into_response();\n };\n let prompt = crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st.engine.submit_session(tpl.repos, tpl.skills, prompt, std::collections::HashMap::new(), meta).await {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody { pub token: Option<String> }\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"token required\"}))).into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> =\n crate::engine::providers::load_list().iter().map(provider_view).collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid provider: {e}\")}))).into_response(),\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"capability cannot be NaN\"}))).into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"priority cannot be NaN\"}))).into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"cost cannot be NaN\"}))).into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name, base_url, model are required\"}))).into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"router validation failed: {e}\")}))).into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"no such provider\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"MCP server '{name}' not found\")}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody {\n pub name: String,\n pub description: String,\n /// The skill's markdown body — the actual instructions the agent loads. Optional for\n /// back-compat; without it the created skill is an empty shell (frontmatter only).\n #[serde(default)]\n pub instructions: String,\n}\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description, &b.instructions) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n/// GET /api/skills/catalog — the curated external skill store (anthropics/skills), cached.\npub async fn skills_catalog_route() -> Response {\n match crate::engine::skill_install::fetch_catalog().await {\n Ok(skills) => Json(json!({ \"skills\": skills })).into_response(),\n Err(e) => (StatusCode::BAD_GATEWAY, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct InstallSkillBody { pub source: String }\n\n/// POST /api/skills/install — download a skill (SKILL.md + companion files) from a GitHub\n/// source (`owner/repo[/path]` or a github.com URL) into the skills dir.\npub async fn skills_install_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: InstallSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n // Parse errors are the caller's fault (400) BEFORE any network is touched.\n if let Err(e) = crate::engine::skill_install::parse_github_source(&b.source) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skill_install::install_from_source(&skills, &b.source).await {\n Ok(_name) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n // Everything else mixes remote and local causes; BAD_GATEWAY for remote-ish messages\n // would be guesswork — a 400 with the human-readable reason serves the app either way.\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"skill '{name}' not found\")}))).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill path: {e}\")}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody { pub id: String }\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::install_plugin(&base, &id)\n }).await.map_err(|e| format!(\"task error: {e}\")).and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(&st.config.claude_config_base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n }).await.map_err(|e| format!(\"task error: {e}\")).and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(&st.config.claude_config_base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{test_state, auth, oneshot_req};\n use axum::body::Body;\n use axum::http::Request;\n use std::sync::Arc;\n use parking_lot::Mutex;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(), base_url: \"https://x\".into(), api_key: \"SECRET\".into(),\n api_key_env: None, model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic, capability: 0.5,\n description: None, priority: 0.5, cost: 0.3, router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(!json.contains(\"SECRET\"), \"key must never appear in the view: {json}\");\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider { api_key: String::new(), ..p };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard { _dir: dir, _lock: lock }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(!models.is_empty(), \"native Claude candidates should be present\");\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"].as_str().unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move { *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}})) })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| Box::pin(async {\n Err(crate::engine::usage::UsageError::Status(429)) })));\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req((*st2).clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2)).body(Body::empty()).unwrap()).await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles).await\n .into_iter().map(|r| r.unwrap()).collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(*s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\", s);\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\");\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(st.clone(), Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(st.clone(), Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone()).body(Body::empty()).unwrap()).await;\n assert_eq!(s2, StatusCode::OK);\n assert!(arr.as_array().unwrap().iter().any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\");\n // Delete\n let (s3, _) = oneshot_req(st.clone(), Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone()).body(Body::empty()).unwrap()).await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(st.clone(), Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert!(!arr2.as_array().unwrap().iter().any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\");\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(st.clone(), Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert!(arr.as_array().unwrap().iter().any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\");\n // Delete\n let (s2, _) = oneshot_req(st.clone(), Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n}\n"}, "18f2e612fec937d7232ced19f05a8ec04c0e54f8": {"t": 1785760906.657136, "n": 47056, "h": "98b8d535f10a6ac4", "c": "use axum::{extract::State, http::{StatusCode, HeaderMap}, response::{IntoResponse, Response}, Json};\nuse serde_json::json;\nuse crate::api::state::AppState;\nuse crate::util::now_ms;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c.last_error.clone().unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n { let mut c = st.usage_cache.lock(); c.last_attempt_at = now_ms(); }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n { let mut c = st.usage_cache.lock(); c.at = now_ms(); c.data = Some(data.clone()); c.last_error = None; }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": e.to_string()}))).into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(&st.config.claude_config_base))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" | \"mcp\" => {\n let components = crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components.iter().any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)}))).into_response();\n }\n }\n other => {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown kind: {other}\")}))).into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // MCP: move the definition between mcpServers and the disabled parking key in\n // .claude.json. list_components enumerates both sides, so the id is known-valid\n // here; a concurrent external edit could still make it vanish → surface as an error.\n \"mcp\" => crate::engine::user_config::set_mcp_server_enabled(base, &req.id, req.enabled).and_then(|found| {\n if found { Ok(()) } else {\n Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!(\"unknown mcp id: {}\", req.id)))\n }\n }),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(base, &st.config.skills_dir)).into_response(),\n // NotFound = the id vanished between the known-check and the mutate (external edit\n // race) — same logical class as the pre-check's \"unknown id\", so same 400, not a 500.\n Err(e) if e.kind() == std::io::ErrorKind::NotFound =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>, body: axum::body::Bytes) -> Response {\n let b: UpdateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n match st.store.update_group(&id, b.name.as_deref(), b.icon.as_deref()).await {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"group not found\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn groups_delete(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(&st.config.templates_path))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"array of templates required\"}))).into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"template '{name}' not found\")}))).into_response();\n };\n let prompt = crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st.engine.submit_session(tpl.repos, tpl.skills, prompt, std::collections::HashMap::new(), meta).await {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody { pub token: Option<String> }\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"token required\"}))).into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> =\n crate::engine::providers::load_list().iter().map(provider_view).collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid provider: {e}\")}))).into_response(),\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"capability cannot be NaN\"}))).into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"priority cannot be NaN\"}))).into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"cost cannot be NaN\"}))).into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name, base_url, model are required\"}))).into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"router validation failed: {e}\")}))).into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"no such provider\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"MCP server '{name}' not found\")}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody {\n pub name: String,\n pub description: String,\n /// The skill's markdown body — the actual instructions the agent loads. Optional for\n /// back-compat; without it the created skill is an empty shell (frontmatter only).\n #[serde(default)]\n pub instructions: String,\n}\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description, &b.instructions) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"skill '{name}' not found\")}))).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill path: {e}\")}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody { pub id: String }\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::install_plugin(&base, &id)\n }).await.map_err(|e| format!(\"task error: {e}\")).and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(&st.config.claude_config_base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n }).await.map_err(|e| format!(\"task error: {e}\")).and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(&st.config.claude_config_base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{test_state, auth, oneshot_req};\n use axum::body::Body;\n use axum::http::Request;\n use std::sync::Arc;\n use parking_lot::Mutex;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(), base_url: \"https://x\".into(), api_key: \"SECRET\".into(),\n api_key_env: None, model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic, capability: 0.5,\n description: None, priority: 0.5, cost: 0.3, router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(!json.contains(\"SECRET\"), \"key must never appear in the view: {json}\");\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider { api_key: String::new(), ..p };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard { _dir: dir, _lock: lock }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(!models.is_empty(), \"native Claude candidates should be present\");\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"].as_str().unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move { *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}})) })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| Box::pin(async {\n Err(crate::engine::usage::UsageError::Status(429)) })));\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req((*st2).clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2)).body(Body::empty()).unwrap()).await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles).await\n .into_iter().map(|r| r.unwrap()).collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(*s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\", s);\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\");\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(st.clone(), Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(st.clone(), Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone()).body(Body::empty()).unwrap()).await;\n assert_eq!(s2, StatusCode::OK);\n assert!(arr.as_array().unwrap().iter().any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\");\n // Delete\n let (s3, _) = oneshot_req(st.clone(), Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone()).body(Body::empty()).unwrap()).await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(st.clone(), Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert!(!arr2.as_array().unwrap().iter().any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\");\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(st.clone(), Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert!(arr.as_array().unwrap().iter().any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\");\n // Delete\n let (s2, _) = oneshot_req(st.clone(), Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n}\n"}, "a3c18089a8d7c533b4bef490f2c02001b33ff272": {"t": 1785760908.1919522, "n": 45889, "h": "4aabc02abf286edb", "c": "use axum::{extract::State, http::{StatusCode, HeaderMap}, response::{IntoResponse, Response}, Json};\nuse serde_json::json;\nuse crate::api::state::AppState;\nuse crate::util::now_ms;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c.last_error.clone().unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n { let mut c = st.usage_cache.lock(); c.last_attempt_at = now_ms(); }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n { let mut c = st.usage_cache.lock(); c.at = now_ms(); c.data = Some(data.clone()); c.last_error = None; }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": e.to_string()}))).into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(&st.config.claude_config_base))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" => {\n let components = crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components.iter().any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)}))).into_response();\n }\n }\n other => {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown kind: {other}\")}))).into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(base, &st.config.skills_dir)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>, body: axum::body::Bytes) -> Response {\n let b: UpdateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n match st.store.update_group(&id, b.name.as_deref(), b.icon.as_deref()).await {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"group not found\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn groups_delete(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(&st.config.templates_path))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"array of templates required\"}))).into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"template '{name}' not found\")}))).into_response();\n };\n let prompt = crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st.engine.submit_session(tpl.repos, tpl.skills, prompt, std::collections::HashMap::new(), meta).await {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody { pub token: Option<String> }\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"token required\"}))).into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> =\n crate::engine::providers::load_list().iter().map(provider_view).collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid provider: {e}\")}))).into_response(),\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"capability cannot be NaN\"}))).into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"priority cannot be NaN\"}))).into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"cost cannot be NaN\"}))).into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name, base_url, model are required\"}))).into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"router validation failed: {e}\")}))).into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"no such provider\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n// ── Component CRUD — add/delete MCP servers, skills, plugins ──\n\npub async fn mcp_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let def: crate::engine::store::McpServerDef = match serde_json::from_slice(&body) {\n Ok(d) => d,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_component_name(&def.name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid or reserved MCP name: {:?}\", def.name)}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::add_mcp_server(&base, &def) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn mcp_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid MCP name: {name:?}\")}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n match crate::engine::user_config::delete_mcp_server(&base, &name) {\n Ok(true) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"MCP server '{name}' not found\")}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddSkillBody { pub name: String, pub description: String }\n\npub async fn skills_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddSkillBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_component_name(&b.name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill name: {:?}\", b.name)}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::add_skill(&skills, &b.name, &b.description) {\n Ok(()) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"skill '{}' already exists\", b.name)}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn skills_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(name): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_component_name(&name) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill name: {name:?}\")}))).into_response();\n }\n let skills = st.config.skills_dir.clone();\n let base = st.config.claude_config_base.clone();\n match crate::engine::skills::delete_skill(&skills, &name) {\n Ok(true) => Json(crate::engine::components::list_components(&base, &skills)).into_response(),\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"skill '{name}' not found\")}))).into_response(),\n Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied =>\n (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid skill path: {e}\")}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize)]\npub struct AddPluginBody { pub id: String }\n\npub async fn plugins_add_route(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: AddPluginBody = match serde_json::from_slice(&body) {\n Ok(b) => b,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid body: {e}\")}))).into_response(),\n };\n if !crate::api::validation::valid_plugin_id(&b.id) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid plugin id: {:?}\", b.id)}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id = b.id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::install_plugin(&base, &id)\n }).await.map_err(|e| format!(\"task error: {e}\")).and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(&st.config.claude_config_base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\npub async fn plugins_delete_route(\n State(st): State<AppState>,\n axum::extract::Path(id): axum::extract::Path<String>,\n) -> Response {\n if !crate::api::validation::valid_plugin_id(&id) {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid plugin id: {id:?}\")}))).into_response();\n }\n let base = st.config.claude_config_base.clone();\n let skills = st.config.skills_dir.clone();\n let id2 = id.clone();\n let result = tokio::task::spawn_blocking(move || {\n crate::engine::plugin_cli::uninstall_plugin(&base, &id2)\n }).await.map_err(|e| format!(\"task error: {e}\")).and_then(|r| r);\n match result {\n Ok(_stdout) => Json(crate::engine::components::list_components(&st.config.claude_config_base, &skills)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{test_state, auth, oneshot_req};\n use axum::body::Body;\n use axum::http::Request;\n use std::sync::Arc;\n use parking_lot::Mutex;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(), base_url: \"https://x\".into(), api_key: \"SECRET\".into(),\n api_key_env: None, model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic, capability: 0.5,\n description: None, priority: 0.5, cost: 0.3, router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(!json.contains(\"SECRET\"), \"key must never appear in the view: {json}\");\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider { api_key: String::new(), ..p };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard { _dir: dir, _lock: lock }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(!models.is_empty(), \"native Claude candidates should be present\");\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"].as_str().unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move { *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}})) })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| Box::pin(async {\n Err(crate::engine::usage::UsageError::Status(429)) })));\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req((*st2).clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2)).body(Body::empty()).unwrap()).await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles).await\n .into_iter().map(|r| r.unwrap()).collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(*s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\", s);\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n\n // ── Component CRUD tests ──\n\n #[tokio::test]\n async fn mcp_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"../evil\",\"command\":\"x\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"invalid\"));\n }\n\n #[tokio::test]\n async fn mcp_add_rejects_reserved_agentic_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"agentic\",\"command\":\"x\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n let err = b[\"error\"].as_str().unwrap();\n assert!(err.to_lowercase().contains(\"reserved\") || err.contains(\"invalid\"),\n \"error should mention reserved or invalid: {err}\");\n }\n\n #[tokio::test]\n async fn mcp_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Add\n let (s, _) = oneshot_req(st.clone(), Request::post(\"/api/mcp-servers\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"test-mcp\",\"command\":\"node\",\"args\":[\"s.js\"]}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n // Verify in list\n let (s2, arr) = oneshot_req(st.clone(), Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok.clone()).body(Body::empty()).unwrap()).await;\n assert_eq!(s2, StatusCode::OK);\n assert!(arr.as_array().unwrap().iter().any(|c| c[\"kind\"] == \"mcp\" && c[\"id\"] == \"test-mcp\"),\n \"mcp must appear after add: {arr}\");\n // Delete\n let (s3, _) = oneshot_req(st.clone(), Request::delete(\"/api/mcp-servers/test-mcp\")\n .header(\"authorization\", tok.clone()).body(Body::empty()).unwrap()).await;\n assert_eq!(s3, StatusCode::OK);\n // Verify gone\n let (_, arr2) = oneshot_req(st.clone(), Request::get(\"/api/global-settings\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert!(!arr2.as_array().unwrap().iter().any(|c| c[\"id\"] == \"test-mcp\"),\n \"mcp must be gone after delete: {arr2}\");\n }\n\n #[tokio::test]\n async fn mcp_delete_absent_is_404() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::delete(\"/api/mcp-servers/no-such\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_rejects_bad_name() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/skills\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"a/b\",\"description\":\"d\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n\n #[tokio::test]\n async fn skills_add_and_delete_round_trip() {\n let st = test_state().await;\n let tok = auth(&st);\n // Ensure skills_dir exists (test_state sets it to temp/skills)\n std::fs::create_dir_all(&st.config.skills_dir).unwrap();\n // Add\n let (s, arr) = oneshot_req(st.clone(), Request::post(\"/api/skills\")\n .header(\"authorization\", tok.clone())\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"my-skill\",\"description\":\"does stuff\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert!(arr.as_array().unwrap().iter().any(|c| c[\"kind\"] == \"skill\" && c[\"id\"] == \"my-skill\"),\n \"skill must appear in component list: {arr}\");\n // Delete\n let (s2, _) = oneshot_req(st.clone(), Request::delete(\"/api/skills/my-skill\")\n .header(\"authorization\", tok).body(Body::empty()).unwrap()).await;\n assert_eq!(s2, StatusCode::OK);\n }\n\n #[tokio::test]\n async fn plugins_add_rejects_bad_id() {\n let st = test_state().await;\n let tok = auth(&st);\n let (s, b) = oneshot_req(st, Request::post(\"/api/plugins\")\n .header(\"authorization\", tok)\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"id\":\"a b\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().is_some());\n }\n}\n"}, "a629c9bf3d29e73b270146877a4577ae6eaa8e71": {"t": 1785760909.7879097, "n": 34835, "h": "c7215b63c760d306", "c": "use axum::{extract::State, http::{StatusCode, HeaderMap}, response::{IntoResponse, Response}, Json};\nuse serde_json::json;\nuse crate::api::state::AppState;\nuse crate::util::now_ms;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c.last_error.clone().unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n { let mut c = st.usage_cache.lock(); c.last_attempt_at = now_ms(); }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n { let mut c = st.usage_cache.lock(); c.at = now_ms(); c.data = Some(data.clone()); c.last_error = None; }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": e.to_string()}))).into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(&st.config.claude_config_base))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" => {\n let components = crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components.iter().any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)}))).into_response();\n }\n }\n other => {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown kind: {other}\")}))).into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(base, &st.config.skills_dir)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>, body: axum::body::Bytes) -> Response {\n let b: UpdateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n match st.store.update_group(&id, b.name.as_deref(), b.icon.as_deref()).await {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"group not found\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn groups_delete(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(&st.config.templates_path))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"array of templates required\"}))).into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"template '{name}' not found\")}))).into_response();\n };\n let prompt = crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n forced_on_plugins: Vec::new(), // templates don't carry forced-on overrides (yet)\n forced_on_skills: Vec::new(),\n forced_on_mcp_servers: Vec::new(),\n };\n match st.engine.submit_session(tpl.repos, tpl.skills, prompt, std::collections::HashMap::new(), meta).await {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody { pub token: Option<String> }\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"token required\"}))).into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> =\n crate::engine::providers::load_list().iter().map(provider_view).collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid provider: {e}\")}))).into_response(),\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"capability cannot be NaN\"}))).into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"priority cannot be NaN\"}))).into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"cost cannot be NaN\"}))).into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name, base_url, model are required\"}))).into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"router validation failed: {e}\")}))).into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"no such provider\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{test_state, auth, oneshot_req};\n use axum::body::Body;\n use axum::http::Request;\n use std::sync::Arc;\n use parking_lot::Mutex;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(), base_url: \"https://x\".into(), api_key: \"SECRET\".into(),\n api_key_env: None, model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic, capability: 0.5,\n description: None, priority: 0.5, cost: 0.3, router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(!json.contains(\"SECRET\"), \"key must never appear in the view: {json}\");\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider { api_key: String::new(), ..p };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard { _dir: dir, _lock: lock }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(!models.is_empty(), \"native Claude candidates should be present\");\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"].as_str().unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move { *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}})) })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| Box::pin(async {\n Err(crate::engine::usage::UsageError::Status(429)) })));\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req((*st2).clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2)).body(Body::empty()).unwrap()).await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles).await\n .into_iter().map(|r| r.unwrap()).collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(*s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\", s);\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n}\n"}, "34828f71d770f6ce942e6cbe6170c37b2e82d1d1": {"t": 1785760911.2374823, "n": 34664, "h": "d1c382675d5b659c", "c": "use axum::{extract::State, http::{StatusCode, HeaderMap}, response::{IntoResponse, Response}, Json};\nuse serde_json::json;\nuse crate::api::state::AppState;\nuse crate::util::now_ms;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c.last_error.clone().unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n { let mut c = st.usage_cache.lock(); c.last_attempt_at = now_ms(); }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n { let mut c = st.usage_cache.lock(); c.at = now_ms(); c.data = Some(data.clone()); c.last_error = None; }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": e.to_string()}))).into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(&st.config.claude_config_base))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" => {\n let components = crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components.iter().any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)}))).into_response();\n }\n }\n other => {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown kind: {other}\")}))).into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(base, &st.config.skills_dir)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>, body: axum::body::Bytes) -> Response {\n let b: UpdateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n match st.store.update_group(&id, b.name.as_deref(), b.icon.as_deref()).await {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"group not found\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn groups_delete(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(&st.config.templates_path))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"array of templates required\"}))).into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"template '{name}' not found\")}))).into_response();\n };\n let prompt = crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n hidden_mcp_servers: Vec::new(), // templates don't carry an MCP blacklist (yet)\n extra_mcp_servers: Vec::new(), // templates don't carry extra MCP servers (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n };\n match st.engine.submit_session(tpl.repos, tpl.skills, prompt, std::collections::HashMap::new(), meta).await {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody { pub token: Option<String> }\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"token required\"}))).into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> =\n crate::engine::providers::load_list().iter().map(provider_view).collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid provider: {e}\")}))).into_response(),\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"capability cannot be NaN\"}))).into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"priority cannot be NaN\"}))).into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"cost cannot be NaN\"}))).into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name, base_url, model are required\"}))).into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"router validation failed: {e}\")}))).into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"no such provider\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{test_state, auth, oneshot_req};\n use axum::body::Body;\n use axum::http::Request;\n use std::sync::Arc;\n use parking_lot::Mutex;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(), base_url: \"https://x\".into(), api_key: \"SECRET\".into(),\n api_key_env: None, model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic, capability: 0.5,\n description: None, priority: 0.5, cost: 0.3, router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(!json.contains(\"SECRET\"), \"key must never appear in the view: {json}\");\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider { api_key: String::new(), ..p };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard { _dir: dir, _lock: lock }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(!models.is_empty(), \"native Claude candidates should be present\");\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"].as_str().unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move { *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}})) })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| Box::pin(async {\n Err(crate::engine::usage::UsageError::Status(429)) })));\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req((*st2).clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2)).body(Body::empty()).unwrap()).await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles).await\n .into_iter().map(|r| r.unwrap()).collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(*s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\", s);\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n}\n"}, "dc6a2600f42d41200e0890b163077917147b7855": {"t": 1785760913.0446198, "n": 34488, "h": "937c52056b8be221", "c": "use axum::{extract::State, http::{StatusCode, HeaderMap}, response::{IntoResponse, Response}, Json};\nuse serde_json::json;\nuse crate::api::state::AppState;\nuse crate::util::now_ms;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c.last_error.clone().unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n { let mut c = st.usage_cache.lock(); c.last_attempt_at = now_ms(); }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n { let mut c = st.usage_cache.lock(); c.at = now_ms(); c.data = Some(data.clone()); c.last_error = None; }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": e.to_string()}))).into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(&st.config.claude_config_base))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n\n // Validate kind first; then confirm the id is an installed/enumerable component of that kind.\n // Globally-disabled components are still enumerated by list_components, so toggling them back\n // on must succeed — we only reject ids that are not installed at all.\n match req.kind.as_str() {\n \"plugin\" | \"skill\" => {\n let components = crate::engine::components::list_components(base, &st.config.skills_dir);\n let known = components.iter().any(|c| c.kind == req.kind && c.id == req.id);\n if !known {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown {} id: {}\", req.kind, req.id)}))).into_response();\n }\n }\n other => {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown kind: {other}\")}))).into_response();\n }\n }\n\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n // Unreachable: the match above already validated kind.\n _ => unreachable!(),\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(base, &st.config.skills_dir)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>, body: axum::body::Bytes) -> Response {\n let b: UpdateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n match st.store.update_group(&id, b.name.as_deref(), b.icon.as_deref()).await {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"group not found\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn groups_delete(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(&st.config.templates_path))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"array of templates required\"}))).into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"template '{name}' not found\")}))).into_response();\n };\n let prompt = crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n };\n match st.engine.submit_session(tpl.repos, tpl.skills, prompt, std::collections::HashMap::new(), meta).await {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody { pub token: Option<String> }\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"token required\"}))).into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> =\n crate::engine::providers::load_list().iter().map(provider_view).collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid provider: {e}\")}))).into_response(),\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"capability cannot be NaN\"}))).into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"priority cannot be NaN\"}))).into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"cost cannot be NaN\"}))).into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name, base_url, model are required\"}))).into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"router validation failed: {e}\")}))).into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"no such provider\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{test_state, auth, oneshot_req};\n use axum::body::Body;\n use axum::http::Request;\n use std::sync::Arc;\n use parking_lot::Mutex;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(), base_url: \"https://x\".into(), api_key: \"SECRET\".into(),\n api_key_env: None, model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic, capability: 0.5,\n description: None, priority: 0.5, cost: 0.3, router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(!json.contains(\"SECRET\"), \"key must never appear in the view: {json}\");\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider { api_key: String::new(), ..p };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard { _dir: dir, _lock: lock }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(!models.is_empty(), \"native Claude candidates should be present\");\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"].as_str().unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move { *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}})) })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| Box::pin(async {\n Err(crate::engine::usage::UsageError::Status(429)) })));\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req((*st2).clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2)).body(Body::empty()).unwrap()).await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles).await\n .into_iter().map(|r| r.unwrap()).collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(*s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\", s);\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n}\n"}, "b3f43a7b2fc2b2ac060a040977ae52e34591efcf": {"t": 1785760915.752118, "n": 33675, "h": "c02b796eb824f665", "c": "use axum::{extract::State, http::{StatusCode, HeaderMap}, response::{IntoResponse, Response}, Json};\nuse serde_json::json;\nuse crate::api::state::AppState;\nuse crate::util::now_ms;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c.last_error.clone().unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n { let mut c = st.usage_cache.lock(); c.last_attempt_at = now_ms(); }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n { let mut c = st.usage_cache.lock(); c.at = now_ms(); c.data = Some(data.clone()); c.last_error = None; }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": e.to_string()}))).into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(&st.config.claude_config_base))\n}\n\n/// GET /api/global-settings — unified skill+plugin components with their global on/off state.\npub async fn global_settings_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::components::list_components(\n &st.config.claude_config_base,\n &st.config.skills_dir,\n ))\n}\n\n#[derive(serde::Deserialize)]\npub struct ToggleReq {\n pub kind: String,\n pub id: String,\n pub enabled: bool,\n}\n\n/// POST /api/global-settings/toggle — flip one component globally (writes settings.local.json).\npub async fn global_settings_toggle_route(\n State(st): State<AppState>,\n Json(req): Json<ToggleReq>,\n) -> Response {\n let base = &st.config.claude_config_base;\n let res = match req.kind.as_str() {\n \"plugin\" => crate::engine::global_settings::set_plugin_enabled(base, &req.id, req.enabled),\n \"skill\" => crate::engine::global_settings::set_skill_enabled(base, &req.id, req.enabled),\n other => {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"unknown kind: {other}\")}))).into_response();\n }\n };\n match res {\n Ok(()) => Json(crate::engine::components::list_components(base, &st.config.skills_dir)).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>, body: axum::body::Bytes) -> Response {\n let b: UpdateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n match st.store.update_group(&id, b.name.as_deref(), b.icon.as_deref()).await {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"group not found\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn groups_delete(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(&st.config.templates_path))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"array of templates required\"}))).into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"template '{name}' not found\")}))).into_response();\n };\n let prompt = crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n };\n match st.engine.submit_session(tpl.repos, tpl.skills, prompt, std::collections::HashMap::new(), meta).await {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody { pub token: Option<String> }\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"token required\"}))).into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> =\n crate::engine::providers::load_list().iter().map(provider_view).collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid provider: {e}\")}))).into_response(),\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"capability cannot be NaN\"}))).into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"priority cannot be NaN\"}))).into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"cost cannot be NaN\"}))).into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name, base_url, model are required\"}))).into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"router validation failed: {e}\")}))).into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"no such provider\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{test_state, auth, oneshot_req};\n use axum::body::Body;\n use axum::http::Request;\n use std::sync::Arc;\n use parking_lot::Mutex;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(), base_url: \"https://x\".into(), api_key: \"SECRET\".into(),\n api_key_env: None, model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic, capability: 0.5,\n description: None, priority: 0.5, cost: 0.3, router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(!json.contains(\"SECRET\"), \"key must never appear in the view: {json}\");\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider { api_key: String::new(), ..p };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard { _dir: dir, _lock: lock }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(!models.is_empty(), \"native Claude candidates should be present\");\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"].as_str().unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move { *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}})) })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| Box::pin(async {\n Err(crate::engine::usage::UsageError::Status(429)) })));\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req((*st2).clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2)).body(Body::empty()).unwrap()).await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles).await\n .into_iter().map(|r| r.unwrap()).collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(*s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\", s);\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n}\n"}, "6132c7db349680ea3acb5894a6f3cd83560d8428": {"t": 1785760917.14181, "n": 32316, "h": "371802d450afb18f", "c": "use axum::{extract::State, http::{StatusCode, HeaderMap}, response::{IntoResponse, Response}, Json};\nuse serde_json::json;\nuse crate::api::state::AppState;\nuse crate::util::now_ms;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c.last_error.clone().unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n { let mut c = st.usage_cache.lock(); c.last_attempt_at = now_ms(); }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n { let mut c = st.usage_cache.lock(); c.at = now_ms(); c.data = Some(data.clone()); c.last_error = None; }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": e.to_string()}))).into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(&st.config.claude_config_base))\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>, body: axum::body::Bytes) -> Response {\n let b: UpdateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n match st.store.update_group(&id, b.name.as_deref(), b.icon.as_deref()).await {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"group not found\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn groups_delete(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(&st.config.templates_path))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"array of templates required\"}))).into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"template '{name}' not found\")}))).into_response();\n };\n let prompt = crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n };\n match st.engine.submit_session(tpl.repos, tpl.skills, prompt, std::collections::HashMap::new(), meta).await {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody { pub token: Option<String> }\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"token required\"}))).into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> =\n crate::engine::providers::load_list().iter().map(provider_view).collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid provider: {e}\")}))).into_response(),\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"capability cannot be NaN\"}))).into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"priority cannot be NaN\"}))).into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"cost cannot be NaN\"}))).into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name, base_url, model are required\"}))).into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"router validation failed: {e}\")}))).into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"no such provider\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{test_state, auth, oneshot_req};\n use axum::body::Body;\n use axum::http::Request;\n use std::sync::Arc;\n use parking_lot::Mutex;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(), base_url: \"https://x\".into(), api_key: \"SECRET\".into(),\n api_key_env: None, model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic, capability: 0.5,\n description: None, priority: 0.5, cost: 0.3, router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(!json.contains(\"SECRET\"), \"key must never appear in the view: {json}\");\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider { api_key: String::new(), ..p };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). Uses the data-race-free\n /// PROVIDERS_FILE_OVERRIDE static instead of `env::set_var` — setenv racing a concurrent getenv\n /// from any other test thread is UB in glibc (the \"process-global set_var race\" flake). The\n /// override is process-global, so a lock still serializes the tests that use it; Drop clears it.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = None;\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n *crate::engine::providers::PROVIDERS_FILE_OVERRIDE.lock() = Some(dir.path().join(\"providers.json\"));\n ProvidersFileGuard { _dir: dir, _lock: lock }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(!models.is_empty(), \"native Claude candidates should be present\");\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"].as_str().unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move { *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}})) })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| Box::pin(async {\n Err(crate::engine::usage::UsageError::Status(429)) })));\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req((*st2).clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2)).body(Body::empty()).unwrap()).await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles).await\n .into_iter().map(|r| r.unwrap()).collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(*s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\", s);\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n}\n"}, "a2be59a5e2a46c6492ce8dbcca7c745ea36b6380": {"t": 1785760918.475745, "n": 32059, "h": "00e93720b1294447", "c": "use axum::{extract::State, http::{StatusCode, HeaderMap}, response::{IntoResponse, Response}, Json};\nuse serde_json::json;\nuse crate::api::state::AppState;\nuse crate::util::now_ms;\n\nconst USAGE_FRESH_MS: i64 = 60_000;\nconst USAGE_STALE_MAX_MS: i64 = 10 * 60_000;\n\npub async fn usage_route(State(st): State<AppState>) -> Response {\n let now = now_ms();\n // Fast path: fresh cache.\n {\n let c = st.usage_cache.lock();\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n }\n // Coalesce concurrent misses behind the inflight async lock (single-flight).\n // This ensures exactly ONE upstream fetch fires per burst, even on failures —\n // single-flight: exactly one upstream fetch fires per burst, coalescing concurrent waiters.\n let _guard = st.usage_inflight.lock().await;\n // Re-check after acquiring the lock: another waiter may have just refreshed the cache,\n // OR may have just attempted a fetch (even a failed one). In the failure case we check\n // last_attempt_at so that waiters queued behind a failed fetch don't each fire their\n // own upstream call — they short-circuit to the stale/503 path immediately.\n {\n let c = st.usage_cache.lock();\n let t = now_ms();\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_FRESH_MS { return Json(data.clone()).into_response(); }\n }\n // If the holder just attempted (and failed) within the fresh window, serve stale or 503\n // rather than re-firing the upstream call.\n if c.last_attempt_at > 0 && t - c.last_attempt_at < USAGE_FRESH_MS {\n if let Some(ref data) = c.data {\n if t - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n // Return the real upstream error from the holder's failed fetch,\n // falling back to a generic message before any attempt has failed.\n let err = c.last_error.clone().unwrap_or_else(|| \"usage fetch failed\".to_string());\n return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": err}))).into_response();\n }\n }\n // Record attempt time BEFORE firing so late-arriving waiters (who acquire after us) skip.\n { let mut c = st.usage_cache.lock(); c.last_attempt_at = now_ms(); }\n let base = st.config.claude_config_base.clone();\n let fetched = match &st.usage_fn {\n Some(f) => f().await,\n None => crate::engine::usage::fetch_usage(&base, None).await,\n };\n match fetched {\n Ok(data) => {\n { let mut c = st.usage_cache.lock(); c.at = now_ms(); c.data = Some(data.clone()); c.last_error = None; }\n Json(data).into_response()\n }\n Err(e) => {\n // Transient failure: record the error (so single-flight losers can surface it) and\n // serve last-good while not too stale, with x-usage-stale: 1.\n let mut c = st.usage_cache.lock();\n c.last_error = Some(e.to_string());\n if let Some(ref data) = c.data {\n if now - c.at < USAGE_STALE_MAX_MS {\n let mut headers = HeaderMap::new();\n headers.insert(\"x-usage-stale\", \"1\".parse().unwrap());\n return (headers, Json(data.clone())).into_response();\n }\n }\n (StatusCode::SERVICE_UNAVAILABLE, Json(json!({\"error\": e.to_string()}))).into_response()\n }\n }\n}\n\npub async fn repos_route(State(st): State<AppState>) -> Json<serde_json::Value> {\n let local = crate::engine::repos::list_repos(&st.config.src_root);\n let remote = crate::engine::repos::list_remote_repos(&st.config.git_org, None);\n Json(json!({ \"local\": local, \"remote\": remote }))\n}\n\npub async fn skills_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::skills::list_skills(&st.config.skills_dir))\n}\n\n/// GET /api/plugins — installed Claude Code plugins (`<plugin>@<marketplace>` ids), read from\n/// `<claude_config_base>/plugins/installed_plugins.json`. Sessions share that config dir, so\n/// this is exactly the plugin set a new session would load. Same shape philosophy as\n/// [skills_route]: a plain JSON array of `{name}` objects.\npub async fn plugins_route(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::plugins::list_plugins(&st.config.claude_config_base))\n}\n\n// ── DB-backed groups CRUD (replaces the old file-based groups_get / groups_put) ──\n\npub async fn groups_list(State(st): State<AppState>) -> Response {\n match st.store.list_groups().await {\n Ok(groups) => Json(json!({ \"groups\": groups })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct CreateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_create(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: CreateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.trim().is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n match st.store.create_group(name.trim(), b.icon.as_deref()).await {\n Ok(group) => Json(json!({ \"group\": group })).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct UpdateGroupBody {\n pub name: Option<String>,\n pub icon: Option<String>,\n}\n\npub async fn groups_update(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>, body: axum::body::Bytes) -> Response {\n let b: UpdateGroupBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n match st.store.update_group(&id, b.name.as_deref(), b.icon.as_deref()).await {\n Ok(Some(group)) => Json(json!({ \"group\": group })).into_response(),\n Ok(None) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"group not found\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn groups_delete(State(st): State<AppState>, axum::extract::Path(id): axum::extract::Path<String>) -> Response {\n match st.store.delete_group(&id).await {\n Ok(()) => Json(json!({\"ok\": true})).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\npub async fn templates_get(State(st): State<AppState>) -> impl axum::response::IntoResponse {\n Json(crate::engine::templates::list_templates(&st.config.templates_path))\n}\n\npub async fn templates_put(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let v: serde_json::Value = serde_json::from_slice(&body).unwrap_or(serde_json::Value::Null);\n if !v.is_array() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"array of templates required\"}))).into_response();\n }\n match crate::engine::templates::save_templates(&st.config.templates_path, &v) {\n Ok(t) => Json(t).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct TemplateStartBody {\n pub name: Option<String>,\n pub vars: Option<std::collections::HashMap<String, String>>,\n pub model: Option<String>,\n pub effort: Option<String>,\n pub mode: Option<String>,\n}\n\npub async fn templates_start(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: TemplateStartBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let Some(name) = b.name.filter(|n| !n.is_empty()) else {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name required\"}))).into_response();\n };\n let templates = crate::engine::templates::list_templates(&st.config.templates_path);\n let Some(tpl) = templates.into_iter().find(|t| t.name == name) else {\n return (StatusCode::NOT_FOUND, Json(json!({\"error\": format!(\"template '{name}' not found\")}))).into_response();\n };\n let prompt = crate::engine::templates::resolve_prompt(&tpl.prompt_body, &b.vars.unwrap_or_default());\n let meta = crate::engine::SubmitMeta {\n model: b.model.or(tpl.model),\n effort: b.effort.or(tpl.effort),\n mode: b.mode.or(tpl.mode),\n permission_mode: None, // templates don't carry a permission_mode (yet)\n hidden_skills: Vec::new(), // templates don't carry a skill blacklist (yet)\n hidden_plugins: Vec::new(), // templates don't carry a plugin blacklist (yet)\n claude_md: None, // templates don't carry session-scoped CLAUDE.md (yet)\n staged_uploads: Vec::new(), // templates don't carry pre-session attachments\n };\n match st.engine.submit_session(tpl.repos, tpl.skills, prompt, std::collections::HashMap::new(), meta).await {\n Ok(id) => Json(json!({ \"id\": id })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e}))).into_response(),\n }\n}\n\n#[derive(serde::Deserialize, Default)]\npub struct DeviceBody { pub token: Option<String> }\n\npub async fn devices_post(State(st): State<AppState>, body: axum::body::Bytes) -> Response {\n let b: DeviceBody = if body.is_empty() { Default::default() }\n else { serde_json::from_slice(&body).unwrap_or_default() };\n let token = b.token.unwrap_or_default();\n let token = token.trim().to_string();\n if token.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"token required\"}))).into_response();\n }\n match crate::engine::push::save_device_token(&st.config.device_token_path, &token) {\n Ok(rec) => Json(json!({ \"ok\": true, \"registeredAt\": rec.registered_at })).into_response(),\n Err(e) => (StatusCode::BAD_REQUEST, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── provider registry CRUD (BYOK: keys live server-side; GET masks them) ──\n\n#[derive(serde::Serialize)]\nstruct ProviderView {\n name: String,\n base_url: String,\n model: String,\n protocol: String,\n capability: f32,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n description: Option<String>,\n priority: f32,\n cost: f32,\n router: bool,\n has_key: bool,\n}\n\nfn provider_view(p: &crate::engine::providers::Provider) -> ProviderView {\n ProviderView {\n name: p.name.clone(),\n base_url: p.base_url.clone(),\n model: p.model.clone(),\n protocol: format!(\"{:?}\", p.protocol).to_lowercase(),\n capability: p.capability,\n description: p.description.clone(),\n priority: p.priority,\n cost: p.cost,\n router: p.router,\n has_key: !p.resolved_key().is_empty(),\n }\n}\n\n/// GET /api/providers — list registered providers, keys masked (api_key is never returned).\npub async fn providers_get() -> Response {\n let views: Vec<ProviderView> =\n crate::engine::providers::load_list().iter().map(provider_view).collect();\n Json(json!({ \"providers\": views })).into_response()\n}\n\n/// POST /api/providers — add or replace a provider by name. Body (snake_case):\n/// {name, base_url, api_key | api_key_env, model, protocol?, capability?, description?, priority?, router?}.\n/// The key is write-only: on an EDIT (a name that already exists) a blank `api_key` means \"keep the\n/// stored key\" — `upsert` preserves the existing credential rather than wiping it.\npub async fn providers_post(body: axum::body::Bytes) -> Response {\n let mut p: crate::engine::providers::Provider = match serde_json::from_slice(&body) {\n Ok(p) => p,\n Err(e) => return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"invalid provider: {e}\")}))).into_response(),\n };\n // Trim before saving: routing/deletion use exact (case-insensitive) name equality, so a stored\n // \" minimax \" could never be matched/deleted by \"minimax\".\n p.name = p.name.trim().to_string();\n p.base_url = p.base_url.trim().to_string();\n p.model = p.model.trim().to_string();\n // Capability is the routing axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.capability.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"capability cannot be NaN\"}))).into_response();\n }\n p.capability = p.capability.clamp(0.0, 1.0);\n // Priority is the routing-preference axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.priority.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"priority cannot be NaN\"}))).into_response();\n }\n p.priority = p.priority.clamp(0.0, 1.0);\n // Cost is the routing-cost axis (0–1): reject NaN (clamp panics on NaN) then clamp.\n if p.cost.is_nan() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"cost cannot be NaN\"}))).into_response();\n }\n p.cost = p.cost.clamp(0.0, 1.0);\n if p.name.is_empty() || p.base_url.is_empty() || p.model.is_empty() {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\":\"name, base_url, model are required\"}))).into_response();\n }\n // Validate the ROUTER relationship at set time: a provider flagged as the router that can't produce\n // a usable routing reply would silently make every later delegate fan-out fall back to native\n // Claude. Probe it now and reject the save with a clear reason instead. Only runs when router=true,\n // so ordinary provider saves are unaffected. Use the EFFECTIVE key — a blank incoming api_key on an\n // edit means \"keep the stored key\" (mirrors upsert), so resolve it from the existing record first.\n if p.router {\n let mut probe = p.clone();\n if probe.api_key.is_empty() && probe.api_key_env.is_none() {\n if let Some(existing) = crate::engine::providers::load_list()\n .into_iter()\n .find(|x| x.name.eq_ignore_ascii_case(&p.name))\n {\n probe.api_key = existing.api_key;\n probe.api_key_env = existing.api_key_env;\n }\n }\n if let Err(e) = crate::engine::router::validate_router(&probe, None).await {\n return (StatusCode::BAD_REQUEST, Json(json!({\"error\": format!(\"router validation failed: {e}\")}))).into_response();\n }\n }\n match crate::engine::providers::upsert(p) {\n Ok(()) => {\n // An openai provider may have been added/changed → regenerate the LiteLLM config + restart.\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n/// DELETE /api/providers/{name} — remove a provider.\npub async fn providers_delete(axum::extract::Path(name): axum::extract::Path<String>) -> Response {\n match crate::engine::providers::remove(&name) {\n Ok(true) => {\n crate::engine::litellm::request_reload();\n Json(json!({\"ok\": true})).into_response()\n }\n Ok(false) => (StatusCode::NOT_FOUND, Json(json!({\"error\":\"no such provider\"}))).into_response(),\n Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({\"error\": e.to_string()}))).into_response(),\n }\n}\n\n// ── model catalog (native Claude tiers + registered BYOK providers) ──\n\n#[derive(serde::Serialize)]\nstruct ModelEntry {\n key: String, // \"claude-opus-4-8\"\n label: String, // \"Opus 4.8\"\n native: bool, // true for subscription tiers\n default: bool, // true for the strongest native Claude\n capability: f32, // 0..1, for ordering\n cost: f32, // 0..1 (lower = cheaper), for the UI cost indicator\n}\n\n#[derive(serde::Deserialize)]\npub struct ModelsQuery {\n scope: Option<String>,\n}\n\nfn native_model_entries() -> Vec<ModelEntry> {\n use crate::engine::providers::{family_metrics, native_claude_models};\n\n let models = native_claude_models();\n // Default = the newest Opus-family model (the subscription's daily driver), else the first\n // discovered model. The list is in API order (newest first), so `find` picks the newest.\n let default_key = models\n .iter()\n .find(|m| m.id.contains(\"opus\"))\n .or_else(|| models.first())\n .map(|m| m.id.clone())\n .unwrap_or_default();\n let mut entries: Vec<ModelEntry> = models\n .iter()\n .map(|m| {\n let (capability, cost) = family_metrics(&m.id);\n ModelEntry {\n key: m.id.clone(),\n label: m.display_name.clone(),\n native: true,\n default: m.id == default_key,\n capability,\n cost,\n }\n })\n .collect();\n // Cheap → capable for the slider, id as a deterministic tiebreaker. For the current\n // single-generation id shapes this reads oldest → newest within a family (…Sonnet 4.5, 4.6, 5,\n // Opus 4.5…4.8, Fable 5), so the whole slider is monotonically \"stronger going right\".\n // Cross-generation legacy ids (claude-3-5-* vs claude-3-*) or a two-digit minor (\"…-4-10\")\n // would read out of order — cosmetic only: same capability tier, key-based selection unaffected.\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\nfn full_model_entries() -> Vec<ModelEntry> {\n let mut entries = native_model_entries();\n for p in &crate::engine::providers::load_list() {\n entries.push(ModelEntry {\n key: p.name.clone(),\n label: format!(\"{} ({})\", p.model, p.name),\n native: false,\n default: false,\n capability: p.capability,\n cost: p.cost,\n });\n }\n // Same ordering contract as native_model_entries: cheap → capable, id as the deterministic\n // tiebreaker (within a Claude family that reads oldest → newest).\n entries.sort_by(|a, b| a.capability.total_cmp(&b.capability).then_with(|| a.key.cmp(&b.key)));\n entries\n}\n\n/// GET /api/models — model catalog.\n/// - no scope: native Claude tiers + registered BYOK providers\n/// - scope=session_start: native Claude tiers only, for main-thread model pickers\npub async fn models_get(axum::extract::Query(q): axum::extract::Query<ModelsQuery>) -> Response {\n let entries = match q.scope.as_deref() {\n None | Some(\"\") => full_model_entries(),\n Some(\"session_start\") => native_model_entries(),\n Some(other) => {\n return (\n StatusCode::BAD_REQUEST,\n Json(json!({ \"error\": format!(\"invalid models scope: {other}\") })),\n )\n .into_response();\n }\n };\n\n Json(json!({ \"models\": entries })).into_response()\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::api::test_support::{test_state, auth, oneshot_req};\n use axum::body::Body;\n use axum::http::Request;\n use std::sync::Arc;\n use parking_lot::Mutex;\n\n #[test]\n fn provider_view_masks_the_key() {\n let p = crate::engine::providers::Provider {\n name: \"minimax\".into(), base_url: \"https://x\".into(), api_key: \"SECRET\".into(),\n api_key_env: None, model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic, capability: 0.5,\n description: None, priority: 0.5, cost: 0.3, router: false,\n };\n let json = serde_json::to_string(&provider_view(&p)).unwrap();\n assert!(!json.contains(\"SECRET\"), \"key must never appear in the view: {json}\");\n assert!(!json.contains(\"api_key\"));\n assert!(json.contains(\"\\\"has_key\\\":true\"));\n let p2 = crate::engine::providers::Provider { api_key: String::new(), ..p };\n assert!(!provider_view(&p2).has_key);\n }\n\n /// Points `providers_file_path()` at a fresh temp file for the duration of a test, so tests\n /// exercising the BYOK registry NEVER touch the real `~/.agentic-dev/providers.json` (a\n /// non-empty upsert there would overwrite the user's stored API keys). The env var is\n /// process-global, so a lock serializes the tests that use it; Drop restores the env.\n struct ProvidersFileGuard {\n _dir: tempfile::TempDir,\n _lock: std::sync::MutexGuard<'static, ()>,\n }\n impl Drop for ProvidersFileGuard {\n fn drop(&mut self) {\n std::env::remove_var(\"AGENTIC_PROVIDERS_FILE\");\n }\n }\n fn isolated_providers_file() -> ProvidersFileGuard {\n static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());\n let lock = LOCK.lock().unwrap_or_else(|e| e.into_inner());\n let dir = tempfile::tempdir().unwrap();\n std::env::set_var(\"AGENTIC_PROVIDERS_FILE\", dir.path().join(\"providers.json\"));\n ProvidersFileGuard { _dir: dir, _lock: lock }\n }\n\n #[tokio::test]\n async fn models_get_default_scope_includes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"minimax\".into(),\n base_url: \"https://api.minimaxi.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"MiniMax-M3\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.5,\n description: None,\n priority: 0.5,\n cost: 0.3,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(models.iter().any(|m| m[\"key\"] == \"minimax\"));\n assert!(models.iter().any(|m| m[\"native\"] == false));\n }\n\n #[tokio::test]\n async fn models_get_session_start_scope_excludes_registered_providers() {\n let _providers = isolated_providers_file();\n let st = test_state().await;\n crate::engine::providers::seed_claude_models_for_tests();\n\n crate::engine::providers::upsert_at(\n &crate::engine::providers::providers_file_path(),\n crate::engine::providers::Provider {\n name: \"deepseek\".into(),\n base_url: \"https://api.deepseek.com/anthropic\".into(),\n api_key: \"SECRET\".into(),\n api_key_env: None,\n model: \"deepseek-chat\".into(),\n protocol: crate::engine::providers::Protocol::Anthropic,\n capability: 0.6,\n description: None,\n priority: 0.5,\n cost: 0.5,\n router: false,\n },\n )\n .unwrap();\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=session_start\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::OK);\n let models = b[\"models\"].as_array().unwrap();\n assert!(!models.is_empty(), \"native Claude candidates should be present\");\n assert!(!models.iter().any(|m| m[\"key\"] == \"deepseek\"));\n assert!(models.iter().all(|m| m[\"native\"] == true));\n // Slider reads monotonically \"stronger going right\": families cheap → capable, and WITHIN\n // a family oldest → newest (opus 4.7 before 4.8). Seed: fable-5, opus-4-8, opus-4-7,\n // sonnet-4-6, haiku-4-5-20251001.\n let keys: Vec<&str> = models.iter().map(|m| m[\"key\"].as_str().unwrap()).collect();\n assert_eq!(\n keys,\n vec![\n \"claude-haiku-4-5-20251001\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-7\",\n \"claude-opus-4-8\",\n \"claude-fable-5\",\n ]\n );\n // Default = the NEWEST opus, even though it's not the last entry.\n let default_key = models.iter().find(|m| m[\"default\"] == true).unwrap()[\"key\"].as_str().unwrap();\n assert_eq!(default_key, \"claude-opus-4-8\");\n }\n\n #[tokio::test]\n async fn models_get_rejects_unknown_scope() {\n let st = test_state().await;\n\n let (s, b) = oneshot_req(\n st.clone(),\n Request::get(\"/api/models?scope=delegate\")\n .header(\"authorization\", auth(&st))\n .body(Body::empty())\n .unwrap(),\n )\n .await;\n\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"scope\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_missing_model() {\n // Validation fires BEFORE the upsert, so a 400 here never touches the providers file.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"x\",\"base_url\":\"https://x/anthropic\",\"model\":\"\",\"api_key\":\"k\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"required\"));\n }\n\n #[tokio::test]\n async fn providers_post_rejects_unvalidatable_router() {\n // router=true on an openai-protocol provider can never run the (anthropic) routing call, so the\n // set-time validation rejects it BEFORE the upsert — the providers file is never written. This\n // path is network-free: validate_router checks protocol before making any HTTP call.\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/providers\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"rtr\",\"base_url\":\"https://x/anthropic\",\"model\":\"m\",\"api_key\":\"k\",\"protocol\":\"openai\",\"router\":true}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert!(b[\"error\"].as_str().unwrap().contains(\"router validation failed\"));\n }\n\n #[tokio::test]\n async fn usage_serves_fetch_then_caches() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move { *c.lock() += 1;\n Ok(serde_json::json!({\"five_hour\":{\"utilization\":12,\"resets_at\":\"x\"}})) })\n }));\n for _ in 0..2 {\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"five_hour\"][\"utilization\"], 12);\n }\n // Within the 60s freshness window the second call is served from cache → fetch ran once.\n assert_eq!(*calls.lock(), 1);\n }\n\n #[tokio::test]\n async fn usage_failure_with_no_cache_is_503() {\n let mut st = test_state().await;\n st.usage_fn = Some(Arc::new(|| Box::pin(async {\n Err(crate::engine::usage::UsageError::Status(429)) })));\n let (s, b) = oneshot_req(st.clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st)).body(Body::empty()).unwrap()).await;\n assert_eq!(s, StatusCode::SERVICE_UNAVAILABLE);\n assert_eq!(b[\"error\"], \"usage endpoint 429\");\n }\n\n /// 5 concurrent /api/usage calls where the usage_fn always errors should\n /// result in exactly ONE upstream call (single-flight coalescing on failure path).\n #[tokio::test(flavor = \"multi_thread\", worker_threads = 4)]\n async fn usage_single_flight_coalesces_on_failure() {\n let mut st = test_state().await;\n let calls = Arc::new(Mutex::new(0u32));\n let c = calls.clone();\n st.usage_fn = Some(Arc::new(move || {\n let c = c.clone();\n Box::pin(async move {\n // Slight delay so concurrent callers pile up behind the inflight lock.\n tokio::time::sleep(std::time::Duration::from_millis(20)).await;\n *c.lock() += 1;\n Err(crate::engine::usage::UsageError::Status(429))\n })\n }));\n\n let st = Arc::new(st);\n let mut handles = Vec::new();\n for _ in 0..5 {\n let st2 = st.clone();\n handles.push(tokio::spawn(async move {\n oneshot_req((*st2).clone(), Request::get(\"/api/usage\")\n .header(\"authorization\", auth(&st2)).body(Body::empty()).unwrap()).await\n }));\n }\n let results: Vec<_> = futures_util::future::join_all(handles).await\n .into_iter().map(|r| r.unwrap()).collect();\n\n // All must return an error status (503 or 503/stale).\n for (s, _b) in &results {\n assert!(*s == StatusCode::SERVICE_UNAVAILABLE,\n \"expected 503 on failure, got {}\", s);\n }\n // The upstream usage_fn must have been called exactly ONCE.\n let n = *calls.lock();\n assert_eq!(n, 1, \"expected exactly 1 upstream call, got {n}\");\n }\n\n #[tokio::test]\n async fn devices_post_requires_token() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"token required\");\n }\n\n #[tokio::test]\n async fn devices_post_saves_and_returns_ok() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/devices\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"token\":\"my-device-token\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::OK);\n assert_eq!(b[\"ok\"], true);\n assert!(b[\"registeredAt\"].is_i64());\n }\n\n #[tokio::test]\n async fn templates_start_no_name_is_400() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(\"{}\")).unwrap()).await;\n assert_eq!(s, StatusCode::BAD_REQUEST);\n assert_eq!(b[\"error\"], \"name required\");\n }\n\n #[tokio::test]\n async fn templates_start_unknown_name_is_404() {\n let st = test_state().await;\n let (s, b) = oneshot_req(st.clone(), Request::post(\"/api/templates/start\")\n .header(\"authorization\", auth(&st))\n .header(\"content-type\", \"application/json\")\n .body(Body::from(r#\"{\"name\":\"no-such\"}\"#)).unwrap()).await;\n assert_eq!(s, StatusCode::NOT_FOUND);\n assert!(b[\"error\"].as_str().unwrap().contains(\"no-such\"));\n }\n}\n"}}}} |