feat: merge opus comfyui and login updates

This commit is contained in:
boos
2026-05-21 16:32:04 +02:00
parent c16479da7c
commit 9c84759498
105 changed files with 20160 additions and 1034 deletions
+8
View File
@@ -24,6 +24,12 @@ pub enum AppError {
#[error("Rate limited")]
RateLimited,
#[error("Service unavailable: {0}")]
ServiceUnavailable(String),
#[error("Gateway timeout: {0}")]
GatewayTimeout(String),
#[error("Internal error: {0}")]
Internal(#[from] anyhow::Error),
@@ -40,6 +46,8 @@ impl IntoResponse for AppError {
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::UpstreamError(msg) => (StatusCode::BAD_GATEWAY, msg.clone()),
AppError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "rate_limited".into()),
AppError::ServiceUnavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg.clone()),
AppError::GatewayTimeout(msg) => (StatusCode::GATEWAY_TIMEOUT, msg.clone()),
AppError::Internal(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
AppError::Database(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
};
+12 -1
View File
@@ -16,6 +16,7 @@ pub struct AppState {
pub config: config::AppConfig,
pub db: Arc<SqlitePool>,
pub comfyui: Arc<services::comfyui_process::ComfyUIProcess>,
pub comfyui_pool: Arc<services::comfyui_pool::ComfyUIPool>,
pub builtin_keys: Arc<services::builtin_keys::BuiltinKeys>,
}
@@ -34,15 +35,25 @@ impl AppState {
let comfyui_agent_secret = builtin_keys.comfyui_agent.1.clone();
let comfyui = services::comfyui_process::ComfyUIProcess::new(
config.comfyui.clone(),
boosapi_url,
boosapi_url.clone(),
config.auth.proxy_token.clone(),
comfyui_agent_secret.clone(),
);
// Per-user multi-tenant ComfyUI pool. Backed by Docker today; the
// ContainerRuntime trait makes K3S a drop-in v2.
let docker = Arc::new(services::docker_runtime::DockerRuntime::new());
let pool_cfg = services::comfyui_pool::PoolConfig::from_env(
format!("{}/v1", boosapi_url.trim_end_matches('/')),
comfyui_agent_secret,
);
let comfyui_pool = services::comfyui_pool::ComfyUIPool::new(docker, pool_cfg);
Self {
config,
db: Arc::new(db),
comfyui,
comfyui_pool,
builtin_keys: Arc::new(builtin_keys),
}
}
+21
View File
@@ -105,6 +105,20 @@ async fn main() -> anyhow::Result<()> {
}
let comfyui_process = state.comfyui.clone();
// Multi-tenant ComfyUI pool: adopt any containers that survived a
// previous API restart, then start the idle-reaper task. Only
// wired in when the `multi_tenant_comfy` feature is on; under the
// legacy singleton path the pool is constructed but unused, and
// `comfyui_pool` is `None` so we skip shutdown_all on exit.
#[cfg(feature = "multi_tenant_comfy")]
let comfyui_pool = {
state.comfyui_pool.reconcile_on_startup().await;
boosapi::services::comfyui_pool::spawn_reaper(state.comfyui_pool.clone());
Some(state.comfyui_pool.clone())
};
#[cfg(not(feature = "multi_tenant_comfy"))]
let comfyui_pool: Option<std::sync::Arc<boosapi::services::comfyui_pool::ComfyUIPool>> = None;
// Build router
let app = boosapi::routes::build_router(state);
@@ -123,6 +137,13 @@ async fn main() -> anyhow::Result<()> {
tracing::warn!(%err, "ComfyUI embedded process did not stop cleanly");
}
// Tear down any per-user ComfyUI containers we spawned. This is
// best-effort — a hard kill of the API leaves them running and the
// next startup will adopt them via reconcile_on_startup().
if let Some(pool) = comfyui_pool {
pool.shutdown_all().await;
}
Ok(())
}
+77
View File
@@ -209,3 +209,80 @@ pub async fn execute_image_generation(
Ok(json)
}
/// One field forwarded to the upstream `/images/edits` endpoint.
///
/// `bytes` is None → text field; Some → file field. We don't try to
/// detect the difference at the HTTP layer because OpenAI's spec already
/// pins which fields are files (`image`, `image[]`, `mask`) — the route
/// handler classifies them based on the field's content-type/filename.
pub struct MultipartField {
pub name: String,
pub bytes: Option<Vec<u8>>,
pub filename: Option<String>,
pub content_type: Option<String>,
/// Text value for non-file fields. Ignored when `bytes` is Some.
pub text: Option<String>,
}
/// Execute an image edits request (`POST /v1/images/edits`).
///
/// Unlike /images/generations this is `multipart/form-data` (the request
/// carries one or more image binaries plus the prompt). We rebuild a
/// fresh upstream multipart form from the parsed fields rather than
/// streaming the raw body — that lets us swap out the `model` field with
/// the route-resolved upstream model and skip any client-side fields
/// that don't belong on the wire.
pub async fn execute_image_edit(
_state: AppState,
route: UpstreamRoute,
fields: Vec<MultipartField>,
) -> AppResult<Value> {
use reqwest::multipart::{Form, Part};
let client = http_client();
let upstream_url = format!("{}/images/edits", route.base_url.trim_end_matches('/'));
let mut form = Form::new();
for field in fields {
if let Some(bytes) = field.bytes {
let mut part = Part::bytes(bytes);
if let Some(filename) = field.filename {
part = part.file_name(filename);
}
if let Some(ct) = field.content_type {
part = part
.mime_str(&ct)
.map_err(|e| AppError::BadRequest(format!("invalid content-type: {e}")))?;
}
form = form.part(field.name, part);
} else {
form = form.text(field.name, field.text.unwrap_or_default());
}
}
let resp = client
.post(&upstream_url)
.header("Authorization", format!("Bearer {}", route.api_key))
.multipart(form)
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("Upstream request failed: {e}")))?;
let status = resp.status();
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(format!("Failed to read upstream response: {e}")))?;
if !status.is_success() {
return Err(AppError::UpstreamError(format!(
"Upstream returned {status}: {text}"
)));
}
let json: Value = serde_json::from_str(&text)
.map_err(|e| AppError::UpstreamError(format!("Invalid JSON from upstream: {e}")))?;
Ok(json)
}
+16
View File
@@ -376,6 +376,22 @@ mod tests {
.execute(&pool)
.await
.unwrap();
// select_channel_for_route's COALESCE subquery joins through
// site_api_endpoints. The table must exist or the SELECT errors
// out — we leave it empty and the COALESCE falls back to sites.url.
sqlx::query(
"CREATE TABLE site_api_endpoints (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0,
cooldown_until TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO sites (id, name, url, api_key) VALUES (1, 'OpenAI', 'https://api.example/v1', 'site-key')")
.execute(&pool)
+162 -11
View File
@@ -147,16 +147,8 @@ pub async fn list_accounts(
let enriched: Vec<Value> = accounts
.iter()
.map(|account| {
let mut row = serde_json::to_value(account).unwrap_or(json!({}));
if let Some(site) = site_by_id.get(&account.site_id) {
if let Value::Object(map) = &mut row {
map.insert(
"site".to_string(),
serde_json::to_value(site).unwrap_or(Value::Null),
);
}
}
row
let site = site_by_id.get(&account.site_id).copied();
account_response_value(account, site)
})
.collect();
@@ -168,6 +160,49 @@ pub async fn list_accounts(
})))
}
fn account_response_value(account: &Account, site: Option<&Site>) -> Value {
let credential_mode = resolve_account_credential_mode(account);
let mut row = serde_json::to_value(account).unwrap_or(json!({}));
if let Value::Object(map) = &mut row {
map.insert("credentialMode".to_string(), json!(credential_mode));
map.insert(
"capabilities".to_string(),
json!({
"canCheckin": credential_mode == "session",
"canRefreshBalance": credential_mode == "session",
"proxyOnly": credential_mode == "apikey",
}),
);
if let Some(site) = site {
map.insert(
"site".to_string(),
serde_json::to_value(site).unwrap_or(Value::Null),
);
}
}
row
}
fn resolve_account_credential_mode(account: &Account) -> &'static str {
let Some(extra_config) = account.extra_config.as_deref() else {
return "apikey";
};
let Ok(value) = serde_json::from_str::<Value>(extra_config) else {
return "apikey";
};
match value
.get("credentialMode")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_ascii_lowercase()
.as_str()
{
"session" => "session",
_ => "apikey",
}
}
/// POST /api/accounts
pub async fn create_account(
State(state): State<AppState>,
@@ -279,12 +314,17 @@ pub async fn create_account(
spawn_route_rebuild(state.clone());
let site: Option<Site> = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
.bind(account.site_id)
.fetch_optional(&*state.db)
.await?;
Ok((
StatusCode::CREATED,
Json(json!({
"success": true,
"id": account.id,
"data": account,
"data": account_response_value(&account, site.as_ref()),
})),
))
}
@@ -524,6 +564,117 @@ pub async fn delete_account(
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
}
#[derive(Debug, Deserialize)]
pub struct BatchAccountsRequest {
pub ids: Vec<i64>,
pub action: String,
}
/// POST /api/accounts/batch
///
/// Per-id loop, no transaction: a delete on row 5 failing must not roll back
/// the successful delete on row 3 — the frontend's `runBatchAccountAction`
/// reads `successIds` / `failedItems` and re-selects only the failed ones so
/// the operator can retry the bad rows in isolation.
///
/// Supported actions:
/// * `delete` — `DELETE FROM accounts WHERE id = ?`
/// * `enable` — `status = 'active'`
/// * `disable` — `status = 'disabled'`
/// * `refreshBalance` — not implemented; every id goes to `failedItems` so
/// the UI shows "成功 0, 失败 N" instead of silently
/// claiming success without changing the balance row.
///
/// `spawn_route_rebuild` is fired once at the end if any row was actually
/// touched (status flip or delete) — `rebuild_routes` filters on
/// `status = 'active'`, so any change to that set invalidates the route_channels
/// view and needs a rebuild. Doing it once after the loop (rather than per-id)
/// avoids N concurrent rebuilds racing each other.
pub async fn batch_accounts(
State(state): State<AppState>,
Json(payload): Json<BatchAccountsRequest>,
) -> AppResult<Json<Value>> {
info!(
"Batch account action: action={} count={}",
payload.action,
payload.ids.len()
);
if payload.ids.is_empty() {
return Ok(Json(json!({
"data": { "successIds": [], "failedItems": [] }
})));
}
let mut success_ids: Vec<i64> = Vec::with_capacity(payload.ids.len());
let mut failed_items: Vec<Value> = Vec::new();
let mut routing_dirty = false;
for id in &payload.ids {
let result: Result<(), String> = match payload.action.as_str() {
"delete" => {
match sqlx::query("DELETE FROM accounts WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await
{
Ok(r) if r.rows_affected() == 0 => Err("账号不存在".into()),
Ok(_) => {
routing_dirty = true;
Ok(())
}
Err(e) => Err(format!("DB error: {}", e)),
}
}
"enable" | "disable" => {
let new_status = if payload.action == "enable" {
"active"
} else {
"disabled"
};
match sqlx::query(
"UPDATE accounts SET status = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(new_status)
.bind(id)
.execute(&*state.db)
.await
{
Ok(r) if r.rows_affected() == 0 => Err("账号不存在".into()),
Ok(_) => {
routing_dirty = true;
Ok(())
}
Err(e) => Err(format!("DB error: {}", e)),
}
}
"refreshBalance" => {
// Per-site balance probing isn't implemented yet — see proposal
// notes. Mark every id failed so the UI surfaces "成功 0" rather
// than pretending the balance was refreshed.
Err("单站点余额探测尚未实现".into())
}
other => Err(format!("不支持的批量操作: {}", other)),
};
match result {
Ok(()) => success_ids.push(*id),
Err(msg) => failed_items.push(json!({ "id": id, "error": msg })),
}
}
if routing_dirty {
spawn_route_rebuild(state.clone());
}
Ok(Json(json!({
"data": {
"successIds": success_ids,
"failedItems": failed_items,
}
})))
}
async fn resolve_site_api_base_url(state: &AppState, site: &Site) -> AppResult<String> {
let endpoint: Option<String> = sqlx::query_scalar(
"SELECT url
+6 -2
View File
@@ -365,8 +365,12 @@ async fn clear_route_cooldown(
Ok(Json(json!({ "success": true })))
}
async fn rebuild_routes() -> Json<Value> {
Json(json!({ "success": true, "message": "routes already available" }))
async fn rebuild_routes(State(state): State<AppState>) -> AppResult<Json<Value>> {
let summary = crate::services::route_rebuild::rebuild_routes(&state).await?;
Ok(Json(json!({
"success": true,
"rebuild": summary,
})))
}
async fn batch_routes(
+272 -1
View File
@@ -13,6 +13,38 @@ use serde_json::{json, Value};
use tracing::info;
use uuid::Uuid;
/// Sentinel name for the built-in ComfyUI Agent downstream key.
///
/// The system row is uniquely identified by `name = SYSTEM_KEY_NAME AND
/// user_id IS NULL` — see `services::builtin_keys::ensure_comfyui_agent_key`.
const SYSTEM_KEY_NAME: &str = "comfyui-agent";
fn is_system_key(name: &str, user_id: Option<i64>) -> bool {
name == SYSTEM_KEY_NAME && user_id.is_none()
}
/// Mask a key down to `••••••••<last4>`. Operates on chars so non-ASCII
/// keys (unlikely but possible) do not panic on byte slicing.
fn mask_key(key: &str) -> String {
let chars: Vec<char> = key.chars().collect();
let last4: String = chars[chars.len().saturating_sub(4)..].iter().collect();
format!("••••••••{}", last4)
}
/// Project a `DownstreamApiKey` row to its admin-list JSON shape, masking the
/// secret and tagging `system: true` for the built-in `comfyui-agent` row.
fn key_to_response(key: DownstreamApiKey) -> Value {
let system = is_system_key(&key.name, key.user_id);
let mut value = serde_json::to_value(&key).unwrap_or_else(|_| json!({}));
if system {
if let Value::Object(ref mut map) = value {
map.insert("system".to_string(), Value::Bool(true));
map.insert("key".to_string(), Value::String(mask_key(&key.key)));
}
}
value
}
#[derive(Debug, Deserialize)]
pub struct ListKeysQuery {
pub q: Option<String>,
@@ -89,8 +121,9 @@ pub async fn list_downstream_keys(
.fetch_all(&*state.db)
.await?;
let projected: Vec<Value> = keys.into_iter().map(key_to_response).collect();
Ok(Json(
json!({ "success": true, "items": keys, "data": keys }),
json!({ "success": true, "items": projected, "data": projected }),
))
}
@@ -170,6 +203,16 @@ pub async fn update_downstream_key(
AppError::NotFound(format!("Downstream key with id {} not found", id))
})?;
// Guard: refuse any request that tries to disable the built-in
// `comfyui-agent` system key (`name='comfyui-agent' AND user_id IS NULL`).
// The agent's outbound calls in Local mode bear this key — disabling it
// would silently break image / TTS / chat for every user.
if is_system_key(&existing.name, existing.user_id) && payload.enabled == Some(false) {
return Err(AppError::Forbidden(
"system key cannot be disabled".into(),
));
}
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
validate_supported_models(models)?;
Some(
@@ -214,6 +257,24 @@ pub async fn delete_downstream_key(
) -> AppResult<Json<Value>> {
info!("Deleting downstream API key: {}", id);
// Pre-fetch so we can refuse the built-in `comfyui-agent` row before
// running the DELETE. Returning 403 instead of silently filtering keeps
// the failure observable to operators who attempt the action.
let existing: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| {
AppError::NotFound(format!("Downstream key with id {} not found", id))
})?;
if is_system_key(&existing.name, existing.user_id) {
return Err(AppError::Forbidden(
"system key cannot be deleted".into(),
));
}
let affected = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ?")
.bind(id)
.execute(&*state.db)
@@ -266,3 +327,213 @@ pub async fn reset_downstream_key(
Ok(Json(json!({ "data": api_key })))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
use crate::services::builtin_keys::BuiltinKeys;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query(
"CREATE TABLE downstream_api_keys (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
key TEXT NOT NULL,
description TEXT,
group_name TEXT,
tags TEXT,
enabled INTEGER DEFAULT 1,
expires_at TEXT,
max_cost REAL,
used_cost REAL DEFAULT 0,
max_requests INTEGER,
used_requests INTEGER DEFAULT 0,
supported_models TEXT,
allowed_route_ids TEXT,
site_weight_multipliers TEXT,
excluded_site_ids TEXT,
excluded_credential_refs TEXT,
last_used_at TEXT,
user_id INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.unwrap();
// Row 1: built-in system key (name='comfyui-agent', user_id IS NULL)
sqlx::query(
"INSERT INTO downstream_api_keys (id, name, key, enabled, user_id) VALUES (1, 'comfyui-agent', 'sk-comfyui-abcdef0123456789', 1, NULL)"
)
.execute(&pool)
.await
.unwrap();
// Row 2: regular admin-scoped key
sqlx::query(
"INSERT INTO downstream_api_keys (id, name, key, enabled, user_id) VALUES (2, 'team-prod', 'sk-prod-xyz123', 1, NULL)"
)
.execute(&pool)
.await
.unwrap();
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
},
comfyui: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()),
},
)
}
/// 4.2 — Listing: the system row carries `system: true` and a masked
/// `key`, while regular rows pass through unchanged.
#[tokio::test]
async fn list_masks_system_key_and_passes_regular_keys_through() {
let state = test_state().await;
let resp = list_downstream_keys(
State(state),
Query(ListKeysQuery {
q: None,
user_id: None,
}),
)
.await
.expect("list should succeed");
let items = resp.0.get("items").and_then(Value::as_array).unwrap();
assert_eq!(items.len(), 2);
let system = items
.iter()
.find(|item| item["id"] == 1)
.expect("system row");
assert_eq!(system["system"], Value::Bool(true));
assert_eq!(system["key"], "••••••••6789");
assert_eq!(system["name"], "comfyui-agent");
let regular = items
.iter()
.find(|item| item["id"] == 2)
.expect("regular row");
assert!(
regular.get("system").is_none(),
"regular keys must not carry the system flag"
);
assert_eq!(regular["key"], "sk-prod-xyz123");
}
/// 4.3 — DELETE on the built-in row returns 403 with the expected message.
#[tokio::test]
async fn delete_rejects_system_key_with_forbidden() {
let state = test_state().await;
let result = delete_downstream_key(State(state), Path(1)).await;
match result {
Err(AppError::Forbidden(msg)) => assert_eq!(msg, "system key cannot be deleted"),
other => panic!("expected Forbidden, got {:?}", other),
}
}
/// 4.5 — DELETE on a regular row succeeds (control case).
#[tokio::test]
async fn delete_succeeds_for_regular_key() {
let state = test_state().await;
let resp = delete_downstream_key(State(state.clone()), Path(2))
.await
.expect("delete should succeed for regular key");
assert_eq!(resp.0["data"]["deleted"], Value::Bool(true));
}
/// 4.4 — Setting `enabled: false` on the system row returns 403; the row
/// remains enabled in the DB.
#[tokio::test]
async fn update_rejects_disabling_system_key() {
let state = test_state().await;
let payload = UpdateDownstreamKeyRequest {
name: None,
description: None,
group_name: None,
tags: None,
enabled: Some(false),
expires_at: None,
max_cost: None,
max_requests: None,
supported_models: None,
allowed_route_ids: None,
user_id: None,
};
let result = update_downstream_key(State(state.clone()), Path(1), Json(payload)).await;
match result {
Err(AppError::Forbidden(msg)) => assert_eq!(msg, "system key cannot be disabled"),
other => panic!("expected Forbidden, got {:?}", other),
}
// Confirm the row was not touched.
let still_enabled: Option<bool> =
sqlx::query_scalar("SELECT enabled FROM downstream_api_keys WHERE id = 1")
.fetch_one(&*state.db)
.await
.unwrap();
assert_eq!(still_enabled, Some(true));
}
/// 4.4 — Other field updates on the system row (e.g. `description`) are
/// allowed; only `enabled = false` is gated.
#[tokio::test]
async fn update_allows_non_disable_changes_on_system_key() {
let state = test_state().await;
let payload = UpdateDownstreamKeyRequest {
name: None,
description: Some("internal: do not delete".into()),
group_name: None,
tags: None,
enabled: None,
expires_at: None,
max_cost: None,
max_requests: None,
supported_models: None,
allowed_route_ids: None,
user_id: None,
};
let resp = update_downstream_key(State(state), Path(1), Json(payload))
.await
.expect("non-disable update should pass");
assert_eq!(resp.0["data"]["description"], "internal: do not delete");
}
}
+172 -2
View File
@@ -18,6 +18,8 @@ mod account_tokens;
mod accounts;
mod compat;
mod downstream_keys;
mod model_candidates;
mod models_marketplace;
mod sites;
mod tokens;
mod users;
@@ -66,6 +68,11 @@ pub fn routes(state: AppState) -> Router<AppState> {
get(sites::get_site)
.put(sites::update_site)
.delete(sites::delete_site),
)
.route(
"/api/sites/{id}/disabled-models",
get(sites::list_site_disabled_models)
.put(sites::update_site_disabled_models),
);
let accounts_routes = Router::new()
@@ -73,6 +80,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
"/api/accounts",
get(accounts::list_accounts).post(accounts::create_account),
)
.route("/api/accounts/batch", post(accounts::batch_accounts))
.route("/api/accounts/verify-token", post(accounts::verify_token))
.route(
"/api/accounts/{id}",
@@ -87,6 +95,10 @@ pub fn routes(state: AppState) -> Router<AppState> {
.route(
"/api/accounts/{id}/models/manual",
post(accounts::add_account_manual_models),
)
.route(
"/api/accounts/{id}/models/refresh",
post(accounts::refresh_account_models),
);
let tokens_routes = Router::new()
@@ -137,6 +149,16 @@ pub fn routes(state: AppState) -> Router<AppState> {
let settings_routes =
Router::new().route("/api/settings", get(get_settings).put(update_setting));
let model_candidates_routes = Router::new().route(
"/api/models/token-candidates",
get(model_candidates::token_candidates),
);
let models_marketplace_routes = Router::new().route(
"/api/models/marketplace",
get(models_marketplace::get_marketplace),
);
let users_routes = Router::new()
.route(
"/api/admin/users",
@@ -170,6 +192,8 @@ pub fn routes(state: AppState) -> Router<AppState> {
.merge(downstream_routes)
.merge(settings_routes)
.merge(users_routes)
.merge(model_candidates_routes)
.merge(models_marketplace_routes)
.merge(account_tokens::routes())
.merge(compat::routes())
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
@@ -177,7 +201,20 @@ pub fn routes(state: AppState) -> Router<AppState> {
// ── Settings handlers ────────────────────────────────────────────────
/// Settings rows whose keys begin with this prefix are managed by
/// `services::builtin_keys` and contain server-issued secrets (e.g. the
/// `comfyui-agent` downstream key). They must never appear in admin
/// list/get/edit responses.
const HIDDEN_SETTINGS_PREFIX: &str = "builtin_key.";
fn is_hidden_setting_key(key: &str) -> bool {
key.starts_with(HIDDEN_SETTINGS_PREFIX)
}
/// GET /api/settings — return all settings as a key-value map.
///
/// Filters out any keys with the `builtin_key.` prefix so the admin UI
/// never sees secrets bootstrapped by `services::builtin_keys`.
async fn get_settings(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Getting all settings");
@@ -186,14 +223,21 @@ async fn get_settings(State(state): State<AppState>) -> AppResult<Json<Value>> {
.fetch_all(&*state.db)
.await?;
let map: std::collections::BTreeMap<String, Option<String>> =
rows.into_iter().map(|s| (s.key, s.value)).collect();
let map: std::collections::BTreeMap<String, Option<String>> = rows
.into_iter()
.filter(|s| !is_hidden_setting_key(&s.key))
.map(|s| (s.key, s.value))
.collect();
Ok(Json(json!({ "data": map })))
}
/// PUT /api/settings — upsert a setting by key.
/// Expects JSON body: { "key": "...", "value": "..." }
///
/// Refuses any key with the `builtin_key.` prefix: those rows are owned by
/// the bootstrap path and a stray PUT would clobber the secret used by the
/// ComfyUI Agent's outbound calls.
async fn update_setting(
State(state): State<AppState>,
Json(payload): Json<serde_json::Value>,
@@ -203,6 +247,12 @@ async fn update_setting(
.and_then(|k| k.as_str())
.ok_or_else(|| AppError::BadRequest("key is required".into()))?;
if is_hidden_setting_key(key) {
return Err(AppError::Forbidden(
"system setting cannot be modified".into(),
));
}
let value = payload.get("value").and_then(|v| {
if v.is_null() {
None
@@ -226,3 +276,123 @@ async fn update_setting(
}
})))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
use crate::services::builtin_keys::BuiltinKeys;
use sqlx::sqlite::SqlitePoolOptions;
async fn settings_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query(
"CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO settings (key, value) VALUES ('site.title', 'BoosAPI')")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO settings (key, value) VALUES ('builtin_key.comfyui_agent.secret', 'sk-comfyui-secret')",
)
.execute(&pool)
.await
.unwrap();
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
},
comfyui: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-secret".into()),
},
)
}
/// 5.2 — `GET /api/settings` returns regular settings but never any
/// `builtin_key.*` rows.
#[tokio::test]
async fn list_settings_filters_builtin_key_prefix() {
let state = settings_state().await;
let resp = get_settings(State(state)).await.expect("list ok");
let data = resp.0.get("data").and_then(Value::as_object).unwrap();
assert!(data.contains_key("site.title"));
assert!(
!data.contains_key("builtin_key.comfyui_agent.secret"),
"builtin_key.* rows must not appear in /api/settings response"
);
}
/// 5.3 — `PUT /api/settings` rejects writes targeting `builtin_key.*`
/// keys with 403; the row is left untouched.
#[tokio::test]
async fn update_settings_rejects_builtin_key_writes() {
let state = settings_state().await;
let payload = json!({
"key": "builtin_key.comfyui_agent.secret",
"value": "sk-attacker"
});
let result = update_setting(State(state.clone()), Json(payload)).await;
match result {
Err(AppError::Forbidden(msg)) => {
assert_eq!(msg, "system setting cannot be modified");
}
other => panic!("expected Forbidden, got {:?}", other),
}
let stored: Option<String> = sqlx::query_scalar(
"SELECT value FROM settings WHERE key = 'builtin_key.comfyui_agent.secret'",
)
.fetch_one(&*state.db)
.await
.unwrap();
assert_eq!(stored.as_deref(), Some("sk-comfyui-secret"));
}
/// 5.3 — Regular settings still upsert as before.
#[tokio::test]
async fn update_settings_allows_regular_keys() {
let state = settings_state().await;
let payload = json!({ "key": "site.title", "value": "Renamed" });
let resp = update_setting(State(state.clone()), Json(payload))
.await
.expect("update ok");
assert_eq!(resp.0["data"]["value"], "Renamed");
}
}
+176
View File
@@ -0,0 +1,176 @@
//! `GET /api/models/token-candidates` — feeds the frontend "candidate models"
//! panel and the new-route dialog dropdown (`TokenRoutes.tsx:252`).
//!
//! Response shape (must match the legacy Node handler):
//!
//! ```json
//! {
//! "models": {
//! "<modelName>": [
//! { "accountId", "tokenId", "tokenName", "isDefault",
//! "username", "siteId", "siteName" }, ...
//! ]
//! },
//! "modelsWithoutToken": {
//! "<modelName>": [ { "accountId", "username", "siteId", "siteName" }, ... ]
//! },
//! "modelsMissingTokenGroups": {},
//! "endpointTypesByModel": {}
//! }
//! ```
//!
//! `modelsMissingTokenGroups` and `endpointTypesByModel` are intentionally empty:
//! this codebase doesn't have the pricing-catalog metadata or
//! marketplace-cache machinery the Node version relies on. Returning empty
//! objects keeps the contract intact (frontend tolerates them) without
//! pulling in unrelated infrastructure.
use crate::{error::AppResult, AppState};
use axum::{extract::State, Json};
use serde_json::{json, Map, Value};
use std::collections::{HashMap, HashSet};
/// Row from `model_availability ⋈ account_tokens ⋈ accounts ⋈ sites` — every
/// `(model, token)` combo that's currently usable.
#[derive(Debug, sqlx::FromRow)]
struct CoveredRow {
model_name: String,
account_id: i64,
token_id: i64,
token_name: String,
is_default: Option<bool>,
username: Option<String>,
site_id: i64,
site_name: Option<String>,
}
/// Row from `model_availability ⋈ accounts ⋈ sites` — every `(model, account)`
/// combo regardless of token coverage. We diff this against `CoveredRow` to
/// surface accounts that have the model but no usable token.
#[derive(Debug, sqlx::FromRow)]
struct AccountModelRow {
model_name: String,
account_id: i64,
username: Option<String>,
site_id: i64,
site_name: Option<String>,
}
pub async fn token_candidates(State(state): State<AppState>) -> AppResult<Json<Value>> {
// 1. Models that already have at least one usable token. The joins drop:
// inactive accounts/sites, disabled tokens, and tokens whose value
// isn't ready (e.g. mid-rotation).
let covered: Vec<CoveredRow> = sqlx::query_as(
"SELECT ma.model_name AS model_name,
a.id AS account_id,
at.id AS token_id,
at.name AS token_name,
at.is_default AS is_default,
a.username AS username,
s.id AS site_id,
s.name AS site_name
FROM model_availability ma
JOIN account_tokens at ON at.account_id = ma.account_id
JOIN accounts a ON a.id = ma.account_id
JOIN sites s ON s.id = a.site_id
WHERE COALESCE(ma.available, 0) = 1
AND COALESCE(at.enabled, 1) = 1
AND COALESCE(at.value_status, 'ready') = 'ready'
AND a.status = 'active'
AND COALESCE(s.status, 'active') = 'active'",
)
.fetch_all(&*state.db)
.await?;
// 2. All available `(account, model)` pairs — used to compute which
// accounts have the model but no usable token (`modelsWithoutToken`).
let account_models: Vec<AccountModelRow> = sqlx::query_as(
"SELECT ma.model_name AS model_name,
a.id AS account_id,
a.username AS username,
s.id AS site_id,
s.name AS site_name
FROM model_availability ma
JOIN accounts a ON a.id = ma.account_id
JOIN sites s ON s.id = a.site_id
WHERE COALESCE(ma.available, 0) = 1
AND a.status = 'active'
AND COALESCE(s.status, 'active') = 'active'",
)
.fetch_all(&*state.db)
.await?;
// Build `models` map. Dedupe candidates by tokenId per model so a token
// marked available against the same model twice doesn't render twice.
let mut models: HashMap<String, Vec<Value>> = HashMap::new();
let mut seen_token_per_model: HashMap<String, HashSet<i64>> = HashMap::new();
let mut covered_account_model: HashSet<(i64, String)> = HashSet::new();
for row in covered {
let model_name = row.model_name.trim().to_string();
if model_name.is_empty() {
continue;
}
covered_account_model.insert((row.account_id, model_name.to_lowercase()));
let seen = seen_token_per_model.entry(model_name.clone()).or_default();
if !seen.insert(row.token_id) {
continue;
}
models.entry(model_name).or_default().push(json!({
"accountId": row.account_id,
"tokenId": row.token_id,
"tokenName": row.token_name,
"isDefault": row.is_default.unwrap_or(false),
"username": row.username,
"siteId": row.site_id,
"siteName": row.site_name,
}));
}
// Build `modelsWithoutToken`: every (account, model) in `account_models`
// that didn't show up in `covered`. Dedupe by accountId per model.
let mut models_without_token: HashMap<String, Vec<Value>> = HashMap::new();
let mut seen_account_per_model: HashMap<String, HashSet<i64>> = HashMap::new();
for row in account_models {
let model_name = row.model_name.trim().to_string();
if model_name.is_empty() {
continue;
}
let key = (row.account_id, model_name.to_lowercase());
if covered_account_model.contains(&key) {
continue;
}
let seen = seen_account_per_model
.entry(model_name.clone())
.or_default();
if !seen.insert(row.account_id) {
continue;
}
models_without_token.entry(model_name).or_default().push(json!({
"accountId": row.account_id,
"username": row.username,
"siteId": row.site_id,
"siteName": row.site_name,
}));
}
// Convert HashMap -> serde_json::Map so the response keys serialize
// deterministically as a JSON object (HashMap also works but Map is the
// canonical type for object-shaped payloads).
let models_obj: Map<String, Value> = models
.into_iter()
.map(|(k, v)| (k, Value::Array(v)))
.collect();
let without_obj: Map<String, Value> = models_without_token
.into_iter()
.map(|(k, v)| (k, Value::Array(v)))
.collect();
Ok(Json(json!({
"models": models_obj,
"modelsWithoutToken": without_obj,
"modelsMissingTokenGroups": {},
"endpointTypesByModel": {},
})))
}
+379
View File
@@ -0,0 +1,379 @@
//! `GET /api/models/marketplace` — model marketplace listing for the admin
//! `/models` page (frontend: `Models.tsx`).
//!
//! Stage 1: aggregates `model_availability` × `accounts` × `account_tokens`
//! × `sites` into the shape `ModelsMarketplaceResponse` expects. Per-model
//! latency / success rate / description / tags / supportedEndpointTypes /
//! pricingSources are returned as null/empty placeholders — the frontend
//! falls back gracefully and stage 2 will hydrate them from `proxy_logs`
//! and pricing tables.
//!
//! `?refresh=1` and `?includePricing=1` are accepted but currently no-ops:
//! we always return the in-memory aggregation and an empty `meta`. Stage 2
//! will wire them to an async refresh job and the pricing hydration path.
use crate::{error::AppResult, AppState};
use axum::{
extract::{Query, State},
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::FromRow;
use std::collections::{BTreeMap, HashMap};
#[derive(Debug, Deserialize)]
#[allow(dead_code)] // stage 2 will read these; keep the fields to lock the API shape.
pub struct MarketplaceQuery {
pub refresh: Option<String>,
#[serde(rename = "includePricing")]
pub include_pricing: Option<String>,
}
#[derive(FromRow)]
struct AvailRow {
account_id: i64,
model_name: String,
}
#[derive(FromRow)]
struct AccountRow {
id: i64,
site_id: i64,
username: Option<String>,
balance: Option<f64>,
}
#[derive(FromRow)]
struct TokenRow {
id: i64,
account_id: i64,
name: String,
is_default: Option<bool>,
}
pub async fn get_marketplace(
State(state): State<AppState>,
Query(_query): Query<MarketplaceQuery>,
) -> AppResult<Json<Value>> {
let value = marketplace_inner(&state.db).await?;
Ok(Json(value))
}
/// Pool-only entry point. Identical to `get_marketplace` but decoupled from
/// `AppState` and axum extractors so unit tests can drive it with an
/// in-memory SQLite pool (mirrors the `rebuild_routes_inner` pattern in
/// `services::route_rebuild`).
pub(crate) async fn marketplace_inner(
pool: &sqlx::Pool<sqlx::Sqlite>,
) -> AppResult<Value> {
// 1. Active (account, model) pairs. Filtering on accounts.status =
// 'active' here keeps the rest of the aggregation small.
let avail_rows: Vec<AvailRow> = sqlx::query_as(
"SELECT ma.account_id, ma.model_name
FROM model_availability ma
JOIN accounts a ON a.id = ma.account_id
WHERE COALESCE(ma.available, 0) = 1
AND a.status = 'active'",
)
.fetch_all(pool)
.await?;
if avail_rows.is_empty() {
return Ok(json!({ "models": [], "meta": {} }));
}
// 2. One-shot lookups — accounts, sites, tokens. Bounded by the active
// account count (typically dozens), so a full scan beats per-model
// in-loop selects.
let accounts: Vec<AccountRow> = sqlx::query_as(
"SELECT id, site_id, username, balance FROM accounts WHERE status = 'active'",
)
.fetch_all(pool)
.await?;
let accounts_by_id: HashMap<i64, AccountRow> =
accounts.into_iter().map(|a| (a.id, a)).collect();
let sites: Vec<(i64, String)> = sqlx::query_as("SELECT id, name FROM sites")
.fetch_all(pool)
.await?;
let site_name_by_id: HashMap<i64, String> = sites.into_iter().collect();
let tokens: Vec<TokenRow> = sqlx::query_as(
"SELECT id, account_id, name, is_default
FROM account_tokens
WHERE COALESCE(enabled, 1) = 1",
)
.fetch_all(pool)
.await?;
let mut tokens_by_account: HashMap<i64, Vec<TokenRow>> = HashMap::new();
for t in tokens {
tokens_by_account.entry(t.account_id).or_default().push(t);
}
// 3. Group by model name. BTreeMap → output sorted by name (matches the
// deterministic ordering the frontend's table view expects).
let mut by_model: BTreeMap<String, Vec<i64>> = BTreeMap::new();
for row in avail_rows {
by_model
.entry(row.model_name)
.or_default()
.push(row.account_id);
}
// 4. Render. Stage 1 returns null/empty for the metadata fields; the
// frontend has fallback handling for each one.
let mut models: Vec<Value> = Vec::with_capacity(by_model.len());
for (model_name, account_ids) in by_model {
let account_count = account_ids.len();
let mut token_count: usize = 0;
let mut accounts_json: Vec<Value> = Vec::with_capacity(account_count);
for aid in &account_ids {
let acc = match accounts_by_id.get(aid) {
Some(a) => a,
None => continue,
};
let site_name = site_name_by_id
.get(&acc.site_id)
.cloned()
.unwrap_or_default();
let acc_tokens: &[TokenRow] = tokens_by_account
.get(aid)
.map(Vec::as_slice)
.unwrap_or(&[]);
token_count += acc_tokens.len();
let token_json: Vec<Value> = acc_tokens
.iter()
.map(|t| {
json!({
"id": t.id,
"name": t.name,
"isDefault": t.is_default.unwrap_or(false),
})
})
.collect();
accounts_json.push(json!({
"id": acc.id,
"site": site_name,
"username": acc.username,
"latency": Value::Null,
"balance": acc.balance.unwrap_or(0.0),
"tokens": token_json,
}));
}
models.push(json!({
"name": model_name,
"accountCount": account_count,
"tokenCount": token_count,
"avgLatency": Value::Null,
"successRate": Value::Null,
"description": Value::Null,
"tags": [],
"supportedEndpointTypes": [],
"pricingSources": [],
"accounts": accounts_json,
}));
}
Ok(json!({
"models": models,
"meta": {},
}))
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::{Pool, Sqlite};
/// Minimal schema for the marketplace aggregation. Mirrors
/// `services::route_rebuild::tests::fixture_pool` — we create only the
/// columns `marketplace_inner` actually reads, so adding unrelated
/// migrations later doesn't break these tests.
async fn fixture_pool() -> Pool<Sqlite> {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("open in-memory sqlite");
for ddl in [
"CREATE TABLE sites (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
)",
"CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL,
username TEXT,
balance REAL,
status TEXT NOT NULL DEFAULT 'active'
)",
"CREATE TABLE model_availability (
account_id INTEGER NOT NULL,
model_name TEXT NOT NULL,
available INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (account_id, model_name)
)",
"CREATE TABLE account_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
is_default INTEGER DEFAULT 0
)",
] {
sqlx::query(ddl)
.execute(&pool)
.await
.unwrap_or_else(|err| panic!("failed to create schema: {err}\nddl: {ddl}"));
}
pool
}
/// Empty DB → empty `models` array with empty `meta`. Locks the early-return
/// path at the top of `marketplace_inner` so an accidental refactor that
/// drops the guard still produces a frontend-safe response shape rather
/// than 500-ing on the follow-up `accounts`/`sites` selects.
#[tokio::test]
async fn empty_db_returns_empty_models_array() {
let pool = fixture_pool().await;
let value = marketplace_inner(&pool).await.expect("inner ok");
assert_eq!(value, json!({ "models": [], "meta": {} }));
}
/// Inactive accounts and unavailable models must be filtered out at the
/// SQL level. Account 2 is `disabled` and account 3's row has
/// `available = 0` — neither should contribute to the response.
#[tokio::test]
async fn filters_inactive_accounts_and_unavailable_models() {
let pool = fixture_pool().await;
sqlx::query("INSERT INTO sites (id, name) VALUES (1, 'site-a')")
.execute(&pool).await.unwrap();
sqlx::query(
"INSERT INTO accounts (id, site_id, username, balance, status) VALUES
(1, 1, 'alice', 10.0, 'active'),
(2, 1, 'bob', 5.0, 'disabled'),
(3, 1, 'carol', 7.0, 'active')",
).execute(&pool).await.unwrap();
sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-x', 1),
(2, 'gpt-x', 1),
(3, 'gpt-x', 0)",
).execute(&pool).await.unwrap();
let value = marketplace_inner(&pool).await.expect("inner ok");
let models = value.get("models").and_then(Value::as_array).unwrap();
assert_eq!(models.len(), 1, "exactly one model should survive filtering");
let m = &models[0];
assert_eq!(m["name"], "gpt-x");
assert_eq!(m["accountCount"], 1, "only account 1 (active + available) counts");
let accounts = m["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0]["username"], "alice");
}
/// Multi-account, multi-token aggregation. Verifies:
/// - `accountCount` == distinct active accounts advertising the model
/// - `tokenCount` == sum of enabled tokens across those accounts
/// - per-account `tokens[]` is correctly nested with `isDefault`
/// - disabled tokens (`enabled = 0`) don't show up
/// - site name is resolved from `sites.name`
#[tokio::test]
async fn aggregates_accounts_and_tokens_per_model() {
let pool = fixture_pool().await;
sqlx::query("INSERT INTO sites (id, name) VALUES (1, 'openai-mirror'), (2, 'anthropic-mirror')")
.execute(&pool).await.unwrap();
sqlx::query(
"INSERT INTO accounts (id, site_id, username, balance, status) VALUES
(1, 1, 'alice', 100.5, 'active'),
(2, 2, 'bob', 50.0, 'active')",
).execute(&pool).await.unwrap();
sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-x', 1),
(2, 'gpt-x', 1)",
).execute(&pool).await.unwrap();
// Account 1: two enabled tokens (one default), one disabled token (must
// be excluded). Account 2: one enabled non-default token.
sqlx::query(
"INSERT INTO account_tokens (id, account_id, name, enabled, is_default) VALUES
(10, 1, 'primary', 1, 1),
(11, 1, 'backup', 1, 0),
(12, 1, 'retired', 0, 0),
(20, 2, 'only-one', 1, 0)",
).execute(&pool).await.unwrap();
let value = marketplace_inner(&pool).await.expect("inner ok");
let models = value["models"].as_array().unwrap();
assert_eq!(models.len(), 1);
let m = &models[0];
assert_eq!(m["name"], "gpt-x");
assert_eq!(m["accountCount"], 2);
assert_eq!(m["tokenCount"], 3, "2 from account 1 + 1 from account 2; disabled token excluded");
// Stage 1 placeholders.
assert!(m["avgLatency"].is_null());
assert!(m["successRate"].is_null());
assert!(m["description"].is_null());
assert_eq!(m["tags"], json!([]));
assert_eq!(m["supportedEndpointTypes"], json!([]));
assert_eq!(m["pricingSources"], json!([]));
let accounts = m["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 2);
// Find each account by id rather than relying on insertion order in
// the HashMap-driven account_ids vec.
let alice = accounts.iter().find(|a| a["id"] == 1).expect("alice present");
assert_eq!(alice["site"], "openai-mirror");
assert_eq!(alice["username"], "alice");
assert_eq!(alice["balance"], 100.5);
let alice_tokens = alice["tokens"].as_array().unwrap();
assert_eq!(alice_tokens.len(), 2, "disabled 'retired' token must not be exposed");
let primary = alice_tokens.iter().find(|t| t["id"] == 10).unwrap();
assert_eq!(primary["name"], "primary");
assert_eq!(primary["isDefault"], true);
let backup = alice_tokens.iter().find(|t| t["id"] == 11).unwrap();
assert_eq!(backup["isDefault"], false);
let bob = accounts.iter().find(|a| a["id"] == 2).expect("bob present");
assert_eq!(bob["site"], "anthropic-mirror");
assert_eq!(bob["balance"], 50.0);
assert_eq!(bob["tokens"].as_array().unwrap().len(), 1);
}
/// Models must come back in deterministic name order (BTreeMap). The
/// frontend's table view assumes a stable sort so toggling refresh
/// doesn't reshuffle rows; lock that contract here.
#[tokio::test]
async fn models_are_sorted_by_name() {
let pool = fixture_pool().await;
sqlx::query("INSERT INTO sites (id, name) VALUES (1, 's')")
.execute(&pool).await.unwrap();
sqlx::query("INSERT INTO accounts (id, site_id, status) VALUES (1, 1, 'active')")
.execute(&pool).await.unwrap();
// Insert in scrambled order; expect output sorted lexicographically.
sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-z', 1),
(1, 'gpt-a', 1),
(1, 'gpt-m', 1)",
).execute(&pool).await.unwrap();
let value = marketplace_inner(&pool).await.expect("inner ok");
let names: Vec<&str> = value["models"]
.as_array()
.unwrap()
.iter()
.map(|m| m["name"].as_str().unwrap())
.collect();
assert_eq!(names, vec!["gpt-a", "gpt-m", "gpt-z"]);
}
}
+76
View File
@@ -356,6 +356,82 @@ async fn site_api_endpoints(state: &AppState, site_id: i64) -> AppResult<Vec<Val
.collect())
}
#[derive(Debug, Deserialize)]
pub struct UpdateDisabledModelsRequest {
#[serde(default)]
pub models: Vec<String>,
}
/// GET /api/sites/{id}/disabled-models — list disabled model names for a site.
pub async fn list_site_disabled_models(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
// Validate the site exists so we return a clean 404 instead of an empty list
// for unknown ids.
let exists: Option<i64> =
sqlx::query_scalar("SELECT id FROM sites WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("site not found".into()));
}
let models: Vec<String> =
sqlx::query_scalar("SELECT model_name FROM site_disabled_models WHERE site_id = ? ORDER BY model_name")
.bind(id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "models": models })))
}
/// PUT /api/sites/{id}/disabled-models — replace the disabled-model list for a site.
pub async fn update_site_disabled_models(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<UpdateDisabledModelsRequest>,
) -> AppResult<Json<Value>> {
let exists: Option<i64> =
sqlx::query_scalar("SELECT id FROM sites WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("site not found".into()));
}
// Normalise + dedupe the inbound list. Empty strings are dropped.
let mut normalised: Vec<String> = payload
.models
.into_iter()
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty())
.collect();
normalised.sort();
normalised.dedup();
let mut tx = state.db.begin().await?;
sqlx::query("DELETE FROM site_disabled_models WHERE site_id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
for model in &normalised {
sqlx::query(
"INSERT INTO site_disabled_models (site_id, model_name) VALUES (?, ?)\n ON CONFLICT(site_id, model_name) DO NOTHING",
)
.bind(id)
.bind(model)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
info!(site_id = id, count = normalised.len(), "updated site disabled models");
Ok(Json(json!({ "models": normalised })))
}
async fn replace_site_api_endpoints(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
site_id: i64,
+78 -1
View File
@@ -3,7 +3,7 @@
use crate::{
auth::{jwt::generate_jwt, password::verify_password},
db::models::user::User,
db::models::{downstream_api_key::DownstreamApiKey, user::User},
error::{AppError, AppResult},
AppState,
};
@@ -32,6 +32,12 @@ pub struct LoginRequest {
pub password: String,
}
#[derive(Debug, Deserialize)]
pub struct LoginApiKeyRequest {
#[serde(alias = "apiKey", alias = "key")]
pub api_key: String,
}
#[allow(dead_code)]
#[derive(Debug, Serialize)]
pub struct AuthResponse {
@@ -56,6 +62,8 @@ pub fn routes() -> Router<AppState> {
.route("/api/users/register", post(register))
.route("/api/auth/login", post(login))
.route("/api/users/login", post(login))
.route("/api/auth/login-api-key", post(login_api_key))
.route("/api/users/login-api-key", post(login_api_key))
.route("/api/auth/me", get(me))
.route("/api/users/me", get(me).patch(update_me))
.route("/api/users/me/password", post(change_password))
@@ -197,6 +205,75 @@ async fn login(
})))
}
/// POST /api/users/login-api-key — exchange a downstream API key for a user JWT.
///
/// The API key must belong to a managed user (downstream_api_keys.user_id IS NOT NULL),
/// be enabled, and the owning user must be active.
async fn login_api_key(
State(state): State<AppState>,
Json(payload): Json<LoginApiKeyRequest>,
) -> AppResult<Json<Value>> {
let key = payload.api_key.trim();
if key.is_empty() {
return Err(AppError::BadRequest("api_key is required".into()));
}
let row: Option<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE key = ? AND COALESCE(enabled, 1) = 1",
)
.bind(key)
.fetch_optional(&*state.db)
.await?;
let api_key = row.ok_or_else(|| AppError::Unauthorized("Invalid API key".into()))?;
let user_id = api_key
.user_id
.ok_or_else(|| AppError::Unauthorized("API key is not bound to a user".into()))?;
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::Unauthorized("User not found".into()))?;
if user.status != "active" {
return Err(AppError::Forbidden("Account is disabled".into()));
}
info!("Login via API key: user_id={}", user.id);
let token = generate_jwt(
&state.config.auth.jwt_secret,
&user.id.to_string(),
&user.role,
state.config.auth.jwt_expiry_hours,
)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
Ok(Json(json!({
"token": token,
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"status": user.status,
"createdAt": user.created_at,
},
"data": {
"token": token,
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"status": user.status,
"createdAt": user.created_at,
}
}
})))
}
/// GET /api/auth/me — returns current user profile from JWT
async fn me(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let auth = headers
+447
View File
@@ -0,0 +1,447 @@
//! `/comfy/*` reverse proxy.
//!
//! Authenticates the caller via JWT/admin token (Authorization header, cookie,
//! or `?token=` query param where browsers can't set headers),
//! looks up the per-user container in [`ComfyUIPool`], and forwards
//! both HTTP and WS traffic to that container's loopback port.
//!
//! NO path rewriting on the response — ComfyUI's HTML/JS reference
//! root-relative URLs which the browser will request under `/comfy/...`,
//! and the path stripping done here lines them up with the upstream.
use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::{
body::Body,
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
OriginalUri, RawQuery, State,
},
http::{
header::{
CONNECTION, HOST, PROXY_AUTHENTICATE, PROXY_AUTHORIZATION, TE, TRAILER,
TRANSFER_ENCODING, UPGRADE,
},
HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode, Uri,
},
response::IntoResponse,
routing::{any, get},
Router,
};
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message as TMessage;
use tracing::{debug, warn};
use crate::services::comfyui_pool::{ComfyUIPool, ContainerState, PoolError};
use crate::AppState;
const COLD_START_HOLD: Duration = Duration::from_secs(30);
const COLD_START_POLL_INTERVAL: Duration = Duration::from_millis(500);
const COMFY_SESSION_COOKIE: &str = "boosapi_comfy_token";
const ADMIN_WORKSPACE_USER_ID: i64 = 0;
pub fn routes() -> Router<AppState> {
Router::new()
// WS upgrade endpoints — must be matched before the catch-all.
.route("/comfy/ws", get(ws_handler))
.route("/comfy/ws/", get(ws_handler))
// Catch-all for HTTP. `{*path}` is axum 0.8 wildcard syntax.
.route("/comfy", any(http_handler))
.route("/comfy/", any(http_handler))
.route("/comfy/{*path}", any(http_handler))
}
// ── Auth ───────────────────────────────────────────────────────────────
fn user_id_from_token(state: &AppState, token: &str) -> Option<i64> {
let token = token.trim();
if token.is_empty() {
return None;
}
if token == state.config.auth.auth_token || token == state.config.auth.proxy_token {
return Some(ADMIN_WORKSPACE_USER_ID);
}
let claims = crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, token).ok()?;
claims.sub.parse::<i64>().ok()
}
/// Resolve the calling user from `Authorization: Bearer <token>`.
fn user_id_from_headers(state: &AppState, headers: &HeaderMap) -> Option<i64> {
let raw = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())?;
let token = raw.strip_prefix("Bearer ").unwrap_or(raw).trim();
user_id_from_token(state, token)
}
fn user_id_from_cookie(state: &AppState, headers: &HeaderMap) -> Option<i64> {
let raw = headers
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())?;
for part in raw.split(';') {
let mut it = part.trim().splitn(2, '=');
let name = it.next()?.trim();
let value = it.next().unwrap_or("").trim();
if name == COMFY_SESSION_COOKIE {
let token = urlencoding::decode(value).ok()?;
return user_id_from_token(state, token.as_ref());
}
}
None
}
fn token_from_query(raw_query: Option<&str>) -> Option<String> {
let q = raw_query?;
for pair in q.split('&') {
let mut it = pair.splitn(2, '=');
let k = it.next()?;
let v = it.next().unwrap_or("");
if k == "token" {
return Some(urlencoding::decode(v).ok()?.into_owned());
}
}
None
}
fn query_without_token(raw_query: Option<&str>) -> Option<String> {
let query = raw_query?;
let kept: Vec<&str> = query
.split('&')
.filter(|pair| pair.splitn(2, '=').next() != Some("token"))
.filter(|pair| !pair.is_empty())
.collect();
if kept.is_empty() {
None
} else {
Some(kept.join("&"))
}
}
fn comfy_session_cookie(token: &str) -> String {
format!(
"{}={}; Path=/comfy; HttpOnly; SameSite=Lax",
COMFY_SESSION_COOKIE,
urlencoding::encode(token)
)
}
// ── HTTP forwarder ─────────────────────────────────────────────────────
async fn http_handler(
State(state): State<AppState>,
req: Request<Body>,
) -> Response<Body> {
let query_token = token_from_query(req.uri().query());
let user_id = user_id_from_headers(&state, req.headers())
.or_else(|| user_id_from_cookie(&state, req.headers()))
.or_else(|| {
query_token
.as_deref()
.and_then(|token| user_id_from_token(&state, token))
});
let Some(user_id) = user_id else {
return unauthorized();
};
let entry = match state.comfyui_pool.get_or_start(user_id).await {
Ok(e) => e,
Err(PoolError::AtCapacity(_)) => return at_capacity(),
Err(e) => return upstream_error(format!("pool: {}", e)),
};
if entry.state != ContainerState::Ready {
if let Err(e) = wait_for_ready(&state.comfyui_pool, user_id).await {
return upstream_error(e);
}
}
state.comfyui_pool.touch(user_id).await;
let path = req.uri().path();
let upstream_path = path.strip_prefix("/comfy").unwrap_or(path);
let upstream_path = if upstream_path.is_empty() { "/" } else { upstream_path };
let query = query_without_token(req.uri().query());
let port = match state.comfyui_pool.entry(user_id).await {
Some(e) => e.host_port,
None => return upstream_error("container vanished".into()),
};
let upstream = match build_upstream_uri("http", port, upstream_path, query.as_deref()) {
Ok(u) => u,
Err(e) => return upstream_error(e),
};
let mut response = forward_http(state.comfyui_pool.clone(), user_id, req, upstream).await;
if let Some(token) = query_token {
if let Ok(value) = HeaderValue::from_str(&comfy_session_cookie(&token)) {
response
.headers_mut()
.append(axum::http::header::SET_COOKIE, value);
}
}
response
}
fn build_upstream_uri(
scheme: &str,
port: u16,
path: &str,
query: Option<&str>,
) -> Result<Uri, String> {
let mut s = format!("{}://127.0.0.1:{}{}", scheme, port, path);
if let Some(q) = query {
s.push('?');
s.push_str(q);
}
Uri::try_from(s).map_err(|e| format!("bad upstream uri: {}", e))
}
async fn forward_http(
pool: Arc<ComfyUIPool>,
user_id: i64,
req: Request<Body>,
upstream: Uri,
) -> Response<Body> {
let (parts, body) = req.into_parts();
let client = match reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(120))
.build()
{
Ok(c) => c,
Err(e) => return upstream_error(format!("client: {}", e)),
};
let body_bytes = match axum::body::to_bytes(body, 64 * 1024 * 1024).await {
Ok(b) => b,
Err(e) => return upstream_error(format!("read body: {}", e)),
};
let mut rb = client.request(reqwest_method(&parts.method), upstream.to_string());
for (name, value) in parts.headers.iter() {
if is_hop_by_hop(name) {
continue;
}
if let Ok(v) = value.to_str() {
rb = rb.header(name.as_str(), v);
}
}
rb = rb.body(body_bytes);
let resp = match rb.send().await {
Ok(r) => r,
Err(e) => return upstream_error(format!("upstream send: {}", e)),
};
pool.touch(user_id).await;
let status = resp.status();
let mut builder = Response::builder().status(status);
for (name, value) in resp.headers().iter() {
if is_hop_by_hop(name) {
continue;
}
if let Ok(v) = HeaderValue::from_bytes(value.as_bytes()) {
builder = builder.header(name.as_str(), v);
}
}
let bytes = match resp.bytes().await {
Ok(b) => b,
Err(e) => return upstream_error(format!("upstream read: {}", e)),
};
builder.body(Body::from(bytes)).unwrap_or_else(|_| {
Response::builder()
.status(StatusCode::BAD_GATEWAY)
.body(Body::empty())
.unwrap()
})
}
fn reqwest_method(m: &Method) -> reqwest::Method {
reqwest::Method::from_bytes(m.as_str().as_bytes()).unwrap_or(reqwest::Method::GET)
}
fn is_hop_by_hop(name: &HeaderName) -> bool {
name == &CONNECTION
|| name == &PROXY_AUTHENTICATE
|| name == &PROXY_AUTHORIZATION
|| name == &TE
|| name == &TRAILER
|| name == &TRANSFER_ENCODING
|| name == &UPGRADE
|| name == &HOST
|| name.as_str().eq_ignore_ascii_case("keep-alive")
}
// ── WebSocket forwarder ────────────────────────────────────────────────
async fn ws_handler(
State(state): State<AppState>,
OriginalUri(orig_uri): OriginalUri,
RawQuery(raw_query): RawQuery,
headers: HeaderMap,
ws: WebSocketUpgrade,
) -> Response<Body> {
// Resolve user from header (preferred) or `?token=` (browser fallback).
let user_id = user_id_from_headers(&state, &headers)
.or_else(|| user_id_from_cookie(&state, &headers))
.or_else(|| {
token_from_query(raw_query.as_deref()).and_then(|t| user_id_from_token(&state, &t))
});
let Some(user_id) = user_id else {
return unauthorized();
};
let entry = match state.comfyui_pool.get_or_start(user_id).await {
Ok(e) => e,
Err(PoolError::AtCapacity(_)) => return at_capacity(),
Err(e) => return upstream_error(format!("pool: {}", e)),
};
if entry.state != ContainerState::Ready {
if let Err(e) = wait_for_ready(&state.comfyui_pool, user_id).await {
return upstream_error(e);
}
}
let port = match state.comfyui_pool.entry(user_id).await {
Some(e) => e.host_port,
None => return upstream_error("container vanished".into()),
};
// Forward whatever query the browser sent (clientId etc).
let upstream = match build_upstream_uri("ws", port, "/ws", orig_uri.query()) {
Ok(u) => u,
Err(e) => return upstream_error(e),
};
let pool = state.comfyui_pool.clone();
ws.on_upgrade(move |socket| async move {
pool.ws_open(user_id).await;
if let Err(e) = pump_ws(socket, upstream.to_string(), pool.clone(), user_id).await {
warn!(user_id, error = %e, "ws proxy ended with error");
}
pool.ws_close(user_id).await;
})
.into_response()
}
async fn pump_ws(
client: WebSocket,
upstream_url: String,
pool: Arc<ComfyUIPool>,
user_id: i64,
) -> Result<(), String> {
let (upstream_ws, _) = tokio_tungstenite::connect_async(&upstream_url)
.await
.map_err(|e| format!("ws connect: {}", e))?;
let (mut up_tx, mut up_rx) = upstream_ws.split();
let (mut cl_tx, mut cl_rx) = client.split();
let pool_a = pool.clone();
let pool_b = pool;
let to_upstream = async move {
while let Some(frame) = cl_rx.next().await {
let frame = match frame {
Ok(f) => f,
Err(e) => return Err::<(), String>(format!("client recv: {}", e)),
};
pool_a.touch(user_id).await;
let out = match frame {
Message::Text(t) => TMessage::Text(t.to_string().into()),
Message::Binary(b) => TMessage::Binary(b.to_vec()),
Message::Ping(p) => TMessage::Ping(p.to_vec()),
Message::Pong(p) => TMessage::Pong(p.to_vec()),
Message::Close(_) => TMessage::Close(None),
};
if up_tx.send(out).await.is_err() {
break;
}
}
Ok(())
};
let to_client = async move {
while let Some(frame) = up_rx.next().await {
let frame = match frame {
Ok(f) => f,
Err(e) => return Err::<(), String>(format!("upstream recv: {}", e)),
};
pool_b.touch(user_id).await;
let out = match frame {
TMessage::Text(t) => Message::Text(t.to_string().into()),
TMessage::Binary(b) => Message::Binary(b.into()),
TMessage::Ping(p) => Message::Ping(p.into()),
TMessage::Pong(p) => Message::Pong(p.into()),
TMessage::Close(_) => Message::Close(None),
TMessage::Frame(_) => continue,
};
if cl_tx.send(out).await.is_err() {
break;
}
}
Ok(())
};
tokio::select! {
r = to_upstream => r,
r = to_client => r,
}
}
// ── Cold-start hold ────────────────────────────────────────────────────
async fn wait_for_ready(pool: &ComfyUIPool, user_id: i64) -> Result<(), String> {
let deadline = Instant::now() + COLD_START_HOLD;
loop {
match pool.state(user_id).await {
ContainerState::Ready => return Ok(()),
ContainerState::Failed | ContainerState::Stopped => {
return Err("container failed to start".into());
}
ContainerState::Starting => {
if Instant::now() >= deadline {
return Err("cold start timeout".into());
}
tokio::time::sleep(COLD_START_POLL_INTERVAL).await;
}
}
}
}
// ── Error responses ────────────────────────────────────────────────────
fn unauthorized() -> Response<Body> {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("WWW-Authenticate", "Bearer")
.body(Body::from(r#"{"error":"unauthorized"}"#))
.unwrap()
}
fn at_capacity() -> Response<Body> {
Response::builder()
.status(StatusCode::SERVICE_UNAVAILABLE)
.header("Retry-After", "60")
.header("Content-Type", "application/json")
.body(Body::from(
r#"{"error":"comfyui pool at capacity","retry_after":60}"#,
))
.unwrap()
}
fn upstream_error(msg: String) -> Response<Body> {
debug!(%msg, "comfy proxy upstream error");
Response::builder()
.status(StatusCode::BAD_GATEWAY)
.header("Content-Type", "application/json")
.body(Body::from(format!(
r#"{{"error":"comfy proxy: {}"}}"#,
msg.replace('"', "\\\"")
)))
.unwrap()
}
+331 -15
View File
@@ -1,4 +1,4 @@
use crate::AppState;
use crate::{auth::jwt::validate_jwt, AppState};
use axum::{
extract::State,
http::HeaderMap,
@@ -62,12 +62,14 @@ emotion: 平静
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/comfyui-agent/status", get(status_handler))
.route("/api/comfyui-agent/models", get(models_handler))
.route("/api/comfyui-agent/chat", post(chat_handler))
}
#[derive(Debug, Deserialize)]
struct AgentChatRequest {
messages: Vec<AgentMessage>,
model: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -76,18 +78,78 @@ struct AgentMessage {
content: String,
}
fn upstream_config(state: &AppState) -> (String, String) {
let base = std::env::var("BOOSAPI_UPSTREAM_BASE")
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AgentMode {
/// Route through this server's own `/v1/*` using the built-in `comfyui-agent` downstream key.
Local,
/// Legacy fallback: external upstream configured via `BOOSAPI_UPSTREAM_BASE` / `BOOSAPI_UPSTREAM_KEY`.
FallbackRemote,
}
impl AgentMode {
fn as_str(self) -> &'static str {
match self {
AgentMode::Local => "local",
AgentMode::FallbackRemote => "fallback_remote",
}
}
}
fn detect_agent_mode() -> AgentMode {
let base_override = std::env::var("BOOSAPI_UPSTREAM_BASE")
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "https://api.boos.lat/v1".to_string());
let key = std::env::var("BOOSAPI_UPSTREAM_KEY")
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
let key_override = std::env::var("BOOSAPI_UPSTREAM_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| state.config.auth.proxy_token.clone());
(base, key)
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
if base_override.is_some() || key_override.is_some() {
AgentMode::FallbackRemote
} else {
AgentMode::Local
}
}
fn local_base(state: &AppState) -> String {
let host = if state.config.server.host == "0.0.0.0" || state.config.server.host == "::" {
"127.0.0.1".to_string()
} else {
state.config.server.host.clone()
};
format!("http://{}:{}/v1", host, state.config.server.port)
}
/// Resolve `(mode, api_base, api_key)` for the agent's outbound calls.
///
/// Defaults to `AgentMode::Local`, which targets this server's own `/v1`
/// endpoints with the built-in `comfyui-agent` downstream key — every call
/// then runs through the regular accounting / routing pipeline.
///
/// Setting `BOOSAPI_UPSTREAM_BASE` or `BOOSAPI_UPSTREAM_KEY` switches to
/// `FallbackRemote` for operators that still want to point straight at an
/// external aggregator.
fn upstream_config(state: &AppState) -> (AgentMode, String, String) {
match detect_agent_mode() {
AgentMode::Local => {
let base = local_base(state);
let key = state.builtin_keys.comfyui_agent.1.clone();
(AgentMode::Local, base, key)
}
AgentMode::FallbackRemote => {
let base = std::env::var("BOOSAPI_UPSTREAM_BASE")
.ok()
.map(|value| value.trim().trim_end_matches('/').to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "https://api.boos.lat/v1".to_string());
let key = std::env::var("BOOSAPI_UPSTREAM_KEY")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| state.config.auth.proxy_token.clone());
(AgentMode::FallbackRemote, base, key)
}
}
}
fn authorize(headers: &HeaderMap, state: &AppState) -> bool {
@@ -101,7 +163,21 @@ fn authorize(headers: &HeaderMap, state: &AppState) -> bool {
.or_else(|| auth_header.strip_prefix("bearer "))
.unwrap_or_default();
token == state.config.auth.auth_token
if token == state.config.auth.auth_token {
return true;
}
validate_jwt(&state.config.auth.jwt_secret, token)
.map(|claims| claims.role == "admin" || claims.role == "user")
.unwrap_or(false)
}
fn is_exact_model_pattern(value: &str) -> bool {
let normalized = value.trim();
!normalized.is_empty()
&& !normalized.to_ascii_lowercase().starts_with("re:")
&& !normalized.contains('*')
&& !normalized.contains('?')
}
async fn status_handler(
@@ -114,15 +190,62 @@ async fn status_handler(
Json(json!({ "error": "Unauthorized" })),
));
}
let (upstream_base, _) = upstream_config(&state);
let (mode, upstream_base, _) = upstream_config(&state);
let key_name = match mode {
AgentMode::Local => "comfyui-agent",
AgentMode::FallbackRemote => "external",
};
Ok(Json(json!({
"success": true,
"model": state.config.comfyui.agent_model,
"mode": mode.as_str(),
"keyName": key_name,
"upstreamBase": upstream_base,
"comfyui": state.comfyui.status_response().await,
})))
}
async fn models_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, (axum::http::StatusCode, Json<Value>)> {
if !authorize(&headers, &state) {
return Err((
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized" })),
));
}
let rows: Vec<String> = sqlx::query_scalar(
"SELECT model_pattern
FROM token_routes
WHERE COALESCE(enabled, 1) = 1
ORDER BY model_pattern COLLATE NOCASE",
)
.fetch_all(&*state.db)
.await
.map_err(|err| {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": err.to_string() })),
)
})?;
let mut seen = std::collections::HashSet::new();
let models: Vec<String> = rows
.into_iter()
.map(|value| value.trim().to_string())
.filter(|value| is_exact_model_pattern(value))
.filter(|value| seen.insert(value.clone()))
.collect();
Ok(Json(json!({
"success": true,
"models": models,
"defaultModel": state.config.comfyui.agent_model,
})))
}
#[derive(Debug, Default)]
struct ParsedSections {
characters: Vec<CharacterSection>,
@@ -373,6 +496,13 @@ async fn chat_handler(
));
}
let requested_model = body
.model
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let messages: Vec<Value> = body
.messages
.into_iter()
@@ -383,8 +513,13 @@ async fn chat_handler(
})
})
.collect();
let (api_base, api_key) = upstream_config(&state);
let agent_model = state.config.comfyui.agent_model.clone();
let (mode, api_base, api_key) = upstream_config(&state);
info!(
mode = mode.as_str(),
upstream_base = %api_base,
"comfyui-agent: resolved upstream",
);
let agent_model = requested_model.unwrap_or_else(|| state.config.comfyui.agent_model.clone());
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
tokio::spawn(async move {
@@ -723,3 +858,184 @@ async fn chat_handler(
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
use crate::services::builtin_keys::BuiltinKeys;
use axum::http::HeaderValue;
use sqlx::sqlite::SqlitePoolOptions;
use tokio::sync::Mutex;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// `BOOSAPI_UPSTREAM_*` is process-global. Serialize tests that read or
/// mutate it so cargo's parallel runner does not race their assertions.
/// Uses `tokio::sync::Mutex` so the guard can safely span `.await` points
/// (mock server setup, agent worker poll loop) without tripping
/// `clippy::await_holding_lock`.
static ENV_GUARD: Mutex<()> = Mutex::const_new(());
async fn make_state(host: &str, port: u16) -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
AppState::new(
AppConfig {
server: ServerConfig {
host: host.to_string(),
port,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
},
comfyui: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-test".into()),
},
)
}
fn clear_upstream_env() {
std::env::remove_var("BOOSAPI_UPSTREAM_BASE");
std::env::remove_var("BOOSAPI_UPSTREAM_KEY");
}
fn auth_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("Authorization", HeaderValue::from_static("Bearer auth"));
headers
}
async fn call_status(state: AppState) -> Value {
let result = status_handler(State(state), auth_headers()).await;
result.expect("status_handler returned error").0
}
/// 3.5 — `/api/comfyui-agent/status` reflects the env state in one shot:
/// no env → local; whitespace/empty env → still local (treated as unset);
/// real env → fallback_remote with trimmed `upstreamBase`.
#[tokio::test]
async fn status_reflects_env_state_across_three_scenarios() {
let _g = ENV_GUARD.lock().await;
clear_upstream_env();
// Scenario A: env unset → local; 0.0.0.0 host normalizes to 127.0.0.1.
{
let state = make_state("0.0.0.0", 7860).await;
let body = call_status(state).await;
assert_eq!(body["mode"], "local");
assert_eq!(body["keyName"], "comfyui-agent");
assert_eq!(body["upstreamBase"], "http://127.0.0.1:7860/v1");
}
// Scenario B: env set to whitespace/empty → treated as unset → local.
std::env::set_var("BOOSAPI_UPSTREAM_BASE", " ");
std::env::set_var("BOOSAPI_UPSTREAM_KEY", "");
{
let state = make_state("127.0.0.1", 9000).await;
let body = call_status(state).await;
assert_eq!(body["mode"], "local");
assert_eq!(body["keyName"], "comfyui-agent");
assert_eq!(body["upstreamBase"], "http://127.0.0.1:9000/v1");
}
// Scenario C: env set → fallback_remote, base trimmed of trailing slash.
std::env::set_var("BOOSAPI_UPSTREAM_BASE", "https://api.example.com/v1/");
std::env::remove_var("BOOSAPI_UPSTREAM_KEY");
{
let state = make_state("0.0.0.0", 7860).await;
let body = call_status(state).await;
assert_eq!(body["mode"], "fallback_remote");
assert_eq!(body["keyName"], "external");
assert_eq!(body["upstreamBase"], "https://api.example.com/v1");
}
clear_upstream_env();
}
/// 3.6 — In Local mode the agent's chat call must hit the local
/// `/v1/chat/completions` endpoint with the built-in `comfyui-agent`
/// downstream key as the bearer token.
#[tokio::test]
async fn agent_local_mode_routes_chat_to_local_upstream() {
let _g = ENV_GUARD.lock().await;
clear_upstream_env();
let mock = MockServer::start().await;
let sse = "data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\ndata: [DONE]\n\n";
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.and(header("authorization", "Bearer sk-comfyui-test"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.set_body_string(sse),
)
.mount(&mock)
.await;
let addr = mock.address();
let host = addr.ip().to_string();
let port = addr.port();
let state = make_state(&host, port).await;
let result = chat_handler(
State(state),
auth_headers(),
Json(AgentChatRequest {
messages: vec![AgentMessage {
role: "user".to_string(),
content: "hello".to_string(),
}],
}),
)
.await;
assert!(result.is_ok(), "chat_handler should accept the request");
// The Sse stream is dropped here; the worker still runs the upstream
// POST before its streaming loop notices `tx.closed()`.
// Wait up to ~2s for the spawned worker's POST to land at wiremock.
let mut hit = false;
for _ in 0..40 {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let received = mock.received_requests().await.unwrap_or_default();
if received
.iter()
.any(|r| r.url.path() == "/v1/chat/completions")
{
hit = true;
break;
}
}
assert!(
hit,
"expected /v1/chat/completions to be hit by the local-mode agent"
);
}
}
+17 -1
View File
@@ -13,6 +13,8 @@ use tower_http::services::ServeDir;
mod admin;
mod auth;
mod checkin;
#[cfg(feature = "multi_tenant_comfy")]
mod comfy_proxy;
mod comfyui;
mod comfyui_agent;
mod events;
@@ -28,6 +30,13 @@ mod tasks;
mod update_center;
mod user;
mod user_api_keys;
// Two implementations of /api/video-agent/*: legacy singleton (default)
// vs per-user pool. Exactly one is mounted, picked at compile time by
// the `multi_tenant_comfy` feature flag.
#[cfg(feature = "multi_tenant_comfy")]
mod video_agent;
#[cfg(not(feature = "multi_tenant_comfy"))]
#[path = "video_agent_legacy.rs"]
mod video_agent;
pub fn build_router(state: AppState) -> Router {
@@ -39,7 +48,14 @@ pub fn build_router(state: AppState) -> Router {
.merge(admin::routes(state.clone()))
.merge(proxy::routes())
.merge(comfyui_agent::routes())
.merge(comfyui::routes())
.merge(comfyui::routes());
// /comfy/* reverse proxy is only mounted when the per-user pool is
// active; without it there's no per-user container to forward to.
#[cfg(feature = "multi_tenant_comfy")]
let api_routes = api_routes.merge(comfy_proxy::routes());
let api_routes = api_routes
.merge(auth::routes())
.merge(events::routes())
.merge(checkin::routes())
+309 -13
View File
@@ -17,7 +17,7 @@ use crate::{
AppState,
};
use axum::{
extract::State,
extract::{Multipart, State},
http::HeaderMap,
response::{IntoResponse, Response, Sse},
routing::{get, post},
@@ -25,7 +25,7 @@ use axum::{
};
use futures::{stream, FutureExt, StreamExt};
use serde_json::{json, Value};
use std::{convert::Infallible, sync::Arc};
use std::{convert::Infallible, sync::Arc, time::Instant};
use tracing::info;
// ── Auth types ────────────────────────────────────────────────────────
@@ -58,6 +58,7 @@ pub fn routes() -> Router<AppState> {
.route("/v1/audio/translations", post(audio_translations))
.route("/v1/audio/speech", post(audio_speech))
.route("/v1/images/generations", post(images_generations))
.route("/v1/images/edits", post(images_edits))
.route("/v1/videos", post(videos_handler))
.route("/v1/models", get(list_models))
// ── ComfyUI shim aliases ──────────────────────────────────────
@@ -70,6 +71,10 @@ pub fn routes() -> Router<AppState> {
"/api/comfyui-agent/proxy/openai/images/generations",
post(images_generations),
)
.route(
"/api/comfyui-agent/proxy/openai/images/edits",
post(images_edits),
)
.route(
"/api/comfyui-agent/proxy/openai/v1/responses",
post(responses_handler),
@@ -321,6 +326,98 @@ async fn images_generations(
Ok(Json(result))
}
/// POST /v1/images/edits
///
/// `multipart/form-data` request: one or more image binaries
/// (`image`, `image[]`, `mask`) plus text fields (`prompt`, `model`,
/// `n`, `size`, `response_format`, `user`, `quality`, …). Mirrors
/// /images/generations for auth + routing + billing, but rebuilds a
/// fresh upstream multipart form rather than streaming the raw body —
/// that lets the route override `model` with the conductor-resolved
/// upstream model and skip frontend-only fields.
async fn images_edits(
State(state): State<AppState>,
headers: HeaderMap,
mut multipart: Multipart,
) -> AppResult<Json<Value>> {
info!("POST /v1/images/edits");
let key_id = check_auth(&state, &headers).await?;
let mut fields: Vec<executors::MultipartField> = Vec::new();
let mut model: Option<String> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(format!("invalid multipart body: {e}")))?
{
let name = field
.name()
.ok_or_else(|| AppError::BadRequest("multipart field missing name".into()))?
.to_string();
let filename = field.file_name().map(str::to_string);
let content_type = field.content_type().map(str::to_string);
// OpenAI's spec lists exactly which fields carry binaries; everything
// else is a text field. We tag a field as binary when it has a
// filename _or_ a non-text content-type — covers both well-formed
// SDKs and minimal curl uploads.
let is_file = filename.is_some()
|| content_type
.as_deref()
.map(|ct| !ct.starts_with("text/") && ct != "application/x-www-form-urlencoded")
.unwrap_or(false);
if is_file {
let bytes = field
.bytes()
.await
.map_err(|e| AppError::BadRequest(format!("failed to read upload: {e}")))?
.to_vec();
fields.push(executors::MultipartField {
name,
bytes: Some(bytes),
filename,
content_type,
text: None,
});
} else {
let text = field
.text()
.await
.map_err(|e| AppError::BadRequest(format!("failed to read field: {e}")))?;
if name == "model" {
model = Some(text.clone());
}
fields.push(executors::MultipartField {
name,
bytes: None,
filename: None,
content_type: None,
text: Some(text),
});
}
}
let model = model.ok_or_else(|| AppError::BadRequest("model is required".into()))?;
let route = resolve_route(&state, &model).await?;
// Override the multipart `model` field with the conductor-resolved
// upstream model name (route may map "dall-e-3" → upstream-specific id).
if route.model != model {
for field in fields.iter_mut() {
if field.name == "model" && field.bytes.is_none() {
field.text = Some(route.model.clone());
}
}
}
let result = executors::execute_image_edit(state.clone(), route, fields).await?;
record_billing(&state, key_id, &model, 0, 0, 0.0).await;
Ok(Json(result))
}
/// GET /v1/models
async fn list_models(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
info!("GET /v1/models");
@@ -401,6 +498,91 @@ async fn record_billing(
}
}
async fn record_proxy_log_success(
state: &AppState,
key_id: Option<i64>,
selected: &SelectedChannel,
requested_model: &str,
latency_ms: Option<i64>,
prompt_tokens: i64,
completion_tokens: i64,
cost: f64,
attempts: usize,
is_stream: bool,
) {
if let Err(err) = sqlx::query(
"INSERT INTO proxy_logs (
route_id, channel_id, account_id, downstream_api_key_id,
model_requested, model_actual, status, http_status,
latency_ms, prompt_tokens, completion_tokens, total_tokens, estimated_cost,
retry_count, is_stream
) VALUES (?, ?, ?, ?, ?, ?, 'success', 200, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(selected.route_id)
.bind(selected.channel_id)
.bind(selected.account_id)
.bind(key_id)
.bind(requested_model)
.bind(&selected.actual_model)
.bind(latency_ms)
.bind(prompt_tokens)
.bind(completion_tokens)
.bind(prompt_tokens + completion_tokens)
.bind(cost)
.bind(attempts.saturating_sub(1) as i64)
.bind(if is_stream { 1 } else { 0 })
.execute(&*state.db)
.await
{
tracing::warn!(
%err,
model = requested_model,
channel_id = selected.channel_id,
"failed to record proxy_logs success entry",
);
}
}
async fn record_proxy_log_failure(
state: &AppState,
key_id: Option<i64>,
selected: Option<&SelectedChannel>,
requested_model: &str,
status: Option<u16>,
latency_ms: Option<i64>,
error_message: Option<&str>,
attempts: usize,
is_stream: bool,
) {
if let Err(err) = sqlx::query(
"INSERT INTO proxy_logs (
route_id, channel_id, account_id, downstream_api_key_id,
model_requested, model_actual, status, http_status,
latency_ms, error_message, retry_count, is_stream
) VALUES (?, ?, ?, ?, ?, ?, 'error', ?, ?, ?, ?, ?)",
)
.bind(selected.and_then(|item| item.route_id))
.bind(selected.map(|item| item.channel_id))
.bind(selected.and_then(|item| item.account_id))
.bind(key_id)
.bind(requested_model)
.bind(selected.map(|item| item.actual_model.as_str()))
.bind(status.map(i64::from))
.bind(latency_ms)
.bind(error_message.unwrap_or("proxy request failed"))
.bind(attempts.saturating_sub(1) as i64)
.bind(if is_stream { 1 } else { 0 })
.execute(&*state.db)
.await
{
tracing::warn!(
%err,
model = requested_model,
"failed to record proxy_logs failure entry",
);
}
}
/// Pull `usage` block out of an OpenAI-shaped response. Returns
/// (prompt_tokens, completion_tokens). Missing fields default to 0.
fn extract_usage(response: &Value) -> (i64, i64) {
@@ -419,6 +601,10 @@ fn extract_usage(response: &Value) -> (i64, i64) {
(prompt, completion)
}
fn elapsed_ms_since(started_at: Instant) -> i64 {
started_at.elapsed().as_millis().min(i64::MAX as u128) as i64
}
// ── Route Resolution ──────────────────────────────────────────────────
/// Resolve upstream endpoint + API key for a given model.
@@ -491,6 +677,7 @@ async fn execute_chat_completions_with_conductor(
model: String,
body: Value,
) -> AppResult<Value> {
let started_at = Instant::now();
let conductor = build_conductor(&state);
let billing_state = state.clone();
let billing_model = model.clone();
@@ -527,10 +714,55 @@ async fn execute_chat_completions_with_conductor(
})
.await;
let response = conductor_result_to_app_result(result)?;
let (prompt, completion) = extract_usage(&response);
record_billing(&billing_state, key_id, &billing_model, prompt, completion, 0.0).await;
Ok(response)
match result {
ExecuteResult::Success {
selected,
response,
attempts,
} => {
let (prompt, completion) = extract_usage(&response);
record_proxy_log_success(
&billing_state,
key_id,
&selected,
&billing_model,
Some(elapsed_ms_since(started_at)),
prompt,
completion,
0.0,
attempts,
false,
)
.await;
record_billing(&billing_state, key_id, &billing_model, prompt, completion, 0.0).await;
Ok(response)
}
ExecuteResult::Failure {
reason,
selected,
status,
raw_error_text,
attempts,
} => {
record_proxy_log_failure(
&billing_state,
key_id,
selected.as_ref(),
&billing_model,
status,
Some(elapsed_ms_since(started_at)),
raw_error_text.as_deref(),
attempts,
false,
)
.await;
Err(AppError::UpstreamError(format!(
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
status,
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
)))
}
}
}
async fn execute_chat_completions_stream_with_conductor(
@@ -539,6 +771,7 @@ async fn execute_chat_completions_stream_with_conductor(
model: String,
body: Value,
) -> AppResult<Vec<String>> {
let started_at = Instant::now();
let conductor = build_conductor(&state);
let billing_state = state.clone();
let billing_model = model.clone();
@@ -575,19 +808,50 @@ async fn execute_chat_completions_stream_with_conductor(
})
.await;
let chunks = match conductor_result_to_app_result(result)? {
Value::Array(chunks) => chunks
let (selected, attempts, chunks) = match result {
ExecuteResult::Success {
selected,
response,
attempts,
} => match response {
Value::Array(chunks) => (selected, attempts, chunks
.into_iter()
.map(|chunk| {
chunk.as_str().map(str::to_string).ok_or_else(|| {
AppError::UpstreamError("Invalid streaming response from conductor".into())
})
})
.collect::<AppResult<Vec<String>>>()?,
_ => {
return Err(AppError::UpstreamError(
"Invalid streaming response from conductor".into(),
))
.collect::<AppResult<Vec<String>>>()?),
_ => {
return Err(AppError::UpstreamError(
"Invalid streaming response from conductor".into(),
))
}
},
ExecuteResult::Failure {
reason,
selected,
status,
raw_error_text,
attempts,
} => {
record_proxy_log_failure(
&billing_state,
key_id,
selected.as_ref(),
&billing_model,
status,
Some(elapsed_ms_since(started_at)),
raw_error_text.as_deref(),
attempts,
true,
)
.await;
return Err(AppError::UpstreamError(format!(
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
status,
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
)));
}
};
@@ -611,6 +875,19 @@ async fn execute_chat_completions_stream_with_conductor(
}
})
.unwrap_or((0, 0));
record_proxy_log_success(
&billing_state,
key_id,
&selected,
&billing_model,
Some(elapsed_ms_since(started_at)),
prompt,
completion,
0.0,
attempts,
true,
)
.await;
record_billing(&billing_state, key_id, &billing_model, prompt, completion, 0.0).await;
Ok(chunks)
}
@@ -881,6 +1158,25 @@ mod tests {
.execute(&pool)
.await
.unwrap();
// The channel-selection query joins through site_api_endpoints to
// pick a per-site override URL. We don't need any rows — the
// COALESCE falls back to sites.url when this table is empty —
// but the table itself must exist or the SELECT errors out and
// the conductor sees `None` (NoChannel) instead of an attempt
// failure that bumps fail_count.
sqlx::query(
"CREATE TABLE site_api_endpoints (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0,
cooldown_until TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO downstream_api_keys (id, name, key, enabled) VALUES (1, 'test', 'sk-test', 1)")
.execute(&pool)
+52 -2
View File
@@ -121,7 +121,29 @@ async fn proxy_logs(
.max(0);
let items: Vec<Value> = sqlx::query_as::<_, ProxyLogRow>(
"SELECT id, route_id, channel_id, account_id, downstream_api_key_id, model_requested, model_actual, status, http_status, latency_ms, first_byte_latency_ms, prompt_tokens, completion_tokens, total_tokens, estimated_cost, error_message, retry_count, is_stream, client_family, client_app_id, client_app_name, client_confidence, created_at FROM proxy_logs ORDER BY created_at DESC LIMIT ? OFFSET ?",
r#"
SELECT
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id,
l.model_requested, l.model_actual, l.status, l.http_status,
l.latency_ms, l.first_byte_latency_ms,
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.created_at,
a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
s.name AS site_name,
s.url AS site_url,
dk.name AS downstream_key_name,
dk.group_name AS downstream_key_group_name,
dk.tags AS downstream_key_tags
FROM proxy_logs l
LEFT JOIN accounts a ON a.id = l.account_id
LEFT JOIN sites s ON s.id = a.site_id
LEFT JOIN downstream_api_keys dk ON dk.id = l.downstream_api_key_id
ORDER BY l.created_at DESC
LIMIT ? OFFSET ?
"#,
)
.bind(limit)
.bind(offset)
@@ -162,6 +184,13 @@ pub struct ProxyLogRow {
channel_id: Option<i64>,
account_id: Option<i64>,
downstream_api_key_id: Option<i64>,
site_id: Option<i64>,
username: Option<String>,
site_name: Option<String>,
site_url: Option<String>,
downstream_key_name: Option<String>,
downstream_key_group_name: Option<String>,
downstream_key_tags: Option<String>,
model_requested: Option<String>,
model_actual: Option<String>,
status: Option<String>,
@@ -187,7 +216,28 @@ async fn proxy_log_detail(
axum::extract::Path(id): axum::extract::Path<i64>,
) -> AppResult<Json<Value>> {
let item: Option<ProxyLogRow> = sqlx::query_as(
"SELECT id, route_id, channel_id, account_id, downstream_api_key_id, model_requested, model_actual, status, http_status, latency_ms, first_byte_latency_ms, prompt_tokens, completion_tokens, total_tokens, estimated_cost, error_message, retry_count, is_stream, client_family, client_app_id, client_app_name, client_confidence, created_at FROM proxy_logs WHERE id = ?",
r#"
SELECT
l.id, l.route_id, l.channel_id, l.account_id, l.downstream_api_key_id,
l.model_requested, l.model_actual, l.status, l.http_status,
l.latency_ms, l.first_byte_latency_ms,
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.estimated_cost,
l.error_message, l.retry_count, l.is_stream,
l.client_family, l.client_app_id, l.client_app_name, l.client_confidence,
l.created_at,
a.site_id AS site_id,
COALESCE(NULLIF(a.username, ''), CASE WHEN a.id IS NULL THEN NULL ELSE '账号 #' || a.id END) AS username,
s.name AS site_name,
s.url AS site_url,
dk.name AS downstream_key_name,
dk.group_name AS downstream_key_group_name,
dk.tags AS downstream_key_tags
FROM proxy_logs l
LEFT JOIN accounts a ON a.id = l.account_id
LEFT JOIN sites s ON s.id = a.site_id
LEFT JOIN downstream_api_keys dk ON dk.id = l.downstream_api_key_id
WHERE l.id = ?
"#,
)
.bind(id)
.fetch_optional(&*state.db)
+204 -50
View File
@@ -1,3 +1,15 @@
//! `/api/video-agent/*` — per-user multi-tenant refactor.
//!
//! Every handler resolves the caller from the JWT in the `Authorization`
//! header (no anonymous access) and, where it makes sense, lazily starts
//! that user's ComfyUI container via [`ComfyUIPool`]. The LLM/image/TTS
//! helpers continue to call BoosAPI's own `/v1/*` loopback — they are
//! independent of ComfyUI and stay shared.
//!
//! Status responses preserve every existing JSON key the frontend reads
//! and add `containerState` so the new full-screen loading overlay can
//! poll until the container is ready.
use axum::{
body::Body,
extract::State,
@@ -12,13 +24,20 @@ use axum::{
use futures::stream;
use serde_json::{json, Value};
use std::convert::Infallible;
use std::time::{Duration, Instant};
use tracing::info;
use crate::{
auth::jwt::validate_jwt,
error::{AppError, AppResult},
services::comfyui_pool::{ContainerState, PoolError},
AppState,
};
const COLD_START_HOLD: Duration = Duration::from_secs(30);
const COLD_START_POLL: Duration = Duration::from_millis(500);
const ADMIN_WORKSPACE_USER_ID: i64 = 0;
const SCRIPT_ANALYSIS_PROMPT: &str = r#"你是一个 AI 视频生成助手。分析用户提供的脚本,提取以下结构化信息,以 JSON 格式返回:
{
@@ -91,40 +110,156 @@ pub fn routes() -> Router<AppState> {
.route("/api/video-agent/full-workflow", post(full_workflow))
}
// ── Auth ───────────────────────────────────────────────────────────────
/// Resolve the caller workspace from `Authorization: Bearer <token>`.
/// User JWTs map to their real user id; admin/global proxy tokens map to
/// workspace 0 so the admin console can use the same Docker-backed ComfyUI.
fn resolve_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
let auth = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| {
AppError::Unauthorized("Missing or invalid Authorization header".into())
})?;
if auth == state.config.auth.auth_token || auth == state.config.auth.proxy_token {
return Ok(ADMIN_WORKSPACE_USER_ID);
}
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
claims
.sub
.parse::<i64>()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))
}
// ── Pool helpers ───────────────────────────────────────────────────────
/// Map a [`PoolError`] to the appropriate [`AppError`].
fn pool_err_to_app(e: PoolError) -> AppError {
match e {
PoolError::AtCapacity(_) => {
AppError::ServiceUnavailable("comfyui pool at capacity".into())
}
PoolError::StartTimeout(_) => {
AppError::GatewayTimeout("comfyui container start timeout".into())
}
other => AppError::Internal(anyhow::anyhow!(other)),
}
}
/// Lazily start the caller's container and wait for readiness up to 30 s.
/// Returns the host port for the loopback HTTP API.
async fn ensure_container_ready(state: &AppState, user_id: i64) -> AppResult<u16> {
state
.comfyui_pool
.get_or_start(user_id)
.await
.map_err(pool_err_to_app)?;
let deadline = Instant::now() + COLD_START_HOLD;
loop {
match state.comfyui_pool.state(user_id).await {
ContainerState::Ready => break,
ContainerState::Failed | ContainerState::Stopped => {
return Err(AppError::UpstreamError(
"comfyui container failed to start".into(),
));
}
ContainerState::Starting => {
if Instant::now() >= deadline {
return Err(AppError::GatewayTimeout(
"comfyui cold-start timeout".into(),
));
}
tokio::time::sleep(COLD_START_POLL).await;
}
}
}
state.comfyui_pool.touch(user_id).await;
state
.comfyui_pool
.entry(user_id)
.await
.map(|e| e.host_port)
.ok_or_else(|| AppError::UpstreamError("container vanished after ready".into()))
}
/// JSON status response. Preserves the keys the frontend already reads
/// (`status`, `pid`, `uptime_secs`, `mode`, `model_backend`, `base_url`,
/// `version`, `last_error`) and adds `containerState` (task 5.2).
async fn pool_status_json(state: &AppState, user_id: i64) -> Value {
let cs = state.comfyui_pool.state(user_id).await;
let entry = state.comfyui_pool.entry(user_id).await;
let port = entry.as_ref().map(|e| e.host_port);
let last_error = entry.as_ref().and_then(|e| e.last_error.clone());
let status = match cs {
ContainerState::Starting => "starting",
ContainerState::Ready => "running",
ContainerState::Stopped => "stopped",
ContainerState::Failed => "unhealthy",
};
let base_url = port
.map(|p| format!("http://127.0.0.1:{}", p))
.unwrap_or_default();
let model_backend = format!("{}/v1", state.comfyui_pool.config().boosapi_base.trim_end_matches("/v1").trim_end_matches('/'));
json!({
"status": status,
"pid": Value::Null,
"uptime_secs": 0,
"mode": "container",
"model_backend": model_backend,
"base_url": base_url,
"version": Value::Null,
"last_error": last_error,
"containerState": cs.as_str(),
})
}
// ── Lifecycle handlers ─────────────────────────────────────────────────
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
Ok(Json(
serde_json::to_value(state.comfyui.status_response().await)
.unwrap_or_else(|_| json!({ "status": "unknown" })),
))
let user_id = resolve_user_id(&headers, &state)?;
Ok(Json(pool_status_json(&state, user_id).await))
}
async fn start_comfyui(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let user_id = resolve_user_id(&headers, &state)?;
// Idempotent: get_or_start returns the existing entry if already up.
state
.comfyui
.start()
.comfyui_pool
.get_or_start(user_id)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
.map_err(pool_err_to_app)?;
Ok(Json(json!({
"success": true,
"comfyui": state.comfyui.status_response().await
"comfyui": pool_status_json(&state, user_id).await,
})))
}
async fn stop_comfyui(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let user_id = resolve_user_id(&headers, &state)?;
state
.comfyui
.stop()
.comfyui_pool
.stop(user_id)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
.map_err(pool_err_to_app)?;
Ok(Json(json!({
"success": true,
"comfyui": state.comfyui.status_response().await
"comfyui": pool_status_json(&state, user_id).await,
})))
}
@@ -132,24 +267,45 @@ async fn restart_comfyui(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let user_id = resolve_user_id(&headers, &state)?;
// Stop is best-effort: if the container is already gone, that's fine.
let _ = state.comfyui_pool.stop(user_id).await;
state
.comfyui
.restart()
.comfyui_pool
.get_or_start(user_id)
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
.map_err(pool_err_to_app)?;
Ok(Json(json!({
"success": true,
"comfyui": state.comfyui.status_response().await
"comfyui": pool_status_json(&state, user_id).await,
})))
}
// ── BoosAPI loopback helpers ───────────────────────────────────────────
//
// LLM, image, and TTS calls go to BoosAPI's own `/v1/*` endpoints — they
// are not ComfyUI traffic. We pull the base URL from the same config the
// pool uses so there's exactly one source of truth.
fn boosapi_v1_base(state: &AppState) -> String {
let raw = state.comfyui_pool.config().boosapi_base.trim_end_matches('/');
if raw.ends_with("/v1") {
raw.to_string()
} else {
format!("{}/v1", raw)
}
}
async fn chat(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>> {
check_auth(&headers, &state)?;
let user_id = resolve_user_id(&headers, &state)?;
// Lazily warm the container so a subsequent generate-* call doesn't
// pay the full cold start.
let _ = state.comfyui_pool.get_or_start(user_id).await;
state.comfyui_pool.touch(user_id).await;
let prompt = body
.get("message")
@@ -183,25 +339,6 @@ async fn chat(
}))))
}
fn local_api_base(state: &AppState) -> String {
state.comfyui.model_backend_url()
}
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
let expected = format!("Bearer {}", state.config.auth.auth_token);
let actual = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if actual != expected {
return Err(AppError::BadRequest("unauthorized".to_string()));
}
Ok(())
}
fn build_basic_workflow(prompt: &str) -> Value {
json!({
"boosapi": {
@@ -255,7 +392,7 @@ fn parse_llm_json(raw: &str) -> AppResult<Value> {
async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<String> {
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", local_api_base(state));
let url = format!("{}/chat/completions", boosapi_v1_base(state));
info!("video-agent calling BoosAPI LLM endpoint: {}", url);
@@ -301,7 +438,7 @@ async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<Str
async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
let client = reqwest::Client::new();
let url = format!("{}/images/generations", local_api_base(state));
let url = format!("{}/images/generations", boosapi_v1_base(state));
info!("video-agent calling BoosAPI image endpoint: {}", url);
@@ -344,7 +481,7 @@ async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8>> {
let client = reqwest::Client::new();
let url = format!("{}/audio/speech", local_api_base(state));
let url = format!("{}/audio/speech", boosapi_v1_base(state));
info!("video-agent calling BoosAPI TTS endpoint: {}", url);
@@ -379,12 +516,15 @@ async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8
Ok(bytes.to_vec())
}
// ── Per-request handlers (5.5) ─────────────────────────────────────────
async fn analyze(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let user_id = resolve_user_id(&headers, &state)?;
state.comfyui_pool.touch(user_id).await;
let script = required_str(&body, "script")?;
@@ -402,7 +542,8 @@ async fn character_prompts(
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let user_id = resolve_user_id(&headers, &state)?;
state.comfyui_pool.touch(user_id).await;
let character = required_value(&body, "character")?;
@@ -438,7 +579,10 @@ async fn generate_character(
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let user_id = resolve_user_id(&headers, &state)?;
// Image generation can take a while; ensure the container is warm so
// downstream ComfyUI workflow reuse doesn't pay another cold start.
let _ = ensure_container_ready(&state, user_id).await?;
let prompts = required_value(&body, "prompts")?;
let prompt_obj = prompts
@@ -456,6 +600,8 @@ async fn generate_character(
results.insert(angle.clone(), Value::String(url));
}
state.comfyui_pool.touch(user_id).await;
Ok(Json(json!({
"success": true,
"data": Value::Object(results)
@@ -467,7 +613,8 @@ async fn scene_prompt(
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let user_id = resolve_user_id(&headers, &state)?;
state.comfyui_pool.touch(user_id).await;
let scene = required_value(&body, "scene")?;
@@ -495,11 +642,14 @@ async fn generate_scene(
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let user_id = resolve_user_id(&headers, &state)?;
let _ = ensure_container_ready(&state, user_id).await?;
let prompt = required_str(&body, "prompt")?;
let url = call_image_gen(&state, prompt).await?;
state.comfyui_pool.touch(user_id).await;
Ok(Json(json!({
"success": true,
"data": {
@@ -513,7 +663,8 @@ async fn generate_tts(
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Response<Body>> {
check_auth(&headers, &state)?;
let user_id = resolve_user_id(&headers, &state)?;
state.comfyui_pool.touch(user_id).await;
let text = required_str(&body, "text")?;
let voice = body
@@ -537,7 +688,8 @@ async fn full_workflow(
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let user_id = resolve_user_id(&headers, &state)?;
let _ = ensure_container_ready(&state, user_id).await?;
let script = required_str(&body, "script")?;
let requested_style = body.get("style").and_then(Value::as_str);
@@ -611,6 +763,8 @@ async fn full_workflow(
.or_else(|| requested_style.map(|s| Value::String(s.to_string())))
.unwrap_or(Value::Null);
state.comfyui_pool.touch(user_id).await;
Ok(Json(json!({
"success": true,
"data": {
+623
View File
@@ -0,0 +1,623 @@
use axum::{
body::Body,
extract::State,
http::{header, HeaderMap, Response, StatusCode},
response::{
sse::{Event, Sse},
IntoResponse,
},
routing::{get, post},
Json, Router,
};
use futures::stream;
use serde_json::{json, Value};
use std::convert::Infallible;
use tracing::info;
use crate::{
error::{AppError, AppResult},
AppState,
};
const SCRIPT_ANALYSIS_PROMPT: &str = r#"你是一个 AI 视频生成助手。分析用户提供的脚本,提取以下结构化信息,以 JSON 格式返回:
{
"title": "视频标题",
"characters": [
{
"name": "角色名",
"gender": "男/女",
"age": "年龄描述",
"appearance": "外貌详细描述(发型、脸型、服装、体型等)",
"personality": "性格特征"
}
],
"scenes": [
{
"id": 1,
"description": "场景描述",
"environment": "环境详细描述(时间、地点、天气、光线等)",
"characters": ["出现的角色名"],
"action": "场景中发生的动作",
"dialogue": [
{"character": "角色名", "line": "台词", "emotion": "情感语气"}
]
}
],
"style": "整体视觉风格(动画/写实/像素等)"
}
只返回 JSON,不要其他文字。"#;
const CHARACTER_PROMPT_GEN: &str = r#"根据角色描述生成 4 组稳定的图像生成提示词(正面、背面、左侧面、右侧面),用于 AI 图像生成模型。
每组提示词应包含:人物姿态、服装、光照、背景。
返回 JSON 格式:
{
"front": "正面提示词",
"back": "背面提示词",
"left": "左侧面提示词",
"right": "右侧面提示词"
}
只返回 JSON。"#;
const SCENE_PROMPT_GEN: &str = r#"根据场景描述生成图像生成提示词,用于 AI 图像生成模型创建场景背景。
包含:环境、光照、色调、氛围。
返回 JSON:
{
"prompt": "场景生成提示词",
"negative_prompt": "负面提示词"
}
只返回 JSON。"#;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/video-agent/status", get(status))
.route("/api/video-agent/start", post(start_comfyui))
.route("/api/video-agent/stop", post(stop_comfyui))
.route("/api/video-agent/restart", post(restart_comfyui))
.route("/api/video-agent/chat", post(chat))
.route("/api/video-agent/analyze", post(analyze))
.route(
"/api/video-agent/character-prompts",
post(character_prompts),
)
.route(
"/api/video-agent/generate-character",
post(generate_character),
)
.route("/api/video-agent/scene-prompt", post(scene_prompt))
.route("/api/video-agent/generate-scene", post(generate_scene))
.route("/api/video-agent/generate-tts", post(generate_tts))
.route("/api/video-agent/full-workflow", post(full_workflow))
}
async fn status(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
Ok(Json(
serde_json::to_value(state.comfyui.status_response().await)
.unwrap_or_else(|_| json!({ "status": "unknown" })),
))
}
async fn start_comfyui(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
state
.comfyui
.start()
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
Ok(Json(json!({
"success": true,
"comfyui": state.comfyui.status_response().await
})))
}
async fn stop_comfyui(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
state
.comfyui
.stop()
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
Ok(Json(json!({
"success": true,
"comfyui": state.comfyui.status_response().await
})))
}
async fn restart_comfyui(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
state
.comfyui
.restart()
.await
.map_err(|err| AppError::Internal(anyhow::anyhow!(err)))?;
Ok(Json(json!({
"success": true,
"comfyui": state.comfyui.status_response().await
})))
}
async fn chat(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Sse<impl futures::Stream<Item = Result<Event, Infallible>>>> {
check_auth(&headers, &state)?;
let prompt = body
.get("message")
.and_then(Value::as_str)
.or_else(|| {
body.get("messages")
.and_then(Value::as_array)
.and_then(|messages| messages.last())
.and_then(|message| message.get("content"))
.and_then(Value::as_str)
})
.unwrap_or("生成一个短视频工作流");
let workflow = build_basic_workflow(prompt);
let events = vec![
json!({
"type": "text",
"content": "已收到需求,正在生成 ComfyUI workflow..."
}),
json!({
"type": "workflow_generated",
"workflow": workflow
}),
json!({
"type": "done"
}),
];
Ok(Sse::new(stream::iter(events.into_iter().map(|event| {
Ok(Event::default().data(event.to_string()))
}))))
}
fn local_api_base(state: &AppState) -> String {
state.comfyui.model_backend_url()
}
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
let expected = format!("Bearer {}", state.config.auth.auth_token);
let actual = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if actual != expected {
return Err(AppError::BadRequest("unauthorized".to_string()));
}
Ok(())
}
fn build_basic_workflow(prompt: &str) -> Value {
json!({
"boosapi": {
"kind": "video-agent-workflow",
"version": 1,
"prompt": prompt,
},
"nodes": {},
})
}
fn required_str<'a>(body: &'a Value, key: &str) -> AppResult<&'a str> {
body.get(key)
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
}
fn required_value<'a>(body: &'a Value, key: &str) -> AppResult<&'a Value> {
body.get(key)
.filter(|v| !v.is_null())
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
}
fn strip_json_markers(s: &str) -> String {
let mut text = s.trim().to_string();
if text.starts_with("```json") {
text = text.trim_start_matches("```json").to_string();
} else if text.starts_with("```") {
text = text.trim_start_matches("```").to_string();
}
if text.ends_with("```") {
text = text.trim_end_matches("```").to_string();
}
text.trim().to_string()
}
fn parse_llm_json(raw: &str) -> AppResult<Value> {
let cleaned = strip_json_markers(raw);
serde_json::from_str::<Value>(&cleaned).map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"failed to parse LLM JSON response: {}; response: {}",
e,
cleaned
))
})
}
async fn call_llm(state: &AppState, prompt: &str, system: &str) -> AppResult<String> {
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", local_api_base(state));
info!("video-agent calling BoosAPI LLM endpoint: {}", url);
let resp = client
.post(url)
.bearer_auth(&state.config.auth.proxy_token)
.json(&json!({
"model": &state.config.comfyui.agent_model,
"messages": [
{ "role": "system", "content": system },
{ "role": "user", "content": prompt }
],
"temperature": 0.7
}))
.send()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM request failed: {}", e)))?;
let status = resp.status();
let data: Value = resp
.json()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM response parse failed: {}", e)))?;
if !status.is_success() {
return Err(AppError::Internal(anyhow::anyhow!(
"LLM upstream returned {}: {}",
status,
data
)));
}
Ok(data
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("message"))
.and_then(|message| message.get("content"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string())
}
async fn call_image_gen(state: &AppState, prompt: &str) -> AppResult<String> {
let client = reqwest::Client::new();
let url = format!("{}/images/generations", local_api_base(state));
info!("video-agent calling BoosAPI image endpoint: {}", url);
let resp = client
.post(url)
.bearer_auth(&state.config.auth.proxy_token)
.json(&json!({
"model": "gpt-image-2",
"prompt": prompt,
"n": 1,
"size": "1024x1024"
}))
.send()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("image request failed: {}", e)))?;
let status = resp.status();
let data: Value = resp
.json()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("image response parse failed: {}", e)))?;
if !status.is_success() {
return Err(AppError::Internal(anyhow::anyhow!(
"image upstream returned {}: {}",
status,
data
)));
}
Ok(data
.get("data")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("url"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string())
}
async fn call_tts(state: &AppState, text: &str, voice: &str) -> AppResult<Vec<u8>> {
let client = reqwest::Client::new();
let url = format!("{}/audio/speech", local_api_base(state));
info!("video-agent calling BoosAPI TTS endpoint: {}", url);
let resp = client
.post(url)
.bearer_auth(&state.config.auth.proxy_token)
.json(&json!({
"model": "tts-1",
"input": text,
"voice": voice
}))
.send()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS request failed: {}", e)))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(AppError::Internal(anyhow::anyhow!(
"TTS upstream returned {}: {}",
status,
body
)));
}
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS response read failed: {}", e)))?;
Ok(bytes.to_vec())
}
async fn analyze(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let script = required_str(&body, "script")?;
let result = call_llm(&state, script, SCRIPT_ANALYSIS_PROMPT).await?;
let parsed = parse_llm_json(&result)?;
Ok(Json(json!({
"success": true,
"data": parsed
})))
}
async fn character_prompts(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let character = required_value(&body, "character")?;
let name = character
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let appearance = character
.get("appearance")
.and_then(Value::as_str)
.unwrap_or_default();
let gender = character
.get("gender")
.and_then(Value::as_str)
.unwrap_or_default();
let age = character
.get("age")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{name}: {appearance}, {gender}, {age}");
let result = call_llm(&state, &desc, CHARACTER_PROMPT_GEN).await?;
let prompts = parse_llm_json(&result)?;
Ok(Json(json!({
"success": true,
"data": prompts
})))
}
async fn generate_character(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let prompts = required_value(&body, "prompts")?;
let prompt_obj = prompts
.as_object()
.ok_or_else(|| AppError::BadRequest("prompts must be an object".to_string()))?;
let mut results = serde_json::Map::new();
for (angle, prompt_value) in prompt_obj {
let prompt = prompt_value.as_str().ok_or_else(|| {
AppError::BadRequest(format!("prompt for angle '{angle}' must be a string"))
})?;
let url = call_image_gen(&state, prompt).await?;
results.insert(angle.clone(), Value::String(url));
}
Ok(Json(json!({
"success": true,
"data": Value::Object(results)
})))
}
async fn scene_prompt(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let scene = required_value(&body, "scene")?;
let environment = scene
.get("environment")
.and_then(Value::as_str)
.unwrap_or_default();
let description = scene
.get("description")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{environment}. {description}");
let result = call_llm(&state, &desc, SCENE_PROMPT_GEN).await?;
let prompt = parse_llm_json(&result)?;
Ok(Json(json!({
"success": true,
"data": prompt
})))
}
async fn generate_scene(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let prompt = required_str(&body, "prompt")?;
let url = call_image_gen(&state, prompt).await?;
Ok(Json(json!({
"success": true,
"data": {
"url": url
}
})))
}
async fn generate_tts(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Response<Body>> {
check_auth(&headers, &state)?;
let text = required_str(&body, "text")?;
let voice = body
.get("voice")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.unwrap_or("alloy");
let audio = call_tts(&state, text, voice).await?;
Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "audio/mpeg")],
audio,
)
.into_response())
}
async fn full_workflow(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let script = required_str(&body, "script")?;
let requested_style = body.get("style").and_then(Value::as_str);
let analysis_raw = call_llm(&state, script, SCRIPT_ANALYSIS_PROMPT).await?;
let analysis = parse_llm_json(&analysis_raw)?;
let mut characters = Vec::new();
if let Some(chars) = analysis.get("characters").and_then(Value::as_array) {
for character in chars {
let name = character
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let appearance = character
.get("appearance")
.and_then(Value::as_str)
.unwrap_or_default();
let gender = character
.get("gender")
.and_then(Value::as_str)
.unwrap_or_default();
let age = character
.get("age")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{name}: {appearance}, {gender}, {age}");
let prompts_raw = call_llm(&state, &desc, CHARACTER_PROMPT_GEN).await?;
let prompts = parse_llm_json(&prompts_raw)?;
let mut item = character.clone();
if let Some(obj) = item.as_object_mut() {
obj.insert("imagePrompts".to_string(), prompts);
}
characters.push(item);
}
}
let mut scenes = Vec::new();
if let Some(scene_items) = analysis.get("scenes").and_then(Value::as_array) {
for scene in scene_items {
let environment = scene
.get("environment")
.and_then(Value::as_str)
.unwrap_or_default();
let description = scene
.get("description")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{environment}. {description}");
let scene_prompt_raw = call_llm(&state, &desc, SCENE_PROMPT_GEN).await?;
let image_prompt = parse_llm_json(&scene_prompt_raw)?;
let mut item = scene.clone();
if let Some(obj) = item.as_object_mut() {
obj.insert("imagePrompt".to_string(), image_prompt);
}
scenes.push(item);
}
}
let style = analysis
.get("style")
.cloned()
.or_else(|| requested_style.map(|s| Value::String(s.to_string())))
.unwrap_or(Value::Null);
Ok(Json(json!({
"success": true,
"data": {
"title": analysis.get("title").cloned().unwrap_or(Value::Null),
"style": style,
"characters": characters,
"scenes": scenes
}
})))
}
+654
View File
@@ -0,0 +1,654 @@
//! Per-user ComfyUI container pool.
//!
//! Owns the in-memory map `user_id -> PoolEntry`, allocates ports from the
//! 8200..=8299 range, enforces the 30-container ceiling, runs the readiness
//! probe, and reaps idle containers.
//!
//! All Docker/K3S specifics are encapsulated by the [`ContainerRuntime`]
//! trait; this file MUST stay backend-agnostic.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use parking_lot::Mutex;
use tokio::sync::{Mutex as AsyncMutex, RwLock};
use tracing::{debug, error, info, warn};
use super::container_runtime::{
BindMount, ContainerRuntime, ContainerSpec, ResourceLimits, RuntimeError, TmpfsMount,
};
/// Maximum concurrent containers (§design Decision 2: 30 × 2 GB ≈ 60 GB).
pub const DEFAULT_MAX_CONCURRENT: usize = 30;
/// Idle timeout before the reaper stops a container.
pub const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(60 * 60);
/// Host port range for per-user containers.
pub const PORT_RANGE_START: u16 = 8200;
pub const PORT_RANGE_END: u16 = 8299;
/// Time we wait for a freshly started container to answer `/system_stats`.
pub const READINESS_TIMEOUT: Duration = Duration::from_secs(30);
/// How often the reaper task wakes up.
pub const REAPER_TICK: Duration = Duration::from_secs(60);
/// Image tag we run for per-user tenants.
pub const DEFAULT_IMAGE: &str = "boosapi-comfyui:multi-tenant";
/// Host directory under which per-user volumes live.
pub const DEFAULT_DATA_ROOT: &str = "/data/comfyui-users";
/// Lifecycle state visible to callers (frontend renders loading vs ready).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContainerState {
Starting,
Ready,
Stopped,
Failed,
}
impl ContainerState {
pub fn as_str(self) -> &'static str {
match self {
ContainerState::Starting => "starting",
ContainerState::Ready => "ready",
ContainerState::Stopped => "stopped",
ContainerState::Failed => "failed",
}
}
}
/// One running (or starting) per-user container.
#[derive(Debug, Clone)]
pub struct PoolEntry {
pub user_id: i64,
pub container_name: String,
pub host_port: u16,
pub state: ContainerState,
pub last_used_at: Instant,
pub active_ws: u32,
pub last_error: Option<String>,
}
#[derive(thiserror::Error, Debug)]
pub enum PoolError {
#[error("pool at capacity ({0} containers running)")]
AtCapacity(usize),
#[error("no free port in pool range")]
OutOfPorts,
#[error("container failed to become ready in {0:?}")]
StartTimeout(Duration),
#[error("runtime error: {0}")]
Runtime(#[from] RuntimeError),
#[error("internal: {0}")]
Internal(String),
}
pub type PoolResult<T> = Result<T, PoolError>;
/// Tunable knobs the binary wires up at boot.
#[derive(Debug, Clone)]
pub struct PoolConfig {
pub image: String,
pub max_concurrent: usize,
pub idle_timeout: Duration,
pub data_root: PathBuf,
pub boosapi_base: String,
pub builtin_key: String,
}
impl PoolConfig {
pub fn from_env(boosapi_base: String, builtin_key: String) -> Self {
let image = std::env::var("BOOSAPI_COMFY_IMAGE").unwrap_or_else(|_| DEFAULT_IMAGE.into());
let max_concurrent: usize = std::env::var("BOOSAPI_COMFY_MAX")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_MAX_CONCURRENT);
let idle_secs: u64 = std::env::var("BOOSAPI_COMFY_IDLE_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(DEFAULT_IDLE_TIMEOUT.as_secs());
let data_root = std::env::var("BOOSAPI_COMFY_DATA_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_DATA_ROOT));
Self {
image,
max_concurrent,
idle_timeout: Duration::from_secs(idle_secs),
data_root,
boosapi_base,
builtin_key,
}
}
}
/// Thread-safe allocator over `PORT_RANGE_START..=PORT_RANGE_END`.
struct PortAllocator {
in_use: Mutex<HashSet<u16>>,
}
impl PortAllocator {
fn new() -> Self {
Self {
in_use: Mutex::new(HashSet::new()),
}
}
fn claim(&self, port: u16) {
self.in_use.lock().insert(port);
}
fn allocate(&self) -> Option<u16> {
let mut g = self.in_use.lock();
for p in PORT_RANGE_START..=PORT_RANGE_END {
if !g.contains(&p) {
g.insert(p);
return Some(p);
}
}
None
}
fn release(&self, port: u16) {
self.in_use.lock().remove(&port);
}
}
/// Per-user start lock. Prevents two concurrent calls to
/// `get_or_start(7)` from spawning two containers for user 7.
type StartLock = Arc<AsyncMutex<()>>;
/// The pool itself.
pub struct ComfyUIPool {
runtime: Arc<dyn ContainerRuntime>,
config: PoolConfig,
containers: RwLock<HashMap<i64, PoolEntry>>,
start_locks: Mutex<HashMap<i64, StartLock>>,
ports: PortAllocator,
http: reqwest::Client,
}
impl ComfyUIPool {
pub fn new(runtime: Arc<dyn ContainerRuntime>, config: PoolConfig) -> Arc<Self> {
Arc::new(Self {
runtime,
config,
containers: RwLock::new(HashMap::new()),
start_locks: Mutex::new(HashMap::new()),
ports: PortAllocator::new(),
http: reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.expect("reqwest client"),
})
}
pub fn config(&self) -> &PoolConfig {
&self.config
}
/// Start the user's container if it isn't already running and return a
/// handle. Idempotent.
pub async fn get_or_start(&self, user_id: i64) -> PoolResult<PoolEntry> {
// Fast path: already ready.
if let Some(entry) = self.containers.read().await.get(&user_id).cloned() {
if entry.state == ContainerState::Ready {
self.touch_inner(user_id).await;
return Ok(entry);
}
}
// Per-user start lock — only one task gets to spawn per user.
let lock = self
.start_locks
.lock()
.entry(user_id)
.or_insert_with(|| Arc::new(AsyncMutex::new(())))
.clone();
let _g = lock.lock().await;
// Re-check after acquiring the lock; another task may have done the work.
if let Some(entry) = self.containers.read().await.get(&user_id).cloned() {
if entry.state == ContainerState::Ready {
self.touch_inner(user_id).await;
return Ok(entry);
}
if entry.state == ContainerState::Starting {
// Probe ourselves — the previous winner may have died mid-start.
return self.probe_until_ready(user_id, entry).await;
}
}
// Capacity check.
{
let map = self.containers.read().await;
let live = map
.values()
.filter(|e| {
matches!(
e.state,
ContainerState::Starting | ContainerState::Ready
)
})
.count();
if live >= self.config.max_concurrent {
return Err(PoolError::AtCapacity(live));
}
}
// Allocate port.
let port = self.ports.allocate().ok_or(PoolError::OutOfPorts)?;
let name = format!("comfyui-user-{}", user_id);
// Insert a Starting marker so concurrent callers see it.
{
let mut map = self.containers.write().await;
map.insert(
user_id,
PoolEntry {
user_id,
container_name: name.clone(),
host_port: port,
state: ContainerState::Starting,
last_used_at: Instant::now(),
active_ws: 0,
last_error: None,
},
);
}
let spec = self.build_spec(user_id, &name, port);
match self.runtime.start(spec).await {
Ok(handle) => {
debug!(user_id, container = %handle.name, port = handle.host_port, "container started");
}
Err(e) => {
self.ports.release(port);
let mut map = self.containers.write().await;
if let Some(entry) = map.get_mut(&user_id) {
entry.state = ContainerState::Failed;
entry.last_error = Some(e.to_string());
}
return Err(PoolError::Runtime(e));
}
}
let entry_now = self
.containers
.read()
.await
.get(&user_id)
.cloned()
.ok_or_else(|| PoolError::Internal("entry vanished mid-start".into()))?;
self.probe_until_ready(user_id, entry_now).await
}
/// Wait for the container to answer `/system_stats`, then mark Ready.
async fn probe_until_ready(
&self,
user_id: i64,
entry: PoolEntry,
) -> PoolResult<PoolEntry> {
let url = format!("http://127.0.0.1:{}/system_stats", entry.host_port);
let deadline = Instant::now() + READINESS_TIMEOUT;
while Instant::now() < deadline {
match self.http.get(&url).send().await {
Ok(r) if r.status().is_success() => {
let mut map = self.containers.write().await;
if let Some(e) = map.get_mut(&user_id) {
e.state = ContainerState::Ready;
e.last_used_at = Instant::now();
e.last_error = None;
return Ok(e.clone());
}
return Err(PoolError::Internal("entry removed during probe".into()));
}
_ => {
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
}
// Timeout. Mark Failed and let the next call retry.
let mut map = self.containers.write().await;
if let Some(e) = map.get_mut(&user_id) {
e.state = ContainerState::Failed;
e.last_error = Some("readiness probe timed out".into());
}
// Best-effort stop so the slot frees on the next reaper tick.
let name = entry.container_name.clone();
let runtime = self.runtime.clone();
tokio::spawn(async move {
let _ = runtime.stop(&name).await;
});
Err(PoolError::StartTimeout(READINESS_TIMEOUT))
}
/// Stop the user's container, release its port.
pub async fn stop(&self, user_id: i64) -> PoolResult<()> {
let entry = {
let mut map = self.containers.write().await;
map.remove(&user_id)
};
let Some(entry) = entry else {
return Ok(());
};
self.ports.release(entry.host_port);
self.runtime.stop(&entry.container_name).await?;
Ok(())
}
/// Current state for a user (or `Stopped` if no entry).
pub async fn state(&self, user_id: i64) -> ContainerState {
self.containers
.read()
.await
.get(&user_id)
.map(|e| e.state)
.unwrap_or(ContainerState::Stopped)
}
/// Snapshot of the user's pool entry, if any.
pub async fn entry(&self, user_id: i64) -> Option<PoolEntry> {
self.containers.read().await.get(&user_id).cloned()
}
/// Bump `last_used_at`. Cheap; called on every proxied frame.
pub async fn touch(&self, user_id: i64) {
self.touch_inner(user_id).await
}
async fn touch_inner(&self, user_id: i64) {
if let Some(e) = self.containers.write().await.get_mut(&user_id) {
e.last_used_at = Instant::now();
}
}
/// Increment / decrement the active-WS counter for a user.
pub async fn ws_open(&self, user_id: i64) {
if let Some(e) = self.containers.write().await.get_mut(&user_id) {
e.active_ws = e.active_ws.saturating_add(1);
e.last_used_at = Instant::now();
}
}
pub async fn ws_close(&self, user_id: i64) {
if let Some(e) = self.containers.write().await.get_mut(&user_id) {
e.active_ws = e.active_ws.saturating_sub(1);
e.last_used_at = Instant::now();
}
}
/// Reconcile against the runtime at startup. Adopts existing
/// `comfyui-user-*` containers so a Rust restart doesn't orphan them.
pub async fn reconcile_on_startup(&self) {
let listings = match self.runtime.list_user_containers().await {
Ok(v) => v,
Err(e) => {
warn!(error = %e, "could not reconcile existing containers; assuming none");
return;
}
};
let mut map = self.containers.write().await;
for listing in listings {
// Names look like `comfyui-user-7`.
let Some(suffix) = listing.name.strip_prefix("comfyui-user-") else {
continue;
};
let Ok(user_id) = suffix.parse::<i64>() else {
continue;
};
self.ports.claim(listing.host_port);
map.insert(
user_id,
PoolEntry {
user_id,
container_name: listing.name.clone(),
host_port: listing.host_port,
state: ContainerState::Ready, // assume ready; first request re-probes
last_used_at: Instant::now(),
active_ws: 0,
last_error: None,
},
);
info!(user_id, container = %listing.name, port = listing.host_port, "adopted existing container");
}
}
/// Stop every container we manage (graceful-shutdown hook).
pub async fn shutdown_all(&self) {
let names: Vec<String> = {
let map = self.containers.read().await;
map.values().map(|e| e.container_name.clone()).collect()
};
for name in names {
if let Err(e) = self.runtime.stop(&name).await {
warn!(container = %name, error = %e, "shutdown stop failed");
}
}
self.containers.write().await.clear();
}
/// Build the [`ContainerSpec`] for a given user.
fn build_spec(&self, user_id: i64, name: &str, host_port: u16) -> ContainerSpec {
let user_root = self.config.data_root.join(user_id.to_string());
// Subdirs we mount in. Created on demand by the host operator (or
// the runtime can `mkdir -p` them — Docker `bind` will fail if the
// path is missing).
let mk = |sub: &str| BindMount {
host_path: user_root.join(sub),
container_path: format!("/workspace/ComfyUI/{}", sub),
read_only: false,
};
// Best-effort mkdir so a fresh user just works. Failures here surface
// through the Docker error message later.
for sub in ["input", "output", "temp", "user", "custom_nodes_user"] {
let _ = std::fs::create_dir_all(user_root.join(sub));
}
let mut env = std::collections::HashMap::new();
env.insert(
"BOOSAPI_BUILTIN_KEY".into(),
self.config.builtin_key.clone(),
);
env.insert("BOOSAPI_BASE".into(), self.config.boosapi_base.clone());
env.insert("BOOSAPI_KEY".into(), self.config.builtin_key.clone());
ContainerSpec {
image: self.config.image.clone(),
name: name.to_string(),
host_port,
env,
binds: vec![
mk("input"),
mk("output"),
mk("temp"),
mk("user"),
mk("custom_nodes_user"),
],
tmpfs: vec![TmpfsMount {
container_path: "/tmp".into(),
size_bytes: 512 * 1024 * 1024,
}],
limits: ResourceLimits::default(),
}
}
}
/// Spawn the idle-reaper task. Lives for the life of the process.
pub fn spawn_reaper(pool: Arc<ComfyUIPool>) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(REAPER_TICK);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tick.tick().await;
reap_once(&pool).await;
}
});
}
async fn reap_once(pool: &Arc<ComfyUIPool>) {
let now = Instant::now();
let idle = pool.config.idle_timeout;
let candidates: Vec<i64> = {
let map = pool.containers.read().await;
map.values()
.filter(|e| {
e.active_ws == 0
&& now.saturating_duration_since(e.last_used_at) > idle
&& matches!(e.state, ContainerState::Ready | ContainerState::Failed)
})
.map(|e| e.user_id)
.collect()
};
for user_id in candidates {
info!(user_id, "reaping idle comfyui container");
if let Err(e) = pool.stop(user_id).await {
error!(user_id, error = %e, "reap failed");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
use crate::services::container_runtime::{
ContainerHandle, ContainerListing, RuntimeResult,
};
/// In-memory runtime that succeeds and pretends `/system_stats` is up.
/// Real readiness probe is tested separately via the live Docker setup.
struct MockRuntime {
starts: AtomicUsize,
stops: AtomicUsize,
}
impl MockRuntime {
fn new() -> Self {
Self {
starts: AtomicUsize::new(0),
stops: AtomicUsize::new(0),
}
}
}
#[async_trait]
impl ContainerRuntime for MockRuntime {
async fn start(&self, spec: ContainerSpec) -> RuntimeResult<ContainerHandle> {
self.starts.fetch_add(1, Ordering::SeqCst);
Ok(ContainerHandle {
id: format!("mock-{}", spec.name),
name: spec.name,
host_port: spec.host_port,
})
}
async fn stop(&self, _name: &str) -> RuntimeResult<()> {
self.stops.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn is_alive(&self, _name: &str) -> RuntimeResult<bool> {
Ok(true)
}
async fn list_user_containers(&self) -> RuntimeResult<Vec<ContainerListing>> {
Ok(vec![])
}
}
fn cfg() -> PoolConfig {
PoolConfig {
image: "test".into(),
max_concurrent: 2,
idle_timeout: Duration::from_secs(60),
data_root: std::env::temp_dir().join("boosapi-pool-test"),
boosapi_base: "http://127.0.0.1:9999".into(),
builtin_key: "sk-test".into(),
}
}
#[tokio::test]
async fn capacity_cap_enforced() {
let runtime = Arc::new(MockRuntime::new());
let pool = ComfyUIPool::new(runtime.clone(), cfg());
// Inject 2 containers manually so we don't have to satisfy the
// /system_stats probe (this test isolates the cap check).
{
let mut map = pool.containers.write().await;
map.insert(
1,
PoolEntry {
user_id: 1,
container_name: "comfyui-user-1".into(),
host_port: 8200,
state: ContainerState::Ready,
last_used_at: Instant::now(),
active_ws: 0,
last_error: None,
},
);
map.insert(
2,
PoolEntry {
user_id: 2,
container_name: "comfyui-user-2".into(),
host_port: 8201,
state: ContainerState::Ready,
last_used_at: Instant::now(),
active_ws: 0,
last_error: None,
},
);
}
let err = pool.get_or_start(3).await.unwrap_err();
assert!(matches!(err, PoolError::AtCapacity(2)), "got: {:?}", err);
}
#[tokio::test]
async fn stop_releases_port() {
let runtime = Arc::new(MockRuntime::new());
let pool = ComfyUIPool::new(runtime.clone(), cfg());
{
let mut map = pool.containers.write().await;
map.insert(
7,
PoolEntry {
user_id: 7,
container_name: "comfyui-user-7".into(),
host_port: 8205,
state: ContainerState::Ready,
last_used_at: Instant::now(),
active_ws: 0,
last_error: None,
},
);
}
pool.ports.claim(8205);
pool.stop(7).await.unwrap();
assert_eq!(runtime.stops.load(Ordering::SeqCst), 1);
// Port is back in the free pool.
let next = pool.ports.allocate().unwrap();
assert_eq!(next, PORT_RANGE_START);
}
#[test]
fn port_allocator_assigns_in_order_and_releases() {
let p = PortAllocator::new();
let a = p.allocate().unwrap();
let b = p.allocate().unwrap();
assert_eq!(a, PORT_RANGE_START);
assert_eq!(b, PORT_RANGE_START + 1);
p.release(a);
let c = p.allocate().unwrap();
assert_eq!(c, PORT_RANGE_START);
}
}
+120
View File
@@ -0,0 +1,120 @@
//! Backend-agnostic container lifecycle abstraction.
//!
//! v1 ships [`crate::services::docker_runtime::DockerRuntime`] against the
//! local Docker daemon. v2 will add a K3S implementation behind the same
//! trait so the pool, proxy, and route handlers do NOT have to change.
//!
//! Callers (the pool, in particular) MUST go through this trait — never
//! import `bollard`, the `docker` CLI, or `kube` types directly.
use async_trait::async_trait;
use std::collections::HashMap;
use std::path::PathBuf;
/// Resource and security caps for a container.
///
/// Mirrors §design Decision 5 verbatim: 2 CPU, 2 GB RAM, 2 GB swap,
/// `pids_limit=512`, read-only rootfs, drop all caps, `no-new-privileges`.
#[derive(Debug, Clone)]
pub struct ResourceLimits {
pub cpus: f64,
pub memory_bytes: u64,
pub memory_swap_bytes: u64,
pub pids_limit: u32,
pub read_only_rootfs: bool,
pub cap_drop_all: bool,
pub no_new_privileges: bool,
}
impl Default for ResourceLimits {
fn default() -> Self {
Self {
cpus: 2.0,
memory_bytes: 2 * 1024 * 1024 * 1024,
memory_swap_bytes: 2 * 1024 * 1024 * 1024,
pids_limit: 512,
read_only_rootfs: true,
cap_drop_all: true,
no_new_privileges: true,
}
}
}
/// One bind mount: `host_path -> container_path`.
#[derive(Debug, Clone)]
pub struct BindMount {
pub host_path: PathBuf,
pub container_path: String,
pub read_only: bool,
}
/// Tmpfs mount (used to satisfy a few writable paths under read-only rootfs).
#[derive(Debug, Clone)]
pub struct TmpfsMount {
pub container_path: String,
pub size_bytes: u64,
}
/// A complete container start request.
#[derive(Debug, Clone)]
pub struct ContainerSpec {
pub image: String,
pub name: String,
/// Host port to bind to. The container's internal port is fixed at 8188.
pub host_port: u16,
pub env: HashMap<String, String>,
pub binds: Vec<BindMount>,
pub tmpfs: Vec<TmpfsMount>,
pub limits: ResourceLimits,
}
/// Result of a successful start: identifies the container and tells the
/// caller which loopback port to forward traffic to.
#[derive(Debug, Clone)]
pub struct ContainerHandle {
/// Backend-specific id (Docker container id, k8s pod name, ...).
pub id: String,
/// Container name we asked for, e.g. `comfyui-user-7`.
pub name: String,
/// Loopback host port the runtime mapped for us.
pub host_port: u16,
}
/// Listing entry returned by [`ContainerRuntime::list_user_containers`].
#[derive(Debug, Clone)]
pub struct ContainerListing {
pub id: String,
pub name: String,
pub host_port: u16,
}
#[derive(thiserror::Error, Debug)]
pub enum RuntimeError {
#[error("backend error: {0}")]
Backend(String),
#[error("container not found: {0}")]
NotFound(String),
}
pub type RuntimeResult<T> = Result<T, RuntimeError>;
/// Backend-agnostic container lifecycle.
#[async_trait]
pub trait ContainerRuntime: Send + Sync + 'static {
/// Start a container and return a handle once it is running.
///
/// The runtime is NOT required to wait for the application inside the
/// container to be ready — that probe lives in the pool.
async fn start(&self, spec: ContainerSpec) -> RuntimeResult<ContainerHandle>;
/// Stop and remove a container by name. Idempotent: removing a
/// nonexistent container returns `Ok`.
async fn stop(&self, name: &str) -> RuntimeResult<()>;
/// Check whether a container is running.
async fn is_alive(&self, name: &str) -> RuntimeResult<bool>;
/// List every running container whose name starts with `comfyui-user-`.
/// Used at API startup to reconstruct the pool's in-memory map.
async fn list_user_containers(&self) -> RuntimeResult<Vec<ContainerListing>>;
}
+288
View File
@@ -0,0 +1,288 @@
//! Docker implementation of [`ContainerRuntime`].
//!
//! We shell out to the `docker` CLI rather than pull in a Rust Docker SDK,
//! to keep the dependency footprint small. The CLI is universally
//! available on the hosts we ship to, and the surface area we need
//! (`run / stop / rm / inspect / ps`) is tiny.
use async_trait::async_trait;
use std::process::Stdio;
use tokio::process::Command;
use tracing::{debug, warn};
use super::container_runtime::{
ContainerHandle, ContainerListing, ContainerRuntime, ContainerSpec, RuntimeError,
RuntimeResult,
};
/// Internal port the ComfyUI process listens on inside the container.
/// Hard-coded to match the image's ENTRYPOINT.
const CONTAINER_INTERNAL_PORT: u16 = 8188;
/// Label we stamp onto every container we manage. Used by
/// `list_user_containers` and as a sanity-check on `stop`.
pub const MANAGED_LABEL: &str = "boosapi.comfy.tenant=1";
/// Default Docker runtime. Stateless — every method shells out fresh.
pub struct DockerRuntime {
docker_bin: String,
}
impl DockerRuntime {
pub fn new() -> Self {
Self {
docker_bin: std::env::var("BOOSAPI_DOCKER_BIN").unwrap_or_else(|_| "docker".into()),
}
}
fn cmd(&self) -> Command {
let mut c = Command::new(&self.docker_bin);
c.stdin(Stdio::null());
c
}
}
impl Default for DockerRuntime {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ContainerRuntime for DockerRuntime {
async fn start(&self, spec: ContainerSpec) -> RuntimeResult<ContainerHandle> {
// Best-effort: kill any stale container with the same name.
// `docker rm -f <name>` is a no-op if the name is free.
let _ = self
.cmd()
.args(["rm", "-f", &spec.name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
let mut cmd = self.cmd();
cmd.arg("run")
.arg("-d")
.arg("--rm")
.arg("--name")
.arg(&spec.name)
.arg("--label")
.arg(MANAGED_LABEL)
.arg("-p")
.arg(format!(
"127.0.0.1:{}:{}",
spec.host_port, CONTAINER_INTERNAL_PORT
))
// Resource limits (§design Decision 5)
.arg(format!("--cpus={}", spec.limits.cpus))
.arg(format!("--memory={}", spec.limits.memory_bytes))
.arg(format!("--memory-swap={}", spec.limits.memory_swap_bytes))
.arg(format!("--pids-limit={}", spec.limits.pids_limit));
if spec.limits.read_only_rootfs {
cmd.arg("--read-only");
}
if spec.limits.cap_drop_all {
cmd.args(["--cap-drop", "ALL"]);
}
if spec.limits.no_new_privileges {
cmd.args(["--security-opt", "no-new-privileges"]);
}
for (k, v) in &spec.env {
cmd.arg("-e").arg(format!("{}={}", k, v));
}
for bind in &spec.binds {
// Using `--mount` over `-v` so the syntax is unambiguous and
// we get a clear error if the host path is wrong.
let mut s = format!(
"type=bind,source={},target={}",
bind.host_path.display(),
bind.container_path
);
if bind.read_only {
s.push_str(",readonly");
}
cmd.arg("--mount").arg(s);
}
for tmp in &spec.tmpfs {
cmd.arg("--tmpfs")
.arg(format!("{}:size={}", tmp.container_path, tmp.size_bytes));
}
cmd.arg(&spec.image);
debug!(?cmd, "starting comfyui container");
let output = cmd.output().await.map_err(io_to_runtime)?;
if !output.status.success() {
return Err(RuntimeError::Backend(format!(
"docker run failed (exit {}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let id = String::from_utf8_lossy(&output.stdout).trim().to_string();
if id.is_empty() {
return Err(RuntimeError::Backend(
"docker run returned empty container id".into(),
));
}
Ok(ContainerHandle {
id,
name: spec.name,
host_port: spec.host_port,
})
}
async fn stop(&self, name: &str) -> RuntimeResult<()> {
// `docker stop` flushes nicely; `--rm` from start removes it after.
// If it's already gone, treat as success.
let output = self
.cmd()
.args(["stop", "-t", "10", name])
.output()
.await
.map_err(io_to_runtime)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("No such container") {
return Ok(());
}
warn!(name, stderr = stderr.trim(), "docker stop failed");
// Fall through to rm -f as a last resort.
}
let _ = self
.cmd()
.args(["rm", "-f", name])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
Ok(())
}
async fn is_alive(&self, name: &str) -> RuntimeResult<bool> {
let output = self
.cmd()
.args([
"inspect",
"-f",
"{{.State.Running}}",
name,
])
.output()
.await
.map_err(io_to_runtime)?;
if !output.status.success() {
// `docker inspect` returns 1 if the container is unknown.
return Ok(false);
}
Ok(String::from_utf8_lossy(&output.stdout).trim() == "true")
}
async fn list_user_containers(&self) -> RuntimeResult<Vec<ContainerListing>> {
// `--format {{json .}}` gives one JSON object per line. We pull the
// fields we need without depending on a JSON-deserialize crate
// schema for Docker's somewhat-fluid output.
let output = self
.cmd()
.args([
"ps",
"--filter",
"name=comfyui-user-",
"--filter",
&format!("label={}", MANAGED_LABEL),
"--format",
"{{.Names}}\t{{.ID}}\t{{.Ports}}",
])
.output()
.await
.map_err(io_to_runtime)?;
if !output.status.success() {
return Err(RuntimeError::Backend(format!(
"docker ps failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut out = Vec::new();
for line in stdout.lines() {
let mut parts = line.splitn(3, '\t');
let name = parts.next().unwrap_or("").trim().to_string();
let id = parts.next().unwrap_or("").trim().to_string();
let ports = parts.next().unwrap_or("").trim();
if name.is_empty() || id.is_empty() {
continue;
}
let host_port = match parse_host_port(ports) {
Some(p) => p,
None => {
warn!(name, ports, "could not parse host port from docker ps; skipping");
continue;
}
};
out.push(ContainerListing { id, name, host_port });
}
Ok(out)
}
}
/// Pull the host-side port out of a `docker ps` Ports column.
///
/// Examples it must handle:
/// "127.0.0.1:8200->8188/tcp"
/// "127.0.0.1:8200->8188/tcp, 127.0.0.1:8200->8188/udp"
fn parse_host_port(ports: &str) -> Option<u16> {
for spec in ports.split(',') {
let spec = spec.trim();
// Accept `<host>:<port>->...`
let arrow = spec.find("->")?;
let left = &spec[..arrow];
let port_str = left.rsplit(':').next()?;
if let Ok(p) = port_str.parse::<u16>() {
return Some(p);
}
}
None
}
fn io_to_runtime(e: std::io::Error) -> RuntimeError {
RuntimeError::Backend(format!("docker cli io error: {}", e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_port_mapping() {
assert_eq!(parse_host_port("127.0.0.1:8200->8188/tcp"), Some(8200));
}
#[test]
fn parses_port_mapping_with_extra_entries() {
assert_eq!(
parse_host_port("127.0.0.1:8201->8188/tcp, 127.0.0.1:8201->8188/udp"),
Some(8201)
);
}
#[test]
fn returns_none_for_garbage() {
assert_eq!(parse_host_port(""), None);
assert_eq!(parse_host_port("nope"), None);
}
}
+4
View File
@@ -1,9 +1,13 @@
pub mod announcement;
pub mod billing;
pub mod builtin_keys;
pub mod comfyui_pool;
pub mod comfyui_process;
pub mod container_runtime;
pub mod cron;
pub mod docker_runtime;
pub mod notification;
pub mod oauth;
pub mod route_rebuild;
pub mod shared;
pub mod update_center;
+174 -4
View File
@@ -13,6 +13,14 @@
//! marked `manual_override = 1` are NEVER touched (created, updated, deleted).
//! Other channels are reconciled to match the current model availability:
//! missing combos get inserted, stale combos get hard-deleted.
//! - After channels are reconciled, a route orphan sweep deletes
//! `token_routes` rows that (a) no active account advertises any more,
//! (b) have no surviving channels (so no manual_override channel pins
//! them), and (c) have empty `display_name` / `display_icon` /
//! `model_mapping` (so no operator has touched the route's display
//! metadata). Without this sweep, `/api/routes/summary` keeps surfacing
//! phantom routes for models nothing can serve, and the user-visible
//! "rebuild" button appears to do nothing.
//! - Disabled site models (`site_disabled_models`) are filtered out before the
//! write. So is anything from accounts whose status isn't `active`, or models
//! where `model_availability.available != 1`.
@@ -30,6 +38,7 @@ use std::collections::{HashMap, HashSet};
pub struct RebuildSummary {
pub created_routes: i64,
pub reused_routes: i64,
pub deleted_routes: i64,
pub created_channels: i64,
pub deleted_channels: i64,
pub skipped_manual_channels: i64,
@@ -255,6 +264,50 @@ pub(crate) async fn rebuild_routes_inner(
}
}
// 6. Route orphan sweep. After channels for "no longer routable" models
// have been deleted above, the parent `token_routes` row can also
// safely go away — but only if it's a pure auto-managed leftover.
// We preserve a route when ANY of these is true:
// - the model is still in `by_model` (an active account advertises it);
// - the route still has at least one channel left, which after the
// channel sweep above can only be a `manual_override = 1` channel;
// - an operator has touched the route's display metadata
// (`display_name`, `display_icon`, `model_mapping`).
// Without this, every route ever created sticks around forever — the
// `/api/routes/summary` endpoint then surfaces phantom routes for
// models nothing can serve, and rebuild "does nothing" because the
// main loop only visits models that are in `by_model`.
let route_orphan_candidates: Vec<(i64, Option<String>, Option<String>, Option<String>)> =
sqlx::query_as(
"SELECT tr.id, tr.display_name, tr.display_icon, tr.model_mapping
FROM token_routes tr
WHERE NOT EXISTS (
SELECT 1 FROM route_channels rc WHERE rc.route_id = tr.id
)",
)
.fetch_all(&mut *tx)
.await?;
for (route_id, display_name, display_icon, model_mapping) in route_orphan_candidates {
if processed_route_ids.contains(&route_id) {
// Still backed by an active model — leave it, even if it currently
// has no channels (channels will be re-inserted on next refresh).
continue;
}
let touched_by_operator = display_name.as_deref().map(str::trim).unwrap_or("").is_empty()
== false
|| display_icon.as_deref().map(str::trim).unwrap_or("").is_empty() == false
|| model_mapping.as_deref().map(str::trim).unwrap_or("").is_empty() == false;
if touched_by_operator {
continue;
}
sqlx::query("DELETE FROM token_routes WHERE id = ?")
.bind(route_id)
.execute(&mut *tx)
.await?;
summary.deleted_routes += 1;
}
tx.commit().await?;
Ok(summary)
}
@@ -296,6 +349,9 @@ mod tests {
"CREATE TABLE token_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model_pattern TEXT NOT NULL UNIQUE,
model_mapping TEXT,
display_name TEXT,
display_icon TEXT,
routing_strategy TEXT NOT NULL DEFAULT 'weighted',
route_mode TEXT NOT NULL DEFAULT 'pattern',
enabled INTEGER NOT NULL DEFAULT 1
@@ -387,14 +443,23 @@ mod tests {
summary.deleted_channels, 1,
"summary should report exactly one deletion"
);
// The route row itself stays — operators may have edited display
// metadata on it, and the next time an active account advertises
// `gpt-x` we want to reuse it. The rebuild never deletes routes.
// The route row also goes — it's a pure auto-managed leftover (no
// surviving channels, no operator-edited display metadata, no active
// account advertising the model). Leaving it behind is what causes
// `/api/routes/summary` to surface phantom routes for models nothing
// can serve.
let route_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM token_routes")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(route_count, 1, "token_routes row should be reused, not dropped");
assert_eq!(
route_count, 0,
"pure auto-managed route must be swept once its last channel is gone"
);
assert_eq!(
summary.deleted_routes, 1,
"summary should report the route deletion"
);
}
/// Sanity check: manual_override channels survive an orphan sweep. Even
@@ -423,6 +488,10 @@ mod tests {
let summary = rebuild_routes_inner(&pool).await.expect("rebuild");
assert_eq!(summary.deleted_channels, 0, "manual channel must survive");
assert_eq!(
summary.deleted_routes, 0,
"route is pinned by a manual_override channel and must survive"
);
let manual_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM route_channels WHERE manual_override = 1",
@@ -431,5 +500,106 @@ mod tests {
.await
.unwrap();
assert_eq!(manual_count, 1);
let route_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM token_routes")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(route_count, 1);
}
/// User-reported bug: empty DB still shows hundreds of routes on the
/// `/routes` page, and clicking "rebuild" does nothing. Reproduces by
/// pre-seeding `token_routes` rows that no account / availability row
/// references — exactly the state we found in the live `boosapi.db`.
/// The rebuild must hard-delete those phantom routes.
#[tokio::test]
async fn phantom_routes_with_no_accounts_are_swept_on_rebuild() {
let pool = fixture_pool().await;
// No accounts, no availability — but three left-behind auto-managed
// routes. Mirror the production layout: empty display_name /
// display_icon / model_mapping.
sqlx::query(
"INSERT INTO token_routes (id, model_pattern) VALUES
(1, 'gpt-4'),
(2, 'claude-opus-4.5'),
(3, 'glm-5')",
)
.execute(&pool)
.await
.unwrap();
let summary = rebuild_routes_inner(&pool).await.expect("rebuild");
let route_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM token_routes")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
route_count, 0,
"all phantom routes must be swept when no account advertises any model"
);
assert_eq!(summary.deleted_routes, 3, "summary reports the three sweeps");
assert_eq!(summary.created_routes, 0);
assert_eq!(summary.created_channels, 0);
}
/// Operator-edited routes survive the sweep. We probe each of the three
/// "touched" columns (`display_name`, `display_icon`, `model_mapping`)
/// independently so an accidental refactor that drops a check in the
/// disjunction gets caught.
#[tokio::test]
async fn route_with_operator_metadata_is_preserved() {
for (label, sql) in [
(
"display_name",
"INSERT INTO token_routes (id, model_pattern, display_name) VALUES (1, 'gpt-4', 'Renamed by ops')",
),
(
"display_icon",
"INSERT INTO token_routes (id, model_pattern, display_icon) VALUES (1, 'gpt-4', 'openai-color')",
),
(
"model_mapping",
"INSERT INTO token_routes (id, model_pattern, model_mapping) VALUES (1, 'gpt-4', '{\"alias\":\"gpt-4o\"}')",
),
] {
let pool = fixture_pool().await;
sqlx::query(sql).execute(&pool).await.unwrap();
let summary = rebuild_routes_inner(&pool).await.expect("rebuild");
assert_eq!(
summary.deleted_routes, 0,
"operator-edited {label} must pin the route through a sweep"
);
let route_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM token_routes")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(route_count, 1, "route must survive ({label} edited)");
}
}
/// Whitespace-only metadata is not an "edit" — `' '` should still get
/// swept. Otherwise a stray space saved by the admin form would
/// silently keep dead routes around forever.
#[tokio::test]
async fn whitespace_only_metadata_does_not_pin_a_route() {
let pool = fixture_pool().await;
sqlx::query(
"INSERT INTO token_routes (id, model_pattern, display_name, display_icon, model_mapping)
VALUES (1, 'gpt-4', ' ', '', '\t')",
)
.execute(&pool)
.await
.unwrap();
let summary = rebuild_routes_inner(&pool).await.expect("rebuild");
assert_eq!(summary.deleted_routes, 1);
let route_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM token_routes")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(route_count, 0);
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { Login } from './App.js';
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
import { SITE_DOCS_URL, SITE_GITHUB_URL } from './docsLink.js';
function collectText(node: ReactTestInstance): string {
@@ -23,7 +23,7 @@ describe('Login surface', () => {
it('renders a poster-style hero with a floating admin login panel', () => {
const root = create(
<MemoryRouter>
<Login onLogin={vi.fn()} t={(text) => text} initialAudience="admin" />
<AdminLoginPage onLogin={vi.fn()} t={(text) => text} />
</MemoryRouter>,
);
@@ -79,7 +79,7 @@ describe('Login surface', () => {
expect(githubLink.props['aria-label']).toBe('GitHub');
expect(githubLink.props.target).toBe('_blank');
expect(tokenInput.props.type).toBe('password');
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['普通用户', '管理员', 'Auth Token', '账号密码']);
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['Auth Token', '账号密码']);
} finally {
root?.unmount();
}
+9
View File
@@ -12,6 +12,9 @@ const { apiMock, authSessionMock } = vi.hoisted(() => ({
hasValidAuthSession: vi.fn(),
persistAuthSession: vi.fn(),
clearAuthSession: vi.fn(),
getSessionToken: vi.fn(),
commitSession: vi.fn(),
clearSession: vi.fn(),
},
}));
@@ -31,6 +34,9 @@ vi.mock('./authSession.js', () => ({
hasValidAuthSession: authSessionMock.hasValidAuthSession,
persistAuthSession: authSessionMock.persistAuthSession,
clearAuthSession: authSessionMock.clearAuthSession,
getSessionToken: authSessionMock.getSessionToken,
commitSession: authSessionMock.commitSession,
clearSession: authSessionMock.clearSession,
}));
vi.mock('./components/SearchModal.js', () => ({
@@ -138,6 +144,9 @@ describe('App mobile layout', () => {
vi.useFakeTimers();
apiMock.getEvents.mockResolvedValue([]);
authSessionMock.hasValidAuthSession.mockReturnValue(true);
authSessionMock.getSessionToken.mockImplementation((audience: string) =>
audience === 'admin' ? 'fake-admin-token' : null,
);
});
afterEach(() => {
+9
View File
@@ -12,6 +12,9 @@ const { apiMock, authSessionMock } = vi.hoisted(() => ({
hasValidAuthSession: vi.fn(),
persistAuthSession: vi.fn(),
clearAuthSession: vi.fn(),
getSessionToken: vi.fn(),
commitSession: vi.fn(),
clearSession: vi.fn(),
},
}));
@@ -31,6 +34,9 @@ vi.mock('./authSession.js', () => ({
hasValidAuthSession: authSessionMock.hasValidAuthSession,
persistAuthSession: authSessionMock.persistAuthSession,
clearAuthSession: authSessionMock.clearAuthSession,
getSessionToken: authSessionMock.getSessionToken,
commitSession: authSessionMock.commitSession,
clearSession: authSessionMock.clearSession,
}));
vi.mock('./components/SearchModal.js', () => ({
@@ -140,6 +146,9 @@ describe('App mobile sidebar', () => {
vi.useFakeTimers();
apiMock.getEvents.mockResolvedValue([]);
authSessionMock.hasValidAuthSession.mockReturnValue(true);
authSessionMock.getSessionToken.mockImplementation((audience: string) =>
audience === 'admin' ? 'fake-admin-token' : null,
);
});
afterEach(() => {
+33 -366
View File
@@ -1,11 +1,11 @@
import React, { Suspense, lazy, useState, useEffect, useRef } from 'react';
import { Routes, Route, NavLink, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom';
import { ToastProvider, useToast } from './components/Toast.js';
import SearchModal from './components/SearchModal.js';
import NotificationPanel from './components/NotificationPanel.js';
import TooltipLayer from './components/TooltipLayer.js';
import { api } from './api.js';
import { clearAuthSession, clearUserSession, hasValidAuthSession, hasValidUserSession, persistAuthSession, persistUserSession } from './authSession.js';
import { clearSession, getSessionToken } from './authSession.js';
import {
FIRST_USE_DOC_REMINDER_KEY,
LEGACY_THEME_STORAGE_KEY,
@@ -13,12 +13,13 @@ import {
USER_PROFILE_STORAGE_KEY,
} from './appLocalState.js';
import { I18nProvider, useI18n } from './i18n.js';
import { resolveLoginErrorMessage } from './loginError.js';
import { SITE_DOCS_URL, SITE_GITHUB_URL } from './docsLink.js';
import { SITE_DOCS_URL } from './docsLink.js';
import { useAnimatedVisibility } from './components/useAnimatedVisibility.js';
import { useIsMobile } from './components/useIsMobile.js';
import { MobileDrawer } from './components/MobileDrawer.js';
import CenteredModal from './components/CenteredModal.js';
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
import UserLoginPage from './pages/auth/UserLoginPage.js';
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
const Sites = lazy(() => import('./pages/Sites.js'));
const Accounts = lazy(() => import('./pages/Accounts.js'));
@@ -126,341 +127,6 @@ function resolveStoredProfile(): UserProfile {
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
}
}
type LoginAudience = 'user' | 'admin';
type LoginMode = 'token' | 'account';
export function Login({
onLogin,
onUserLogin,
t,
initialAudience = 'user',
}: {
onLogin: (token: string) => void;
onUserLogin?: () => void;
t: (text: string) => string;
initialAudience?: LoginAudience;
}) {
const navigate = useNavigate();
const [audience, setAudience] = useState<LoginAudience>(initialAudience);
const [mode, setMode] = useState<LoginMode>('token');
const [token, setToken] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const capabilityRows = [
{
title: t('统一代理网关'),
description: t('一个 Key、一个入口,兼容 OpenAI / Claude 下游格式'),
},
{
title: t('自动模型发现'),
description: t('上游新增模型自动出现在模型列表,零配置路由生成'),
},
{
title: t('智能路由引擎'),
description: t('按成本、延迟、成功率自动选择最优通道,故障自动转移'),
},
];
const handleTokenLogin = async () => {
if (!token) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/settings/auth/info', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
onLogin(token);
} else {
let reason = '';
try {
const text = await res.text();
if (text) {
try {
const parsed = JSON.parse(text) as { message?: unknown; error?: unknown };
if (typeof parsed.message === 'string') reason = parsed.message;
else if (typeof parsed.error === 'string') reason = parsed.error;
else reason = text;
} catch {
reason = text;
}
}
} catch { }
setError(t(resolveLoginErrorMessage(res.status, reason)));
setLoading(false);
}
} catch {
setError(t('无法连接到服务器'));
setLoading(false);
}
};
const handleAccountLogin = async () => {
if (!email.trim() || !password) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password }),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
const rawErr = data?.error;
const message = data?.message
|| (typeof rawErr === 'string' ? rawErr : rawErr?.message)
|| t('登录失败,请检查邮箱和密码');
setError(t(String(message)));
setLoading(false);
return;
}
const jwt = String(data?.token || data?.data?.token || '').trim();
const role = String(data?.user?.role || data?.data?.user?.role || '').trim();
if (!jwt) {
setError(t('登录失败,请检查邮箱和密码'));
setLoading(false);
return;
}
if (audience === 'admin' && role !== 'admin') {
setError(t('该账号不是管理员,无法进入控制台。'));
setLoading(false);
return;
}
if (audience === 'admin') {
onLogin(jwt);
return;
}
persistUserSession(localStorage, jwt);
onUserLogin?.();
navigate('/user/dashboard');
} catch {
setError(t('无法连接到服务器'));
setLoading(false);
}
};
const handleLogin = () => {
if (audience === 'admin' && mode === 'token') {
void handleTokenLogin();
} else {
void handleAccountLogin();
}
};
const canSubmit = audience === 'admin' && mode === 'token'
? !!token.trim()
: !!email.trim() && !!password;
const activeMode = audience === 'user' ? 'account' : mode;
return (
<div className="login-shell">
<div className="login-surface animate-scale-in">
<section className="login-brand-panel login-brand-panel-light">
<div className="login-brand-header">
<div className="brand-mark-frame brand-mark-frame-hero">
<div className="brand-mark-canvas">
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
</div>
</div>
<div className="login-brand-summary">
<div className="login-brand-name">BoosAPI</div>
<div className="login-brand-kicker">{t('中转站的中转站')}</div>
</div>
</div>
<div className="login-brand-copy-block">
<p className="login-brand-copy">
{t('把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。')}
</p>
</div>
<div className="login-compat-line">{t('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API')}</div>
<div className="login-capability-list">
{capabilityRows.map((feature, index) => (
<div key={feature.title} className="login-capability-row">
<div className="login-capability-index">{String(index + 1).padStart(2, '0')}</div>
<div className="login-capability-content">
<div className="login-capability-title">{feature.title}</div>
<p className="login-capability-desc">{feature.description}</p>
</div>
</div>
))}
</div>
<div className="login-brand-footer">
<a
href={SITE_GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="login-icon-link"
aria-label="GitHub"
title="GitHub"
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="login-icon-link-svg">
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.38 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-1.04-.01-1.88-2.78.62-3.37-1.22-3.37-1.22-.45-1.2-1.11-1.52-1.11-1.52-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.57 2.35 1.12 2.92.85.09-.67.35-1.12.64-1.38-2.22-.26-4.55-1.15-4.55-5.13 0-1.13.39-2.05 1.03-2.77-.1-.26-.45-1.31.1-2.73 0 0 .84-.28 2.75 1.06A9.3 9.3 0 0 1 12 6.91c.85 0 1.71.12 2.51.35 1.91-1.34 2.75-1.06 2.75-1.06.55 1.42.2 2.47.1 2.73.64.72 1.03 1.64 1.03 2.77 0 3.99-2.34 4.86-4.57 5.12.36.33.68.97.68 1.96 0 1.42-.01 2.56-.01 2.91 0 .27.18.59.69.49A10.27 10.27 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z"
/>
</svg>
</a>
<a
href={SITE_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="login-doc-link"
>
{t('部署文档')}
</a>
</div>
</section>
<section className="login-auth-stage">
<div className="login-auth-panel">
<div className="login-auth-eyebrow">
{audience === 'admin' ? t('管理员入口') : t('用户入口')}
</div>
<h2 className="login-auth-title">{t('登录')}</h2>
<p className="login-auth-copy">
{audience === 'user'
? t('普通用户使用邮箱和密码进入个人面板。')
: activeMode === 'token'
? t('请输入管理员令牌后继续。')
: t('使用管理员账号和密码进入控制台。')}
</p>
<div className="login-mode-tabs" role="tablist" aria-label={t('登录入口')}>
<button
type="button"
role="tab"
aria-selected={audience === 'user'}
className={`login-mode-tab ${audience === 'user' ? 'active' : ''}`}
onClick={() => {
setAudience('user');
setError('');
}}
>
{t('普通用户')}
</button>
<button
type="button"
role="tab"
aria-selected={audience === 'admin'}
className={`login-mode-tab ${audience === 'admin' ? 'active' : ''}`}
onClick={() => {
setAudience('admin');
setError('');
}}
>
{t('管理员')}
</button>
</div>
<div className="login-mode-tabs" role="tablist" aria-label={t('登录方式')}>
<button
type="button"
role="tab"
aria-selected={activeMode === 'token'}
className={`login-mode-tab ${activeMode === 'token' ? 'active' : ''}`}
onClick={() => {
setAudience('admin');
setMode('token');
setError('');
}}
>
{t('Auth Token')}
</button>
<button
type="button"
role="tab"
aria-selected={activeMode === 'account'}
className={`login-mode-tab ${activeMode === 'account' ? 'active' : ''}`}
onClick={() => {
setMode('account');
setError('');
}}
>
{t('账号密码')}
</button>
</div>
{audience === 'admin' && mode === 'token' ? (
<>
<label className="login-auth-label" htmlFor="admin-token-input">{t('管理员令牌')}</label>
<input
id="admin-token-input"
type="password"
placeholder={t('管理员令牌')}
value={token}
onChange={(e) => {
setToken(e.target.value);
setError('');
}}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
className="login-auth-input"
autoComplete="current-password"
/>
</>
) : (
<>
<label className="login-auth-label" htmlFor="login-email-input">{t('邮箱')}</label>
<input
id="login-email-input"
type="email"
placeholder={audience === 'admin' ? t('例如: admin@example.com') : 'you@example.com'}
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError('');
}}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
className="login-auth-input"
autoComplete="username"
/>
<label className="login-auth-label" htmlFor="login-password-input">{t('密码')}</label>
<input
id="login-password-input"
type="password"
placeholder={t('密码')}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
className="login-auth-input"
autoComplete="current-password"
/>
</>
)}
{error && (
<div className="alert alert-error animate-shake" style={{ marginBottom: 12 }}>
{error}
</div>
)}
<button
onClick={handleLogin}
disabled={loading || !canSubmit}
className="btn btn-primary login-auth-submit"
>
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />{t('验证中...')}</> : t('登录')}
</button>
<div className="login-auth-note">
{audience === 'user'
? t('普通用户登录会进入个人面板,可管理自己的 API Key 和用量。')
: mode === 'token'
? t('仅校验本地服务访问权限,不会把令牌发送到第三方。')
: t('账号密码登录会签发管理员会话,不会保存明文密码。')}
</div>
<div className="login-auth-footer">
{audience === 'user' ? (
<span>
{t('还没有账号?')}
<NavLink to="/user/register" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>
{t('注册')}
</NavLink>
</span>
) : (
<span>{t('管理员登录后继续。')}</span>
)}
</div>
</div>
</section>
</div>
</div>
);
}
function UserProfileModal({
open,
profile,
@@ -638,8 +304,9 @@ function RouteLoadingFallback() {
}
function AppShell() {
const { language, toggleLanguage, t } = useI18n();
const [authed, setAuthed] = useState(() => hasValidAuthSession(localStorage));
const [userAuthed, setUserAuthed] = useState(() => hasValidUserSession(localStorage));
const location = useLocation();
const [authed, setAuthed] = useState(() => getSessionToken('admin') !== null);
const [userAuthed, setUserAuthed] = useState(() => getSessionToken('user') !== null);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
@@ -754,7 +421,7 @@ function AppShell() {
useEffect(() => {
if (!authed) return;
const check = () => {
if (hasValidAuthSession(localStorage)) return;
if (getSessionToken('admin') !== null) return;
setAuthed(false);
toast.info(t('会话已过期,请重新登录'));
};
@@ -799,28 +466,38 @@ function AppShell() {
toast.success(t('个人信息已保存'));
};
const isUserPage = location.pathname.startsWith('/user/');
const isAdminLoginPage = location.pathname === '/boos';
const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
// The default `/` route now serves the user-facing login; admins land on `/boos`.
const isDefaultLoginRoot = !authed && !userAuthed && location.pathname === '/';
if (isUserPage && isPublicUserPage) {
if (userAuthed) return <Navigate to="/user/dashboard" />;
if (authed) return <Navigate to="/" />;
}
if (isAdminLoginPage) {
if (authed) return <Navigate to="/" />;
return (
<AdminLoginPage
t={t}
onLogin={() => setAuthed(true)}
/>
);
}
if (isDefaultLoginRoot) {
return <Navigate to="/user/login" />;
}
if (!authed && !isUserPage) {
if (userAuthed) {
return <Navigate to="/user/dashboard" />;
}
return (
<Login
t={t}
initialAudience="admin"
onLogin={(token) => {
persistAuthSession(localStorage, token);
setAuthed(true);
}}
onUserLogin={() => setUserAuthed(true)}
/>
);
// Anyone hitting an admin-only path without admin auth gets bounced to the
// user login (or dashboard if they are a logged-in user). Admins know to
// visit `/boos` directly.
return <Navigate to="/user/login" />;
}
if (isUserPage && !userAuthed && !isPublicUserPage) {
@@ -833,17 +510,7 @@ function AppShell() {
<Routes>
<Route
path="/user/login"
element={(
<Login
t={t}
initialAudience="user"
onLogin={(token) => {
persistAuthSession(localStorage, token);
setAuthed(true);
}}
onUserLogin={() => setUserAuthed(true)}
/>
)}
element={<UserLoginPage t={t} onLogin={() => setUserAuthed(true)} />}
/>
<Route path="/user/register" element={<UserRegister onLogin={() => setUserAuthed(true)} />} />
</Routes>
@@ -984,8 +651,8 @@ function AppShell() {
{t('个人信息')}
</button>
<button onClick={() => {
clearAuthSession(localStorage);
clearUserSession(localStorage);
clearSession('admin');
clearSession('user');
setAuthed(false);
setUserAuthed(false);
}} className="user-dropdown-item danger">
+23 -11
View File
@@ -1,4 +1,4 @@
import { clearAuthSession, getAuthToken, getUserToken, clearUserSession } from "./authSession.js";
import { clearSession, getSessionToken } from "./authSession.js";
type BufferLike = {
from(data: ArrayBuffer): { toString(encoding: "base64"): string };
@@ -12,10 +12,10 @@ type RequestOptions = RequestInit & {
};
function requireAuthToken(): string {
const token = getAuthToken(localStorage);
const token = getSessionToken('admin', localStorage);
if (!token) {
const hadToken = !!localStorage.getItem("auth_token");
clearAuthSession(localStorage);
clearSession('admin', localStorage);
if (
hadToken &&
typeof window !== "undefined" &&
@@ -127,8 +127,8 @@ async function fetchAuthenticatedResponse(
headers,
});
if (res.status === 401 || res.status === 403) {
const hadToken = !!getAuthToken(localStorage);
clearAuthSession(localStorage);
const hadToken = !!getSessionToken('admin', localStorage);
clearSession('admin', localStorage);
if (
hadToken &&
typeof window !== "undefined" &&
@@ -182,9 +182,9 @@ async function fetchUserResponse(
}
}
const token = getUserToken(localStorage);
const token = getSessionToken('user', localStorage);
if (!token) {
clearUserSession(localStorage);
clearSession('user', localStorage);
throw new Error("User session expired");
}
const headers = new Headers(fetchOptions.headers ?? {});
@@ -200,7 +200,7 @@ async function fetchUserResponse(
headers,
});
if (res.status === 401 || res.status === 403) {
clearUserSession(localStorage);
clearSession('user', localStorage);
throw new Error("User session expired");
}
return res;
@@ -325,8 +325,20 @@ function normalizeProxyLogRow(row: any): any {
routeId: row.routeId ?? row.route_id ?? null,
channelId: row.channelId ?? row.channel_id ?? null,
accountId: row.accountId ?? row.account_id ?? null,
siteId: row.siteId ?? row.site_id ?? null,
username: row.username ?? null,
siteName: row.siteName ?? row.site_name ?? null,
siteUrl: row.siteUrl ?? row.site_url ?? null,
downstreamApiKeyId:
row.downstreamApiKeyId ?? row.downstream_api_key_id ?? null,
downstreamKeyId:
row.downstreamKeyId ?? row.downstream_key_id ?? row.downstreamApiKeyId ?? row.downstream_api_key_id ?? null,
downstreamKeyName:
row.downstreamKeyName ?? row.downstream_key_name ?? null,
downstreamKeyGroupName:
row.downstreamKeyGroupName ?? row.downstream_key_group_name ?? null,
downstreamKeyTags:
row.downstreamKeyTags ?? parseJsonArrayField(row.downstream_key_tags),
modelRequested: row.modelRequested ?? row.model_requested ?? "",
modelActual: row.modelActual ?? row.model_actual ?? null,
httpStatus: row.httpStatus ?? row.http_status ?? null,
@@ -1381,7 +1393,7 @@ export const api = {
normalizeProxyDebugTraceDetail,
) as Promise<ProxyDebugTraceDetail>,
checkModels: (accountId: number) =>
request(`/api/models/check/${accountId}`, { method: "POST" }),
request(`/api/accounts/${accountId}/models/refresh`, { method: "POST" }),
getSiteDistribution: () => request("/api/stats/site-distribution"),
getSiteTrend: (days = 7) => request(`/api/stats/site-trend?days=${days}`),
getSiteSnapshot: async (days = 7, options?: { refresh?: boolean }) => {
@@ -1795,9 +1807,9 @@ export const api = {
data: TestChatRequestPayload,
signal?: AbortSignal,
) => {
const token = getAuthToken(localStorage);
const token = getSessionToken('admin', localStorage);
if (!token) {
clearAuthSession(localStorage);
clearSession('admin', localStorage);
throw new Error("Session expired");
}
return fetch("/api/test/chat/stream", {
+2 -2
View File
@@ -1,4 +1,4 @@
import { clearAuthSession } from './authSession.js';
import { clearSession } from './authSession.js';
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
export const LEGACY_THEME_STORAGE_KEY = 'theme';
@@ -20,7 +20,7 @@ function resolveStorage(storage?: StorageLike | null): StorageLike | null {
export function clearAppInstallationState(storage?: StorageLike | null): void {
const target = resolveStorage(storage);
if (!target) return;
clearAuthSession(target as never);
clearSession('admin', target as never);
target.removeItem(THEME_MODE_STORAGE_KEY);
target.removeItem(LEGACY_THEME_STORAGE_KEY);
target.removeItem(USER_PROFILE_STORAGE_KEY);
+82 -1
View File
@@ -2,14 +2,21 @@ import { describe, expect, it } from 'vitest';
import {
AUTH_SESSION_DURATION_MS,
clearAuthSession,
clearSession,
commitSession,
getAuthToken,
getSessionToken,
getUserToken,
hasValidAuthSession,
hasValidUserSession,
persistAuthSession,
persistUserSession,
} from './authSession.js';
function createMemoryStorage() {
const store = new Map<string, string>();
return {
raw: store,
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
@@ -22,7 +29,7 @@ function createMemoryStorage() {
};
}
describe('authSession', () => {
describe('authSession (legacy admin API)', () => {
it('persists token with expiration and reads it before expiry', () => {
const storage = createMemoryStorage();
persistAuthSession(storage, 'token-1', 60_000, 1_000);
@@ -48,3 +55,77 @@ describe('authSession', () => {
expect(getAuthToken(storage, 2_000)).toBeNull();
});
});
describe('authSession (legacy user API)', () => {
it('persists token with expiration and reads it before expiry', () => {
const storage = createMemoryStorage();
persistUserSession(storage, 'user-token', 60_000, 1_000);
expect(getUserToken(storage, 10_000)).toBe('user-token');
expect(hasValidUserSession(storage, 10_000)).toBe(true);
});
it('admin and user sessions live in separate slots', () => {
const storage = createMemoryStorage();
persistAuthSession(storage, 'admin-token', 60_000, 1_000);
persistUserSession(storage, 'user-token', 60_000, 1_000);
expect(getAuthToken(storage, 5_000)).toBe('admin-token');
expect(getUserToken(storage, 5_000)).toBe('user-token');
clearAuthSession(storage);
expect(getAuthToken(storage, 5_000)).toBeNull();
expect(getUserToken(storage, 5_000)).toBe('user-token');
});
});
describe('authSession (commitSession unified API)', () => {
it('writes admin tokens to the same slot as persistAuthSession', () => {
const storage = createMemoryStorage();
commitSession('admin', 'unified-admin', 60_000, storage, 1_000);
expect(getAuthToken(storage, 5_000)).toBe('unified-admin');
expect(getSessionToken('admin', storage, 5_000)).toBe('unified-admin');
});
it('writes user tokens to the same slot as persistUserSession', () => {
const storage = createMemoryStorage();
commitSession('user', 'unified-user', 60_000, storage, 1_000);
expect(getUserToken(storage, 5_000)).toBe('unified-user');
expect(getSessionToken('user', storage, 5_000)).toBe('unified-user');
});
it('audiences do not bleed across each other', () => {
const storage = createMemoryStorage();
commitSession('admin', 'a', 60_000, storage, 1_000);
commitSession('user', 'u', 60_000, storage, 1_000);
clearSession('admin', storage);
expect(getSessionToken('admin', storage, 5_000)).toBeNull();
expect(getSessionToken('user', storage, 5_000)).toBe('u');
});
it('empty token clears the slot', () => {
const storage = createMemoryStorage();
commitSession('admin', 'first', 60_000, storage, 1_000);
commitSession('admin', ' ', 60_000, storage, 1_000);
expect(getSessionToken('admin', storage, 5_000)).toBeNull();
});
it('expired token is purged on read', () => {
const storage = createMemoryStorage();
commitSession('user', 'expiring', 60_000, storage, 1_000);
expect(getSessionToken('user', storage, 1_000_000)).toBeNull();
});
it('legacy entries without expiresAt get auto-migrated', () => {
const storage = createMemoryStorage();
storage.setItem('auth_token', 'legacy-admin');
expect(getSessionToken('admin', storage, 1_000)).toBe('legacy-admin');
expect(storage.getItem('auth_token_expires_at')).not.toBeNull();
});
});
+129 -74
View File
@@ -4,115 +4,170 @@ const USER_JWT_STORAGE_KEY = 'user_jwt_token';
const USER_JWT_EXPIRES_AT_STORAGE_KEY = 'user_jwt_expires_at';
export const AUTH_SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
export type SessionAudience = 'admin' | 'user';
type StorageLike = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
};
type AudienceKeys = {
token: string;
expiresAt: string;
};
const AUDIENCE_KEYS: Record<SessionAudience, AudienceKeys> = {
admin: {
token: AUTH_TOKEN_STORAGE_KEY,
expiresAt: AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY,
},
user: {
token: USER_JWT_STORAGE_KEY,
expiresAt: USER_JWT_EXPIRES_AT_STORAGE_KEY,
},
};
function resolveStorage(storage?: StorageLike | null): StorageLike | null {
if (storage) return storage;
if (typeof localStorage !== 'undefined') return localStorage;
return null;
}
export function clearAuthSession(storage?: StorageLike | null): void {
/**
* Persist a session token for the given audience. Replaces any existing token.
* Empty / whitespace-only tokens clear the session instead.
*/
export function commitSession(
audience: SessionAudience,
token: string,
ttlMs: number = AUTH_SESSION_DURATION_MS,
storage?: StorageLike | null,
nowMs: number = Date.now(),
): void {
const target = resolveStorage(storage);
if (!target) return;
target.removeItem(AUTH_TOKEN_STORAGE_KEY);
target.removeItem(AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY);
const cleanToken = (token || '').trim();
const keys = AUDIENCE_KEYS[audience];
if (!cleanToken) {
target.removeItem(keys.token);
target.removeItem(keys.expiresAt);
return;
}
const expiresAt = nowMs + Math.max(1, Math.trunc(ttlMs));
target.setItem(keys.token, cleanToken);
target.setItem(keys.expiresAt, String(expiresAt));
}
/** Drop the session token for the given audience. */
export function clearSession(
audience: SessionAudience,
storage?: StorageLike | null,
): void {
const target = resolveStorage(storage);
if (!target) return;
const keys = AUDIENCE_KEYS[audience];
target.removeItem(keys.token);
target.removeItem(keys.expiresAt);
}
/**
* Read the session token for the given audience, returning null if expired or
* missing. Auto-migrates legacy entries (token without an expiresAt) to a
* fresh TTL.
*/
export function getSessionToken(
audience: SessionAudience,
storage?: StorageLike | null,
nowMs: number = Date.now(),
): string | null {
const target = resolveStorage(storage);
if (!target) return null;
const keys = AUDIENCE_KEYS[audience];
const token = (target.getItem(keys.token) || '').trim();
if (!token) return null;
const expiresAtRaw = target.getItem(keys.expiresAt);
if (!expiresAtRaw) {
// Legacy migration: stamp a fresh TTL the first time we see a tokenless entry.
commitSession(audience, token, AUTH_SESSION_DURATION_MS, target, nowMs);
return token;
}
const expiresAt = Number(expiresAtRaw);
if (!Number.isFinite(expiresAt) || expiresAt <= nowMs) {
clearSession(audience, target);
return null;
}
return token;
}
// ---------------------------------------------------------------------------
// Legacy thin-shell API. Prefer commitSession / clearSession / getSessionToken.
// ---------------------------------------------------------------------------
/** @deprecated use {@link clearSession}('admin'). */
export function clearAuthSession(storage?: StorageLike | null): void {
clearSession('admin', storage);
}
/** @deprecated use {@link commitSession}('admin', ...). */
export function persistAuthSession(
storage: StorageLike | null | undefined,
token: string,
ttlMs = AUTH_SESSION_DURATION_MS,
nowMs = Date.now(),
ttlMs: number = AUTH_SESSION_DURATION_MS,
nowMs: number = Date.now(),
): void {
const target = resolveStorage(storage);
if (!target) return;
const cleanToken = (token || '').trim();
if (!cleanToken) {
clearAuthSession(target);
return;
}
const expiresAt = nowMs + Math.max(1, Math.trunc(ttlMs));
target.setItem(AUTH_TOKEN_STORAGE_KEY, cleanToken);
target.setItem(AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY, String(expiresAt));
commitSession('admin', token, ttlMs, storage, nowMs);
}
export function getAuthToken(storage?: StorageLike | null, nowMs = Date.now()): string | null {
const target = resolveStorage(storage);
if (!target) return null;
const token = (target.getItem(AUTH_TOKEN_STORAGE_KEY) || '').trim();
if (!token) return null;
const expiresAtRaw = target.getItem(AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY);
if (!expiresAtRaw) {
// Legacy migration: set a default TTL the first time we read an old session.
persistAuthSession(target, token, AUTH_SESSION_DURATION_MS, nowMs);
return token;
}
const expiresAt = Number(expiresAtRaw);
if (!Number.isFinite(expiresAt) || expiresAt <= nowMs) {
clearAuthSession(target);
return null;
}
return token;
/** @deprecated use {@link getSessionToken}('admin'). */
export function getAuthToken(
storage?: StorageLike | null,
nowMs: number = Date.now(),
): string | null {
return getSessionToken('admin', storage, nowMs);
}
export function hasValidAuthSession(storage?: StorageLike | null, nowMs = Date.now()): boolean {
return !!getAuthToken(storage, nowMs);
/** @deprecated derive from {@link getSessionToken}('admin'). */
export function hasValidAuthSession(
storage?: StorageLike | null,
nowMs: number = Date.now(),
): boolean {
return !!getSessionToken('admin', storage, nowMs);
}
/** @deprecated use {@link clearSession}('user'). */
export function clearUserSession(storage?: StorageLike | null): void {
const target = resolveStorage(storage);
if (!target) return;
target.removeItem(USER_JWT_STORAGE_KEY);
target.removeItem(USER_JWT_EXPIRES_AT_STORAGE_KEY);
clearSession('user', storage);
}
/** @deprecated use {@link commitSession}('user', ...). */
export function persistUserSession(
storage: StorageLike | null | undefined,
token: string,
ttlMs = AUTH_SESSION_DURATION_MS,
nowMs = Date.now(),
ttlMs: number = AUTH_SESSION_DURATION_MS,
nowMs: number = Date.now(),
): void {
const target = resolveStorage(storage);
if (!target) return;
const cleanToken = (token || '').trim();
if (!cleanToken) {
clearUserSession(target);
return;
}
const expiresAt = nowMs + Math.max(1, Math.trunc(ttlMs));
target.setItem(USER_JWT_STORAGE_KEY, cleanToken);
target.setItem(USER_JWT_EXPIRES_AT_STORAGE_KEY, String(expiresAt));
commitSession('user', token, ttlMs, storage, nowMs);
}
export function getUserToken(storage?: StorageLike | null, nowMs = Date.now()): string | null {
const target = resolveStorage(storage);
if (!target) return null;
const token = (target.getItem(USER_JWT_STORAGE_KEY) || '').trim();
if (!token) return null;
const expiresAtRaw = target.getItem(USER_JWT_EXPIRES_AT_STORAGE_KEY);
if (!expiresAtRaw) {
persistUserSession(target, token, AUTH_SESSION_DURATION_MS, nowMs);
return token;
}
const expiresAt = Number(expiresAtRaw);
if (!Number.isFinite(expiresAt) || expiresAt <= nowMs) {
clearUserSession(target);
return null;
}
return token;
/** @deprecated use {@link getSessionToken}('user'). */
export function getUserToken(
storage?: StorageLike | null,
nowMs: number = Date.now(),
): string | null {
return getSessionToken('user', storage, nowMs);
}
export function hasValidUserSession(storage?: StorageLike | null, nowMs = Date.now()): boolean {
return !!getUserToken(storage, nowMs);
/** @deprecated derive from {@link getSessionToken}('user'). */
export function hasValidUserSession(
storage?: StorageLike | null,
nowMs: number = Date.now(),
): boolean {
return !!getSessionToken('user', storage, nowMs);
}
+2 -2
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../api.js';
import { useToast } from './Toast.js';
import { persistAuthSession } from '../authSession.js';
import { commitSession } from '../authSession.js';
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
@@ -43,7 +43,7 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
const res = await api.changeAuthToken(oldToken, newToken);
if (res.success) {
toast.success('Token 已更新,请使用新 Token 重新登录');
persistAuthSession(localStorage, newToken);
commitSession('admin', newToken, undefined, localStorage);
onClose();
setOldToken('');
setNewToken('');
+64
View File
@@ -4125,6 +4125,70 @@ textarea {
color: var(--color-text-muted);
}
.login-shell[data-variant="user"] .login-surface {
width: min(560px, 100%);
grid-template-columns: minmax(0, 1fr);
gap: 14px;
padding: 16px;
}
.login-shell[data-variant="user"] .login-brand-panel {
min-height: 0;
padding: 28px 24px 18px;
border-radius: 26px 26px 18px 18px;
}
.login-shell[data-variant="user"] .login-brand-panel::before {
width: 180px;
height: 180px;
}
.login-shell[data-variant="user"] .login-brand-panel::after {
width: 150px;
height: 150px;
}
.login-shell[data-variant="user"] .login-brand-header {
justify-content: center;
flex-wrap: wrap;
}
.login-shell[data-variant="user"] .login-brand-summary {
min-width: 0;
}
.login-shell[data-variant="user"] .brand-mark-frame-hero {
width: 76px;
height: 76px;
border-radius: 24px;
}
.login-shell[data-variant="user"] .brand-mark-canvas {
border-radius: 18px;
padding: 8px;
}
.login-shell[data-variant="user"] .login-brand-logo {
border-radius: 14px;
}
.login-shell[data-variant="user"] .login-brand-name {
font-size: 34px;
letter-spacing: 0;
line-height: 1;
}
.login-shell[data-variant="user"] .login-auth-stage {
padding: 0;
background: transparent;
border-radius: 0;
}
.login-shell[data-variant="user"] .login-auth-panel {
width: 100%;
max-width: 100%;
}
[data-theme="dark"] .login-surface,
[data-theme="dark"] .brand-mark-frame,
[data-theme="dark"] .brand-mark-canvas,
+28
View File
@@ -11,7 +11,35 @@ describe('resolveLoginErrorMessage', () => {
expect(resolveLoginErrorMessage(401, 'Missing Authorization header')).toBe('登录令牌无效');
});
it('returns generic forbidden message for unrelated 403', () => {
expect(resolveLoginErrorMessage(403, 'something else')).toBe('没有访问权限');
});
it('returns server error message for 5xx', () => {
expect(resolveLoginErrorMessage(500, 'internal error')).toBe('服务端异常,请稍后重试');
expect(resolveLoginErrorMessage(503, '')).toBe('服务端异常,请稍后重试');
});
it('routes 400 by reason', () => {
expect(resolveLoginErrorMessage(400, 'invalid email format')).toBe('邮箱格式不合法');
expect(resolveLoginErrorMessage(400, 'password missing')).toBe('密码不能为空');
expect(resolveLoginErrorMessage(400, '')).toBe('请求参数错误');
});
it('routes 409 by reason', () => {
expect(resolveLoginErrorMessage(409, 'no_account')).toBe('此邮箱尚未注册');
expect(resolveLoginErrorMessage(409, 'NO ACCOUNT')).toBe('此邮箱尚未注册');
expect(resolveLoginErrorMessage(409, 'account locked too many tries')).toBe(
'账号已被锁定,请稍后重试',
);
expect(resolveLoginErrorMessage(409, '')).toBe('登录冲突,请稍后再试');
});
it('handles network sentinel', () => {
expect(resolveLoginErrorMessage('network', '')).toBe('网络异常,请检查连接');
});
it('falls back to generic message for unmapped statuses', () => {
expect(resolveLoginErrorMessage(418, '')).toBe('登录失败,请检查服务状态');
});
});
+37 -2
View File
@@ -1,17 +1,52 @@
export type LoginErrorStatus = number | 'network';
function normalizeReason(reason: string): string {
return (reason || '').trim().toLowerCase();
}
export function resolveLoginErrorMessage(status: number, reason: string): string {
export function resolveLoginErrorMessage(
status: LoginErrorStatus,
reason: string,
): string {
const normalizedReason = normalizeReason(reason);
if (status === 'network') {
return '网络异常,请检查连接';
}
if (status === 400) {
if (normalizedReason.includes('invalid email')) {
return '邮箱格式不合法';
}
if (normalizedReason.includes('password')) {
return '密码不能为空';
}
return '请求参数错误';
}
if (status === 403 && normalizedReason.includes('ip not allowed')) {
return '当前 IP 不在管理白名单中';
}
if (status === 401 || (status === 403 && normalizedReason.includes('invalid token'))) {
return '登录令牌无效';
}
if (status >= 500) {
if (status === 403) {
return '没有访问权限';
}
if (status === 409) {
if (normalizedReason.includes('no_account') || normalizedReason.includes('no account')) {
return '此邮箱尚未注册';
}
if (normalizedReason.includes('locked')) {
return '账号已被锁定,请稍后重试';
}
return '登录冲突,请稍后再试';
}
if (typeof status === 'number' && status >= 500) {
return '服务端异常,请稍后重试';
}
return '登录失败,请检查服务状态';
}
+14 -2
View File
@@ -307,7 +307,19 @@ export default function Accounts() {
const verifyFailureHint = buildVerifyFailureHint(verifyResult);
const addAccountPrereqHint = buildAddAccountPrereqHint(verifyResult);
const setSegment = (nextSegment: ConnectionsSegment) => {
const setSegment = (
nextSegment: ConnectionsSegment,
event?: React.MouseEvent | React.KeyboardEvent,
) => {
// Guard against synthetic clicks dispatched by third-party libraries
// (e.g. @visactor/vrender's event manager has been observed re-firing
// the segmented-button onClick after unrelated interactions, which
// silently navigates the page back to the default "session" segment
// mid-session). Only honour the call when it was driven by a real
// user-initiated DOM event, or when it was invoked programmatically
// without an event argument (e.g. test fixtures).
if (event && !event.isTrusted) return;
if (nextSegment === activeSegment) return;
const params = new URLSearchParams(location.search);
if (nextSegment === "session") params.delete("segment");
else params.set("segment", nextSegment);
@@ -1437,7 +1449,7 @@ export default function Accounts() {
<button
key={segment.value}
type="button"
onClick={() => setSegment(segment.value)}
onClick={(event) => setSegment(segment.value, event)}
data-tooltip={segment.tooltip}
data-tooltip-side={segment.tooltipSide}
data-tooltip-align={segment.tooltipAlign}
+27 -9
View File
@@ -1,7 +1,13 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { getSessionToken } from '../authSession.js';
export default function ComfyUI() {
const [isDark, setIsDark] = React.useState(true);
const location = useLocation();
const audience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const token = getSessionToken(audience, localStorage);
const iframeSrc = token ? `/comfy/?token=${encodeURIComponent(token)}` : '';
React.useEffect(() => {
const el = document.documentElement;
@@ -22,16 +28,28 @@ export default function ComfyUI() {
background: isDark ? '#1a1a2e' : '#f5f5f5',
}}
>
<iframe
src="/comfyui"
style={{
width: '100%',
{iframeSrc ? (
<iframe
src={iframeSrc}
style={{
width: '100%',
height: '100%',
border: 'none',
}}
title="ComfyUI"
allow="clipboard-read; clipboard-write"
/>
) : (
<div style={{
height: '100%',
border: 'none',
}}
title="ComfyUI"
allow="clipboard-read; clipboard-write"
/>
display: 'grid',
placeItems: 'center',
color: 'var(--color-text-muted)',
fontSize: 14,
}}>
登录状态已过期,请重新登录。
</div>
)}
</div>
);
}
+216 -10
View File
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { getAuthToken } from '../authSession.js';
import { useLocation } from 'react-router-dom';
import { getSessionToken } from '../authSession.js';
import { useToast } from '../components/Toast.js';
import { filterModelTesterModelNames } from './helpers/modelTesterSession.js';
type AgentRole = 'user' | 'assistant' | 'system';
@@ -54,6 +56,11 @@ type StreamAccumulator = {
workflowJson: unknown;
};
type StoredAgentSession = {
messages?: AgentMessage[];
selectedModel?: string;
};
const WELCOME_MESSAGE: AgentMessage = {
id: 'welcome',
role: 'system',
@@ -71,6 +78,53 @@ function createId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function sessionStorageKey(audience: 'admin' | 'user') {
return `boosapi.comfyuiAgent.${audience}.session`;
}
function isValidMessage(value: unknown): value is AgentMessage {
if (!value || typeof value !== 'object') return false;
const item = value as Partial<AgentMessage>;
return (
typeof item.id === 'string' &&
(item.role === 'user' || item.role === 'assistant' || item.role === 'system') &&
typeof item.content === 'string'
);
}
function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
try {
const raw = localStorage.getItem(sessionStorageKey(audience));
if (!raw) return {};
const parsed = JSON.parse(raw) as StoredAgentSession;
const messages = Array.isArray(parsed.messages)
? parsed.messages.filter(isValidMessage)
: undefined;
return {
messages: messages?.length ? messages : undefined,
selectedModel:
typeof parsed.selectedModel === 'string' ? parsed.selectedModel : undefined,
};
} catch {
return {};
}
}
function persistStoredSession(
audience: 'admin' | 'user',
messages: AgentMessage[],
selectedModel: string,
) {
try {
localStorage.setItem(
sessionStorageKey(audience),
JSON.stringify({ messages, selectedModel }),
);
} catch {
// Ignore quota/private-mode failures; chat still works for the current page.
}
}
function angleLabel(angle?: string) {
if (angle === 'front') return '正面';
if (angle === 'back') return '背面';
@@ -124,11 +178,25 @@ async function readSseStream(
export default function ComfyUIAgent() {
const toast = useToast();
const [messages, setMessages] = useState<AgentMessage[]>([WELCOME_MESSAGE]);
const location = useLocation();
const sessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const initialStoredSession = useMemo(
() => readStoredSession(sessionAudience),
[sessionAudience],
);
const [messages, setMessages] = useState<AgentMessage[]>(
() => initialStoredSession.messages || [WELCOME_MESSAGE],
);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState<StreamAccumulator | null>(null);
const [status, setStatus] = useState<AgentStatus | null>(null);
const [modelOptions, setModelOptions] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState(
() => initialStoredSession.selectedModel || '',
);
const [modelSearch, setModelSearch] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
@@ -139,13 +207,38 @@ export default function ComfyUIAgent() {
}
return streaming?.workflowJson || null;
}, [messages, streaming]);
const filteredModelOptions = useMemo(
() => {
const filtered = filterModelTesterModelNames(modelOptions, modelSearch).slice(0, 80);
if (selectedModel && !filtered.includes(selectedModel)) {
return [selectedModel, ...filtered];
}
return filtered;
},
[modelOptions, modelSearch, selectedModel],
);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages, streaming?.content]);
useEffect(() => {
const token = getAuthToken(localStorage);
const stored = readStoredSession(sessionAudience);
setMessages(stored.messages || [WELCOME_MESSAGE]);
setSelectedModel(stored.selectedModel || '');
setModelSearch('');
setStreaming(null);
setLoading(false);
abortRef.current?.abort();
abortRef.current = null;
}, [sessionAudience]);
useEffect(() => {
persistStoredSession(sessionAudience, messages, selectedModel);
}, [messages, selectedModel, sessionAudience]);
useEffect(() => {
const token = getSessionToken(sessionAudience, localStorage);
if (!token) return;
void fetch('/api/comfyui-agent/status', {
headers: { Authorization: `Bearer ${token}` },
@@ -153,7 +246,40 @@ export default function ComfyUIAgent() {
.then((response) => (response.ok ? response.json() : null))
.then((payload) => setStatus(payload || null))
.catch(() => setStatus(null));
}, []);
}, [sessionAudience]);
useEffect(() => {
const loadRouteModels = async () => {
const token = getSessionToken(sessionAudience, localStorage);
if (!token) return;
setLoadingModels(true);
try {
const response = await fetch('/api/comfyui-agent/models', {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
const names = Array.isArray(payload?.models)
? payload.models
.map((model: unknown) => (typeof model === 'string' ? model.trim() : ''))
.filter(Boolean)
: [];
setModelOptions(names);
setSelectedModel((current) => {
if (current && names.includes(current)) return current;
const configuredModel = String(payload?.defaultModel || status?.model || '').trim();
if (configuredModel && names.includes(configuredModel)) return configuredModel;
return names[0] || configuredModel;
});
} catch {
toast.error('加载模型路由失败');
} finally {
setLoadingModels(false);
}
};
void loadRouteModels();
}, [sessionAudience, status?.model, toast]);
const exportWorkflow = () => {
const workflow = latestWorkflow || {
@@ -250,7 +376,7 @@ export default function ComfyUIAgent() {
const content = input.trim();
if (!content || loading) return;
const token = getAuthToken(localStorage);
const token = getSessionToken(sessionAudience, localStorage);
if (!token) {
toast.error('登录状态已过期,请重新登录');
return;
@@ -291,7 +417,10 @@ export default function ComfyUIAgent() {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ messages: payloadMessages }),
body: JSON.stringify({
messages: payloadMessages,
model: selectedModel || status?.model,
}),
signal: controller.signal,
});
@@ -395,12 +524,45 @@ export default function ComfyUIAgent() {
<div>
<h1 style={titleStyle}>视频助手</h1>
<div style={statusStyle}>
模型 {status?.model || '加载中'} · 上游 {status?.upstreamBase || '检测中'} · ComfyUI {status?.comfyui?.status || 'unknown'}
模型 {selectedModel || status?.model || '加载中'} · 上游 {status?.upstreamBase || '检测中'} · ComfyUI {status?.comfyui?.status || 'unknown'}
</div>
</div>
<button type="button" onClick={exportWorkflow} style={secondaryButtonStyle}>
导出工作流
</button>
<div style={headerActionsStyle}>
<label style={modelPickerStyle}>
<span style={modelPickerLabelStyle}>对话模型</span>
<input
value={modelSearch}
onChange={(event) => setModelSearch(event.target.value)}
placeholder={loadingModels ? '加载模型...' : '搜索模型路由'}
disabled={loading || loadingModels || modelOptions.length === 0}
style={modelSearchInputStyle}
/>
<select
value={selectedModel}
onChange={(event) => {
setSelectedModel(event.target.value);
setModelSearch('');
}}
disabled={loading || loadingModels || modelOptions.length === 0}
style={modelSelectStyle}
>
{modelOptions.length === 0 ? (
<option value={status?.model || ''}>
{loadingModels ? '模型加载中' : (status?.model || '暂无模型路由')}
</option>
) : filteredModelOptions.length === 0 ? (
<option value={selectedModel}>没有匹配模型</option>
) : (
filteredModelOptions.map((model) => (
<option key={model} value={model}>{model}</option>
))
)}
</select>
</label>
<button type="button" onClick={exportWorkflow} style={secondaryButtonStyle}>
导出工作流
</button>
</div>
</header>
<section style={quickPromptStyle}>
@@ -506,6 +668,14 @@ const headerStyle: CSSProperties = {
background: 'var(--color-surface)',
};
const headerActionsStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 10,
flexWrap: 'wrap',
};
const titleStyle: CSSProperties = {
margin: 0,
fontSize: 18,
@@ -518,6 +688,42 @@ const statusStyle: CSSProperties = {
color: 'var(--color-text-muted)',
};
const modelPickerStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'minmax(130px, 180px) minmax(180px, 260px)',
gap: 8,
alignItems: 'end',
};
const modelPickerLabelStyle: CSSProperties = {
gridColumn: '1 / -1',
fontSize: 12,
fontWeight: 700,
color: 'var(--color-text-muted)',
};
const modelSearchInputStyle: CSSProperties = {
minHeight: 36,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '7px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 12,
outline: 'none',
};
const modelSelectStyle: CSSProperties = {
minHeight: 36,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '7px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 12,
outline: 'none',
};
const quickPromptStyle: CSSProperties = {
display: 'flex',
gap: 8,
+3 -3
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api.js';
import { clearAuthSession, getAuthToken } from '../authSession.js';
import { clearSession, getSessionToken } from '../authSession.js';
import {
DEBUG_TABS,
DEFAULT_INPUTS,
@@ -1555,8 +1555,8 @@ export default function ModelTester() {
try {
const response = await api.proxyTestStream(payload, controller.signal);
if (response.status === 401 || response.status === 403) {
const hadToken = Boolean(getAuthToken(localStorage));
clearAuthSession(localStorage);
const hadToken = Boolean(getSessionToken('admin', localStorage));
clearSession('admin', localStorage);
if (hadToken) window.location.reload();
throw new Error('会话已过期');
}
+12 -3
View File
@@ -266,14 +266,20 @@ async function copyTextToClipboard(text: string) {
document.body.removeChild(textarea);
}
function formatLatency(ms: number) {
function formatLatency(ms?: number | null) {
if (typeof ms !== "number" || !Number.isFinite(ms)) {
return "-";
}
if (ms >= 1000) {
return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s`;
}
return `${ms}ms`;
}
function latencyColor(ms: number) {
function latencyColor(ms?: number | null) {
if (typeof ms !== "number" || !Number.isFinite(ms)) {
return "var(--color-text-muted)";
}
if (ms >= 3000) return "var(--color-danger)";
if (ms >= 2000)
return "color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))";
@@ -285,7 +291,10 @@ function latencyColor(ms: number) {
return "var(--color-success)";
}
function latencyBgColor(ms: number) {
function latencyBgColor(ms?: number | null) {
if (typeof ms !== "number" || !Number.isFinite(ms)) {
return "var(--color-bg-muted, transparent)";
}
if (ms >= 3000)
return "color-mix(in srgb, var(--color-danger) 12%, transparent)";
if (ms >= 1000)
+2 -2
View File
@@ -25,7 +25,7 @@ import {
resolveRoutingProfilePreset,
type RoutingWeights,
} from './helpers/routingProfiles.js';
import { clearAuthSession } from '../authSession.js';
import { clearSession } from '../authSession.js';
import { clearAppInstallationState } from '../appLocalState.js';
import { tr } from '../i18n.js';
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
@@ -2615,7 +2615,7 @@ export default function Settings() {
</button>
<button
onClick={() => {
clearAuthSession(localStorage);
clearSession('admin', localStorage);
window.location.reload();
}}
className="btn btn-danger"
+8 -3
View File
@@ -6,7 +6,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { getAuthToken } from '../authSession.js';
import { getSessionToken } from '../authSession.js';
import { getBrand } from '../components/BrandIcon.js';
import CenteredModal from '../components/CenteredModal.js';
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
@@ -568,9 +568,14 @@ export default function Sites() {
const handleSaveDisabledModels = async () => {
if (!editor || editor.mode !== 'edit') return;
const siteId = editor.editingSiteId;
if (typeof siteId !== 'number' || !Number.isFinite(siteId)) {
toast.error('未选中有效站点,无法保存禁用模型');
return;
}
setDisabledModelsSaving(true);
try {
await api.updateSiteDisabledModels(editor.editingSiteId, disabledModels);
await api.updateSiteDisabledModels(siteId, disabledModels);
try {
await api.rebuildRoutes(false, false);
toast.success('禁用模型列表已保存,路由已重建');
@@ -621,7 +626,7 @@ export default function Sites() {
setProbeCompleted(false);
try {
const token = getAuthToken(localStorage);
const token = getSessionToken('admin', localStorage);
const params = new URLSearchParams({ scope: probeScope });
if (probeScope === 'single' && probeModel.trim()) params.set('modelName', probeModel.trim());
const threshold = parseInt(probeLatencyThreshold, 10);
+3 -3
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { userApi } from '../api.js';
import { clearUserSession, getUserToken } from '../authSession.js';
import { clearSession, getSessionToken } from '../authSession.js';
interface UserProfile {
id: number;
@@ -45,7 +45,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
const [error, setError] = useState('');
const checkAuth = useCallback(() => {
if (!getUserToken(localStorage)) {
if (!getSessionToken('user', localStorage)) {
navigate('/user/login');
return false;
}
@@ -75,7 +75,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
useEffect(() => { loadData(); }, [loadData]);
const handleLogout = () => {
clearUserSession(localStorage);
clearSession('user', localStorage);
onLogout?.();
navigate('/user/login', { replace: true });
};
+4 -106
View File
@@ -1,109 +1,7 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { persistUserSession } from '../authSession.js';
import UserLoginPage from './auth/UserLoginPage.js';
import { useI18n } from '../i18n.js';
export default function UserLogin({ onLogin }: { onLogin?: () => void }) {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim() || !password) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const errMsg = typeof data?.error === 'string' ? data.error : (data?.error?.message || data?.message || `登录失败 (${res.status})`);
throw new Error(errMsg);
}
const result = await res.json();
persistUserSession(localStorage, result.token);
onLogin?.();
navigate('/user/dashboard');
} catch (err: any) {
setError(err?.message || '登录失败,请检查邮箱和密码');
} finally {
setLoading(false);
}
};
return (
<div className="login-shell">
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
<section className="login-brand-panel login-brand-panel-light" style={{ padding: '28px 24px' }}>
<div className="login-brand-header" style={{ justifyContent: 'center' }}>
<div className="brand-mark-frame brand-mark-frame-hero">
<div className="brand-mark-canvas">
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
</div>
</div>
<div className="login-brand-summary" style={{ textAlign: 'center' }}>
<div className="login-brand-name">BoosAPI</div>
<div className="login-brand-kicker">用户登录</div>
</div>
</div>
</section>
<section className="login-auth-stage">
<div className="login-auth-panel">
<h2 className="login-auth-title">欢迎回来</h2>
<p className="login-auth-copy">使用邮箱和密码登录你的账号。</p>
<form onSubmit={handleSubmit}>
<label className="login-auth-label" htmlFor="user-email-input">邮箱</label>
<input
id="user-email-input"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="email"
required
/>
<label className="login-auth-label" htmlFor="user-password-input" style={{ marginTop: 12 }}>密码</label>
<input
id="user-password-input"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="current-password"
required
/>
{error && (
<div className="alert alert-error animate-shake" style={{ marginBottom: 12, marginTop: 8 }}>
{error}
</div>
)}
<button
type="submit"
disabled={loading || !email.trim() || !password}
className="btn btn-primary login-auth-submit"
style={{ marginTop: 4 }}
>
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 登录中...</> : '登录'}
</button>
</form>
<div className="login-auth-footer" style={{ flexDirection: 'column', gap: 8, marginTop: 16 }}>
<span>
还没有账号?<Link to="/user/register" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>注册</Link>
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<Link to="/" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
</span>
</div>
</div>
</section>
</div>
</div>
);
const { t } = useI18n();
return <UserLoginPage t={t} onLogin={onLogin} />;
}
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { persistUserSession } from '../authSession.js';
import { commitSession } from '../authSession.js';
export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
const navigate = useNavigate();
@@ -35,7 +35,7 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
throw new Error(data?.error || `注册失败 (${res.status})`);
}
const result = await res.json();
persistUserSession(localStorage, result.token);
commitSession('user', result.token, undefined, localStorage);
onLogin?.();
navigate('/user/dashboard');
} catch (err: any) {
+111
View File
@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import { commitSession } from '../../authSession.js';
import { resolveLoginErrorMessage } from '../../loginError.js';
import LoginShell from './LoginShell.js';
interface AdminLoginPageProps {
t: (text: string) => string;
/** Called with the admin auth token when login succeeds. */
onLogin: (token: string) => void;
}
async function readErrorReason(res: Response): Promise<string> {
try {
const text = await res.text();
if (!text) return '';
try {
const parsed = JSON.parse(text) as { message?: unknown; error?: unknown };
if (typeof parsed.message === 'string') return parsed.message;
if (typeof parsed.error === 'string') return parsed.error;
const nestedMessage =
parsed.error && typeof parsed.error === 'object'
? (parsed.error as { message?: unknown }).message
: undefined;
if (typeof nestedMessage === 'string') return nestedMessage;
return text;
} catch {
return text;
}
} catch {
return '';
}
}
export default function AdminLoginPage({ t, onLogin }: AdminLoginPageProps) {
const [token, setToken] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleTokenLogin = async () => {
if (!token.trim()) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/settings/auth/info', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
commitSession('admin', token);
onLogin(token);
return;
}
const reason = await readErrorReason(res);
setError(t(resolveLoginErrorMessage(res.status, reason)));
} catch {
setError(t(resolveLoginErrorMessage('network', '')));
} finally {
setLoading(false);
}
};
const canSubmit = !!token.trim();
return (
<LoginShell
t={t}
variant="admin"
eyebrow={t('管理员入口')}
title={t('登录')}
description={t('请输入管理员令牌后继续。')}
error={error}
footer={<span>{t('管理员登录后继续。')}</span>}
>
<label className="login-auth-label" htmlFor="admin-token-input">
{t('管理员令牌')}
</label>
<input
id="admin-token-input"
type="password"
placeholder={t('管理员令牌')}
value={token}
onChange={(e) => {
setToken(e.target.value);
setError('');
}}
onKeyDown={(e) => e.key === 'Enter' && handleTokenLogin()}
className="login-auth-input"
autoComplete="current-password"
/>
<button
onClick={handleTokenLogin}
disabled={loading || !canSubmit}
className="btn btn-primary login-auth-submit"
>
{loading ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
{t('验证中...')}
</>
) : (
t('登录')
)}
</button>
<div className="login-auth-note">
{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}
</div>
</LoginShell>
);
}
+130
View File
@@ -0,0 +1,130 @@
import React from 'react';
import { SITE_DOCS_URL, SITE_GITHUB_URL } from '../../docsLink.js';
export type LoginShellVariant = 'admin' | 'user';
interface LoginShellProps {
t: (text: string) => string;
eyebrow: string;
title: string;
description: React.ReactNode;
error?: string;
/** When set, the brand panel renders the rich admin layout (capability list, footer links). */
variant?: LoginShellVariant;
/** Footer rendered under the form (registration links, mode hints, etc). */
footer?: React.ReactNode;
children: React.ReactNode;
}
const ADMIN_CAPABILITY_KEYS: Array<{ titleKey: string; descKey: string }> = [
{ titleKey: '统一代理网关', descKey: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' },
{ titleKey: '自动模型发现', descKey: '上游新增模型自动出现在模型列表,零配置路由生成' },
{ titleKey: '智能路由引擎', descKey: '按成本、延迟、成功率自动选择最优通道,故障自动转移' },
];
export default function LoginShell({
t,
eyebrow,
title,
description,
error,
variant = 'admin',
footer,
children,
}: LoginShellProps) {
return (
<div className="login-shell" data-variant={variant}>
<div className="login-surface animate-scale-in">
<section
className="login-brand-panel login-brand-panel-light"
style={variant === 'user' ? { padding: '28px 24px' } : undefined}
>
<div
className="login-brand-header"
style={variant === 'user' ? { justifyContent: 'center' } : undefined}
>
<div className="brand-mark-frame brand-mark-frame-hero">
<div className="brand-mark-canvas">
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
</div>
</div>
<div
className="login-brand-summary"
style={variant === 'user' ? { textAlign: 'center' } : undefined}
>
<div className="login-brand-name">BoosAPI</div>
<div className="login-brand-kicker">
{variant === 'user' ? t('用户登录') : t('中转站的中转站')}
</div>
</div>
</div>
{variant === 'admin' && (
<>
<div className="login-brand-copy-block">
<p className="login-brand-copy">
{t('把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。')}
</p>
</div>
<div className="login-compat-line">
{t('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API')}
</div>
<div className="login-capability-list">
{ADMIN_CAPABILITY_KEYS.map((feature, index) => (
<div key={feature.titleKey} className="login-capability-row">
<div className="login-capability-index">
{String(index + 1).padStart(2, '0')}
</div>
<div className="login-capability-content">
<div className="login-capability-title">{t(feature.titleKey)}</div>
<p className="login-capability-desc">{t(feature.descKey)}</p>
</div>
</div>
))}
</div>
<div className="login-brand-footer">
<a
href={SITE_GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="login-icon-link"
aria-label="GitHub"
title="GitHub"
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="login-icon-link-svg">
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.38 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-1.04-.01-1.88-2.78.62-3.37-1.22-3.37-1.22-.45-1.2-1.11-1.52-1.11-1.52-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.57 2.35 1.12 2.92.85.09-.67.35-1.12.64-1.38-2.22-.26-4.55-1.15-4.55-5.13 0-1.13.39-2.05 1.03-2.77-.1-.26-.45-1.31.1-2.73 0 0 .84-.28 2.75 1.06A9.3 9.3 0 0 1 12 6.91c.85 0 1.71.12 2.51.35 1.91-1.34 2.75-1.06 2.75-1.06.55 1.42.2 2.47.1 2.73.64.72 1.03 1.64 1.03 2.77 0 3.99-2.34 4.86-4.57 5.12.36.33.68.97.68 1.96 0 1.42-.01 2.56-.01 2.91 0 .27.18.59.69.49A10.27 10.27 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z"
/>
</svg>
</a>
<a
href={SITE_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="login-doc-link"
>
{t('部署文档')}
</a>
</div>
</>
)}
</section>
<section className="login-auth-stage">
<div className="login-auth-panel">
<div className="login-auth-eyebrow">{eyebrow}</div>
<h2 className="login-auth-title">{title}</h2>
<p className="login-auth-copy">{description}</p>
{children}
{error && (
<div className="alert alert-error animate-shake" style={{ marginBottom: 12 }}>
{error}
</div>
)}
{footer && <div className="login-auth-footer">{footer}</div>}
</div>
</section>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
export interface OAuthSlotProps {
t: (text: string) => string;
variant?: 'user';
}
/**
* Placeholder for the third-party login entry point.
*
* Renders a stable DOM node (`data-testid="oauth-slot"`) so a follow-up
* change can attach the real provider list and `commitSession('user', token)`
* exchange flow without touching `UserLoginPage`.
*
* This component intentionally performs no network I/O, no navigation, and
* does not touch `authSession`. Tests assert that property.
*/
export default function OAuthSlot({ t }: OAuthSlotProps) {
return (
<div
data-testid="oauth-slot"
className="login-oauth-slot"
style={{
marginTop: 16,
padding: '12px 0',
borderTop: '1px dashed var(--color-border)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
fontSize: 12,
color: 'var(--color-text-muted)',
}}
>
<span>{t('第三方登录')}</span>
<span style={{ fontSize: 11 }}>
{t('即将上线,敬请期待')}
</span>
</div>
);
}
+257
View File
@@ -0,0 +1,257 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { commitSession } from '../../authSession.js';
import { resolveLoginErrorMessage } from '../../loginError.js';
import LoginShell from './LoginShell.js';
import OAuthSlot from './OAuthSlot.js';
interface UserLoginPageProps {
t: (text: string) => string;
/** Optional callback invoked after a successful login + persistence. */
onLogin?: () => void;
}
type UserMode = 'account' | 'apiKey';
function readErrorReason(data: any): string {
if (!data) return '';
if (typeof data.error === 'string') return data.error;
const nested =
data.error && typeof data.error === 'object'
? (data.error as { message?: unknown }).message
: undefined;
return typeof nested === 'string' ? nested : (data.message || '');
}
export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
const navigate = useNavigate();
const [mode, setMode] = useState<UserMode>('account');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [apiKey, setApiKey] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleAccountSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!email.trim() || !password) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password }),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
setError(t(resolveLoginErrorMessage(res.status, String(readErrorReason(data)))));
return;
}
const jwt = String(data?.token || data?.data?.token || '').trim();
if (!jwt) {
setError(t(resolveLoginErrorMessage(401, '')));
return;
}
commitSession('user', jwt);
onLogin?.();
navigate('/user/dashboard');
} catch {
setError(t(resolveLoginErrorMessage('network', '')));
} finally {
setLoading(false);
}
};
const handleApiKeySubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!apiKey.trim()) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/users/login-api-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: apiKey.trim() }),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
setError(t(resolveLoginErrorMessage(res.status, String(readErrorReason(data)))));
return;
}
const jwt = String(data?.token || data?.data?.token || '').trim();
if (!jwt) {
setError(t(resolveLoginErrorMessage(401, '')));
return;
}
commitSession('user', jwt);
onLogin?.();
navigate('/user/dashboard');
} catch {
setError(t(resolveLoginErrorMessage('network', '')));
} finally {
setLoading(false);
}
};
const description = mode === 'account'
? t('使用邮箱和密码登录你的账号。')
: t('使用账户中已签发的 API Key 直接登录。');
const footer = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
<span>
{t('还没有账号?')}
<Link
to="/user/register"
style={{ color: 'var(--color-primary)', textDecoration: 'none', marginLeft: 4 }}
>
{t('注册')}
</Link>
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<Link to="/boos" style={{ color: 'var(--color-text-muted)' }}>
← {t('管理员登录')}
</Link>
</span>
</div>
);
return (
<LoginShell
t={t}
variant="user"
eyebrow={t('用户入口')}
title={t('欢迎回来')}
description={description}
error={error}
footer={footer}
>
<div className="login-mode-tabs" role="tablist" aria-label={t('登录方式')}>
<button
type="button"
role="tab"
aria-selected={mode === 'account'}
className={`login-mode-tab ${mode === 'account' ? 'active' : ''}`}
onClick={() => {
setMode('account');
setError('');
}}
>
{t('邮箱密码')}
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'apiKey'}
className={`login-mode-tab ${mode === 'apiKey' ? 'active' : ''}`}
onClick={() => {
setMode('apiKey');
setError('');
}}
>
{t('API Key')}
</button>
</div>
{mode === 'account' ? (
<form onSubmit={handleAccountSubmit}>
<label className="login-auth-label" htmlFor="user-email-input">
{t('邮箱')}
</label>
<input
id="user-email-input"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError('');
}}
className="login-auth-input"
autoComplete="email"
required
/>
<label
className="login-auth-label"
htmlFor="user-password-input"
style={{ marginTop: 12 }}
>
{t('密码')}
</label>
<input
id="user-password-input"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
className="login-auth-input"
autoComplete="current-password"
required
/>
<button
type="submit"
disabled={loading || !email.trim() || !password}
className="btn btn-primary login-auth-submit"
style={{ marginTop: 12 }}
>
{loading ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
{t('登录中...')}
</>
) : (
t('登录')
)}
</button>
</form>
) : (
<form onSubmit={handleApiKeySubmit}>
<label className="login-auth-label" htmlFor="user-api-key-input">
{t('API Key')}
</label>
<input
id="user-api-key-input"
type="password"
placeholder="sk-..."
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value);
setError('');
}}
className="login-auth-input"
autoComplete="off"
spellCheck={false}
required
/>
<button
type="submit"
disabled={loading || !apiKey.trim()}
className="btn btn-primary login-auth-submit"
style={{ marginTop: 12 }}
>
{loading ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
{t('登录中...')}
</>
) : (
t('使用 API Key 登录')
)}
</button>
<div className="login-auth-note">
{t('API Key 可在「我的 API Key」页面创建,仅识别已绑定到本账号的 Key。')}
</div>
</form>
)}
<OAuthSlot t={t} variant="user" />
</LoginShell>
);
}
@@ -15,6 +15,13 @@ describe('accountConnection helpers', () => {
capabilities: { proxyOnly: true },
accessToken: 'api-token-ignored-by-explicit-mode',
})).toBe('session');
expect(resolveAccountCredentialMode({
extraConfig: '{"credentialMode":"apikey"}',
accessToken: 'api-key-is-still-stored-as-access-token',
})).toBe('apikey');
expect(resolveAccountCredentialMode({
extra_config: '{"credentialMode":"session"}',
})).toBe('session');
expect(resolveAccountCredentialMode({})).toBe('apikey');
});
@@ -2,6 +2,21 @@ export function resolveAccountCredentialMode(account: any): 'session' | 'apikey'
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
if (rawMode === 'apikey') return 'apikey';
if (rawMode === 'session') return 'session';
const rawExtraConfig = typeof account?.extraConfig === 'string'
? account.extraConfig
: typeof account?.extra_config === 'string'
? account.extra_config
: '';
if (rawExtraConfig.trim()) {
try {
const parsed = JSON.parse(rawExtraConfig);
const parsedMode = String(parsed?.credentialMode || '').trim().toLowerCase();
if (parsedMode === 'apikey') return 'apikey';
if (parsedMode === 'session') return 'session';
} catch {
// Ignore invalid legacy extra_config and continue with older fallbacks.
}
}
if (typeof account?.capabilities?.proxyOnly === 'boolean') {
return account.capabilities.proxyOnly ? 'apikey' : 'session';
}