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,
|
||||
|
||||
Reference in New Issue
Block a user