Files

1 line
1.1 MiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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"}}}}