feat: Phase 5-6 - Proxy Conductor, Token Router, Services, Dockerfile

- Proxy Conductor: retry/fallback/auth-refresh orchestration loop
- Token Router: exact/wildcard/regex model routing with channel selection
- Services: Cron scheduler, OAuth token refresh, Update center, Announcements
- User routes: profile + API key management
- DB queries module
- Dockerfile: multi-stage build
- Proxy routes integrated with Conductor + TokenRouter
This commit is contained in:
boos
2026-05-18 03:00:09 +08:00
parent 78d46eeddb
commit 56391f2193
75 changed files with 7428 additions and 464 deletions
@@ -0,0 +1,299 @@
// Account token routes — CRUD for account-level API tokens
use crate::{
db::models::account::AccountToken,
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, Query, State},
http::StatusCode,
routing::{get, post},
Json, Router,
};
use chrono::Utc;
use serde::Deserialize;
use serde_json::{json, Value};
use tracing::info;
use uuid::Uuid;
pub fn routes() -> Router<AppState> {
Router::new()
.route(
"/api/account-tokens",
get(list_account_tokens).post(create_account_token),
)
.route(
"/api/account-tokens/sync-all",
post(sync_all_account_tokens),
)
.route(
"/api/account-tokens/{id}",
get(get_account_token)
.put(update_account_token)
.delete(delete_account_token),
)
}
#[derive(Debug, Deserialize)]
pub struct ListAccountTokensQuery {
#[serde(rename = "accountId", alias = "account_id")]
pub account_id: Option<i64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccountTokenRequest {
pub account_id: i64,
pub name: Option<String>,
pub token: Option<String>,
pub source: Option<String>,
pub token_group: Option<String>,
pub value_status: Option<String>,
pub enabled: Option<bool>,
pub is_default: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAccountTokenRequest {
pub account_id: Option<i64>,
pub name: Option<String>,
pub token: Option<String>,
pub source: Option<String>,
pub token_group: Option<String>,
pub value_status: Option<String>,
pub enabled: Option<bool>,
pub is_default: Option<bool>,
}
/// GET /api/account-tokens?accountId=
pub async fn list_account_tokens(
State(state): State<AppState>,
Query(query): Query<ListAccountTokensQuery>,
) -> AppResult<Json<Value>> {
info!(
"Listing account tokens, account_id filter: {:?}",
query.account_id
);
let tokens: Vec<AccountToken> = if let Some(account_id) = query.account_id {
sqlx::query_as::<_, AccountToken>(
"SELECT * FROM account_tokens WHERE account_id = ? ORDER BY token_group, id",
)
.bind(account_id)
.fetch_all(&*state.db)
.await?
} else {
sqlx::query_as::<_, AccountToken>(
"SELECT * FROM account_tokens ORDER BY account_id, token_group, id",
)
.fetch_all(&*state.db)
.await?
};
Ok(Json(json!({ "data": tokens })))
}
/// POST /api/account-tokens
pub async fn create_account_token(
State(state): State<AppState>,
Json(payload): Json<CreateAccountTokenRequest>,
) -> AppResult<Json<Value>> {
info!(
"Creating account token for account_id: {}",
payload.account_id
);
ensure_account_exists(&state, payload.account_id).await?;
let token = normalize_create_token(payload.token);
let name = payload
.name
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "API Token".to_string());
let value_status = payload
.value_status
.map(|status| status.trim().to_string())
.filter(|status| !status.is_empty())
.unwrap_or_else(|| "valid".to_string());
let now = Utc::now().to_rfc3339();
let result = sqlx::query(
"INSERT INTO account_tokens \
(account_id, name, token, source, token_group, value_status, enabled, is_default, created_at, updated_at) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(payload.account_id)
.bind(&name)
.bind(&token)
.bind(&payload.source)
.bind(&payload.token_group)
.bind(&value_status)
.bind(payload.enabled.unwrap_or(true))
.bind(payload.is_default.unwrap_or(false))
.bind(&now)
.bind(&now)
.execute(&*state.db)
.await?;
let token_id = result.last_insert_rowid();
let account_token: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(token_id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": account_token })))
}
/// GET /api/account-tokens/{id}
pub async fn get_account_token(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Getting account token: {}", id);
let account_token: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account token with id {} not found", id)))?;
Ok(Json(json!({ "data": account_token })))
}
/// PUT /api/account-tokens/{id}
pub async fn update_account_token(
State(state): State<AppState>,
Path(id): Path<i64>,
Json(payload): Json<UpdateAccountTokenRequest>,
) -> AppResult<Json<Value>> {
info!("Updating account token: {}", id);
let existing: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound(format!("Account token with id {} not found", id)))?;
let account_id = payload.account_id.unwrap_or(existing.account_id);
if payload.account_id.is_some() {
ensure_account_exists(&state, account_id).await?;
}
let name = payload
.name
.map(|name| name.trim().to_string())
.filter(|name| !name.is_empty())
.unwrap_or(existing.name);
let token = match payload.token {
Some(token) => {
let token = token.trim().to_string();
if token.is_empty() {
return Err(AppError::BadRequest("token cannot be empty".into()));
}
token
}
None => existing.token,
};
let value_status = payload
.value_status
.map(|status| status.trim().to_string())
.filter(|status| !status.is_empty())
.unwrap_or(existing.value_status);
let now = Utc::now().to_rfc3339();
sqlx::query(
"UPDATE account_tokens SET \
account_id = ?, name = ?, token = ?, source = ?, token_group = ?, value_status = ?, \
enabled = ?, is_default = ?, updated_at = ? \
WHERE id = ?",
)
.bind(account_id)
.bind(&name)
.bind(&token)
.bind(payload.source.or(existing.source))
.bind(payload.token_group.or(existing.token_group))
.bind(&value_status)
.bind(payload.enabled.or(existing.enabled))
.bind(payload.is_default.or(existing.is_default))
.bind(&now)
.bind(id)
.execute(&*state.db)
.await?;
let account_token: AccountToken =
sqlx::query_as::<_, AccountToken>("SELECT * FROM account_tokens WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "data": account_token })))
}
/// DELETE /api/account-tokens/{id}
pub async fn delete_account_token(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
info!("Deleting account token: {}", id);
let affected = sqlx::query("DELETE FROM account_tokens WHERE id = ?")
.bind(id)
.execute(&*state.db)
.await?
.rows_affected();
if affected == 0 {
return Err(AppError::NotFound(format!(
"Account token with id {} not found",
id
)));
}
Ok(Json(json!({ "success": true })))
}
/// POST /api/account-tokens/sync-all
pub async fn sync_all_account_tokens() -> (StatusCode, Json<Value>) {
info!("Sync all account tokens requested");
(
StatusCode::NOT_IMPLEMENTED,
Json(json!({
"error": "not implemented"
})),
)
}
async fn ensure_account_exists(state: &AppState, account_id: i64) -> AppResult<()> {
let account_exists: Option<(i64,)> = sqlx::query_as("SELECT id FROM accounts WHERE id = ?")
.bind(account_id)
.fetch_optional(&*state.db)
.await?;
if account_exists.is_none() {
return Err(AppError::BadRequest(format!(
"Account with id {} not found",
account_id
)));
}
Ok(())
}
fn normalize_create_token(token: Option<String>) -> String {
token
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty())
.unwrap_or_else(|| Uuid::new_v4().to_string())
}
+2
View File
@@ -14,6 +14,7 @@ use axum::{
use serde_json::{json, Value};
use tracing::info;
mod account_tokens;
mod accounts;
mod downstream_keys;
mod sites;
@@ -116,6 +117,7 @@ pub fn routes(state: AppState) -> Router<AppState> {
.merge(downstream_routes)
.merge(settings_routes)
.merge(users_routes)
.merge(account_tokens::routes())
.route_layer(middleware::from_fn_with_state(state, require_admin_auth))
}
+25 -17
View File
@@ -59,10 +59,9 @@ impl From<User> for UserResponse {
/// GET /api/admin/users
pub async fn list_users(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Listing admin users");
let users: Vec<User> =
sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY id")
.fetch_all(&*state.db)
.await?;
let users: Vec<User> = sqlx::query_as::<_, User>("SELECT * FROM users ORDER BY id")
.fetch_all(&*state.db)
.await?;
let items: Vec<UserResponse> = users.into_iter().map(Into::into).collect();
Ok(Json(json!({ "data": items })))
}
@@ -95,29 +94,33 @@ pub async fn create_user(
return Err(AppError::BadRequest("email is required".into()));
}
if payload.password.len() < 6 {
return Err(AppError::BadRequest("password must be at least 6 characters".into()));
return Err(AppError::BadRequest(
"password must be at least 6 characters".into(),
));
}
let password_hash = hash_password(&payload.password)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
let role = payload.role.unwrap_or_else(|| "user".to_string());
let result = sqlx::query(
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)"
)
.bind(&payload.username)
.bind(&payload.email)
.bind(&password_hash)
.bind(&role)
.execute(&*state.db)
.await?;
let result =
sqlx::query("INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)")
.bind(&payload.username)
.bind(&payload.email)
.bind(&password_hash)
.bind(&role)
.execute(&*state.db)
.await?;
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(result.last_insert_rowid())
.fetch_one(&*state.db)
.await?;
Ok((axum::http::StatusCode::CREATED, Json(json!({ "data": UserResponse::from(user) }))))
Ok((
axum::http::StatusCode::CREATED,
Json(json!({ "data": UserResponse::from(user) })),
))
}
/// PUT /api/admin/users/{id}
@@ -136,9 +139,14 @@ pub async fn update_user(
let password_hash = if let Some(ref pw) = payload.password {
if pw.len() < 6 {
return Err(AppError::BadRequest("password must be at least 6 characters".into()));
return Err(AppError::BadRequest(
"password must be at least 6 characters".into(),
));
}
Some(hash_password(pw).map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?)
Some(
hash_password(pw)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?,
)
} else {
None
};
+32 -19
View File
@@ -68,10 +68,14 @@ async fn register(
info!("Registering user: {}", payload.email);
if payload.email.trim().is_empty() || payload.username.trim().is_empty() {
return Err(AppError::BadRequest("email and username are required".into()));
return Err(AppError::BadRequest(
"email and username are required".into(),
));
}
if payload.password.len() < 6 {
return Err(AppError::BadRequest("password must be at least 6 characters".into()));
return Err(AppError::BadRequest(
"password must be at least 6 characters".into(),
));
}
// Check if email already exists
@@ -88,13 +92,13 @@ async fn register(
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to hash password: {e}")))?;
let result = sqlx::query(
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')"
"INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, 'user')",
)
.bind(&payload.username)
.bind(&payload.email)
.bind(&password_hash)
.execute(&*state.db)
.await?;
.bind(&payload.username)
.bind(&payload.email)
.bind(&password_hash)
.execute(&*state.db)
.await?;
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
.bind(result.last_insert_rowid())
@@ -106,7 +110,8 @@ async fn register(
&user.id.to_string(),
&user.role,
state.config.auth.jwt_expiry_hours,
).map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
Ok(Json(json!({
"data": {
@@ -150,7 +155,8 @@ async fn login(
&user.id.to_string(),
&user.role,
state.config.auth.jwt_expiry_hours,
).map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
)
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to generate JWT: {e}")))?;
Ok(Json(json!({
"data": {
@@ -166,10 +172,7 @@ async fn login(
}
/// GET /api/auth/me — returns current user profile from JWT
async fn me(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
async fn me(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
@@ -238,13 +241,19 @@ async fn change_password(
) -> AppResult<Json<Value>> {
let (user_id, _role) = extract_jwt(&state, &headers)?;
let old_pw = body.get("oldPassword").and_then(|v| v.as_str())
let old_pw = body
.get("oldPassword")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("oldPassword is required".into()))?;
let new_pw = body.get("newPassword").and_then(|v| v.as_str())
let new_pw = body
.get("newPassword")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("newPassword is required".into()))?;
if new_pw.len() < 6 {
return Err(AppError::BadRequest("new password must be at least 6 characters".into()));
return Err(AppError::BadRequest(
"new password must be at least 6 characters".into(),
));
}
let user: User = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = ?")
@@ -257,7 +266,9 @@ async fn change_password(
.map_err(|e| AppError::Internal(anyhow::anyhow!("Password verification failed: {e}")))?;
if !valid {
return Err(AppError::Unauthorized("Current password is incorrect".into()));
return Err(AppError::Unauthorized(
"Current password is incorrect".into(),
));
}
let new_hash = crate::auth::password::hash_password(new_pw)
@@ -314,7 +325,9 @@ fn extract_jwt(state: &AppState, headers: &HeaderMap) -> AppResult<(i64, String)
let claims = crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
let uid: i64 = claims.sub.parse()
let uid: i64 = claims
.sub
.parse()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))?;
Ok((uid, claims.role))
+45 -32
View File
@@ -48,7 +48,10 @@ pub fn routes() -> Router<AppState> {
.route("/api/checkin", get(list_checkin_logs))
.route("/api/checkin/stats", get(checkin_stats))
.route("/api/checkin/accounts", get(list_checkin_accounts))
.route("/api/checkin/accounts/{id}/toggle", post(toggle_checkin_account))
.route(
"/api/checkin/accounts/{id}/toggle",
post(toggle_checkin_account),
)
.route("/api/checkin/run", post(run_checkin))
}
@@ -96,15 +99,20 @@ async fn checkin_stats(State(state): State<AppState>) -> AppResult<Json<Value>>
FROM accounts a
JOIN sites s ON s.id = a.site_id
WHERE a.status = 'active'
ORDER BY a.last_checkin_at ASC"
ORDER BY a.last_checkin_at ASC",
)
.fetch_all(&*state.db)
.await?;
.fetch_all(&*state.db)
.await?;
let total = rows.len();
let checked_today = rows.iter().filter(|r| {
r.last_checkin.as_deref().map_or(false, |d| d.starts_with(&today_date()))
}).count();
let checked_today = rows
.iter()
.filter(|r| {
r.last_checkin
.as_deref()
.map_or(false, |d| d.starts_with(&today_date()))
})
.count();
Ok(Json(json!({
"total_accounts": total,
@@ -116,22 +124,23 @@ async fn checkin_stats(State(state): State<AppState>) -> AppResult<Json<Value>>
/// GET /api/checkin/accounts — list accounts with checkin info
async fn list_checkin_accounts(State(state): State<AppState>) -> AppResult<Json<Value>> {
let accounts: Vec<Value> = sqlx::query_as::<_, Account>(
"SELECT * FROM accounts ORDER BY site_id, username"
)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|a| json!({
"id": a.id,
"site_id": a.site_id,
"username": a.username,
"status": a.status,
"checkin_enabled": a.checkin_enabled,
"last_checkin_at": a.last_checkin_at,
"balance": a.balance,
}))
.collect();
let accounts: Vec<Value> =
sqlx::query_as::<_, Account>("SELECT * FROM accounts ORDER BY site_id, username")
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|a| {
json!({
"id": a.id,
"site_id": a.site_id,
"username": a.username,
"status": a.status,
"checkin_enabled": a.checkin_enabled,
"last_checkin_at": a.last_checkin_at,
"balance": a.balance,
})
})
.collect();
Ok(Json(json!({ "data": accounts })))
}
@@ -150,13 +159,17 @@ async fn toggle_checkin_account(
.ok_or_else(|| AppError::NotFound(format!("Account {} not found", id)))?;
let new_value = !account.checkin_enabled.unwrap_or(true);
sqlx::query("UPDATE accounts SET checkin_enabled = ?, updated_at = datetime('now') WHERE id = ?")
.bind(new_value)
.bind(id)
.execute(&*state.db)
.await?;
sqlx::query(
"UPDATE accounts SET checkin_enabled = ?, updated_at = datetime('now') WHERE id = ?",
)
.bind(new_value)
.bind(id)
.execute(&*state.db)
.await?;
Ok(Json(json!({ "success": true, "checkin_enabled": new_value })))
Ok(Json(
json!({ "success": true, "checkin_enabled": new_value }),
))
}
/// POST /api/checkin/run — trigger checkin for all enabled accounts
@@ -164,10 +177,10 @@ async fn run_checkin(State(state): State<AppState>) -> AppResult<Json<Value>> {
info!("Running checkin for all enabled accounts");
let accounts: Vec<Account> = sqlx::query_as::<_, Account>(
"SELECT * FROM accounts WHERE status = 'active' AND COALESCE(checkin_enabled, 1) = 1"
"SELECT * FROM accounts WHERE status = 'active' AND COALESCE(checkin_enabled, 1) = 1",
)
.fetch_all(&*state.db)
.await?;
.fetch_all(&*state.db)
.await?;
let mut results: Vec<Value> = vec![];
for account in &accounts {
+92 -36
View File
@@ -5,15 +5,15 @@
// The /api/comfyui/proxy/* route serves static assets (public).
use crate::{
auth::api_key::{ authorize_downstream_token, DownstreamAuthResult },
error::{ AppError, AppResult },
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{ Path, Query, State },
http::{ HeaderMap, StatusCode },
response::{ Html, IntoResponse, Response },
routing::{ get, post },
extract::{Path, Query, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use reqwest::Client;
@@ -49,7 +49,8 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
// Downstream API key
if auth.starts_with("sk-") {
let result = authorize_downstream_token(auth, &state.db).await
let result = authorize_downstream_token(auth, &state.db)
.await
.map_err(|e| AppError::Unauthorized(e))?;
match result {
@@ -123,10 +124,7 @@ async fn proxy_get_history_by_id(
}
/// GET /api/comfyui/queue
async fn proxy_get_queue(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Response> {
async fn proxy_get_queue(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Response> {
info!("GET /api/comfyui/queue");
check_auth(&state, &headers).await?;
proxy_to_comfyui("GET", "/queue", None, None).await
@@ -149,9 +147,7 @@ async fn proxy_get_view(
// ── Static proxy (public — assets/JS/CSS for iframe) ─────────────────
/// GET /api/comfyui/proxy/{path} — proxy everything (HTML, assets)
async fn proxy_static(
Path(path): Path<String>,
) -> AppResult<Response> {
async fn proxy_static(Path(path): Path<String>) -> AppResult<Response> {
info!("GET /api/comfyui/proxy/{}", path);
let target_path = if path.is_empty() || path == "/" {
"/".to_string()
@@ -161,18 +157,24 @@ async fn proxy_static(
let client = http_client();
let url = format!("{}{}", comfyui_base(), target_path);
let resp = client.get(&url).send().await
let resp = client
.get(&url)
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
let status = resp.status();
let content_type = resp.headers()
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
if content_type.contains("html") {
let text = resp.text().await
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
// Rebrand: ComfyUI → BoosAPI
let rebranded = text
@@ -180,21 +182,41 @@ async fn proxy_static(
.replace("comfyui", "BoosAPI");
// Rewrite URLs
let rewritten = rewrite_proxy_urls(&rebranded);
Ok((StatusCode::from_u16(status.as_u16()).unwrap(), [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")], rewritten).into_response())
Ok((
StatusCode::from_u16(status.as_u16()).unwrap(),
[(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
rewritten,
)
.into_response())
} else if content_type.contains("text") || content_type.contains("json") {
let text = resp.text().await
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
Ok((StatusCode::from_u16(status.as_u16()).unwrap(), [(axum::http::header::CONTENT_TYPE, content_type)], text).into_response())
Ok((
StatusCode::from_u16(status.as_u16()).unwrap(),
[(axum::http::header::CONTENT_TYPE, content_type)],
text,
)
.into_response())
} else {
let bytes = resp.bytes().await
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
Ok((StatusCode::from_u16(status.as_u16()).unwrap(), [(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response())
Ok((
StatusCode::from_u16(status.as_u16()).unwrap(),
[(axum::http::header::CONTENT_TYPE, content_type)],
bytes,
)
.into_response())
}
}
/// GET /comfyui — HTML page with iframe
async fn comfyui_page() -> Html<&'static str> {
Html(r##"<!DOCTYPE html>
Html(
r##"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
@@ -209,7 +231,8 @@ async fn comfyui_page() -> Html<&'static str> {
<body>
<iframe src="/api/comfyui/proxy/" title="BoosAPI ComfyUI"></iframe>
</body>
</html>"##)
</html>"##,
)
}
// ── Helpers ──────────────────────────────────────────────────────────
@@ -225,12 +248,23 @@ fn rewrite_proxy_urls(html: &str) -> String {
// Look ahead for rc="/", ref="/", ction="/"
let snippet: String = chars.clone().take(6).map(|(_, c)| c).collect();
let full = format!("{}{}", ch, snippet);
if full.starts_with("src=\"/") || full.starts_with("href=\"/") || full.starts_with("action=\"/") {
let attr_end = if full.starts_with("src=\"") { 4 } else if full.starts_with("href=\"") { 5 } else { 7 };
if full.starts_with("src=\"/")
|| full.starts_with("href=\"/")
|| full.starts_with("action=\"/")
{
let attr_end = if full.starts_with("src=\"") {
4
} else if full.starts_with("href=\"") {
5
} else {
7
};
result.push_str(&full[..attr_end]);
result.push_str("/api/comfyui/proxy/");
// Skip the rest of the attribute
for _ in 0..snippet.len() { chars.next(); }
for _ in 0..snippet.len() {
chars.next();
}
continue;
}
}
@@ -275,22 +309,34 @@ async fn proxy_to_comfyui(
let req = match method {
"POST" => {
let body_str = body.map(|b| b.to_string()).unwrap_or_else(|| "{}".to_string());
client.post(&url)
let body_str = body
.map(|b| b.to_string())
.unwrap_or_else(|| "{}".to_string());
client
.post(&url)
.header("Content-Type", "application/json")
.body(body_str)
}
_ => client.get(&url),
};
let resp = req.send().await
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
let status = resp.status();
let text = resp.text().await
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
Ok((StatusCode::from_u16(status.as_u16()).unwrap(), [(axum::http::header::CONTENT_TYPE, "application/json")], text).into_response())
Ok((
StatusCode::from_u16(status.as_u16()).unwrap(),
[(axum::http::header::CONTENT_TYPE, "application/json")],
text,
)
.into_response())
}
/// Proxy to ComfyUI — returns binary response (for images)
@@ -299,17 +345,27 @@ async fn proxy_to_comfyui_binary(_method: &str, path: &str) -> AppResult<Respons
let url = format!("{}{}", comfyui_base(), path);
let req = client.get(&url);
let resp = req.send().await
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("ComfyUI unreachable: {e}")))?;
let status = resp.status();
let content_type = resp.headers()
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let bytes = resp.bytes().await
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
Ok((StatusCode::from_u16(status.as_u16()).unwrap(), [(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response())
Ok((
StatusCode::from_u16(status.as_u16()).unwrap(),
[(axum::http::header::CONTENT_TYPE, content_type)],
bytes,
)
.into_response())
}
+242
View File
@@ -0,0 +1,242 @@
use crate::AppState;
use axum::{
extract::State,
http::HeaderMap,
response::sse::{Event, Sse},
routing::post,
Json, Router,
};
use futures::stream::Stream;
use reqwest::Client;
use serde_json::{json, Value};
use std::convert::Infallible;
use tokio::sync::mpsc::{unbounded_channel, UnboundedSender};
use tokio_stream::{wrappers::UnboundedReceiverStream, StreamExt};
use tracing::info;
const SYSTEM_PROMPT: &str = r#"你是 BoosAPI 的 AI 视频生成助手,帮助用户通过 ComfyUI 制作 AI 视频。
## 能力
1. **分析脚本** — 提取角色、场景、对话
2. **生成角色提示词** — 为每个角色生成 4 个角度(正面/背面/左侧面/右侧面)
3. **生成场景提示词** — 描述场景的稳定图像生成提示词
4. **生成语音(TTS)** — 将对话台词转为语音描述
5. **导出 ComfyUI 工作流** — 生成 workflow JSON
## 输出格式
直接输出普通文本,用 emoji 标记不同内容类型:
- 🎭 角色名 — 角色描述和相关提示词
- 🌄 场景描述 — 场景提示词
- 🔊 角色名: "台词" — TTS 语音
- 📦 ComfyUI 工作流 — 最终的 workflow JSON(放在 ```json 代码块内)
## 工作流程
1. 用户提供脚本 → 你分析脚本结构
2. 针对每个角色 → 生成 4 角度提示词
3. 针对每个场景 → 生成场景提示词
4. 针对每段对话 → 生成 TTS 语音描述
5. 最终导出 ComfyUI 工作流 JSON
始终用中文回复,文字简洁清晰。"#;
pub fn routes() -> Router<AppState> {
Router::new().route("/api/comfyui-agent/chat", post(chat_handler))
}
async fn chat_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (axum::http::StatusCode, Json<Value>)>
{
let auth_header = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
let token = auth_header
.strip_prefix("Bearer ")
.or_else(|| auth_header.strip_prefix("bearer "))
.unwrap_or_default();
if token != state.config.auth.auth_token {
return Err((
axum::http::StatusCode::UNAUTHORIZED,
Json(json!({ "error": "Unauthorized" })),
));
}
let messages = body
.get("messages")
.and_then(Value::as_array)
.ok_or_else(|| {
(
axum::http::StatusCode::BAD_REQUEST,
Json(json!({ "error": "messages is required" })),
)
})?;
for message in messages {
if message.get("role").and_then(Value::as_str).is_none()
|| message.get("content").and_then(Value::as_str).is_none()
{
return Err((
axum::http::StatusCode::BAD_REQUEST,
Json(json!({ "error": "messages is required" })),
));
}
}
let messages = messages.clone();
let (tx, rx) = unbounded_channel::<Result<Event, Infallible>>();
tokio::spawn(async move {
let upstream_base = std::env::var("BOOSAPI_UPSTREAM_BASE")
.unwrap_or_else(|_| "https://api.boos.lat/v1".to_string());
let upstream_key =
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string());
let upstream_url = format!("{}/chat/completions", upstream_base.trim_end_matches('/'));
let mut upstream_messages = Vec::with_capacity(messages.len() + 1);
upstream_messages.push(json!({
"role": "system",
"content": SYSTEM_PROMPT,
}));
upstream_messages.extend(messages);
let client = Client::new();
info!("comfyui-agent chat streaming request to upstream");
let response = match client
.post(upstream_url)
.bearer_auth(upstream_key)
.json(&json!({
"model": "gpt-5.5",
"messages": upstream_messages,
"temperature": 0.7,
"stream": true,
}))
.send()
.await
{
Ok(response) => response,
Err(_) => {
send_sse(
&tx,
json!({
"type": "text",
"content": "服务暂时不可用,请稍后重试",
}),
);
send_sse(&tx, json!({ "type": "done" }));
return;
}
};
if !response.status().is_success() {
send_sse(
&tx,
json!({
"type": "text",
"content": "服务暂时不可用,请稍后重试",
}),
);
send_sse(&tx, json!({ "type": "done" }));
return;
}
let mut stream = response.bytes_stream();
let mut buffer: Vec<u8> = Vec::new();
loop {
let chunk = tokio::select! {
_ = tx.closed() => {
return;
}
chunk = stream.next() => chunk,
};
let Some(chunk) = chunk else {
break;
};
let chunk = match chunk {
Ok(chunk) => chunk,
Err(error) => {
if !send_sse(
&tx,
json!({
"type": "text",
"content": format!("\n\n[错误: {}]", error),
}),
) {
return;
}
break;
}
};
buffer.extend_from_slice(&chunk);
while let Some(pos) = buffer.iter().position(|byte| *byte == b'\n') {
let mut line_bytes: Vec<u8> = buffer.drain(..=pos).collect();
if line_bytes.last() == Some(&b'\n') {
line_bytes.pop();
}
if line_bytes.last() == Some(&b'\r') {
line_bytes.pop();
}
let line = String::from_utf8_lossy(&line_bytes);
let line = line.trim();
if !line.starts_with("data: ") {
continue;
}
let data = line.trim_start_matches("data: ").trim();
if data == "[DONE]" {
continue;
}
let Ok(parsed) = serde_json::from_str::<Value>(data) else {
continue;
};
let content = parsed
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("delta"))
.and_then(|delta| delta.get("content"))
.and_then(Value::as_str)
.unwrap_or_default();
if !content.is_empty()
&& !send_sse(
&tx,
json!({
"type": "text",
"content": content,
}),
)
{
return;
}
}
}
send_sse(&tx, json!({ "type": "done" }));
});
Ok(Sse::new(UnboundedReceiverStream::new(rx)))
}
fn send_sse(tx: &UnboundedSender<Result<Event, Infallible>>, payload: Value) -> bool {
tx.send(Ok(Event::default().data(payload.to_string())))
.is_ok()
}
+1 -4
View File
@@ -78,10 +78,7 @@ async fn unread_count(State(state): State<AppState>) -> AppResult<Json<Value>> {
}
/// POST /api/events/{id}/read
async fn mark_read(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
async fn mark_read(State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
info!("Marking event {} as read", id);
sqlx::query("UPDATE events SET read = 1 WHERE id = ?")
.bind(id)
+10
View File
@@ -5,32 +5,42 @@ mod admin;
mod auth;
mod checkin;
mod comfyui;
mod comfyui_agent;
mod events;
mod health;
mod monitor;
mod oauth;
mod organizations;
mod proxy;
mod search;
mod site_announcements;
mod stats;
mod tasks;
mod update_center;
mod user;
mod user_api_keys;
mod video_agent;
pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/healthz", get(health::health_check))
.merge(admin::routes(state.clone()))
.merge(proxy::routes())
.merge(comfyui_agent::routes())
.merge(comfyui::routes())
.merge(auth::routes())
.merge(events::routes())
.merge(checkin::routes())
.merge(monitor::routes())
.merge(oauth::routes())
.merge(organizations::routes())
.merge(search::routes())
.merge(site_announcements::routes())
.merge(stats::routes())
.merge(tasks::routes())
.merge(update_center::routes())
.merge(user::routes())
.merge(user_api_keys::routes())
.merge(video_agent::routes())
.with_state(state)
}
+94 -43
View File
@@ -20,7 +20,10 @@ const LDOH_COOKIE_SETTING_KEY: &str = "monitor_ldoh_cookie";
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/monitor/config", get(get_monitor_config).put(update_monitor_config))
.route(
"/api/monitor/config",
get(get_monitor_config).put(update_monitor_config),
)
.route("/api/monitor/session", post(create_session))
.route("/monitor-proxy/ldoh", get(ldoh_proxy_handler))
.route("/monitor-proxy/ldoh/", get(ldoh_proxy_handler))
@@ -28,23 +31,26 @@ pub fn routes() -> Router<AppState> {
}
/// GET /api/monitor/config — returns whether LDOH cookie is configured
async fn get_monitor_config(
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
let cookie: Option<(String,)> = sqlx::query_as(
"SELECT value FROM settings WHERE key = ?"
)
async fn get_monitor_config(State(state): State<AppState>) -> AppResult<Json<Value>> {
let cookie: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(LDOH_COOKIE_SETTING_KEY)
.fetch_optional(&*state.db)
.await?;
let raw = cookie.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v).ok()
.and_then(|parsed| parsed.as_str().map(String::from))
}).unwrap_or_default();
let raw = cookie
.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v)
.ok()
.and_then(|parsed| parsed.as_str().map(String::from))
})
.unwrap_or_default();
let configured = !raw.is_empty();
let masked = if configured { mask_cookie_value(&raw) } else { String::new() };
let masked = if configured {
mask_cookie_value(&raw)
} else {
String::new()
};
Ok(Json(json!({
"ldohCookieConfigured": configured,
@@ -57,7 +63,10 @@ async fn update_monitor_config(
State(state): State<AppState>,
Json(body): Json<serde_json::Value>,
) -> AppResult<Json<Value>> {
let cookie = body.get("ldohCookie").and_then(|v| v.as_str()).unwrap_or("");
let cookie = body
.get("ldohCookie")
.and_then(|v| v.as_str())
.unwrap_or("");
if cookie.is_empty() {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, '')")
@@ -92,9 +101,7 @@ async fn update_monitor_config(
}
/// POST /api/monitor/session — returns cookie value for client to set
async fn create_session(
State(state): State<AppState>,
) -> Json<Value> {
async fn create_session(State(state): State<AppState>) -> Json<Value> {
let token = &state.config.auth.auth_token;
Json(json!({
"success": true,
@@ -110,24 +117,30 @@ async fn ldoh_proxy_handler(
) -> Result<Response, AppError> {
// Auth check via cookie
let cookies = parse_cookies(
headers.get(header::COOKIE).and_then(|v| v.to_str().ok()).unwrap_or("")
headers
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
);
if cookies.get(MONITOR_AUTH_COOKIE).map(|s| s.as_str()) != Some(&state.config.auth.auth_token) {
return Err(AppError::Unauthorized("Missing or invalid monitor session".into()));
return Err(AppError::Unauthorized(
"Missing or invalid monitor session".into(),
));
}
// Get stored LDOH cookie
let stored: Option<(String,)> = sqlx::query_as(
"SELECT value FROM settings WHERE key = ?"
)
let stored: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(LDOH_COOKIE_SETTING_KEY)
.fetch_optional(&*state.db)
.await?;
let ldoh_cookie = stored.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v).ok()
.and_then(|parsed| parsed.as_str().map(String::from))
}).unwrap_or_default();
let ldoh_cookie = stored
.and_then(|(v,)| {
serde_json::from_str::<serde_json::Value>(&v)
.ok()
.and_then(|parsed| parsed.as_str().map(String::from))
})
.unwrap_or_default();
if ldoh_cookie.is_empty() {
return Err(AppError::BadRequest("LDOH cookie not configured".into()));
@@ -146,28 +159,54 @@ async fn ldoh_proxy_handler(
.build()
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to build client: {e}")))?;
let mut req = client.get(&target_url)
let mut req = client
.get(&target_url)
.header("cookie", &ldoh_cookie)
.header("accept", headers.get("accept").and_then(|v| v.to_str().ok()).unwrap_or("*/*"))
.header("accept-language", headers.get("accept-language").and_then(|v| v.to_str().ok()).unwrap_or("zh-CN,zh;q=0.9,en;q=0.8"))
.header("user-agent", headers.get("user-agent").and_then(|v| v.to_str().ok()).unwrap_or("boosapiMonitorProxy/1.0"));
.header(
"accept",
headers
.get("accept")
.and_then(|v| v.to_str().ok())
.unwrap_or("*/*"),
)
.header(
"accept-language",
headers
.get("accept-language")
.and_then(|v| v.to_str().ok())
.unwrap_or("zh-CN,zh;q=0.9,en;q=0.8"),
)
.header(
"user-agent",
headers
.get("user-agent")
.and_then(|v| v.to_str().ok())
.unwrap_or("boosapiMonitorProxy/1.0"),
);
if let Some(referer) = headers.get("referer").and_then(|v| v.to_str().ok()) {
req = req.header("referer", referer.replace("/monitor-proxy/ldoh", ""));
}
let resp = req.send().await
let resp = req
.send()
.await
.map_err(|e| AppError::UpstreamError(format!("LDOH unreachable: {e}")))?;
let status = resp.status();
let content_type = resp.headers()
let content_type = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let mut builder = axum::response::Response::builder().status(status);
if let Some(ctl) = resp.headers().get("cache-control").and_then(|v| v.to_str().ok()) {
if let Some(ctl) = resp
.headers()
.get("cache-control")
.and_then(|v| v.to_str().ok())
{
builder = builder.header("cache-control", ctl);
}
@@ -177,14 +216,20 @@ async fn ldoh_proxy_handler(
|| content_type.contains("text/css")
|| content_type.contains("application/json")
{
let text = resp.text().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
let text = resp
.text()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
return Ok(builder
.header(header::CONTENT_TYPE, &content_type)
.body(axum::body::Body::from(rewrite_proxy_text(&text)))
.map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?);
}
let bytes = resp.bytes().await.map_err(|e| AppError::UpstreamError(e.to_string()))?;
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::UpstreamError(e.to_string()))?;
Ok(builder
.header(header::CONTENT_TYPE, &content_type)
.body(axum::body::Body::from(bytes))
@@ -200,7 +245,9 @@ fn parse_cookies(raw: &str) -> std::collections::HashMap<String, String> {
if let Some(idx) = part.find('=') {
let key = part[..idx].trim().to_string();
let val = part[idx + 1..].trim().to_string();
if !key.is_empty() { map.insert(key, val); }
if !key.is_empty() {
map.insert(key, val);
}
}
}
map
@@ -208,12 +255,14 @@ fn parse_cookies(raw: &str) -> std::collections::HashMap<String, String> {
fn mask_cookie_value(cookie_text: &str) -> String {
let raw = cookie_text.trim();
if raw.is_empty() { return String::new(); }
if raw.is_empty() {
return String::new();
}
let value = raw.split('=').nth(1).unwrap_or(raw);
if value.len() <= 10 {
format!("{}****", &value[..2.min(value.len())])
} else {
format!("{}****{}", &value[..6], &value[value.len()-4..])
format!("{}****{}", &value[..6], &value[value.len() - 4..])
}
}
@@ -230,15 +279,17 @@ fn normalize_ldoh_cookie(raw: &str) -> String {
}
fn rewrite_proxy_text(text: &str) -> String {
text
.replace("https://ldoh.105117.xyz/", "/monitor-proxy/ldoh/")
.replace("https:\\/\\/ldoh.105117.xyz\\/", "\\/monitor-proxy\\/ldoh\\/")
text.replace("https://ldoh.105117.xyz/", "/monitor-proxy/ldoh/")
.replace(
"https:\\/\\/ldoh.105117.xyz\\/",
"\\/monitor-proxy\\/ldoh\\/",
)
.replace("src=\"/", "src=\"/monitor-proxy/ldoh/")
.replace("src='/" , "src='/monitor-proxy/ldoh/")
.replace("src='/", "src='/monitor-proxy/ldoh/")
.replace("href=\"/", "href=\"/monitor-proxy/ldoh/")
.replace("href='/" , "href='/monitor-proxy/ldoh/")
.replace("href='/", "href='/monitor-proxy/ldoh/")
.replace("action=\"/", "action=\"/monitor-proxy/ldoh/")
.replace("action='/" , "action='/monitor-proxy/ldoh/")
.replace("action='/", "action='/monitor-proxy/ldoh/")
.replace("\"/_next/", "\"/monitor-proxy/ldoh/_next/")
.replace("'/_next/", "'/monitor-proxy/ldoh/_next/")
.replace("\"\\\\/api/", "\"\\\\/monitor-proxy\\\\/ldoh\\\\/api/")
+312
View File
@@ -0,0 +1,312 @@
// OAuth routes — provider management, session handling, connection management
// Simplified implementation — complex OAuth flow operations return 501
use crate::{
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse},
routing::{get, post, put},
Json, Router,
};
use serde_json::{json, Value};
use tracing::info;
pub fn routes() -> Router<AppState> {
Router::new()
// Providers
.route("/api/oauth/providers", get(list_providers))
.route(
"/api/oauth/providers/{provider}/start",
post(start_provider_flow),
)
// Sessions
.route("/api/oauth/sessions/{state}", get(get_session))
.route(
"/api/oauth/sessions/{state}/manual-callback",
post(manual_callback),
)
// Connections
.route("/api/oauth/connections", get(list_connections))
.route(
"/api/oauth/connections/{id}/rebind",
post(rebind_connection),
)
.route(
"/api/oauth/connections/{id}/proxy",
post(update_connection_proxy),
)
.route("/api/oauth/connections/import", post(import_connections))
.route(
"/api/oauth/connections/{id}/refresh-quota",
post(refresh_connection_quota),
)
.route(
"/api/oauth/connections/refresh-quota-batch",
post(refresh_quota_batch),
)
// Route units
.route("/api/oauth/route-units", post(create_route_unit))
.route(
"/api/oauth/route-units/{id}",
put(update_route_unit).delete(delete_route_unit),
)
// Callback
.route("/api/oauth/callback", get(oauth_callback_page))
// Delete connection
.route("/api/oauth/connections/{id}", post(delete_connection))
}
// ── Auth helper ────────────────────────────────────────────────────────
fn require_admin(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
let token = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
if token != state.config.auth.auth_token {
return Err(AppError::Unauthorized("Invalid admin token".into()));
}
Ok(())
}
// ── Providers ──────────────────────────────────────────────────────────
/// GET /api/oauth/providers — public
async fn list_providers() -> Json<Value> {
Json(json!({
"defaults": {
"proxyRequired": false,
"useSystemProxy": false,
},
"providers": [
{
"id": "openai",
"name": "OpenAI",
"type": "openai",
"enabled": true,
},
{
"id": "anthropic",
"name": "Anthropic",
"type": "anthropic",
"enabled": true,
},
{
"id": "google",
"name": "Google AI",
"type": "google",
"enabled": true,
},
],
}))
}
/// POST /api/oauth/providers/{provider}/start
async fn start_provider_flow(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_provider): Path<String>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
info!("OAuth provider flow start: {}", _provider);
Err(AppError::BadRequest(
"OAuth provider flow not implemented in Rust backend".into(),
))
}
// ── Sessions ───────────────────────────────────────────────────────────
/// GET /api/oauth/sessions/{state}
async fn get_session(
State(_state): State<AppState>,
headers: HeaderMap,
Path(state): Path<String>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
info!("OAuth session lookup: {}", state);
Err(AppError::NotFound(format!(
"OAuth session not found: {}",
state
)))
}
/// POST /api/oauth/sessions/{state}/manual-callback
async fn manual_callback(
State(_state): State<AppState>,
headers: HeaderMap,
Path(state): Path<String>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
info!("OAuth manual callback: {}", state);
Err(AppError::BadRequest(
"OAuth manual callback not implemented in Rust backend".into(),
))
}
// ── Connections ────────────────────────────────────────────────────────
/// GET /api/oauth/connections
async fn list_connections(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&headers, &state)?;
info!("Listing OAuth connections");
let accounts: Vec<Value> = sqlx::query_as::<_, (i64, i64, Option<String>, String, Option<String>, Option<String>, Option<String>, String)>(
"SELECT id, site_id, username, access_token, oauth_provider, oauth_account_key, oauth_project_id, status \
FROM accounts WHERE oauth_provider IS NOT NULL ORDER BY id"
)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|(id, site_id, username, _access_token, oauth_provider, oauth_account_key, oauth_project_id, status)| {
json!({
"id": id,
"siteId": site_id,
"username": username,
"oauthProvider": oauth_provider,
"oauthAccountKey": oauth_account_key,
"oauthProjectId": oauth_project_id,
"status": status,
})
})
.collect();
Ok(Json(json!({ "data": accounts, "total": accounts.len() })))
}
/// POST /api/oauth/connections/{id}/rebind
async fn rebind_connection(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth rebind not implemented in Rust backend".into(),
))
}
/// POST /api/oauth/connections/{id}/proxy
async fn update_connection_proxy(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth proxy update not implemented in Rust backend".into(),
))
}
/// POST /api/oauth/connections/import
async fn import_connections(
State(_state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth import not implemented in Rust backend".into(),
))
}
/// POST /api/oauth/connections/{id}/refresh-quota
async fn refresh_connection_quota(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth quota refresh not implemented in Rust backend".into(),
))
}
/// POST /api/oauth/connections/refresh-quota-batch
async fn refresh_quota_batch(
State(_state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth batch quota refresh not implemented in Rust backend".into(),
))
}
// ── Route Units ────────────────────────────────────────────────────────
/// POST /api/oauth/route-units
async fn create_route_unit(
State(_state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth route unit creation not implemented in Rust backend".into(),
))
}
/// PUT /api/oauth/route-units/{id}
async fn update_route_unit(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth route unit update not implemented in Rust backend".into(),
))
}
/// DELETE /api/oauth/route-units/{id}
async fn delete_route_unit(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth route unit deletion not implemented in Rust backend".into(),
))
}
// ── Callback ───────────────────────────────────────────────────────────
/// GET /api/oauth/callback — public, returns HTML for popup window
async fn oauth_callback_page(headers: HeaderMap) -> impl IntoResponse {
info!("OAuth callback received");
let html = r#"<!doctype html>
<html lang="zh-CN">
<head><meta charset="utf-8" /><title>OAuth Callback</title></head>
<body>
<script>window.close();</script>
OAuth callback received. You can close this window.
</body>
</html>"#;
(
StatusCode::OK,
[("Content-Type", "text/html; charset=utf-8")],
Html(html),
)
}
// ── Delete ─────────────────────────────────────────────────────────────
/// POST /api/oauth/connections/{id} — delete connection
async fn delete_connection(
State(_state): State<AppState>,
headers: HeaderMap,
Path(_id): Path<i64>,
) -> AppResult<Json<Value>> {
require_admin(&headers, &_state)?;
Err(AppError::BadRequest(
"OAuth connection deletion not implemented in Rust backend".into(),
))
}
+115 -85
View File
@@ -37,34 +37,30 @@ fn extract_jwt_user(headers: &HeaderMap, state: &AppState) -> AppResult<(i64, St
}
/// Check if user is org admin (owner or admin role).
async fn require_org_admin(
db: &sqlx::SqlitePool,
org_id: i64,
user_id: i64,
) -> AppResult<()> {
let member: Option<OrganizationMember> = sqlx::query_as(
"SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?"
)
.bind(org_id)
.bind(user_id)
.fetch_optional(db)
.await?;
async fn require_org_admin(db: &sqlx::SqlitePool, org_id: i64, user_id: i64) -> AppResult<()> {
let member: Option<OrganizationMember> =
sqlx::query_as("SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?")
.bind(org_id)
.bind(user_id)
.fetch_optional(db)
.await?;
match member {
Some(m) if m.role.as_deref() == Some("owner") || m.role.as_deref() == Some("admin") => Ok(()),
Some(m) if m.role.as_deref() == Some("owner") || m.role.as_deref() == Some("admin") => {
Ok(())
}
_ => Err(AppError::Forbidden("Admin access required".into())),
}
}
/// Check if user is a member of an org.
async fn is_org_member(db: &sqlx::SqlitePool, org_id: i64, user_id: i64) -> Result<bool, AppError> {
let member: Option<OrganizationMember> = sqlx::query_as(
"SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?"
)
.bind(org_id)
.bind(user_id)
.fetch_optional(db)
.await?;
let member: Option<OrganizationMember> =
sqlx::query_as("SELECT * FROM organization_members WHERE org_id = ? AND user_id = ?")
.bind(org_id)
.bind(user_id)
.fetch_optional(db)
.await?;
Ok(member.is_some())
}
@@ -78,15 +74,30 @@ pub struct ListOrgsQuery {
pub fn routes() -> Router<AppState> {
Router::new()
// Organizations CRUD
.route("/api/organizations", get(list_organizations).post(create_organization))
.route(
"/api/organizations",
get(list_organizations).post(create_organization),
)
.route("/api/organizations/plans", get(list_plans))
.route("/api/organizations/{org_id}", get(get_organization).patch(update_organization))
.route(
"/api/organizations/{org_id}",
get(get_organization).patch(update_organization),
)
// Members
.route("/api/organizations/{org_id}/members", get(list_members))
.route("/api/organizations/{org_id}/members/{user_id}", patch(update_member_role).delete(remove_member))
.route(
"/api/organizations/{org_id}/members/{user_id}",
patch(update_member_role).delete(remove_member),
)
// Invitations
.route("/api/organizations/{org_id}/invitations", get(list_invitations).post(create_invitation))
.route("/api/organizations/{org_id}/invitations/{inv_id}", delete(cancel_invitation))
.route(
"/api/organizations/{org_id}/invitations",
get(list_invitations).post(create_invitation),
)
.route(
"/api/organizations/{org_id}/invitations/{inv_id}",
delete(cancel_invitation),
)
// Public invitation routes (no org prefix)
.route("/api/invitations/{token}", get(get_invitation))
.route("/api/invitations/{token}/accept", post(accept_invitation))
@@ -108,12 +119,12 @@ async fn list_organizations(
INNER JOIN organization_members om ON om.org_id = o.id
WHERE om.user_id = ?
ORDER BY o.created_at DESC
LIMIT ?"#
LIMIT ?"#,
)
.bind(user_id)
.bind(limit)
.fetch_all(&*state.db)
.await?;
.bind(user_id)
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "organizations": orgs })))
}
@@ -126,14 +137,16 @@ async fn create_organization(
) -> AppResult<(StatusCode, Json<Value>)> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
let name = body.get("name").and_then(|v| v.as_str())
let name = body
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("name is required".into()))?;
let slug = body.get("slug").and_then(|v| v.as_str())
let slug = body
.get("slug")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("slug is required".into()))?;
let result = sqlx::query(
"INSERT INTO organizations (name, slug) VALUES (?, ?)"
)
let result = sqlx::query("INSERT INTO organizations (name, slug) VALUES (?, ?)")
.bind(name)
.bind(slug)
.execute(&*state.db)
@@ -150,9 +163,7 @@ async fn create_organization(
let org_id = result.last_insert_rowid();
// Add creator as owner
sqlx::query(
"INSERT INTO organization_members (org_id, user_id, role) VALUES (?, ?, 'owner')"
)
sqlx::query("INSERT INTO organization_members (org_id, user_id, role) VALUES (?, ?, 'owner')")
.bind(org_id)
.bind(user_id)
.execute(&*state.db)
@@ -204,7 +215,9 @@ async fn get_organization(
let (user_id, _) = extract_jwt_user(&headers, &state)?;
if !is_org_member(&state.db, org_id, user_id).await? {
return Err(AppError::Forbidden("You are not a member of this organization".into()));
return Err(AppError::Forbidden(
"You are not a member of this organization".into(),
));
}
let org: Option<Organization> = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
@@ -269,25 +282,29 @@ async fn list_members(
let (user_id, _) = extract_jwt_user(&headers, &state)?;
if !is_org_member(&state.db, org_id, user_id).await? {
return Err(AppError::Forbidden("You are not a member of this organization".into()));
return Err(AppError::Forbidden(
"You are not a member of this organization".into(),
));
}
let members: Vec<Value> = sqlx::query_as::<_, OrganizationMember>(
"SELECT * FROM organization_members WHERE org_id = ? ORDER BY joined_at ASC"
"SELECT * FROM organization_members WHERE org_id = ? ORDER BY joined_at ASC",
)
.bind(org_id)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|m| json!({
.bind(org_id)
.fetch_all(&*state.db)
.await?
.into_iter()
.map(|m| {
json!({
"id": m.id,
"orgId": m.org_id,
"userId": m.user_id,
"role": m.role,
"status": m.status,
"joinedAt": m.joined_at,
}))
.collect();
})
})
.collect();
Ok(Json(json!({ "members": members })))
}
@@ -302,11 +319,15 @@ async fn update_member_role(
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
let role = body.get("role").and_then(|v| v.as_str())
let role = body
.get("role")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("role is required".into()))?;
if !["owner", "admin", "member"].contains(&role) {
return Err(AppError::BadRequest("Role must be one of: owner, admin, member".into()));
return Err(AppError::BadRequest(
"Role must be one of: owner, admin, member".into(),
));
}
sqlx::query("UPDATE organization_members SET role = ? WHERE org_id = ? AND user_id = ?")
@@ -348,12 +369,11 @@ async fn list_invitations(
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
let invitations: Vec<Invitation> = sqlx::query_as(
"SELECT * FROM invitations WHERE org_id = ? ORDER BY created_at DESC"
)
.bind(org_id)
.fetch_all(&*state.db)
.await?;
let invitations: Vec<Invitation> =
sqlx::query_as("SELECT * FROM invitations WHERE org_id = ? ORDER BY created_at DESC")
.bind(org_id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "invitations": invitations })))
}
@@ -368,12 +388,19 @@ async fn create_invitation(
let (user_id, _) = extract_jwt_user(&headers, &state)?;
require_org_admin(&state.db, org_id, user_id).await?;
let email = body.get("email").and_then(|v| v.as_str())
let email = body
.get("email")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::BadRequest("Email is required".into()))?;
let role = body.get("role").and_then(|v| v.as_str()).unwrap_or("member");
let role = body
.get("role")
.and_then(|v| v.as_str())
.unwrap_or("member");
if !["admin", "member"].contains(&role) {
return Err(AppError::BadRequest("Role must be one of: admin, member".into()));
return Err(AppError::BadRequest(
"Role must be one of: admin, member".into(),
));
}
// Generate token
@@ -429,12 +456,11 @@ async fn get_invitation(
State(state): State<AppState>,
Path(token): Path<String>,
) -> AppResult<Json<Value>> {
let invitation: Option<Invitation> = sqlx::query_as(
"SELECT * FROM invitations WHERE token = ? AND status = 'pending'"
)
.bind(&token)
.fetch_optional(&*state.db)
.await?;
let invitation: Option<Invitation> =
sqlx::query_as("SELECT * FROM invitations WHERE token = ? AND status = 'pending'")
.bind(&token)
.fetch_optional(&*state.db)
.await?;
match invitation {
Some(i) => {
@@ -450,12 +476,11 @@ async fn get_invitation(
}
// Get org name
let org_name: Option<(String,)> = sqlx::query_as(
"SELECT name FROM organizations WHERE id = ?"
)
.bind(i.org_id)
.fetch_optional(&*state.db)
.await?;
let org_name: Option<(String,)> =
sqlx::query_as("SELECT name FROM organizations WHERE id = ?")
.bind(i.org_id)
.fetch_optional(&*state.db)
.await?;
Ok(Json(json!({
"invitation": {
@@ -484,16 +509,19 @@ async fn accept_invitation(
) -> AppResult<Json<Value>> {
let (user_id, _) = extract_jwt_user(&headers, &state)?;
let invitation: Option<Invitation> = sqlx::query_as(
"SELECT * FROM invitations WHERE token = ? AND status = 'pending'"
)
.bind(&token)
.fetch_optional(&*state.db)
.await?;
let invitation: Option<Invitation> =
sqlx::query_as("SELECT * FROM invitations WHERE token = ? AND status = 'pending'")
.bind(&token)
.fetch_optional(&*state.db)
.await?;
let inv = match invitation {
Some(i) => i,
None => return Err(AppError::BadRequest("Invitation not found or expired".into())),
None => {
return Err(AppError::BadRequest(
"Invitation not found or expired".into(),
))
}
};
// Check expiry
@@ -504,7 +532,9 @@ async fn accept_invitation(
});
if expired {
return Err(AppError::BadRequest("Invitation not found or expired".into()));
return Err(AppError::BadRequest(
"Invitation not found or expired".into(),
));
}
// Mark invitation as accepted
@@ -515,13 +545,13 @@ async fn accept_invitation(
// Add user as member
sqlx::query(
"INSERT OR IGNORE INTO organization_members (org_id, user_id, role) VALUES (?, ?, ?)"
"INSERT OR IGNORE INTO organization_members (org_id, user_id, role) VALUES (?, ?, ?)",
)
.bind(inv.org_id)
.bind(user_id)
.bind(&inv.role)
.execute(&*state.db)
.await?;
.bind(inv.org_id)
.bind(user_id)
.bind(&inv.role)
.execute(&*state.db)
.await?;
let org: Option<Organization> = sqlx::query_as("SELECT * FROM organizations WHERE id = ?")
.bind(inv.org_id)
+402 -44
View File
@@ -6,22 +6,25 @@
// GET /v1/models — list available models
use crate::{
auth::api_key::{ authorize_downstream_token, DownstreamAuthResult },
error::{ AppError, AppResult },
proxy::executors::{ self, UpstreamRoute },
auth::api_key::{authorize_downstream_token, DownstreamAuthResult},
error::{AppError, AppResult},
proxy::conductor::{
AttemptResult, ExecuteInput, ExecuteResult, ProxyConductor, SelectedChannel,
},
proxy::executors::{self, UpstreamRoute},
proxy::token_router::TokenRouter,
AppState,
};
use axum::{
extract::State,
http::HeaderMap,
response::{ IntoResponse, Response, Sse },
routing::{ get, post },
response::{IntoResponse, Response, Sse},
routing::{get, post},
Json, Router,
};
use futures::stream;
use futures::StreamExt;
use serde_json::{ json, Value };
use std::convert::Infallible;
use futures::{stream, FutureExt, StreamExt};
use serde_json::{json, Value};
use std::{convert::Infallible, sync::Arc};
use tracing::info;
// ── Auth types ────────────────────────────────────────────────────────
@@ -73,17 +76,20 @@ async fn chat_completions(
.ok_or_else(|| AppError::BadRequest("model is required".into()))?
.to_string();
let route = resolve_route(&state, &model).await?;
let stream = body.get("stream").and_then(|s| s.as_bool()).unwrap_or(false);
let stream = body
.get("stream")
.and_then(|s| s.as_bool())
.unwrap_or(false);
if stream {
let chunks = executors::execute_chat_completions_stream(state, route, body).await?;
let sse = Sse::new(stream::iter(chunks).map(|chunk| {
Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk))
}));
let chunks = execute_chat_completions_stream_with_conductor(state, model, body).await?;
let sse =
Sse::new(stream::iter(chunks).map(|chunk| {
Ok::<_, Infallible>(axum::response::sse::Event::default().data(chunk))
}));
Ok(sse.into_response())
} else {
let result = executors::execute_chat_completions(state, route, body).await?;
let result = execute_chat_completions_with_conductor(state, model, body).await?;
Ok(Json(result).into_response())
}
}
@@ -168,7 +174,8 @@ async fn audio_transcriptions(
.to_string();
let route = resolve_route(&state, &model).await?;
let result = executors::execute_proxy_request(state, route, "/audio/transcriptions", body).await?;
let result =
executors::execute_proxy_request(state, route, "/audio/transcriptions", body).await?;
Ok(Json(result))
}
@@ -189,7 +196,8 @@ async fn audio_translations(
.to_string();
let route = resolve_route(&state, &model).await?;
let result = executors::execute_proxy_request(state, route, "/audio/translations", body).await?;
let result =
executors::execute_proxy_request(state, route, "/audio/translations", body).await?;
Ok(Json(result))
}
@@ -235,19 +243,16 @@ async fn images_generations(
}
/// GET /v1/models
async fn list_models(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
async fn list_models(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
info!("GET /v1/models");
check_auth(&state, &headers).await?;
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT tr.name, tr.model FROM token_routes tr WHERE tr.enabled = 1 ORDER BY tr.name"
"SELECT tr.name, tr.model FROM token_routes tr WHERE tr.enabled = 1 ORDER BY tr.name",
)
.fetch_all(&*state.db)
.await?;
.fetch_all(&*state.db)
.await?;
let models: Vec<Value> = rows
.into_iter()
@@ -277,7 +282,8 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
.and_then(|v| v.trim().split_whitespace().next())
.ok_or_else(|| AppError::Unauthorized("Missing Authorization header".into()))?;
let result = authorize_downstream_token(auth, &state.db).await
let result = authorize_downstream_token(auth, &state.db)
.await
.map_err(|e| AppError::Unauthorized(e))?;
match result {
@@ -295,27 +301,379 @@ async fn check_auth(state: &AppState, headers: &HeaderMap) -> Result<(), AppErro
/// Resolve upstream endpoint + API key for a given model.
async fn resolve_route(state: &AppState, model: &str) -> AppResult<UpstreamRoute> {
let row: Option<(String, String)> = sqlx::query_as(
"SELECT s.base_url, COALESCE(a.api_key, s.default_key, '') as api_key
FROM token_routes tr
JOIN sites s ON s.id = tr.site_id
LEFT JOIN accounts a ON a.id = tr.account_id AND a.site_id = s.id
WHERE tr.enabled = 1 AND tr.model = ?
LIMIT 1"
)
.bind(model)
.fetch_optional(&*state.db)
let selected = TokenRouter::new(state.db.clone())
.resolve_selected(model)
.await?;
Ok(selected_channel_to_route(selected))
}
match row {
Some((base_url, api_key)) => Ok(UpstreamRoute {
base_url,
api_key,
model: model.to_string(),
fn selected_channel_to_route(selected: SelectedChannel) -> UpstreamRoute {
UpstreamRoute {
base_url: selected.base_url,
api_key: selected.api_key,
model: selected.actual_model,
}
}
fn body_with_selected_model(mut body: Value, selected: &SelectedChannel) -> Value {
if let Some(object) = body.as_object_mut() {
object.insert(
"model".to_string(),
Value::String(selected.actual_model.clone()),
);
}
body
}
fn build_conductor(state: &AppState) -> ProxyConductor {
let db = state.db.clone();
let select_db = db.clone();
let select_next_db = db.clone();
let success_db = db.clone();
let failure_db = db;
ProxyConductor {
select_channel: Arc::new(move |model, _policy| {
let router = TokenRouter::new(select_db.clone());
async move { router.resolve_selected(&model).await.ok() }.boxed()
}),
None => Err(AppError::BadRequest(format!(
"No route configured for model '{}'",
model
select_next_channel: Arc::new(move |model, excluded, _policy| {
let router = TokenRouter::new(select_next_db.clone());
async move { router.resolve_next_selected(&model, &excluded).await.ok() }.boxed()
}),
record_success: Arc::new(move |channel_id, metrics| {
let router = TokenRouter::new(success_db.clone());
async move {
if let Err(err) = router.record_success(channel_id, metrics).await {
tracing::warn!(%err, channel_id, "failed to record proxy success");
}
}
.boxed()
}),
record_failure: Arc::new(move |channel_id, info| {
let router = TokenRouter::new(failure_db.clone());
async move {
if let Err(err) = router.record_failure(channel_id, info).await {
tracing::warn!(%err, channel_id, "failed to record proxy failure");
}
}
.boxed()
}),
refresh_auth: None,
}
}
async fn execute_chat_completions_with_conductor(
state: AppState,
model: String,
body: Value,
) -> AppResult<Value> {
let conductor = build_conductor(&state);
let result = conductor
.execute(ExecuteInput {
requested_model: model,
downstream_policy: None,
attempt_fn: Box::new(move |ctx| {
let state = state.clone();
let body = body_with_selected_model(body.clone(), &ctx.selected);
async move {
match executors::execute_chat_completions(
state,
selected_channel_to_route(ctx.selected),
body,
)
.await
{
Ok(response) => AttemptResult::Success {
response,
latency_ms: None,
cost: None,
},
Err(err) => AttemptResult::Failure {
action: crate::proxy::conductor::AttemptFailureAction::Failover,
status: None,
raw_error_text: Some(err.to_string()),
},
}
}
.boxed()
}),
on_terminal_failure: None,
})
.await;
conductor_result_to_app_result(result)
}
async fn execute_chat_completions_stream_with_conductor(
state: AppState,
model: String,
body: Value,
) -> AppResult<Vec<String>> {
let conductor = build_conductor(&state);
let result = conductor
.execute(ExecuteInput {
requested_model: model,
downstream_policy: None,
attempt_fn: Box::new(move |ctx| {
let state = state.clone();
let body = body_with_selected_model(body.clone(), &ctx.selected);
async move {
match executors::execute_chat_completions_stream(
state,
selected_channel_to_route(ctx.selected),
body,
)
.await
{
Ok(chunks) => AttemptResult::Success {
response: Value::Array(chunks.into_iter().map(Value::String).collect()),
latency_ms: None,
cost: None,
},
Err(err) => AttemptResult::Failure {
action: crate::proxy::conductor::AttemptFailureAction::Failover,
status: None,
raw_error_text: Some(err.to_string()),
},
}
}
.boxed()
}),
on_terminal_failure: None,
})
.await;
match conductor_result_to_app_result(result)? {
Value::Array(chunks) => chunks
.into_iter()
.map(|chunk| {
chunk.as_str().map(str::to_string).ok_or_else(|| {
AppError::UpstreamError("Invalid streaming response from conductor".into())
})
})
.collect(),
_ => Err(AppError::UpstreamError(
"Invalid streaming response from conductor".into(),
)),
}
}
fn conductor_result_to_app_result(result: ExecuteResult) -> AppResult<Value> {
match result {
ExecuteResult::Success { response, .. } => Ok(response),
ExecuteResult::Failure {
reason,
status,
raw_error_text,
attempts,
..
} => Err(AppError::UpstreamError(format!(
"Proxy conductor failed after {attempts} attempts ({reason:?}, status: {:?}): {}",
status,
raw_error_text.unwrap_or_else(|| "no upstream error text".to_string())
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AppConfig, AuthConfig, DbConfig, ProxyConfig, ServerConfig};
use axum::http::HeaderValue;
use serde_json::json;
use sqlx::sqlite::SqlitePoolOptions;
async fn test_state(upstream_base_url: String) -> 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();
sqlx::query(
"CREATE TABLE sites (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL,
platform TEXT NOT NULL DEFAULT 'openai',
status TEXT NOT NULL DEFAULT 'active',
api_key TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE token_routes (
id INTEGER PRIMARY KEY,
model_pattern TEXT NOT NULL,
model_mapping TEXT,
route_mode TEXT,
enabled INTEGER DEFAULT 1
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL,
access_token TEXT NOT NULL,
api_token TEXT,
status TEXT NOT NULL DEFAULT 'active'
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE account_tokens (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL,
name TEXT NOT NULL,
token TEXT NOT NULL,
enabled INTEGER DEFAULT 1
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE route_channels (
id INTEGER PRIMARY KEY,
route_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
token_id INTEGER,
source_model TEXT,
priority INTEGER DEFAULT 0,
weight INTEGER DEFAULT 10,
enabled INTEGER DEFAULT 1,
consecutive_fail_count INTEGER NOT NULL DEFAULT 0,
cooldown_level INTEGER NOT NULL DEFAULT 0,
cooldown_until TEXT,
last_selected_at TEXT,
success_count INTEGER DEFAULT 0,
fail_count INTEGER DEFAULT 0,
total_latency_ms INTEGER DEFAULT 0,
total_cost REAL DEFAULT 0,
last_used_at TEXT,
last_fail_at TEXT
)",
)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO downstream_api_keys (id, name, key, enabled) VALUES (1, 'test', 'sk-test', 1)")
.execute(&pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO sites (id, name, url, api_key) VALUES (1, 'OpenAI', ?, 'site-key')",
)
.bind(upstream_base_url)
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO accounts (id, site_id, access_token, api_token) VALUES (1, 1, 'access-key', NULL)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO token_routes (id, model_pattern, model_mapping, route_mode, enabled) VALUES (1, 'gpt-test', 'upstream-test', 'exact', 1)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO route_channels (id, route_id, account_id) VALUES (1, 1, 1)")
.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,
},
},
pool,
)
}
#[tokio::test]
async fn chat_route_uses_token_router_and_conductor() {
let state = test_state("http://127.0.0.1:1".to_string()).await;
let db = state.db.clone();
let mut headers = HeaderMap::new();
headers.insert("Authorization", HeaderValue::from_static("Bearer sk-test"));
let result = chat_completions(
State(state),
headers,
Json(json!({
"model": "gpt-test",
"messages": [{"role": "user", "content": "hello"}]
})),
)
.await;
assert!(matches!(result, Err(AppError::UpstreamError(_))));
let fail_count: i64 =
sqlx::query_scalar("SELECT fail_count FROM route_channels WHERE id = 1")
.fetch_one(&*db)
.await
.unwrap();
assert!(fail_count > 0);
}
}
+26 -24
View File
@@ -1,14 +1,7 @@
// Search routes — cross-entity search across sites, accounts, tokens, etc.
use crate::{
error::AppResult,
AppState,
};
use axum::{
extract::State,
routing::post,
Json, Router,
};
use crate::{error::AppResult, AppState};
use axum::{extract::State, routing::post, Json, Router};
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet};
@@ -21,8 +14,7 @@ pub struct SearchBody {
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/search", post(search_handler))
Router::new().route("/api/search", post(search_handler))
}
/// POST /api/search — search across all entities
@@ -56,10 +48,13 @@ async fn search_handler(
// Deduplicate by id
let mut seen_sites = HashSet::new();
let unique_sites: Vec<Value> = sites.into_iter().filter(|s| {
let id = s["id"].as_i64().unwrap_or(0);
seen_sites.insert(id)
}).collect();
let unique_sites: Vec<Value> = sites
.into_iter()
.filter(|s| {
let id = s["id"].as_i64().unwrap_or(0);
seen_sites.insert(id)
})
.collect();
// 2. Search accounts (with site join)
let accounts: Vec<Value> = sqlx::query_as::<_, (i64, String, String, String, i64, String, String, Option<String>)>(
@@ -151,11 +146,12 @@ async fn search_handler(
INNER JOIN accounts a ON a.id = t.account_id AND a.status = 'active'
INNER JOIN sites s ON s.id = a.site_id
WHERE tma.model_name LIKE ? AND tma.available = 1 AND t.value_status = 'ready'
LIMIT ?"#
LIMIT ?"#,
)
.bind(&q).bind(per_category * 10)
.fetch_all(&*state.db)
.await?;
.bind(&q)
.bind(per_category * 10)
.fetch_all(&*state.db)
.await?;
let direct_model_rows: Vec<(String, i64, i64)> = sqlx::query_as(
r#"SELECT DISTINCT ma.model_name, a.id, s.id
@@ -163,11 +159,12 @@ async fn search_handler(
INNER JOIN accounts a ON a.id = ma.account_id AND a.status = 'active'
INNER JOIN sites s ON s.id = a.site_id AND s.status = 'active'
WHERE ma.model_name LIKE ? AND ma.available = 1
LIMIT ?"#
LIMIT ?"#,
)
.bind(&q).bind(per_category * 10)
.fetch_all(&*state.db)
.await?;
.bind(&q)
.bind(per_category * 10)
.fetch_all(&*state.db)
.await?;
// Aggregate models
let mut model_agg: HashMap<String, (HashSet<i64>, HashSet<i64>, HashSet<i64>)> = HashMap::new();
@@ -197,7 +194,12 @@ async fn search_handler(
models.sort_by(|a, b| {
let bc = b["accountCount"].as_i64().unwrap_or(0);
let ac = a["accountCount"].as_i64().unwrap_or(0);
bc.cmp(&ac).then_with(|| a["name"].as_str().unwrap_or("").cmp(&b["name"].as_str().unwrap_or("")))
bc.cmp(&ac).then_with(|| {
a["name"]
.as_str()
.unwrap_or("")
.cmp(&b["name"].as_str().unwrap_or(""))
})
});
models.truncate(per_category as usize);
+27 -22
View File
@@ -20,13 +20,16 @@ pub struct ListAnnouncementsQuery {
offset: Option<i64>,
site_id: Option<i64>,
platform: Option<String>,
read: Option<String>, // "true" | "false"
status: Option<String>, // "active" | "expired" | "dismissed"
read: Option<String>, // "true" | "false"
status: Option<String>, // "active" | "expired" | "dismissed"
}
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/site-announcements", get(list_announcements).delete(delete_all))
.route(
"/api/site-announcements",
get(list_announcements).delete(delete_all),
)
.route("/api/site-announcements/{id}/read", post(mark_read))
.route("/api/site-announcements/read-all", post(mark_all_read))
}
@@ -80,21 +83,26 @@ async fn list_announcements(
let filtered = if let Some(ref status) = q.status {
let now = chrono::Utc::now().timestamp_millis() as i64;
let status = status.trim().to_lowercase();
announcements.into_iter().filter(|a| {
if status == "dismissed" {
a.dismissed_at.is_some()
} else if status == "expired" {
a.dismissed_at.is_none() && a.ends_at.as_ref().map_or(false, |e| {
parse_dt_millis(e) < now
})
} else if status == "active" {
a.dismissed_at.is_none() && a.ends_at.as_ref().map_or(true, |e| {
parse_dt_millis(e) >= now
})
} else {
true
}
}).collect()
announcements
.into_iter()
.filter(|a| {
if status == "dismissed" {
a.dismissed_at.is_some()
} else if status == "expired" {
a.dismissed_at.is_none()
&& a.ends_at
.as_ref()
.map_or(false, |e| parse_dt_millis(e) < now)
} else if status == "active" {
a.dismissed_at.is_none()
&& a.ends_at
.as_ref()
.map_or(true, |e| parse_dt_millis(e) >= now)
} else {
true
}
})
.collect()
} else {
announcements
};
@@ -131,10 +139,7 @@ async fn list_announcements(
}
/// POST /api/site-announcements/{id}/read
async fn mark_read(
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Value>> {
async fn mark_read(State(state): State<AppState>, Path(id): Path<i64>) -> AppResult<Json<Value>> {
info!("Marking site announcement {} as read", id);
sqlx::query("UPDATE site_announcements SET read_at = datetime('now') WHERE id = ?")
.bind(id)
+22 -16
View File
@@ -1,9 +1,6 @@
// Stats routes — usage analytics queries
use crate::{
error::AppResult,
AppState,
};
use crate::{error::AppResult, AppState};
use axum::{
extract::{Query, State},
routing::get,
@@ -36,7 +33,10 @@ async fn model_by_site(
State(state): State<AppState>,
Query(q): Query<ModelBySiteQuery>,
) -> AppResult<Json<Value>> {
info!("Stats model-by-site: site_id={:?}, days={:?}", q.site_id, q.days);
info!(
"Stats model-by-site: site_id={:?}, days={:?}",
q.site_id, q.days
);
let days = q.days.unwrap_or(7).max(1);
// Calculate cutoff date
@@ -51,26 +51,27 @@ async fn model_by_site(
"SELECT model, local_day, total_calls, total_spend, total_tokens
FROM model_day_usage
WHERE site_id = ? AND local_day >= ?
ORDER BY local_day DESC"
ORDER BY local_day DESC",
)
.bind(site_id)
.bind(&since_day)
.fetch_all(&*state.db)
.await?
.bind(site_id)
.bind(&since_day)
.fetch_all(&*state.db)
.await?
} else {
sqlx::query_as::<_, (String, String, i64, f64, i64)>(
"SELECT model, local_day, total_calls, total_spend, total_tokens
FROM model_day_usage
WHERE local_day >= ?
ORDER BY local_day DESC"
ORDER BY local_day DESC",
)
.bind(&since_day)
.fetch_all(&*state.db)
.await?
.bind(&since_day)
.fetch_all(&*state.db)
.await?
};
// Aggregate by model
let mut model_map: std::collections::HashMap<String, (i64, f64, i64)> = std::collections::HashMap::new();
let mut model_map: std::collections::HashMap<String, (i64, f64, i64)> =
std::collections::HashMap::new();
for (model, _day, calls, spend, tokens) in rows {
let entry = model_map.entry(model).or_insert((0, 0.0, 0));
entry.0 += calls;
@@ -90,7 +91,12 @@ async fn model_by_site(
})
.collect();
models.sort_by(|a, b| b["calls"].as_i64().unwrap_or(0).cmp(&a["calls"].as_i64().unwrap_or(0)));
models.sort_by(|a, b| {
b["calls"]
.as_i64()
.unwrap_or(0)
.cmp(&a["calls"].as_i64().unwrap_or(0))
});
Ok(Json(json!({ "models": models })))
}
+32 -31
View File
@@ -2,11 +2,7 @@
use std::convert::Infallible;
use crate::{
db::models::background_task::BackgroundTask,
error::AppResult,
AppState,
};
use crate::{db::models::background_task::BackgroundTask, error::AppResult, AppState};
use axum::{
extract::{Path, Query, State},
response::{
@@ -31,7 +27,10 @@ pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/tasks", get(list_tasks))
.route("/api/tasks/{id}", get(get_task))
.route("/api/update-center/tasks/{id}/stream", get(stream_task_logs))
.route(
"/api/update-center/tasks/{id}/stream",
get(stream_task_logs),
)
}
/// GET /api/tasks — list background tasks
@@ -43,32 +42,30 @@ async fn list_tasks(
info!("Listing background tasks, limit={}", limit);
let tasks: Vec<BackgroundTask> = sqlx::query_as::<_, BackgroundTask>(
"SELECT * FROM background_tasks ORDER BY created_at DESC LIMIT ?"
"SELECT * FROM background_tasks ORDER BY created_at DESC LIMIT ?",
)
.bind(limit)
.fetch_all(&*state.db)
.await?;
.bind(limit)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "tasks": tasks })))
}
/// GET /api/tasks/{id} — get a single background task
async fn get_task(
State(state): State<AppState>,
Path(id): Path<String>,
) -> AppResult<Json<Value>> {
async fn get_task(State(state): State<AppState>, Path(id): Path<String>) -> AppResult<Json<Value>> {
info!("Getting background task: {}", id);
let task: Option<BackgroundTask> = sqlx::query_as::<_, BackgroundTask>(
"SELECT * FROM background_tasks WHERE id = ?"
)
.bind(&id)
.fetch_optional(&*state.db)
.await?;
let task: Option<BackgroundTask> =
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
.bind(&id)
.fetch_optional(&*state.db)
.await?;
match task {
Some(t) => Ok(Json(json!({ "success": true, "task": t }))),
None => Ok(Json(json!({ "success": false, "message": "task not found" }))),
None => Ok(Json(
json!({ "success": false, "message": "task not found" }),
)),
}
}
@@ -80,15 +77,18 @@ async fn stream_task_logs(
info!("Streaming task logs for: {}", id);
// Check task exists
let task: Option<BackgroundTask> = sqlx::query_as::<_, BackgroundTask>(
"SELECT * FROM background_tasks WHERE id = ?"
)
.bind(&id)
.fetch_optional(&*state.db)
.await
.map_err(|e| {
(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")).into_response()
})?;
let task: Option<BackgroundTask> =
sqlx::query_as::<_, BackgroundTask>("SELECT * FROM background_tasks WHERE id = ?")
.bind(&id)
.fetch_optional(&*state.db)
.await
.map_err(|e| {
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("DB error: {e}"),
)
.into_response()
})?;
let task = match task {
Some(t) => t,
@@ -96,7 +96,8 @@ async fn stream_task_logs(
return Err((
axum::http::StatusCode::NOT_FOUND,
json!({ "success": false, "message": "task not found" }).to_string(),
).into_response());
)
.into_response());
}
};
+433
View File
@@ -0,0 +1,433 @@
// Update Center routes — deploy/rollback orchestration via helper API
use crate::{
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::State,
routing::{get, post, put},
Json, Router,
};
use serde_json::{json, Value};
const UC_CONFIG_KEY: &str = "update_center_config";
const TASK_TYPE_DEPLOY: &str = "update-center-deploy";
const TASK_TYPE_ROLLBACK: &str = "update-center-rollback";
const DEDUPE_KEY_DEPLOY: &str = "update-center:deploy";
const DEDUPE_KEY_ROLLBACK: &str = "update-center:rollback";
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/update-center/status", get(get_status))
.route("/api/update-center/check", post(check_status))
.route("/api/update-center/config", put(update_config))
.route("/api/update-center/deploy", post(deploy_handler))
.route("/api/update-center/rollback", post(rollback_handler))
}
/// GET /api/update-center/status
async fn get_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let last_deploy = load_last_task(&state, TASK_TYPE_DEPLOY).await?;
let last_rollback = load_last_task(&state, TASK_TYPE_ROLLBACK).await?;
Ok(Json(json!({
"enabled": config.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false),
"helperBaseUrl": config.get("helperBaseUrl"),
"namespace": config.get("namespace"),
"releaseName": config.get("releaseName"),
"helperTokenConfigured": !helper_token.is_empty(),
"lastDeploy": last_deploy,
"lastRollback": last_rollback,
})))
}
/// POST /api/update-center/check — refresh status cache
async fn check_status(State(state): State<AppState>) -> AppResult<Json<Value>> {
// Same as get_status for now
get_status(State(state)).await
}
/// PUT /api/update-center/config
async fn update_config(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let serialized = serde_json::to_string(&body)
.map_err(|e| AppError::BadRequest(format!("Invalid config JSON: {e}")))?;
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(UC_CONFIG_KEY)
.bind(&serialized)
.execute(&*state.db)
.await?;
Ok(Json(json!({
"success": true,
"config": body,
})))
}
/// POST /api/update-center/deploy
async fn deploy_handler(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let helper_base = config
.get("helperBaseUrl")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let namespace = config
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let chart_ref = config
.get("chartRef")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let image_repo = config
.get("imageRepository")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if helper_base.is_empty()
|| namespace.is_empty()
|| chart_ref.is_empty()
|| helper_token.is_empty()
{
return Err(AppError::BadRequest(
"Update center not fully configured".into(),
));
}
let target_tag = body
.get("targetTag")
.or_else(|| body.get("targetVersion"))
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
if target_tag.is_empty() {
return Err(AppError::BadRequest("targetTag is required".into()));
}
// Extract owned values for the spawn
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
let namespace_owned = namespace.to_string();
let chart_ref_owned = chart_ref.to_string();
let image_repo_owned = image_repo.to_string();
let release_name = config
.get("releaseName")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let target_tag_owned = target_tag.to_string();
let helper_token_owned = helper_token;
let title = format!("部署 {}", target_tag);
let task_id = uuid::Uuid::new_v4().to_string();
// Create background task
sqlx::query(
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
VALUES (?, ?, ?, 'running', ?, ?)",
)
.bind(&task_id)
.bind(TASK_TYPE_DEPLOY)
.bind(&title)
.bind(format!(
"Starting deploy of {} to {}/{}",
target_tag, namespace, chart_ref
))
.bind(DEDUPE_KEY_DEPLOY)
.execute(&*state.db)
.await?;
// Call deploy helper in background (fire-and-forget via tokio)
let client = reqwest::Client::new();
let deploy_url = format!("{}/api/deploy", helper_base_owned);
let task_id_clone = task_id.clone();
let db_pool = state.db.clone();
tokio::spawn(async move {
let payload = json!({
"namespace": namespace_owned,
"releaseName": release_name,
"chartRef": chart_ref_owned,
"imageRepository": image_repo_owned,
"targetTag": target_tag_owned,
});
match client
.post(&deploy_url)
.header("Authorization", format!("Bearer {}", helper_token_owned))
.json(&payload)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let status_code = status.as_u16();
let body_text = resp.text().await.unwrap_or_default();
if status.is_success() {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'succeeded', message = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Deploy completed (HTTP {})", status_code))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
} else {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!(
"Helper returned HTTP {}: {}",
status_code, body_text
))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
Err(e) => {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Helper unreachable: {}", e))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
});
Ok(Json(json!({
"success": true,
"reused": false,
"task": {
"id": task_id,
"type": TASK_TYPE_DEPLOY,
"title": title,
"status": "running",
},
})))
}
/// POST /api/update-center/rollback
async fn rollback_handler(
State(state): State<AppState>,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
let config = load_uc_config(&state).await?;
let helper_token = get_helper_token();
let helper_base = config
.get("helperBaseUrl")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let namespace = config
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if helper_base.is_empty() || namespace.is_empty() || helper_token.is_empty() {
return Err(AppError::BadRequest(
"Update center not fully configured".into(),
));
}
let target_revision = body
.get("targetRevision")
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("");
if target_revision.is_empty() {
return Err(AppError::BadRequest("targetRevision is required".into()));
}
let helper_base_owned = helper_base.trim_end_matches('/').to_string();
let namespace_owned = namespace.to_string();
let release_name = config
.get("releaseName")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned);
let target_revision_owned = target_revision.to_string();
let helper_token_owned = helper_token;
let title = format!("回退 {}", target_revision);
let task_id = uuid::Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO background_tasks (id, task_type, title, status, message, dedupe_key)
VALUES (?, ?, ?, 'running', ?, ?)",
)
.bind(&task_id)
.bind(TASK_TYPE_ROLLBACK)
.bind(&title)
.bind(format!(
"Starting rollback to {} in {}",
target_revision, namespace
))
.bind(DEDUPE_KEY_ROLLBACK)
.execute(&*state.db)
.await?;
let client = reqwest::Client::new();
let rollback_url = format!("{}/api/rollback", helper_base_owned);
let task_id_clone = task_id.clone();
let db_pool = state.db.clone();
tokio::spawn(async move {
let payload = json!({
"namespace": namespace_owned,
"releaseName": release_name,
"targetRevision": target_revision_owned,
});
match client
.post(&rollback_url)
.header("Authorization", format!("Bearer {}", helper_token_owned))
.json(&payload)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let status_code = status.as_u16();
let body_text = resp.text().await.unwrap_or_default();
if status.is_success() {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'succeeded', message = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Rollback completed (HTTP {})", status_code))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
} else {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!(
"Helper returned HTTP {}: {}",
status_code, body_text
))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
Err(e) => {
let _ = sqlx::query(
"UPDATE background_tasks
SET status = 'failed', error = ?, finished_at = datetime('now')
WHERE id = ?",
)
.bind(format!("Helper unreachable: {}", e))
.bind(&task_id_clone)
.execute(&*db_pool)
.await;
}
}
});
Ok(Json(json!({
"success": true,
"reused": false,
"task": {
"id": task_id,
"type": TASK_TYPE_ROLLBACK,
"title": title,
"status": "running",
},
})))
}
// ── Helpers ────────────────────────────────────────────────────────────
fn get_helper_token() -> String {
std::env::var("UPDATE_CENTER_HELPER_TOKEN")
.or_else(|_| std::env::var("DEPLOY_HELPER_TOKEN"))
.unwrap_or_default()
.trim()
.to_string()
}
async fn load_uc_config(state: &AppState) -> AppResult<Value> {
let row: Option<(String,)> = sqlx::query_as("SELECT value FROM settings WHERE key = ?")
.bind(UC_CONFIG_KEY)
.fetch_optional(&*state.db)
.await?;
match row {
Some((v,)) => serde_json::from_str(&v)
.map_err(|e| AppError::BadRequest(format!("Invalid update center config JSON: {e}"))),
None => Ok(json!({})),
}
}
async fn load_last_task(state: &AppState, task_type: &str) -> AppResult<Option<Value>> {
let row: Option<(
String,
String,
Option<String>,
Option<String>,
String,
Option<String>,
Option<String>,
)> = sqlx::query_as(
"SELECT id, status, message, error, created_at, started_at, finished_at
FROM background_tasks
WHERE task_type = ?
ORDER BY created_at DESC
LIMIT 1",
)
.bind(task_type)
.fetch_optional(&*state.db)
.await?;
Ok(row.map(
|(id, status, message, error, created_at, started_at, finished_at)| {
json!({
"id": id,
"type": task_type,
"status": status,
"message": message,
"error": error,
"createdAt": created_at,
"startedAt": started_at,
"finishedAt": finished_at,
})
},
))
}
+160 -1
View File
@@ -1 +1,160 @@
// Routes - user
use crate::{
auth::jwt::validate_jwt,
db::models::{downstream_api_key::DownstreamApiKey, user::User},
error::{AppError, AppResult},
AppState,
};
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::{delete, get, post, put},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
pub fn routes() -> Router<AppState> {
Router::new()
.route("/v1/user/profile", get(get_profile).put(update_profile))
.route("/v1/user/api-keys", get(list_api_keys).post(create_api_key))
.route("/v1/user/api-keys/{id}", delete(delete_api_key))
}
#[derive(Debug, Deserialize)]
struct UpdateProfilePayload {
username: Option<String>,
email: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CreateApiKeyPayload {
name: String,
description: Option<String>,
expires_at: Option<String>,
max_requests: Option<i64>,
max_cost: Option<f64>,
}
fn current_user_id(headers: &HeaderMap, state: &AppState) -> AppResult<i64> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
let claims = validate_jwt(&state.config.auth.jwt_secret, auth)
.map_err(|_| AppError::Unauthorized("Invalid or expired token".into()))?;
claims
.sub
.parse()
.map_err(|_| AppError::Unauthorized("Invalid user ID in token".into()))
}
async fn get_profile(State(state): State<AppState>, headers: HeaderMap) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let user: User = sqlx::query_as("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
Ok(Json(json!({ "user": user })))
}
async fn update_profile(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<UpdateProfilePayload>,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let current: User = sqlx::query_as("SELECT * FROM users WHERE id = ?")
.bind(user_id)
.fetch_optional(&*state.db)
.await?
.ok_or_else(|| AppError::NotFound("User not found".into()))?;
let username = payload.username.unwrap_or(current.username);
let email = payload.email.unwrap_or(current.email);
sqlx::query(
"UPDATE users
SET username = ?, email = ?, updated_at = datetime('now')
WHERE id = ?",
)
.bind(username)
.bind(email)
.bind(user_id)
.execute(&*state.db)
.await?;
get_profile(State(state), headers).await
}
async fn list_api_keys(
State(state): State<AppState>,
headers: HeaderMap,
) -> AppResult<Json<Value>> {
let user_id = current_user_id(&headers, &state)?;
let keys: Vec<DownstreamApiKey> = sqlx::query_as(
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(user_id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "keys": keys })))
}
async fn create_api_key(
State(state): State<AppState>,
headers: HeaderMap,
Json(payload): Json<CreateApiKeyPayload>,
) -> AppResult<(StatusCode, Json<Value>)> {
let user_id = current_user_id(&headers, &state)?;
if payload.name.trim().is_empty() {
return Err(AppError::BadRequest("name is required".into()));
}
let token = format!("sk-user-{}", uuid::Uuid::new_v4().simple());
let result = sqlx::query(
"INSERT INTO downstream_api_keys
(name, key, description, enabled, expires_at, max_requests, max_cost, user_id)
VALUES (?, ?, ?, 1, ?, ?, ?, ?)",
)
.bind(payload.name)
.bind(&token)
.bind(payload.description)
.bind(payload.expires_at)
.bind(payload.max_requests)
.bind(payload.max_cost)
.bind(user_id)
.execute(&*state.db)
.await?;
let key: DownstreamApiKey = sqlx::query_as("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(result.last_insert_rowid())
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "key": key }))))
}
async fn delete_api_key(
State(state): State<AppState>,
headers: HeaderMap,
Path(id): Path<i64>,
) -> AppResult<StatusCode> {
let user_id = current_user_id(&headers, &state)?;
let result = sqlx::query("DELETE FROM downstream_api_keys WHERE id = ? AND user_id = ?")
.bind(id)
.bind(user_id)
.execute(&*state.db)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("API key not found".into()));
}
Ok(StatusCode::NO_CONTENT)
}
+84 -54
View File
@@ -19,8 +19,14 @@ use tracing::info;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/user-api-keys", get(list_user_keys).post(create_user_key))
.route("/api/user-api-keys/{id}", patch(update_user_key).delete(delete_user_key))
.route(
"/api/user-api-keys",
get(list_user_keys).post(create_user_key),
)
.route(
"/api/user-api-keys/{id}",
patch(update_user_key).delete(delete_user_key),
)
}
/// Extract user_id and role from JWT in Authorization header.
@@ -51,11 +57,11 @@ async fn list_user_keys(
info!("Listing user API keys for user {}", user_id);
let keys: Vec<DownstreamApiKey> = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC"
"SELECT * FROM downstream_api_keys WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(user_id)
.fetch_all(&*state.db)
.await?;
.bind(user_id)
.fetch_all(&*state.db)
.await?;
Ok(Json(json!({ "keys": keys })))
}
@@ -82,9 +88,17 @@ async fn create_user_key(
.get("key")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("sk-user-{}", uuid::Uuid::new_v4().to_string().replace('-', "")));
.unwrap_or_else(|| {
format!(
"sk-user-{}",
uuid::Uuid::new_v4().to_string().replace('-', "")
)
});
let enabled = body.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
let enabled = body
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let expires_at = body.get("expiresAt").and_then(|v| v.as_str());
let max_cost = body.get("maxCost").and_then(|v| v.as_f64());
let max_requests = body.get("maxRequests").and_then(|v| v.as_i64());
@@ -99,31 +113,30 @@ async fn create_user_key(
(name, key, description, tags, enabled, expires_at, max_cost, max_requests,
supported_models, allowed_route_ids, site_weight_multipliers,
excluded_site_ids, excluded_credential_refs, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"#,
)
.bind(name)
.bind(&key_str)
.bind(description)
.bind(tags)
.bind(enabled)
.bind(expires_at)
.bind(max_cost)
.bind(max_requests)
.bind(supported_models)
.bind(allowed_route_ids)
.bind(site_weight_multipliers)
.bind(excluded_site_ids)
.bind(excluded_credential_refs)
.bind(user_id)
.execute(&*state.db)
.await?;
.bind(name)
.bind(&key_str)
.bind(description)
.bind(tags)
.bind(enabled)
.bind(expires_at)
.bind(max_cost)
.bind(max_requests)
.bind(supported_models)
.bind(allowed_route_ids)
.bind(site_weight_multipliers)
.bind(excluded_site_ids)
.bind(excluded_credential_refs)
.bind(user_id)
.execute(&*state.db)
.await?;
let new_key: DownstreamApiKey = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE id = ?"
)
.bind(result.last_insert_rowid())
.fetch_one(&*state.db)
.await?;
let new_key: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(result.last_insert_rowid())
.fetch_one(&*state.db)
.await?;
Ok((StatusCode::CREATED, Json(json!({ "key": new_key }))))
}
@@ -139,13 +152,12 @@ async fn update_user_key(
info!("Updating user API key {} for user {}", id, user_id);
// Verify ownership
let existing: Option<DownstreamApiKey> = sqlx::query_as(
"SELECT * FROM downstream_api_keys WHERE id = ? AND user_id = ?"
)
.bind(id)
.bind(user_id)
.fetch_optional(&*state.db)
.await?;
let existing: Option<DownstreamApiKey> =
sqlx::query_as("SELECT * FROM downstream_api_keys WHERE id = ? AND user_id = ?")
.bind(id)
.bind(user_id)
.fetch_optional(&*state.db)
.await?;
if existing.is_none() {
return Err(AppError::NotFound("Key not found".into()));
@@ -157,35 +169,54 @@ async fn update_user_key(
let mut bind_idx = 0;
if let Some(v) = body.get("name").and_then(|v| v.as_str()) {
bind_idx += 1; sets.push(format!("name = ?{}", bind_idx)); params.push(v.to_string());
bind_idx += 1;
sets.push(format!("name = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("description") {
bind_idx += 1; sets.push(format!("description = ?{}", bind_idx));
bind_idx += 1;
sets.push(format!("description = ?{}", bind_idx));
params.push(v.as_str().unwrap_or_default().to_string());
}
if let Some(v) = body.get("tags") {
bind_idx += 1; sets.push(format!("tags = ?{}", bind_idx)); params.push(v.to_string());
bind_idx += 1;
sets.push(format!("tags = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("enabled").and_then(|v| v.as_bool()) {
bind_idx += 1; sets.push(format!("enabled = ?{}", bind_idx)); params.push(if v { "1" } else { "0" }.to_string());
bind_idx += 1;
sets.push(format!("enabled = ?{}", bind_idx));
params.push(if v { "1" } else { "0" }.to_string());
}
if let Some(v) = body.get("expiresAt").and_then(|v| v.as_str()) {
bind_idx += 1; sets.push(format!("expires_at = ?{}", bind_idx)); params.push(v.to_string());
bind_idx += 1;
sets.push(format!("expires_at = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("maxCost").and_then(|v| v.as_f64()) {
bind_idx += 1; sets.push(format!("max_cost = ?{}", bind_idx)); params.push(v.to_string());
bind_idx += 1;
sets.push(format!("max_cost = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("maxRequests").and_then(|v| v.as_i64()) {
bind_idx += 1; sets.push(format!("max_requests = ?{}", bind_idx)); params.push(v.to_string());
bind_idx += 1;
sets.push(format!("max_requests = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("supportedModels") {
bind_idx += 1; sets.push(format!("supported_models = ?{}", bind_idx)); params.push(v.to_string());
bind_idx += 1;
sets.push(format!("supported_models = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("allowedRouteIds") {
bind_idx += 1; sets.push(format!("allowed_route_ids = ?{}", bind_idx)); params.push(v.to_string());
bind_idx += 1;
sets.push(format!("allowed_route_ids = ?{}", bind_idx));
params.push(v.to_string());
}
if let Some(v) = body.get("excludedSiteIds") {
bind_idx += 1; sets.push(format!("excluded_site_ids = ?{}", bind_idx)); params.push(v.to_string());
bind_idx += 1;
sets.push(format!("excluded_site_ids = ?{}", bind_idx));
params.push(v.to_string());
}
if !sets.is_empty() {
@@ -207,12 +238,11 @@ async fn update_user_key(
q.execute(&*state.db).await?;
}
let updated: DownstreamApiKey = sqlx::query_as::<_, DownstreamApiKey>(
"SELECT * FROM downstream_api_keys WHERE id = ?"
)
.bind(id)
.fetch_one(&*state.db)
.await?;
let updated: DownstreamApiKey =
sqlx::query_as::<_, DownstreamApiKey>("SELECT * FROM downstream_api_keys WHERE id = ?")
.bind(id)
.fetch_one(&*state.db)
.await?;
Ok(Json(json!({ "key": updated })))
}
+517
View File
@@ -0,0 +1,517 @@
use axum::{
body::Body,
extract::State,
http::{header, HeaderMap, Response, StatusCode},
response::IntoResponse,
routing::post,
Json, Router,
};
use serde_json::{json, Value};
use tracing::info;
use crate::{
error::{AppError, AppResult},
AppState,
};
const SCRIPT_ANALYSIS_PROMPT: &str = r#"你是一个 AI 视频生成助手。分析用户提供的脚本,提取以下结构化信息,以 JSON 格式返回:
{
"title": "视频标题",
"characters": [
{
"name": "角色名",
"gender": "男/女",
"age": "年龄描述",
"appearance": "外貌详细描述(发型、脸型、服装、体型等)",
"personality": "性格特征"
}
],
"scenes": [
{
"id": 1,
"description": "场景描述",
"environment": "环境详细描述(时间、地点、天气、光线等)",
"characters": ["出现的角色名"],
"action": "场景中发生的动作",
"dialogue": [
{"character": "角色名", "line": "台词", "emotion": "情感语气"}
]
}
],
"style": "整体视觉风格(动画/写实/像素等)"
}
只返回 JSON,不要其他文字。"#;
const CHARACTER_PROMPT_GEN: &str = r#"根据角色描述生成 4 组稳定的图像生成提示词(正面、背面、左侧面、右侧面),用于 AI 图像生成模型。
每组提示词应包含:人物姿态、服装、光照、背景。
返回 JSON 格式:
{
"front": "正面提示词",
"back": "背面提示词",
"left": "左侧面提示词",
"right": "右侧面提示词"
}
只返回 JSON。"#;
const SCENE_PROMPT_GEN: &str = r#"根据场景描述生成图像生成提示词,用于 AI 图像生成模型创建场景背景。
包含:环境、光照、色调、氛围。
返回 JSON:
{
"prompt": "场景生成提示词",
"negative_prompt": "负面提示词"
}
只返回 JSON。"#;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/video-agent/analyze", post(analyze))
.route(
"/api/video-agent/character-prompts",
post(character_prompts),
)
.route(
"/api/video-agent/generate-character",
post(generate_character),
)
.route("/api/video-agent/scene-prompt", post(scene_prompt))
.route("/api/video-agent/generate-scene", post(generate_scene))
.route("/api/video-agent/generate-tts", post(generate_tts))
.route("/api/video-agent/full-workflow", post(full_workflow))
}
fn upstream_base() -> String {
std::env::var("BOOSAPI_UPSTREAM_BASE")
.unwrap_or_else(|_| "http://127.0.0.1:4000/v1".to_string())
.trim_end_matches('/')
.to_string()
}
fn upstream_key() -> String {
std::env::var("BOOSAPI_UPSTREAM_KEY").unwrap_or_else(|_| "sk-boos4721".to_string())
}
fn check_auth(headers: &HeaderMap, state: &AppState) -> AppResult<()> {
let expected = format!("Bearer {}", state.config.auth.auth_token);
let actual = headers
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if actual != expected {
return Err(AppError::BadRequest("unauthorized".to_string()));
}
Ok(())
}
fn required_str<'a>(body: &'a Value, key: &str) -> AppResult<&'a str> {
body.get(key)
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
}
fn required_value<'a>(body: &'a Value, key: &str) -> AppResult<&'a Value> {
body.get(key)
.filter(|v| !v.is_null())
.ok_or_else(|| AppError::BadRequest(format!("{key} is required")))
}
fn strip_json_markers(s: &str) -> String {
let mut text = s.trim().to_string();
if text.starts_with("```json") {
text = text.trim_start_matches("```json").to_string();
} else if text.starts_with("```") {
text = text.trim_start_matches("```").to_string();
}
if text.ends_with("```") {
text = text.trim_end_matches("```").to_string();
}
text.trim().to_string()
}
fn parse_llm_json(raw: &str) -> AppResult<Value> {
let cleaned = strip_json_markers(raw);
serde_json::from_str::<Value>(&cleaned).map_err(|e| {
AppError::Internal(anyhow::anyhow!(
"failed to parse LLM JSON response: {}; response: {}",
e,
cleaned
))
})
}
async fn call_llm(prompt: &str, system: &str) -> AppResult<String> {
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", upstream_base());
info!("video-agent calling LLM upstream: {}", url);
let resp = client
.post(url)
.bearer_auth(upstream_key())
.json(&json!({
"model": "gpt-5.5",
"messages": [
{ "role": "system", "content": system },
{ "role": "user", "content": prompt }
],
"temperature": 0.7
}))
.send()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM request failed: {}", e)))?;
let status = resp.status();
let data: Value = resp
.json()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("LLM response parse failed: {}", e)))?;
if !status.is_success() {
return Err(AppError::Internal(anyhow::anyhow!(
"LLM upstream returned {}: {}",
status,
data
)));
}
Ok(data
.get("choices")
.and_then(Value::as_array)
.and_then(|choices| choices.first())
.and_then(|choice| choice.get("message"))
.and_then(|message| message.get("content"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string())
}
async fn call_image_gen(prompt: &str) -> AppResult<String> {
let client = reqwest::Client::new();
let url = format!("{}/images/generations", upstream_base());
info!("video-agent calling image upstream: {}", url);
let resp = client
.post(url)
.bearer_auth(upstream_key())
.json(&json!({
"model": "gpt-image-2",
"prompt": prompt,
"n": 1,
"size": "1024x1024"
}))
.send()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("image request failed: {}", e)))?;
let status = resp.status();
let data: Value = resp
.json()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("image response parse failed: {}", e)))?;
if !status.is_success() {
return Err(AppError::Internal(anyhow::anyhow!(
"image upstream returned {}: {}",
status,
data
)));
}
Ok(data
.get("data")
.and_then(Value::as_array)
.and_then(|items| items.first())
.and_then(|item| item.get("url"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string())
}
async fn call_tts(text: &str, voice: &str) -> AppResult<Vec<u8>> {
let client = reqwest::Client::new();
let url = format!("{}/audio/speech", upstream_base());
info!("video-agent calling TTS upstream: {}", url);
let resp = client
.post(url)
.bearer_auth(upstream_key())
.json(&json!({
"model": "tts-1",
"input": text,
"voice": voice
}))
.send()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS request failed: {}", e)))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(AppError::Internal(anyhow::anyhow!(
"TTS upstream returned {}: {}",
status,
body
)));
}
let bytes = resp
.bytes()
.await
.map_err(|e| AppError::Internal(anyhow::anyhow!("TTS response read failed: {}", e)))?;
Ok(bytes.to_vec())
}
async fn analyze(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let script = required_str(&body, "script")?;
let result = call_llm(script, SCRIPT_ANALYSIS_PROMPT).await?;
let parsed = parse_llm_json(&result)?;
Ok(Json(json!({
"success": true,
"data": parsed
})))
}
async fn character_prompts(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let character = required_value(&body, "character")?;
let name = character
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let appearance = character
.get("appearance")
.and_then(Value::as_str)
.unwrap_or_default();
let gender = character
.get("gender")
.and_then(Value::as_str)
.unwrap_or_default();
let age = character
.get("age")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{name}: {appearance}, {gender}, {age}");
let result = call_llm(&desc, CHARACTER_PROMPT_GEN).await?;
let prompts = parse_llm_json(&result)?;
Ok(Json(json!({
"success": true,
"data": prompts
})))
}
async fn generate_character(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let prompts = required_value(&body, "prompts")?;
let prompt_obj = prompts
.as_object()
.ok_or_else(|| AppError::BadRequest("prompts must be an object".to_string()))?;
let mut results = serde_json::Map::new();
for (angle, prompt_value) in prompt_obj {
let prompt = prompt_value.as_str().ok_or_else(|| {
AppError::BadRequest(format!("prompt for angle '{angle}' must be a string"))
})?;
let url = call_image_gen(prompt).await?;
results.insert(angle.clone(), Value::String(url));
}
Ok(Json(json!({
"success": true,
"data": Value::Object(results)
})))
}
async fn scene_prompt(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let scene = required_value(&body, "scene")?;
let environment = scene
.get("environment")
.and_then(Value::as_str)
.unwrap_or_default();
let description = scene
.get("description")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{environment}. {description}");
let result = call_llm(&desc, SCENE_PROMPT_GEN).await?;
let prompt = parse_llm_json(&result)?;
Ok(Json(json!({
"success": true,
"data": prompt
})))
}
async fn generate_scene(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let prompt = required_str(&body, "prompt")?;
let url = call_image_gen(prompt).await?;
Ok(Json(json!({
"success": true,
"data": {
"url": url
}
})))
}
async fn generate_tts(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Response<Body>> {
check_auth(&headers, &state)?;
let text = required_str(&body, "text")?;
let voice = body
.get("voice")
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty())
.unwrap_or("alloy");
let audio = call_tts(text, voice).await?;
Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, "audio/mpeg")],
audio,
)
.into_response())
}
async fn full_workflow(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<Value>,
) -> AppResult<Json<Value>> {
check_auth(&headers, &state)?;
let script = required_str(&body, "script")?;
let requested_style = body.get("style").and_then(Value::as_str);
let analysis_raw = call_llm(script, SCRIPT_ANALYSIS_PROMPT).await?;
let analysis = parse_llm_json(&analysis_raw)?;
let mut characters = Vec::new();
if let Some(chars) = analysis.get("characters").and_then(Value::as_array) {
for character in chars {
let name = character
.get("name")
.and_then(Value::as_str)
.unwrap_or_default();
let appearance = character
.get("appearance")
.and_then(Value::as_str)
.unwrap_or_default();
let gender = character
.get("gender")
.and_then(Value::as_str)
.unwrap_or_default();
let age = character
.get("age")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{name}: {appearance}, {gender}, {age}");
let prompts_raw = call_llm(&desc, CHARACTER_PROMPT_GEN).await?;
let prompts = parse_llm_json(&prompts_raw)?;
let mut item = character.clone();
if let Some(obj) = item.as_object_mut() {
obj.insert("imagePrompts".to_string(), prompts);
}
characters.push(item);
}
}
let mut scenes = Vec::new();
if let Some(scene_items) = analysis.get("scenes").and_then(Value::as_array) {
for scene in scene_items {
let environment = scene
.get("environment")
.and_then(Value::as_str)
.unwrap_or_default();
let description = scene
.get("description")
.and_then(Value::as_str)
.unwrap_or_default();
let desc = format!("{environment}. {description}");
let scene_prompt_raw = call_llm(&desc, SCENE_PROMPT_GEN).await?;
let image_prompt = parse_llm_json(&scene_prompt_raw)?;
let mut item = scene.clone();
if let Some(obj) = item.as_object_mut() {
obj.insert("imagePrompt".to_string(), image_prompt);
}
scenes.push(item);
}
}
let style = analysis
.get("style")
.cloned()
.or_else(|| requested_style.map(|s| Value::String(s.to_string())))
.unwrap_or(Value::Null);
Ok(Json(json!({
"success": true,
"data": {
"title": analysis.get("title").cloned().unwrap_or(Value::Null),
"style": style,
"characters": characters,
"scenes": scenes
}
})))
}