Spaces:
Sleeping
Sleeping
| //! ็ฎก็ API ่ทฏ็ฑๅค็ๅจ โโ ็ปๅฝ/่ฎพ็ฝฎๅฏ็ ใ่ดฆๅทๆฑ ็ถๆใ่ฏทๆฑ็ป่ฎกใๆจกๅๅ่กจใ้ ็ฝฎๆฅ็ | |
| use axum::{ | |
| body::Body, | |
| extract::{Query, State}, | |
| http::{StatusCode, header}, | |
| response::Response, | |
| }; | |
| use serde::{Deserialize, Serialize}; | |
| use super::handlers::AppState; | |
| use crate::config::Config; | |
| // โโ ่ฏทๆฑ/ๅๅบ็ฑปๅ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| pub struct SetupRequest { | |
| pub password: String, | |
| } | |
| pub struct LoginRequest { | |
| pub password: String, | |
| } | |
| pub struct LoginResponse { | |
| pub token: String, | |
| } | |
| pub struct AdminStatusResponse { | |
| pub accounts: Vec<ds_core::AccountStatus>, | |
| pub total: usize, | |
| pub idle: usize, | |
| pub busy: usize, | |
| pub error: usize, | |
| pub invalid: usize, | |
| } | |
| pub struct AdminStatsResponse { | |
| pub stats: super::stats::StatsSnapshot, | |
| } | |
| pub struct AdminConfigResponse { | |
| pub server: ServerConfigView, | |
| pub ds_core: DsCoreView, | |
| pub proxy: ProxyConfigView, | |
| pub admin: AdminConfigView, | |
| pub api_keys: Vec<ApiKeyEntryView>, | |
| } | |
| pub struct DsCoreView { | |
| pub accounts: Vec<AccountView>, | |
| pub api_base: String, | |
| pub wasm_url: String, | |
| pub user_agent: String, | |
| pub client_version: String, | |
| pub client_platform: String, | |
| pub client_locale: String, | |
| pub model_types: Vec<String>, | |
| pub max_input_tokens: Vec<u32>, | |
| pub max_output_tokens: Vec<u32>, | |
| pub input_character_limits: Vec<u32>, | |
| pub model_aliases: Vec<String>, | |
| pub tool_call: ToolCallTagConfigView, | |
| } | |
| pub struct ServerConfigView { | |
| pub host: String, | |
| pub port: u16, | |
| pub cors_origins: Vec<String>, | |
| } | |
| pub struct ToolCallTagConfigView { | |
| pub extra_starts: Vec<String>, | |
| pub extra_ends: Vec<String>, | |
| } | |
| pub struct ProxyConfigView { | |
| pub url: Option<String>, | |
| } | |
| pub struct AdminConfigView { | |
| pub password_set: bool, | |
| pub jwt_issued_at: u64, | |
| } | |
| pub struct ApiKeyEntryView { | |
| pub key: String, | |
| pub description: String, | |
| } | |
| pub struct AccountView { | |
| pub email: String, | |
| pub mobile: String, | |
| pub area_code: String, | |
| pub password: String, | |
| } | |
| // โโ ่ฑๆ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| fn mask_config(config: &Config) -> AdminConfigResponse { | |
| AdminConfigResponse { | |
| server: ServerConfigView { | |
| host: config.server.host.clone(), | |
| port: config.server.port, | |
| cors_origins: config.server.cors_origins.clone(), | |
| }, | |
| ds_core: DsCoreView { | |
| accounts: config | |
| .ds_core | |
| .accounts | |
| .iter() | |
| .map(|a| AccountView { | |
| email: a.email.clone(), | |
| mobile: a.mobile.clone(), | |
| area_code: a.area_code.clone(), | |
| password: a.password.clone(), | |
| }) | |
| .collect(), | |
| api_base: config.ds_core.api_base.clone(), | |
| wasm_url: config.ds_core.wasm_url.clone(), | |
| user_agent: config.ds_core.user_agent.clone(), | |
| client_version: config.ds_core.client_version.clone(), | |
| client_platform: config.ds_core.client_platform.clone(), | |
| client_locale: config.ds_core.client_locale.clone(), | |
| model_types: config.ds_core.model_types.clone(), | |
| max_input_tokens: config.ds_core.max_input_tokens.clone(), | |
| max_output_tokens: config.ds_core.max_output_tokens.clone(), | |
| input_character_limits: config.ds_core.input_character_limits.clone(), | |
| model_aliases: config.ds_core.model_aliases.clone(), | |
| tool_call: ToolCallTagConfigView { | |
| extra_starts: config.ds_core.tool_call.extra_starts.clone(), | |
| extra_ends: config.ds_core.tool_call.extra_ends.clone(), | |
| }, | |
| }, | |
| proxy: ProxyConfigView { | |
| url: config.proxy.url.clone(), | |
| }, | |
| admin: AdminConfigView { | |
| password_set: !config.admin.password_hash.is_empty(), | |
| jwt_issued_at: config.admin.jwt_issued_at, | |
| }, | |
| api_keys: config | |
| .api_keys | |
| .iter() | |
| .map(|k| ApiKeyEntryView { | |
| key: k.key.clone(), | |
| description: k.description.clone(), | |
| }) | |
| .collect(), | |
| } | |
| } | |
| // โโ Handlers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| /// POST /admin/api/setup โ ้ฆๆฌก่ฎพ็ฝฎๅฏ็ | |
| pub(crate) async fn admin_setup( | |
| State(state): State<AppState>, | |
| body: axum::body::Bytes, | |
| ) -> Response { | |
| let req: SetupRequest = match serde_json::from_slice(&body) { | |
| Ok(r) => r, | |
| Err(e) => return error_response(StatusCode::BAD_REQUEST, &format!("่ฏทๆฑๆ ผๅผ้่ฏฏ: {}", e)), | |
| }; | |
| match super::auth::setup_admin(&state.store, &state.login_limiter, &req.password).await { | |
| Ok(token) => json_response(&LoginResponse { token }), | |
| Err(msg) => { | |
| let status = if msg.contains("ๅทฒ่ฎพ็ฝฎ") { | |
| StatusCode::FORBIDDEN | |
| } else if msg.contains("ๆฌกๆฐ่ฟๅค") { | |
| StatusCode::TOO_MANY_REQUESTS | |
| } else if msg.contains("่ณๅฐ 6 ไฝ") { | |
| StatusCode::BAD_REQUEST | |
| } else { | |
| StatusCode::INTERNAL_SERVER_ERROR | |
| }; | |
| error_response(status, &msg) | |
| } | |
| } | |
| } | |
| pub(crate) async fn admin_login( | |
| State(state): State<AppState>, | |
| body: axum::body::Bytes, | |
| ) -> Response { | |
| let req: LoginRequest = match serde_json::from_slice(&body) { | |
| Ok(r) => r, | |
| Err(e) => return error_response(StatusCode::BAD_REQUEST, &format!("่ฏทๆฑๆ ผๅผ้่ฏฏ: {}", e)), | |
| }; | |
| match super::auth::login_admin(&state.store, &state.login_limiter, &req.password).await { | |
| Ok(token) => json_response(&LoginResponse { token }), | |
| Err(msg) => { | |
| let status = if msg.contains("ๆฌกๆฐ่ฟๅค") { | |
| StatusCode::TOO_MANY_REQUESTS | |
| } else if msg.contains("ๆช่ฎพ็ฝฎๅฏ็ ") { | |
| StatusCode::FORBIDDEN | |
| } else { | |
| StatusCode::UNAUTHORIZED | |
| }; | |
| error_response(status, &msg) | |
| } | |
| } | |
| } | |
| /// GET /admin/api/status | |
| pub(crate) async fn admin_status(State(state): State<AppState>) -> Response { | |
| let statuses = state.adapter.account_statuses(); | |
| let total = statuses.len(); | |
| let busy = statuses.iter().filter(|a| a.state == "busy").count(); | |
| let idle = statuses.iter().filter(|a| a.state == "idle").count(); | |
| let error = statuses.iter().filter(|a| a.state == "error").count(); | |
| let invalid = statuses.iter().filter(|a| a.state == "invalid").count(); | |
| let resp = AdminStatusResponse { | |
| accounts: statuses, | |
| total, | |
| idle, | |
| busy, | |
| error, | |
| invalid, | |
| }; | |
| json_response(&resp) | |
| } | |
| /// GET /admin/api/stats | |
| pub(crate) async fn admin_stats(State(state): State<AppState>) -> Response { | |
| let snapshot = state.stats.snapshot(); | |
| let resp = AdminStatsResponse { stats: snapshot }; | |
| json_response(&resp) | |
| } | |
| /// GET /admin/api/models | |
| pub(crate) async fn admin_models(State(state): State<AppState>) -> Response { | |
| let models = state.adapter.list_models().await; | |
| json_response(&models) | |
| } | |
| /// GET /admin/api/config | |
| pub(crate) async fn admin_config(State(state): State<AppState>) -> Response { | |
| let config = state.config.read().await; | |
| let config_view = mask_config(&config); | |
| json_response(&config_view) | |
| } | |
| /// PUT /admin/api/config โ ๆดๆฐๅนถ็ญ้่ฝฝ้ ็ฝฎ | |
| pub(crate) async fn admin_put_config( | |
| State(state): State<AppState>, | |
| body: axum::body::Bytes, | |
| ) -> Response { | |
| let mut new_config: Config = match serde_json::from_slice(&body) { | |
| Ok(c) => c, | |
| Err(e) => return error_response(StatusCode::BAD_REQUEST, &format!("JSON ่งฃๆๅคฑ่ดฅ: {}", e)), | |
| }; | |
| // Validate | |
| if let Err(e) = new_config.validate() { | |
| return error_response(StatusCode::BAD_REQUEST, &e.to_string()); | |
| } | |
| // Merge: empty/"***" passwords keep existing values from current config; | |
| // API keys match by `id` (stable identifier), falling back to description for old-format migration. | |
| { | |
| let current = state.config.read().await; | |
| for a in &mut new_config.ds_core.accounts { | |
| if (a.password.is_empty() || a.password == "***") | |
| && let Some(existing) = current | |
| .ds_core | |
| .accounts | |
| .iter() | |
| .find(|e| e.email == a.email && e.mobile == a.mobile) | |
| { | |
| a.password.clone_from(&existing.password); | |
| } | |
| } | |
| // Admin ้ ็ฝฎ๏ผ็ฉบ็ password_hash/jwt_secret ไฟ็็ฐๆๅผ๏ผๅ็ซฏไธ่ฟๅ่ฟไบๅญๆฎต๏ผ | |
| if new_config.admin.password_hash.is_empty() { | |
| new_config | |
| .admin | |
| .password_hash | |
| .clone_from(¤t.admin.password_hash); | |
| } | |
| if new_config.admin.jwt_secret.is_empty() { | |
| new_config | |
| .admin | |
| .jwt_secret | |
| .clone_from(¤t.admin.jwt_secret); | |
| } | |
| // ๅฏ็ ไฟฎๆน๏ผๅ็ซฏๅ old_password + new_password | |
| if !new_config.admin.old_password.is_empty() || !new_config.admin.new_password.is_empty() { | |
| if new_config.admin.old_password.is_empty() || new_config.admin.new_password.is_empty() | |
| { | |
| return error_response( | |
| StatusCode::BAD_REQUEST, | |
| "ไฟฎๆนๅฏ็ ้่ฆๅๆถๆไพๆงๅฏ็ ๅๆฐๅฏ็ ", | |
| ); | |
| } | |
| if !bcrypt::verify(&new_config.admin.old_password, ¤t.admin.password_hash) | |
| .unwrap_or(false) | |
| { | |
| return error_response(StatusCode::BAD_REQUEST, "ๆงๅฏ็ ไธๆญฃ็กฎ"); | |
| } | |
| new_config.admin.password_hash = | |
| super::store::hash_password(&new_config.admin.new_password); | |
| new_config.admin.jwt_secret = super::store::generate_hex_secret(); | |
| new_config.admin.jwt_issued_at += 1; | |
| } | |
| } | |
| // Persist | |
| { | |
| let mut guard = state.config.write().await; | |
| *guard = new_config.clone(); | |
| if let Err(e) = guard.save(&state.config_path) { | |
| return error_response( | |
| StatusCode::INTERNAL_SERVER_ERROR, | |
| &format!("ไฟๅญๅคฑ่ดฅ: {}", e), | |
| ); | |
| } | |
| } | |
| // Hot-reload: sync accounts from the new config | |
| state | |
| .adapter | |
| .sync_accounts(&new_config.ds_core.accounts) | |
| .await; | |
| json_response(&serde_json::json!({"ok": true})) | |
| } | |
| pub struct LogsQuery { | |
| pub limit: usize, | |
| } | |
| fn default_limit() -> usize { | |
| 50 | |
| } | |
| /// GET /admin/api/logs โ ่ทๅๆ่ฟ็่ฏทๆฑๆฅๅฟ | |
| pub(crate) async fn admin_logs( | |
| Query(query): Query<LogsQuery>, | |
| State(state): State<AppState>, | |
| ) -> Response { | |
| let logs = state.stats.recent_logs(query.limit); | |
| json_response(&logs) | |
| } | |
| pub struct RuntimeLogsQuery { | |
| pub offset: usize, | |
| pub limit: usize, | |
| } | |
| fn default_runtime_limit() -> usize { | |
| 100 | |
| } | |
| /// GET /admin/api/runtime-logs โ ๅ้กตๆฅ่ฏข่ฟ่กๆฅๅฟ | |
| pub(crate) async fn admin_runtime_logs(Query(query): Query<RuntimeLogsQuery>) -> Response { | |
| let (total, logs) = super::runtime_log::query_logs(query.offset, query.limit).await; | |
| json_response(&serde_json::json!({ | |
| "total": total, | |
| "offset": query.offset, | |
| "limit": query.limit, | |
| "logs": logs, | |
| })) | |
| } | |
| // โโ Helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| fn json_response<T: Serialize>(data: &T) -> Response { | |
| let bytes = serde_json::to_vec(data).unwrap(); | |
| Response::builder() | |
| .status(StatusCode::OK) | |
| .header(header::CONTENT_TYPE, "application/json") | |
| .body(Body::from(bytes)) | |
| .unwrap() | |
| } | |
| fn error_response(status: StatusCode, message: &str) -> Response { | |
| let body = serde_json::json!({"error": message}); | |
| let bytes = serde_json::to_vec(&body).unwrap(); | |
| Response::builder() | |
| .status(status) | |
| .header(header::CONTENT_TYPE, "application/json") | |
| .body(Body::from(bytes)) | |
| .unwrap() | |
| } | |