forked from boos/BoosAPI
feat: merge opus comfyui and login updates
This commit is contained in:
+162
-11
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {},
|
||||
})))
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})))
|
||||
}
|
||||
Reference in New Issue
Block a user