feat: merge opus comfyui and login updates

This commit is contained in:
boos
2026-05-21 16:32:04 +02:00
parent c16479da7c
commit 9c84759498
105 changed files with 20160 additions and 1034 deletions
+162 -11
View File
@@ -147,16 +147,8 @@ pub async fn list_accounts(
let enriched: Vec<Value> = accounts
.iter()
.map(|account| {
let mut row = serde_json::to_value(account).unwrap_or(json!({}));
if let Some(site) = site_by_id.get(&account.site_id) {
if let Value::Object(map) = &mut row {
map.insert(
"site".to_string(),
serde_json::to_value(site).unwrap_or(Value::Null),
);
}
}
row
let site = site_by_id.get(&account.site_id).copied();
account_response_value(account, site)
})
.collect();
@@ -168,6 +160,49 @@ pub async fn list_accounts(
})))
}
fn account_response_value(account: &Account, site: Option<&Site>) -> Value {
let credential_mode = resolve_account_credential_mode(account);
let mut row = serde_json::to_value(account).unwrap_or(json!({}));
if let Value::Object(map) = &mut row {
map.insert("credentialMode".to_string(), json!(credential_mode));
map.insert(
"capabilities".to_string(),
json!({
"canCheckin": credential_mode == "session",
"canRefreshBalance": credential_mode == "session",
"proxyOnly": credential_mode == "apikey",
}),
);
if let Some(site) = site {
map.insert(
"site".to_string(),
serde_json::to_value(site).unwrap_or(Value::Null),
);
}
}
row
}
fn resolve_account_credential_mode(account: &Account) -> &'static str {
let Some(extra_config) = account.extra_config.as_deref() else {
return "apikey";
};
let Ok(value) = serde_json::from_str::<Value>(extra_config) else {
return "apikey";
};
match value
.get("credentialMode")
.and_then(Value::as_str)
.unwrap_or("")
.trim()
.to_ascii_lowercase()
.as_str()
{
"session" => "session",
_ => "apikey",
}
}
/// POST /api/accounts
pub async fn create_account(
State(state): State<AppState>,
@@ -279,12 +314,17 @@ pub async fn create_account(
spawn_route_rebuild(state.clone());
let site: Option<Site> = sqlx::query_as::<_, Site>("SELECT * FROM sites WHERE id = ?")
.bind(account.site_id)
.fetch_optional(&*state.db)
.await?;
Ok((
StatusCode::CREATED,
Json(json!({
"success": true,
"id": account.id,
"data": account,
"data": account_response_value(&account, site.as_ref()),
})),
))
}
@@ -524,6 +564,117 @@ pub async fn delete_account(
Ok(Json(json!({ "data": { "deleted": true, "id": id } })))
}
#[derive(Debug, Deserialize)]
pub struct BatchAccountsRequest {
pub ids: Vec<i64>,
pub action: String,
}
/// POST /api/accounts/batch
///
/// Per-id loop, no transaction: a delete on row 5 failing must not roll back
/// the successful delete on row 3 — the frontend's `runBatchAccountAction`
/// reads `successIds` / `failedItems` and re-selects only the failed ones so
/// the operator can retry the bad rows in isolation.
///
/// Supported actions:
/// * `delete` — `DELETE FROM accounts WHERE id = ?`
/// * `enable` — `status = 'active'`
/// * `disable` — `status = 'disabled'`
/// * `refreshBalance` — not implemented; every id goes to `failedItems` so
/// the UI shows "成功 0, 失败 N" instead of silently
/// claiming success without changing the balance row.
///
/// `spawn_route_rebuild` is fired once at the end if any row was actually
/// touched (status flip or delete) — `rebuild_routes` filters on
/// `status = 'active'`, so any change to that set invalidates the route_channels
/// view and needs a rebuild. Doing it once after the loop (rather than per-id)
/// avoids N concurrent rebuilds racing each other.
pub async fn batch_accounts(
State(state): State<AppState>,
Json(payload): Json<BatchAccountsRequest>,
) -> AppResult<Json<Value>> {
info!(
"Batch account action: action={} count={}",
payload.action,
payload.ids.len()
);
if payload.ids.is_empty() {
return Ok(Json(json!({
"data": { "successIds": [], "failedItems": [] }
})));
}
let mut success_ids: Vec<i64> = Vec::with_capacity(payload.ids.len());
let mut failed_items: Vec<Value> = Vec::new();
let mut routing_dirty = false;
for id in &payload.ids {
let result: Result<(), String> = match payload.action.as_str() {
"delete" => {
match sqlx::query("DELETE FROM accounts WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await
{
Ok(r) if r.rows_affected() == 0 => Err("账号不存在".into()),
Ok(_) => {
routing_dirty = true;
Ok(())
}
Err(e) => Err(format!("DB error: {}", e)),
}
}
"enable" | "disable" => {
let new_status = if payload.action == "enable" {
"active"
} else {
"disabled"
};
match sqlx::query(
"UPDATE accounts SET status = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(new_status)
.bind(id)
.execute(&*state.db)
.await
{
Ok(r) if r.rows_affected() == 0 => Err("账号不存在".into()),
Ok(_) => {
routing_dirty = true;
Ok(())
}
Err(e) => Err(format!("DB error: {}", e)),
}
}
"refreshBalance" => {
// Per-site balance probing isn't implemented yet — see proposal
// notes. Mark every id failed so the UI surfaces "成功 0" rather
// than pretending the balance was refreshed.
Err("单站点余额探测尚未实现".into())
}
other => Err(format!("不支持的批量操作: {}", other)),
};
match result {
Ok(()) => success_ids.push(*id),
Err(msg) => failed_items.push(json!({ "id": id, "error": msg })),
}
}
if routing_dirty {
spawn_route_rebuild(state.clone());
}
Ok(Json(json!({
"data": {
"successIds": success_ids,
"failedItems": failed_items,
}
})))
}
async fn resolve_site_api_base_url(state: &AppState, site: &Site) -> AppResult<String> {
let endpoint: Option<String> = sqlx::query_scalar(
"SELECT url
+6 -2
View File
@@ -365,8 +365,12 @@ async fn clear_route_cooldown(
Ok(Json(json!({ "success": true })))
}
async fn rebuild_routes() -> Json<Value> {
Json(json!({ "success": true, "message": "routes already available" }))
async fn rebuild_routes(State(state): State<AppState>) -> AppResult<Json<Value>> {
let summary = crate::services::route_rebuild::rebuild_routes(&state).await?;
Ok(Json(json!({
"success": true,
"rebuild": summary,
})))
}
async fn batch_routes(
+272 -1
View File
@@ -13,6 +13,38 @@ use serde_json::{json, Value};
use tracing::info;
use uuid::Uuid;
/// Sentinel name for the built-in ComfyUI Agent downstream key.
///
/// The system row is uniquely identified by `name = SYSTEM_KEY_NAME AND
/// user_id IS NULL` — see `services::builtin_keys::ensure_comfyui_agent_key`.
const SYSTEM_KEY_NAME: &str = "comfyui-agent";
fn is_system_key(name: &str, user_id: Option<i64>) -> bool {
name == SYSTEM_KEY_NAME && user_id.is_none()
}
/// Mask a key down to `••••••••<last4>`. Operates on chars so non-ASCII
/// keys (unlikely but possible) do not panic on byte slicing.
fn mask_key(key: &str) -> String {
let chars: Vec<char> = key.chars().collect();
let last4: String = chars[chars.len().saturating_sub(4)..].iter().collect();
format!("••••••••{}", last4)
}
/// Project a `DownstreamApiKey` row to its admin-list JSON shape, masking the
/// secret and tagging `system: true` for the built-in `comfyui-agent` row.
fn key_to_response(key: DownstreamApiKey) -> Value {
let system = is_system_key(&key.name, key.user_id);
let mut value = serde_json::to_value(&key).unwrap_or_else(|_| json!({}));
if system {
if let Value::Object(ref mut map) = value {
map.insert("system".to_string(), Value::Bool(true));
map.insert("key".to_string(), Value::String(mask_key(&key.key)));
}
}
value
}
#[derive(Debug, Deserialize)]
pub struct ListKeysQuery {
pub q: Option<String>,
@@ -89,8 +121,9 @@ pub async fn list_downstream_keys(
.fetch_all(&*state.db)
.await?;
let projected: Vec<Value> = keys.into_iter().map(key_to_response).collect();
Ok(Json(
json!({ "success": true, "items": keys, "data": keys }),
json!({ "success": true, "items": projected, "data": projected }),
))
}
@@ -170,6 +203,16 @@ pub async fn update_downstream_key(
AppError::NotFound(format!("Downstream key with id {} not found", id))
})?;
// Guard: refuse any request that tries to disable the built-in
// `comfyui-agent` system key (`name='comfyui-agent' AND user_id IS NULL`).
// The agent's outbound calls in Local mode bear this key — disabling it
// would silently break image / TTS / chat for every user.
if is_system_key(&existing.name, existing.user_id) && payload.enabled == Some(false) {
return Err(AppError::Forbidden(
"system key cannot be disabled".into(),
));
}
let supported_models: Option<String> = if let Some(ref models) = payload.supported_models {
validate_supported_models(models)?;
Some(
@@ -214,6 +257,24 @@ pub async fn delete_downstream_key(
) -> AppResult<Json<Value>> {
info!("Deleting downstream API key: {}", id);
// Pre-fetch so we can refuse the built-in `comfyui-agent` row before
// running the DELETE. Returning 403 instead of silently filtering keeps
// the failure observable to operators who attempt the action.
let existing: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| {
AppError::NotFound(format!("Downstream key with id {} not found", id))
})?;
if is_system_key(&existing.name, existing.user_id) {
return Err(AppError::Forbidden(
"system key cannot be deleted".into(),
));
}
let affected = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ?")
.bind(id)
.execute(&*state.db)
@@ -266,3 +327,213 @@ pub async fn reset_downstream_key(
Ok(Json(json!({ "data": api_key })))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
use crate::services::builtin_keys::BuiltinKeys;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query(
"CREATE TABLE downstream_api_keys (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
key TEXT NOT NULL,
description TEXT,
group_name TEXT,
tags TEXT,
enabled INTEGER DEFAULT 1,
expires_at TEXT,
max_cost REAL,
used_cost REAL DEFAULT 0,
max_requests INTEGER,
used_requests INTEGER DEFAULT 0,
supported_models TEXT,
allowed_route_ids TEXT,
site_weight_multipliers TEXT,
excluded_site_ids TEXT,
excluded_credential_refs TEXT,
last_used_at TEXT,
user_id INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)",
)
.execute(&pool)
.await
.unwrap();
// Row 1: built-in system key (name='comfyui-agent', user_id IS NULL)
sqlx::query(
"INSERT INTO downstream_api_keys (id, name, key, enabled, user_id) VALUES (1, 'comfyui-agent', 'sk-comfyui-abcdef0123456789', 1, NULL)"
)
.execute(&pool)
.await
.unwrap();
// Row 2: regular admin-scoped key
sqlx::query(
"INSERT INTO downstream_api_keys (id, name, key, enabled, user_id) VALUES (2, 'team-prod', 'sk-prod-xyz123', 1, NULL)"
)
.execute(&pool)
.await
.unwrap();
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
},
comfyui: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-abcdef0123456789".into()),
},
)
}
/// 4.2 — Listing: the system row carries `system: true` and a masked
/// `key`, while regular rows pass through unchanged.
#[tokio::test]
async fn list_masks_system_key_and_passes_regular_keys_through() {
let state = test_state().await;
let resp = list_downstream_keys(
State(state),
Query(ListKeysQuery {
q: None,
user_id: None,
}),
)
.await
.expect("list should succeed");
let items = resp.0.get("items").and_then(Value::as_array).unwrap();
assert_eq!(items.len(), 2);
let system = items
.iter()
.find(|item| item["id"] == 1)
.expect("system row");
assert_eq!(system["system"], Value::Bool(true));
assert_eq!(system["key"], "••••••••6789");
assert_eq!(system["name"], "comfyui-agent");
let regular = items
.iter()
.find(|item| item["id"] == 2)
.expect("regular row");
assert!(
regular.get("system").is_none(),
"regular keys must not carry the system flag"
);
assert_eq!(regular["key"], "sk-prod-xyz123");
}
/// 4.3 — DELETE on the built-in row returns 403 with the expected message.
#[tokio::test]
async fn delete_rejects_system_key_with_forbidden() {
let state = test_state().await;
let result = delete_downstream_key(State(state), Path(1)).await;
match result {
Err(AppError::Forbidden(msg)) => assert_eq!(msg, "system key cannot be deleted"),
other => panic!("expected Forbidden, got {:?}", other),
}
}
/// 4.5 — DELETE on a regular row succeeds (control case).
#[tokio::test]
async fn delete_succeeds_for_regular_key() {
let state = test_state().await;
let resp = delete_downstream_key(State(state.clone()), Path(2))
.await
.expect("delete should succeed for regular key");
assert_eq!(resp.0["data"]["deleted"], Value::Bool(true));
}
/// 4.4 — Setting `enabled: false` on the system row returns 403; the row
/// remains enabled in the DB.
#[tokio::test]
async fn update_rejects_disabling_system_key() {
let state = test_state().await;
let payload = UpdateDownstreamKeyRequest {
name: None,
description: None,
group_name: None,
tags: None,
enabled: Some(false),
expires_at: None,
max_cost: None,
max_requests: None,
supported_models: None,
allowed_route_ids: None,
user_id: None,
};
let result = update_downstream_key(State(state.clone()), Path(1), Json(payload)).await;
match result {
Err(AppError::Forbidden(msg)) => assert_eq!(msg, "system key cannot be disabled"),
other => panic!("expected Forbidden, got {:?}", other),
}
// Confirm the row was not touched.
let still_enabled: Option<bool> =
sqlx::query_scalar("SELECT enabled FROM downstream_api_keys WHERE id = 1")
.fetch_one(&*state.db)
.await
.unwrap();
assert_eq!(still_enabled, Some(true));
}
/// 4.4 — Other field updates on the system row (e.g. `description`) are
/// allowed; only `enabled = false` is gated.
#[tokio::test]
async fn update_allows_non_disable_changes_on_system_key() {
let state = test_state().await;
let payload = UpdateDownstreamKeyRequest {
name: None,
description: Some("internal: do not delete".into()),
group_name: None,
tags: None,
enabled: None,
expires_at: None,
max_cost: None,
max_requests: None,
supported_models: None,
allowed_route_ids: None,
user_id: None,
};
let resp = update_downstream_key(State(state), Path(1), Json(payload))
.await
.expect("non-disable update should pass");
assert_eq!(resp.0["data"]["description"], "internal: do not delete");
}
}
+172 -2
View File
@@ -18,6 +18,8 @@ mod account_tokens;
mod accounts;
mod compat;
mod downstream_keys;
mod model_candidates;
mod models_marketplace;
mod sites;
mod tokens;
mod users;
@@ -66,6 +68,11 @@ pub fn routes(state: AppState) -> Router<AppState> {
get(sites::get_site)
.put(sites::update_site)
.delete(sites::delete_site),
)
.route(
"/api/sites/{id}/disabled-models",
get(sites::list_site_disabled_models)
.put(sites::update_site_disabled_models),
);
let accounts_routes = Router::new()
@@ -73,6 +80,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
"/api/accounts",
get(accounts::list_accounts).post(accounts::create_account),
)
.route("/api/accounts/batch", post(accounts::batch_accounts))
.route("/api/accounts/verify-token", post(accounts::verify_token))
.route(
"/api/accounts/{id}",
@@ -87,6 +95,10 @@ pub fn routes(state: AppState) -> Router<AppState> {
.route(
"/api/accounts/{id}/models/manual",
post(accounts::add_account_manual_models),
)
.route(
"/api/accounts/{id}/models/refresh",
post(accounts::refresh_account_models),
);
let tokens_routes = Router::new()
@@ -137,6 +149,16 @@ pub fn routes(state: AppState) -> Router<AppState> {
let settings_routes =
Router::new().route("/api/settings", get(get_settings).put(update_setting));
let model_candidates_routes = Router::new().route(
"/api/models/token-candidates",
get(model_candidates::token_candidates),
);
let models_marketplace_routes = Router::new().route(
"/api/models/marketplace",
get(models_marketplace::get_marketplace),
);
let users_routes = Router::new()
.route(
"/api/admin/users",
@@ -170,6 +192,8 @@ pub fn routes(state: AppState) -> Router<AppState> {
.merge(downstream_routes)
.merge(settings_routes)
.merge(users_routes)
.merge(model_candidates_routes)
.merge(models_marketplace_routes)
.merge(account_tokens::routes())
.merge(compat::routes())
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
@@ -177,7 +201,20 @@ pub fn routes(state: AppState) -> Router<AppState> {
// ── Settings handlers ────────────────────────────────────────────────
/// Settings rows whose keys begin with this prefix are managed by
/// `services::builtin_keys` and contain server-issued secrets (e.g. the
/// `comfyui-agent` downstream key). They must never appear in admin
/// list/get/edit responses.
const HIDDEN_SETTINGS_PREFIX: &str = "builtin_key.";
fn is_hidden_setting_key(key: &str) -> bool {
key.starts_with(HIDDEN_SETTINGS_PREFIX)
}
/// GET /api/settings — return all settings as a key-value map.
///
/// Filters out any keys with the `builtin_key.` prefix so the admin UI
/// never sees secrets bootstrapped by `services::builtin_keys`.
async fn get_settings(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Getting all settings");
@@ -186,14 +223,21 @@ async fn get_settings(State(state): State<AppState>) -> AppResult<Json<Value>> {
.fetch_all(&*state.db)
.await?;
let map: std::collections::BTreeMap<String, Option<String>> =
rows.into_iter().map(|s| (s.key, s.value)).collect();
let map: std::collections::BTreeMap<String, Option<String>> = rows
.into_iter()
.filter(|s| !is_hidden_setting_key(&s.key))
.map(|s| (s.key, s.value))
.collect();
Ok(Json(json!({ "data": map })))
}
/// PUT /api/settings — upsert a setting by key.
/// Expects JSON body: { "key": "...", "value": "..." }
///
/// Refuses any key with the `builtin_key.` prefix: those rows are owned by
/// the bootstrap path and a stray PUT would clobber the secret used by the
/// ComfyUI Agent's outbound calls.
async fn update_setting(
State(state): State<AppState>,
Json(payload): Json<serde_json::Value>,
@@ -203,6 +247,12 @@ async fn update_setting(
.and_then(|k| k.as_str())
.ok_or_else(|| AppError::BadRequest("key is required".into()))?;
if is_hidden_setting_key(key) {
return Err(AppError::Forbidden(
"system setting cannot be modified".into(),
));
}
let value = payload.get("value").and_then(|v| {
if v.is_null() {
None
@@ -226,3 +276,123 @@ async fn update_setting(
}
})))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
use crate::services::builtin_keys::BuiltinKeys;
use sqlx::sqlite::SqlitePoolOptions;
async fn settings_state() -> AppState {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.unwrap();
sqlx::query(
"CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO settings (key, value) VALUES ('site.title', 'BoosAPI')")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO settings (key, value) VALUES ('builtin_key.comfyui_agent.secret', 'sk-comfyui-secret')",
)
.execute(&pool)
.await
.unwrap();
AppState::new(
AppConfig {
server: ServerConfig {
host: "127.0.0.1".into(),
port: 0,
log_level: "info".into(),
cors_origins: vec![],
},
db: DbConfig {
url: "sqlite::memory:".into(),
max_connections: 1,
ssl: false,
runtime_db_url: None,
runtime_db_dialect: None,
runtime_db_ssl: None,
},
auth: AuthConfig {
jwt_secret: "secret".into(),
jwt_expiry_hours: 1,
auth_token: "auth".into(),
proxy_token: "proxy".into(),
},
proxy: ProxyConfig {
upstream_timeout_secs: 30,
first_byte_timeout_secs: 30,
max_retries: 3,
proxy_log_retention_days: 7,
},
comfyui: Default::default(),
},
pool,
BuiltinKeys {
comfyui_agent: (1, "sk-comfyui-secret".into()),
},
)
}
/// 5.2 — `GET /api/settings` returns regular settings but never any
/// `builtin_key.*` rows.
#[tokio::test]
async fn list_settings_filters_builtin_key_prefix() {
let state = settings_state().await;
let resp = get_settings(State(state)).await.expect("list ok");
let data = resp.0.get("data").and_then(Value::as_object).unwrap();
assert!(data.contains_key("site.title"));
assert!(
!data.contains_key("builtin_key.comfyui_agent.secret"),
"builtin_key.* rows must not appear in /api/settings response"
);
}
/// 5.3 — `PUT /api/settings` rejects writes targeting `builtin_key.*`
/// keys with 403; the row is left untouched.
#[tokio::test]
async fn update_settings_rejects_builtin_key_writes() {
let state = settings_state().await;
let payload = json!({
"key": "builtin_key.comfyui_agent.secret",
"value": "sk-attacker"
});
let result = update_setting(State(state.clone()), Json(payload)).await;
match result {
Err(AppError::Forbidden(msg)) => {
assert_eq!(msg, "system setting cannot be modified");
}
other => panic!("expected Forbidden, got {:?}", other),
}
let stored: Option<String> = sqlx::query_scalar(
"SELECT value FROM settings WHERE key = 'builtin_key.comfyui_agent.secret'",
)
.fetch_one(&*state.db)
.await
.unwrap();
assert_eq!(stored.as_deref(), Some("sk-comfyui-secret"));
}
/// 5.3 — Regular settings still upsert as before.
#[tokio::test]
async fn update_settings_allows_regular_keys() {
let state = settings_state().await;
let payload = json!({ "key": "site.title", "value": "Renamed" });
let resp = update_setting(State(state.clone()), Json(payload))
.await
.expect("update ok");
assert_eq!(resp.0["data"]["value"], "Renamed");
}
}
+176
View File
@@ -0,0 +1,176 @@
//! `GET /api/models/token-candidates` — feeds the frontend "candidate models"
//! panel and the new-route dialog dropdown (`TokenRoutes.tsx:252`).
//!
//! Response shape (must match the legacy Node handler):
//!
//! ```json
//! {
//! "models": {
//! "<modelName>": [
//! { "accountId", "tokenId", "tokenName", "isDefault",
//! "username", "siteId", "siteName" }, ...
//! ]
//! },
//! "modelsWithoutToken": {
//! "<modelName>": [ { "accountId", "username", "siteId", "siteName" }, ... ]
//! },
//! "modelsMissingTokenGroups": {},
//! "endpointTypesByModel": {}
//! }
//! ```
//!
//! `modelsMissingTokenGroups` and `endpointTypesByModel` are intentionally empty:
//! this codebase doesn't have the pricing-catalog metadata or
//! marketplace-cache machinery the Node version relies on. Returning empty
//! objects keeps the contract intact (frontend tolerates them) without
//! pulling in unrelated infrastructure.
use crate::{error::AppResult, AppState};
use axum::{extract::State, Json};
use serde_json::{json, Map, Value};
use std::collections::{HashMap, HashSet};
/// Row from `model_availability ⋈ account_tokens ⋈ accounts ⋈ sites` — every
/// `(model, token)` combo that's currently usable.
#[derive(Debug, sqlx::FromRow)]
struct CoveredRow {
model_name: String,
account_id: i64,
token_id: i64,
token_name: String,
is_default: Option<bool>,
username: Option<String>,
site_id: i64,
site_name: Option<String>,
}
/// Row from `model_availability ⋈ accounts ⋈ sites` — every `(model, account)`
/// combo regardless of token coverage. We diff this against `CoveredRow` to
/// surface accounts that have the model but no usable token.
#[derive(Debug, sqlx::FromRow)]
struct AccountModelRow {
model_name: String,
account_id: i64,
username: Option<String>,
site_id: i64,
site_name: Option<String>,
}
pub async fn token_candidates(State(state): State<AppState>) -> AppResult<Json<Value>> {
// 1. Models that already have at least one usable token. The joins drop:
// inactive accounts/sites, disabled tokens, and tokens whose value
// isn't ready (e.g. mid-rotation).
let covered: Vec<CoveredRow> = sqlx::query_as(
"SELECT ma.model_name AS model_name,
a.id AS account_id,
at.id AS token_id,
at.name AS token_name,
at.is_default AS is_default,
a.username AS username,
s.id AS site_id,
s.name AS site_name
FROM model_availability ma
JOIN account_tokens at ON at.account_id = ma.account_id
JOIN accounts a ON a.id = ma.account_id
JOIN sites s ON s.id = a.site_id
WHERE COALESCE(ma.available, 0) = 1
AND COALESCE(at.enabled, 1) = 1
AND COALESCE(at.value_status, 'ready') = 'ready'
AND a.status = 'active'
AND COALESCE(s.status, 'active') = 'active'",
)
.fetch_all(&*state.db)
.await?;
// 2. All available `(account, model)` pairs — used to compute which
// accounts have the model but no usable token (`modelsWithoutToken`).
let account_models: Vec<AccountModelRow> = sqlx::query_as(
"SELECT ma.model_name AS model_name,
a.id AS account_id,
a.username AS username,
s.id AS site_id,
s.name AS site_name
FROM model_availability ma
JOIN accounts a ON a.id = ma.account_id
JOIN sites s ON s.id = a.site_id
WHERE COALESCE(ma.available, 0) = 1
AND a.status = 'active'
AND COALESCE(s.status, 'active') = 'active'",
)
.fetch_all(&*state.db)
.await?;
// Build `models` map. Dedupe candidates by tokenId per model so a token
// marked available against the same model twice doesn't render twice.
let mut models: HashMap<String, Vec<Value>> = HashMap::new();
let mut seen_token_per_model: HashMap<String, HashSet<i64>> = HashMap::new();
let mut covered_account_model: HashSet<(i64, String)> = HashSet::new();
for row in covered {
let model_name = row.model_name.trim().to_string();
if model_name.is_empty() {
continue;
}
covered_account_model.insert((row.account_id, model_name.to_lowercase()));
let seen = seen_token_per_model.entry(model_name.clone()).or_default();
if !seen.insert(row.token_id) {
continue;
}
models.entry(model_name).or_default().push(json!({
"accountId": row.account_id,
"tokenId": row.token_id,
"tokenName": row.token_name,
"isDefault": row.is_default.unwrap_or(false),
"username": row.username,
"siteId": row.site_id,
"siteName": row.site_name,
}));
}
// Build `modelsWithoutToken`: every (account, model) in `account_models`
// that didn't show up in `covered`. Dedupe by accountId per model.
let mut models_without_token: HashMap<String, Vec<Value>> = HashMap::new();
let mut seen_account_per_model: HashMap<String, HashSet<i64>> = HashMap::new();
for row in account_models {
let model_name = row.model_name.trim().to_string();
if model_name.is_empty() {
continue;
}
let key = (row.account_id, model_name.to_lowercase());
if covered_account_model.contains(&key) {
continue;
}
let seen = seen_account_per_model
.entry(model_name.clone())
.or_default();
if !seen.insert(row.account_id) {
continue;
}
models_without_token.entry(model_name).or_default().push(json!({
"accountId": row.account_id,
"username": row.username,
"siteId": row.site_id,
"siteName": row.site_name,
}));
}
// Convert HashMap -> serde_json::Map so the response keys serialize
// deterministically as a JSON object (HashMap also works but Map is the
// canonical type for object-shaped payloads).
let models_obj: Map<String, Value> = models
.into_iter()
.map(|(k, v)| (k, Value::Array(v)))
.collect();
let without_obj: Map<String, Value> = models_without_token
.into_iter()
.map(|(k, v)| (k, Value::Array(v)))
.collect();
Ok(Json(json!({
"models": models_obj,
"modelsWithoutToken": without_obj,
"modelsMissingTokenGroups": {},
"endpointTypesByModel": {},
})))
}
+379
View File
@@ -0,0 +1,379 @@
//! `GET /api/models/marketplace` — model marketplace listing for the admin
//! `/models` page (frontend: `Models.tsx`).
//!
//! Stage 1: aggregates `model_availability` × `accounts` × `account_tokens`
//! × `sites` into the shape `ModelsMarketplaceResponse` expects. Per-model
//! latency / success rate / description / tags / supportedEndpointTypes /
//! pricingSources are returned as null/empty placeholders — the frontend
//! falls back gracefully and stage 2 will hydrate them from `proxy_logs`
//! and pricing tables.
//!
//! `?refresh=1` and `?includePricing=1` are accepted but currently no-ops:
//! we always return the in-memory aggregation and an empty `meta`. Stage 2
//! will wire them to an async refresh job and the pricing hydration path.
use crate::{error::AppResult, AppState};
use axum::{
extract::{Query, State},
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use sqlx::FromRow;
use std::collections::{BTreeMap, HashMap};
#[derive(Debug, Deserialize)]
#[allow(dead_code)] // stage 2 will read these; keep the fields to lock the API shape.
pub struct MarketplaceQuery {
pub refresh: Option<String>,
#[serde(rename = "includePricing")]
pub include_pricing: Option<String>,
}
#[derive(FromRow)]
struct AvailRow {
account_id: i64,
model_name: String,
}
#[derive(FromRow)]
struct AccountRow {
id: i64,
site_id: i64,
username: Option<String>,
balance: Option<f64>,
}
#[derive(FromRow)]
struct TokenRow {
id: i64,
account_id: i64,
name: String,
is_default: Option<bool>,
}
pub async fn get_marketplace(
State(state): State<AppState>,
Query(_query): Query<MarketplaceQuery>,
) -> AppResult<Json<Value>> {
let value = marketplace_inner(&state.db).await?;
Ok(Json(value))
}
/// Pool-only entry point. Identical to `get_marketplace` but decoupled from
/// `AppState` and axum extractors so unit tests can drive it with an
/// in-memory SQLite pool (mirrors the `rebuild_routes_inner` pattern in
/// `services::route_rebuild`).
pub(crate) async fn marketplace_inner(
pool: &sqlx::Pool<sqlx::Sqlite>,
) -> AppResult<Value> {
// 1. Active (account, model) pairs. Filtering on accounts.status =
// 'active' here keeps the rest of the aggregation small.
let avail_rows: Vec<AvailRow> = sqlx::query_as(
"SELECT ma.account_id, ma.model_name
FROM model_availability ma
JOIN accounts a ON a.id = ma.account_id
WHERE COALESCE(ma.available, 0) = 1
AND a.status = 'active'",
)
.fetch_all(pool)
.await?;
if avail_rows.is_empty() {
return Ok(json!({ "models": [], "meta": {} }));
}
// 2. One-shot lookups — accounts, sites, tokens. Bounded by the active
// account count (typically dozens), so a full scan beats per-model
// in-loop selects.
let accounts: Vec<AccountRow> = sqlx::query_as(
"SELECT id, site_id, username, balance FROM accounts WHERE status = 'active'",
)
.fetch_all(pool)
.await?;
let accounts_by_id: HashMap<i64, AccountRow> =
accounts.into_iter().map(|a| (a.id, a)).collect();
let sites: Vec<(i64, String)> = sqlx::query_as("SELECT id, name FROM sites")
.fetch_all(pool)
.await?;
let site_name_by_id: HashMap<i64, String> = sites.into_iter().collect();
let tokens: Vec<TokenRow> = sqlx::query_as(
"SELECT id, account_id, name, is_default
FROM account_tokens
WHERE COALESCE(enabled, 1) = 1",
)
.fetch_all(pool)
.await?;
let mut tokens_by_account: HashMap<i64, Vec<TokenRow>> = HashMap::new();
for t in tokens {
tokens_by_account.entry(t.account_id).or_default().push(t);
}
// 3. Group by model name. BTreeMap → output sorted by name (matches the
// deterministic ordering the frontend's table view expects).
let mut by_model: BTreeMap<String, Vec<i64>> = BTreeMap::new();
for row in avail_rows {
by_model
.entry(row.model_name)
.or_default()
.push(row.account_id);
}
// 4. Render. Stage 1 returns null/empty for the metadata fields; the
// frontend has fallback handling for each one.
let mut models: Vec<Value> = Vec::with_capacity(by_model.len());
for (model_name, account_ids) in by_model {
let account_count = account_ids.len();
let mut token_count: usize = 0;
let mut accounts_json: Vec<Value> = Vec::with_capacity(account_count);
for aid in &account_ids {
let acc = match accounts_by_id.get(aid) {
Some(a) => a,
None => continue,
};
let site_name = site_name_by_id
.get(&acc.site_id)
.cloned()
.unwrap_or_default();
let acc_tokens: &[TokenRow] = tokens_by_account
.get(aid)
.map(Vec::as_slice)
.unwrap_or(&[]);
token_count += acc_tokens.len();
let token_json: Vec<Value> = acc_tokens
.iter()
.map(|t| {
json!({
"id": t.id,
"name": t.name,
"isDefault": t.is_default.unwrap_or(false),
})
})
.collect();
accounts_json.push(json!({
"id": acc.id,
"site": site_name,
"username": acc.username,
"latency": Value::Null,
"balance": acc.balance.unwrap_or(0.0),
"tokens": token_json,
}));
}
models.push(json!({
"name": model_name,
"accountCount": account_count,
"tokenCount": token_count,
"avgLatency": Value::Null,
"successRate": Value::Null,
"description": Value::Null,
"tags": [],
"supportedEndpointTypes": [],
"pricingSources": [],
"accounts": accounts_json,
}));
}
Ok(json!({
"models": models,
"meta": {},
}))
}
#[cfg(test)]
mod tests {
use super::*;
use sqlx::sqlite::SqlitePoolOptions;
use sqlx::{Pool, Sqlite};
/// Minimal schema for the marketplace aggregation. Mirrors
/// `services::route_rebuild::tests::fixture_pool` — we create only the
/// columns `marketplace_inner` actually reads, so adding unrelated
/// migrations later doesn't break these tests.
async fn fixture_pool() -> Pool<Sqlite> {
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("open in-memory sqlite");
for ddl in [
"CREATE TABLE sites (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
)",
"CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL,
username TEXT,
balance REAL,
status TEXT NOT NULL DEFAULT 'active'
)",
"CREATE TABLE model_availability (
account_id INTEGER NOT NULL,
model_name TEXT NOT NULL,
available INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (account_id, model_name)
)",
"CREATE TABLE account_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
is_default INTEGER DEFAULT 0
)",
] {
sqlx::query(ddl)
.execute(&pool)
.await
.unwrap_or_else(|err| panic!("failed to create schema: {err}\nddl: {ddl}"));
}
pool
}
/// Empty DB → empty `models` array with empty `meta`. Locks the early-return
/// path at the top of `marketplace_inner` so an accidental refactor that
/// drops the guard still produces a frontend-safe response shape rather
/// than 500-ing on the follow-up `accounts`/`sites` selects.
#[tokio::test]
async fn empty_db_returns_empty_models_array() {
let pool = fixture_pool().await;
let value = marketplace_inner(&pool).await.expect("inner ok");
assert_eq!(value, json!({ "models": [], "meta": {} }));
}
/// Inactive accounts and unavailable models must be filtered out at the
/// SQL level. Account 2 is `disabled` and account 3's row has
/// `available = 0` — neither should contribute to the response.
#[tokio::test]
async fn filters_inactive_accounts_and_unavailable_models() {
let pool = fixture_pool().await;
sqlx::query("INSERT INTO sites (id, name) VALUES (1, 'site-a')")
.execute(&pool).await.unwrap();
sqlx::query(
"INSERT INTO accounts (id, site_id, username, balance, status) VALUES
(1, 1, 'alice', 10.0, 'active'),
(2, 1, 'bob', 5.0, 'disabled'),
(3, 1, 'carol', 7.0, 'active')",
).execute(&pool).await.unwrap();
sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-x', 1),
(2, 'gpt-x', 1),
(3, 'gpt-x', 0)",
).execute(&pool).await.unwrap();
let value = marketplace_inner(&pool).await.expect("inner ok");
let models = value.get("models").and_then(Value::as_array).unwrap();
assert_eq!(models.len(), 1, "exactly one model should survive filtering");
let m = &models[0];
assert_eq!(m["name"], "gpt-x");
assert_eq!(m["accountCount"], 1, "only account 1 (active + available) counts");
let accounts = m["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 1);
assert_eq!(accounts[0]["username"], "alice");
}
/// Multi-account, multi-token aggregation. Verifies:
/// - `accountCount` == distinct active accounts advertising the model
/// - `tokenCount` == sum of enabled tokens across those accounts
/// - per-account `tokens[]` is correctly nested with `isDefault`
/// - disabled tokens (`enabled = 0`) don't show up
/// - site name is resolved from `sites.name`
#[tokio::test]
async fn aggregates_accounts_and_tokens_per_model() {
let pool = fixture_pool().await;
sqlx::query("INSERT INTO sites (id, name) VALUES (1, 'openai-mirror'), (2, 'anthropic-mirror')")
.execute(&pool).await.unwrap();
sqlx::query(
"INSERT INTO accounts (id, site_id, username, balance, status) VALUES
(1, 1, 'alice', 100.5, 'active'),
(2, 2, 'bob', 50.0, 'active')",
).execute(&pool).await.unwrap();
sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-x', 1),
(2, 'gpt-x', 1)",
).execute(&pool).await.unwrap();
// Account 1: two enabled tokens (one default), one disabled token (must
// be excluded). Account 2: one enabled non-default token.
sqlx::query(
"INSERT INTO account_tokens (id, account_id, name, enabled, is_default) VALUES
(10, 1, 'primary', 1, 1),
(11, 1, 'backup', 1, 0),
(12, 1, 'retired', 0, 0),
(20, 2, 'only-one', 1, 0)",
).execute(&pool).await.unwrap();
let value = marketplace_inner(&pool).await.expect("inner ok");
let models = value["models"].as_array().unwrap();
assert_eq!(models.len(), 1);
let m = &models[0];
assert_eq!(m["name"], "gpt-x");
assert_eq!(m["accountCount"], 2);
assert_eq!(m["tokenCount"], 3, "2 from account 1 + 1 from account 2; disabled token excluded");
// Stage 1 placeholders.
assert!(m["avgLatency"].is_null());
assert!(m["successRate"].is_null());
assert!(m["description"].is_null());
assert_eq!(m["tags"], json!([]));
assert_eq!(m["supportedEndpointTypes"], json!([]));
assert_eq!(m["pricingSources"], json!([]));
let accounts = m["accounts"].as_array().unwrap();
assert_eq!(accounts.len(), 2);
// Find each account by id rather than relying on insertion order in
// the HashMap-driven account_ids vec.
let alice = accounts.iter().find(|a| a["id"] == 1).expect("alice present");
assert_eq!(alice["site"], "openai-mirror");
assert_eq!(alice["username"], "alice");
assert_eq!(alice["balance"], 100.5);
let alice_tokens = alice["tokens"].as_array().unwrap();
assert_eq!(alice_tokens.len(), 2, "disabled 'retired' token must not be exposed");
let primary = alice_tokens.iter().find(|t| t["id"] == 10).unwrap();
assert_eq!(primary["name"], "primary");
assert_eq!(primary["isDefault"], true);
let backup = alice_tokens.iter().find(|t| t["id"] == 11).unwrap();
assert_eq!(backup["isDefault"], false);
let bob = accounts.iter().find(|a| a["id"] == 2).expect("bob present");
assert_eq!(bob["site"], "anthropic-mirror");
assert_eq!(bob["balance"], 50.0);
assert_eq!(bob["tokens"].as_array().unwrap().len(), 1);
}
/// Models must come back in deterministic name order (BTreeMap). The
/// frontend's table view assumes a stable sort so toggling refresh
/// doesn't reshuffle rows; lock that contract here.
#[tokio::test]
async fn models_are_sorted_by_name() {
let pool = fixture_pool().await;
sqlx::query("INSERT INTO sites (id, name) VALUES (1, 's')")
.execute(&pool).await.unwrap();
sqlx::query("INSERT INTO accounts (id, site_id, status) VALUES (1, 1, 'active')")
.execute(&pool).await.unwrap();
// Insert in scrambled order; expect output sorted lexicographically.
sqlx::query(
"INSERT INTO model_availability (account_id, model_name, available) VALUES
(1, 'gpt-z', 1),
(1, 'gpt-a', 1),
(1, 'gpt-m', 1)",
).execute(&pool).await.unwrap();
let value = marketplace_inner(&pool).await.expect("inner ok");
let names: Vec<&str> = value["models"]
.as_array()
.unwrap()
.iter()
.map(|m| m["name"].as_str().unwrap())
.collect();
assert_eq!(names, vec!["gpt-a", "gpt-m", "gpt-z"]);
}
}
+76
View File
@@ -356,6 +356,82 @@ async fn site_api_endpoints(state: &AppState, site_id: i64) -> AppResult<Vec<Val
.collect())
}
#[derive(Debug, Deserialize)]
pub struct UpdateDisabledModelsRequest {
#[serde(default)]
pub models: Vec<String>,
}
/// GET /api/sites/{id}/disabled-models — list disabled model names for a site.
pub async fn list_site_disabled_models(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
// Validate the site exists so we return a clean 404 instead of an empty list
// for unknown ids.
let exists: Option<i64> =
sqlx::query_scalar("SELECT id FROM sites WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("site not found".into()));
}
let models: Vec<String> =
sqlx::query_scalar("SELECT model_name FROM site_disabled_models WHERE site_id = ? ORDER BY model_name")
.bind(id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "models": models })))
}
/// PUT /api/sites/{id}/disabled-models — replace the disabled-model list for a site.
pub async fn update_site_disabled_models(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<UpdateDisabledModelsRequest>,
) -> AppResult<Json<Value>> {
let exists: Option<i64> =
sqlx::query_scalar("SELECT id FROM sites WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?;
if exists.is_none() {
return Err(AppError::NotFound("site not found".into()));
}
// Normalise + dedupe the inbound list. Empty strings are dropped.
let mut normalised: Vec<String> = payload
.models
.into_iter()
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty())
.collect();
normalised.sort();
normalised.dedup();
let mut tx = state.db.begin().await?;
sqlx::query("DELETE FROM site_disabled_models WHERE site_id = ?")
.bind(id)
.execute(&mut *tx)
.await?;
for model in &normalised {
sqlx::query(
"INSERT INTO site_disabled_models (site_id, model_name) VALUES (?, ?)\n ON CONFLICT(site_id, model_name) DO NOTHING",
)
.bind(id)
.bind(model)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
info!(site_id = id, count = normalised.len(), "updated site disabled models");
Ok(Json(json!({ "models": normalised })))
}
async fn replace_site_api_endpoints(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
site_id: i64,