diff --git a/Cargo.lock b/Cargo.lock index d60524a..e2524d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "aitokenpool" -version = "0.6.3" +version = "0.6.4" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index cf0b3b9..50fb175 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aitokenpool" -version = "0.6.3" +version = "0.6.4" edition = "2021" description = "AI Token 共享池 — 企业 key 池 + 公共共享市场" license = "MIT" diff --git a/README.md b/README.md index 8b76b1b..fc41da7 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke - ✅ **v0.6.1**:**首次启动自动创建初始管理员(rant 2026-08-19T14:35:05)**——空库启动时创建 `admin@aitokenpool.local` + 随机 16 位密码(打印到启动日志,仅首次)+ quotas 账户(balance=0),幂等不重复;新增 `POST /api/auth/change-password` 改密端点(旧密码校验 + argon2 更新);不再需要手工插库 - ✅ **v0.6.2**:**用户自助注册 + 邮箱验证(rant 2026-08-19T14:36:19 方案 B)**——`POST /api/auth/register` + `verify` + `resend-code`;6 位数字验证码(10 分钟有效、5 次错误失效、60 秒重发限频);未验证邮箱不可登录(403);登录页注册表单 + 验证码页(中英 i18n);SMTP 发信(`[mail]` 配置,未配置时 dev 模式验证码打日志/响应) - ✅ **v0.6.3**:**接入方式 URL 配置化(rant 2026-08-19T20:37:37)**——设置页「接入方式」端点不再硬编码域名:新增 `[server].public_url` 配置(缺省 `http://localhost:8080`)+ `GET /api/config` 下发;前端从配置拼接 `{public_url}/v1`、`{public_url}/anthropic`,取不到配置时回退同源 origin +- ✅ **v0.6.4**:**管理员模型信息 CRUD(rant 2026-08-19T20:40:29)**——models 表补 context_length / max_output / vision / cache_hit_input_per_m(迁移 v7,幂等)+ seed 从 models.example.json 写入;新增 `GET|POST /api/admin/models` + `PATCH|DELETE /api/admin/models/:id`(admin 权限,唯一冲突 409,删除后按 0 计费);管理视图「模型管理」tab(搜索/新增/编辑/删除,行内表单 + 二次确认,中英 i18n);`GET /api/models` 市场列表补新字段(读图/上下文真实值) `ui/` 已由纯静态原型升级为**对接真实 API**(登录、钱包、市场、共享、交易、设置、管理、运营全部真实数据),由后端 `ServeDir` 静态托管,无需单独部署前端。 @@ -109,14 +110,15 @@ open http://localhost:8080/ # 浏览器访问 ## API 端点(Bearer 认证) -- `GET /healthz` → `{"status":"ok","version":"0.6.3"}` +- `GET /healthz` → `{"status":"ok","version":"0.6.4"}` - `POST /api/auth/login` → `{api_key}`;`POST /api/auth/change-password`(改密);`POST /api/auth/register|verify|resend-code`(注册+邮箱验证);`GET /api/me` → `{id,email,name,role}`;`GET /api/config` → `{public_url}`(接入端点 base,rant 2026-08-19T20:37:37) - `POST|GET /api/api-keys`(key 脱敏 `atk_live_****xxxx`);`DELETE /api/api-keys/:id`(撤销) - `POST /v1/chat/completions` / `POST /anthropic/v1/messages` / `POST /v1/responses`(网关,三协议互转,非流式 + 流式 SSE 跨协议转换);`GET /v1/models`(OpenAI 兼容模型列表,认证可选) -- `GET /api/models`(模型市场) +- `GET /api/models`(模型市场,含 context_length / max_output / vision / cache_hit_input_per_m) - `POST|GET /api/sharings` + `PATCH /api/sharings/:id`(上架 / 列表 / 暂停 / 恢复 / 删除) - `GET /api/wallet` / `GET /api/transactions?type=` / `GET /api/dashboard`(钱包 / 交易 / 仪表盘) - 管理员:`POST /api/admin/credits` / `GET /api/admin/users` + `PATCH /api/admin/users/:id` / `GET /api/admin/usage` +- 模型管理(rant 2026-08-19T20:40:29):`GET|POST /api/admin/models`(列表 / 新增)+ `PATCH|DELETE /api/admin/models/:id`(更新 / 删除;删除后该 model 调用按 0 计费) - 部门:`GET|POST /api/admin/departments` + `PATCH|DELETE /api/admin/departments/:id` - 加额:`POST|GET /api/raise-requests` + `POST /api/admin/raise-requests/:id/approve|reject` - 运营者:`GET /api/ops/runtime` / `POST /api/ops/credits` / `GET /api/ops/users` diff --git a/src/dao.rs b/src/dao.rs index ad09120..8a0b1c6 100644 --- a/src/dao.rs +++ b/src/dao.rs @@ -158,6 +158,7 @@ pub fn get_model_price( pub fn list_models_with_availability(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT m.provider, m.model, m.currency, m.input_per_m, m.output_per_m, m.context_window, \ + m.context_length, m.max_output, m.vision, m.cache_hit_input_per_m, \ (SELECT COUNT(*) FROM keys k WHERE k.model = m.model AND k.status = 'on') AS avail \ FROM models m ORDER BY m.provider, m.model", )?; @@ -169,7 +170,40 @@ pub fn list_models_with_availability(conn: &Connection) -> Result(3)?, "output_per_m": r.get::<_, f64>(4)?, "context_window": r.get::<_, i64>(5)?, - "available_keys": r.get::<_, i64>(6)?, + "context_length": r.get::<_, i64>(6)?, + "max_output": r.get::<_, i64>(7)?, + "vision": r.get::<_, i64>(8)?, + "cache_hit_input_per_m": r.get::<_, f64>(9)?, + "available_keys": r.get::<_, i64>(10)?, + })) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) +} + +/// 管理员模型列表(rant 2026-08-19T20:40:29):全部字段 + id,供 /api/admin/models +pub fn list_all_models(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, provider, model, currency, input_per_m, output_per_m, \ + context_length, max_output, vision, cache_hit_input_per_m, updated_at \ + FROM models ORDER BY provider, model", + )?; + let rows = stmt.query_map([], |r| { + Ok(serde_json::json!({ + "id": r.get::<_, i64>(0)?, + "provider": r.get::<_, String>(1)?, + "model": r.get::<_, String>(2)?, + "currency": r.get::<_, String>(3)?, + "input_per_m": r.get::<_, f64>(4)?, + "output_per_m": r.get::<_, f64>(5)?, + "context_length": r.get::<_, i64>(6)?, + "max_output": r.get::<_, i64>(7)?, + "vision": r.get::<_, i64>(8)?, + "cache_hit_input_per_m": r.get::<_, f64>(9)?, + "updated_at": r.get::<_, String>(10)?, })) })?; let mut out = Vec::new(); diff --git a/src/db.rs b/src/db.rs index ffba305..d109bae 100644 --- a/src/db.rs +++ b/src/db.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result}; use rusqlite::Connection; -pub const SCHEMA_VERSION: i64 = 6; +pub const SCHEMA_VERSION: i64 = 7; /// 打开(或创建)数据库并执行幂等迁移(生产标准:空库只建表,不种任何假数据) pub fn open(path: &str) -> Result { @@ -214,6 +214,33 @@ pub fn migrate(conn: &Connection) -> Result<()> { created_at TEXT NOT NULL DEFAULT (datetime('now')) );", )?; + // v7(rant 2026-08-19T20:40:29):管理员模型信息 CRUD—— + // models 补 context_length / max_output / vision / cache_hit_input_per_m + //(context_window 为 OpenAI 兼容 /v1/models 字段,与 context_length 并列保留) + ensure_column( + conn, + "models", + "context_length", + "context_length INTEGER NOT NULL DEFAULT 0", + )?; + ensure_column( + conn, + "models", + "max_output", + "max_output INTEGER NOT NULL DEFAULT 0", + )?; + ensure_column( + conn, + "models", + "vision", + "vision INTEGER NOT NULL DEFAULT 0", + )?; + ensure_column( + conn, + "models", + "cache_hit_input_per_m", + "cache_hit_input_per_m REAL NOT NULL DEFAULT 0", + )?; // schema_version:INSERT OR REPLACE 保证幂等 let v: i64 = conn .query_row("SELECT version FROM schema_version", [], |r| r.get(0)) @@ -422,13 +449,17 @@ pub fn seed_models(conn: &Connection, cfg: &crate::config::Config) -> Result<()> ) }); conn.execute( - "INSERT INTO models (provider, model, currency, input_per_m, output_per_m, context_window, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, datetime('now')) \ + "INSERT INTO models (provider, model, currency, input_per_m, output_per_m, context_window, context_length, max_output, vision, cache_hit_input_per_m, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now')) \ ON CONFLICT(provider, model) DO UPDATE SET \ currency = excluded.currency, \ input_per_m = excluded.input_per_m, \ output_per_m = excluded.output_per_m, \ context_window = excluded.context_window, \ + context_length = excluded.context_length, \ + max_output = excluded.max_output, \ + vision = excluded.vision, \ + cache_hit_input_per_m = excluded.cache_hit_input_per_m, \ updated_at = datetime('now')", rusqlite::params![ provider, @@ -436,7 +467,11 @@ pub fn seed_models(conn: &Connection, cfg: &crate::config::Config) -> Result<()> currency, input, output, - m["context_length"].as_i64().unwrap_or(1048576) + m["context_length"].as_i64().unwrap_or(1048576), + m["context_length"].as_i64().unwrap_or(0), + m["max_output"].as_i64().unwrap_or(0), + m["vision"].as_i64().unwrap_or(0), + m["cache_hit_input_per_m"].as_f64().unwrap_or(0.0), ], )?; n += 1; diff --git a/src/routes/admin_models.rs b/src/routes/admin_models.rs new file mode 100644 index 0000000..d60c347 --- /dev/null +++ b/src/routes/admin_models.rs @@ -0,0 +1,293 @@ +//! 管理员模型信息 CRUD(rant 2026-08-19T20:40:29) +//! +//! - GET /api/admin/models:全部模型列表(含 id / 所有字段,供管理表格) +//! - POST /api/admin/models:新增 {provider, model, currency, input_per_m, output_per_m, +//! context_length?, max_output?, vision?, cache_hit_input_per_m?} +//! 校验:provider/model 非空、价格 ≥ 0、currency ∈ {USD, CNY}、(provider, model) 唯一冲突 409 +//! - PATCH /api/admin/models/:id:部分更新(任意字段) +//! - DELETE /api/admin/models/:id:直接删除(模型删除后该 model 的调用无价格行 → 0 计费,README 说明) +//! - 权限:require_admin(role=admin,否则 403) + +use axum::extract::State; +use axum::Json; +use rusqlite::params; +use serde::Deserialize; + +use crate::routes::{internal, ApiErr, AppState, AuthUser}; + +/// 新增模型请求 +#[derive(Debug, Deserialize)] +pub struct ModelCreate { + pub provider: String, + pub model: String, + #[serde(default = "default_currency")] + pub currency: String, + #[serde(default)] + pub input_per_m: f64, + #[serde(default)] + pub output_per_m: f64, + #[serde(default)] + pub context_length: i64, + #[serde(default)] + pub max_output: i64, + #[serde(default)] + pub vision: i64, + #[serde(default)] + pub cache_hit_input_per_m: f64, +} + +fn default_currency() -> String { + "USD".to_string() +} + +/// 部分更新请求(全字段 Option,只更新出现的字段) +#[derive(Debug, Default, Deserialize)] +pub struct ModelPatch { + pub provider: Option, + pub model: Option, + pub currency: Option, + pub input_per_m: Option, + pub output_per_m: Option, + pub context_length: Option, + pub max_output: Option, + pub vision: Option, + pub cache_hit_input_per_m: Option, +} + +/// 校验通用字段:价格 ≥ 0、vision ∈ {0,1}、currency 枚举 +fn validate_common( + currency: &str, + input_per_m: f64, + output_per_m: f64, + vision: i64, +) -> Result<(), ApiErr> { + if !(currency == "USD" || currency == "CNY") { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "currency 仅支持 USD | CNY" })), + )); + } + if input_per_m < 0.0 || output_per_m < 0.0 { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "价格不能为负数" })), + )); + } + if vision != 0 && vision != 1 { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "vision 仅支持 0 | 1" })), + )); + } + Ok(()) +} + +/// 行 → JSON(与 dao::list_all_models 字段一致,另含 OpenAI 兼容 context_window) +fn row_to_json(r: &rusqlite::Row) -> rusqlite::Result { + Ok(serde_json::json!({ + "id": r.get::<_, i64>(0)?, + "provider": r.get::<_, String>(1)?, + "model": r.get::<_, String>(2)?, + "currency": r.get::<_, String>(3)?, + "input_per_m": r.get::<_, f64>(4)?, + "output_per_m": r.get::<_, f64>(5)?, + "context_length": r.get::<_, i64>(6)?, + "max_output": r.get::<_, i64>(7)?, + "vision": r.get::<_, i64>(8)?, + "cache_hit_input_per_m": r.get::<_, f64>(9)?, + "updated_at": r.get::<_, String>(10)?, + "context_window": r.get::<_, i64>(11)?, + })) +} + +const ROW_SELECT: &str = "SELECT id, provider, model, currency, input_per_m, output_per_m, \ + context_length, max_output, vision, cache_hit_input_per_m, updated_at, context_window \ + FROM models "; + +/// GET /api/admin/models:全部模型(管理表格数据源) +pub async fn list( + State(st): State, + auth: AuthUser, +) -> Result>, ApiErr> { + crate::routes::org::require_admin(&auth)?; + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let out = crate::dao::list_all_models(&conn).map_err(internal)?; + Ok(Json(out)) +} + +/// POST /api/admin/models:新增模型 +pub async fn create( + State(st): State, + auth: AuthUser, + Json(req): Json, +) -> Result, ApiErr> { + crate::routes::org::require_admin(&auth)?; + let provider = req.provider.trim().to_string(); + let model = req.model.trim().to_string(); + if provider.is_empty() || model.is_empty() { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "provider 与 model 不能为空" })), + )); + } + validate_common(&req.currency, req.input_per_m, req.output_per_m, req.vision)?; + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let dup: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM models WHERE provider = ?1 AND model = ?2)", + params![provider, model], + |r| r.get(0), + ) + .unwrap_or(false); + if dup { + return Err(( + axum::http::StatusCode::CONFLICT, + Json(serde_json::json!({ "error": "模型已存在(provider+model 唯一)" })), + )); + } + let id: i64 = conn + .query_row( + "INSERT INTO models (provider, model, currency, input_per_m, output_per_m, \ + context_length, max_output, vision, cache_hit_input_per_m, context_window, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, datetime('now')) \ + RETURNING id", + params![ + provider, + model, + req.currency, + req.input_per_m, + req.output_per_m, + req.context_length, + req.max_output, + req.vision, + req.cache_hit_input_per_m, + // OpenAI 兼容 context_window 与 context_length 对齐(/v1/models 用) + req.context_length, + ], + |r| r.get::<_, i64>(0), + ) + .map_err(internal)?; + let row = conn + .query_row(&format!("{ROW_SELECT} WHERE id = ?1"), [id], row_to_json) + .map_err(internal)?; + Ok(Json(row)) +} + +/// PATCH /api/admin/models/:id:部分更新 +pub async fn patch( + State(st): State, + auth: AuthUser, + axum::extract::Path(id): axum::extract::Path, + Json(req): Json, +) -> Result, ApiErr> { + crate::routes::org::require_admin(&auth)?; + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM models WHERE id = ?1)", + [id], + |r| r.get(0), + ) + .unwrap_or(false); + if !exists { + return Err(( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "模型不存在" })), + )); + } + // 部分更新:先取当前值,合并后校验 + 写回(简单且避免逐列拼接 SQL) + let cur: (String, String, String, f64, f64, i64, i64, i64, f64) = conn + .query_row( + "SELECT provider, model, currency, input_per_m, output_per_m, \ + context_length, max_output, vision, cache_hit_input_per_m \ + FROM models WHERE id = ?1", + [id], + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + r.get(8)?, + )) + }, + ) + .map_err(internal)?; + let provider = req.provider.unwrap_or(cur.0).trim().to_string(); + let model = req.model.unwrap_or(cur.1).trim().to_string(); + let currency = req.currency.unwrap_or(cur.2); + let input_per_m = req.input_per_m.unwrap_or(cur.3); + let output_per_m = req.output_per_m.unwrap_or(cur.4); + let context_length = req.context_length.unwrap_or(cur.5); + let max_output = req.max_output.unwrap_or(cur.6); + let vision = req.vision.unwrap_or(cur.7); + let cache_hit_input_per_m = req.cache_hit_input_per_m.unwrap_or(cur.8); + if provider.is_empty() || model.is_empty() { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "provider 与 model 不能为空" })), + )); + } + validate_common(¤cy, input_per_m, output_per_m, vision)?; + // 改名时检查 (provider, model) 唯一(排除自身) + let dup: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM models WHERE provider = ?1 AND model = ?2 AND id <> ?3)", + params![provider, model, id], + |r| r.get(0), + ) + .unwrap_or(false); + if dup { + return Err(( + axum::http::StatusCode::CONFLICT, + Json(serde_json::json!({ "error": "模型已存在(provider+model 唯一)" })), + )); + } + conn.execute( + "UPDATE models SET provider = ?1, model = ?2, currency = ?3, input_per_m = ?4, \ + output_per_m = ?5, context_length = ?6, max_output = ?7, vision = ?8, \ + cache_hit_input_per_m = ?9, context_window = ?6, updated_at = datetime('now') \ + WHERE id = ?10", + params![ + provider, + model, + currency, + input_per_m, + output_per_m, + context_length, + max_output, + vision, + cache_hit_input_per_m, + id, + ], + ) + .map_err(internal)?; + let row = conn + .query_row(&format!("{ROW_SELECT} WHERE id = ?1"), [id], row_to_json) + .map_err(internal)?; + Ok(Json(row)) +} + +/// DELETE /api/admin/models/:id:直接删除(0 计费语义见 rant:无价格行 → 调用按 0 计费) +pub async fn remove( + State(st): State, + auth: AuthUser, + axum::extract::Path(id): axum::extract::Path, +) -> Result, ApiErr> { + crate::routes::org::require_admin(&auth)?; + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + let n = conn + .execute("DELETE FROM models WHERE id = ?1", [id]) + .map_err(internal)?; + if n == 0 { + return Err(( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "模型不存在" })), + )); + } + Ok(Json(serde_json::json!({ "status": "ok", "id": id }))) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 693b5b2..af7b668 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -10,6 +10,7 @@ //! - GET /api/models(市场) pub mod admin; +pub mod admin_models; pub mod api_keys; pub mod ops; pub mod org; @@ -420,6 +421,15 @@ pub fn router() -> Router { "/api/admin/departments/:id", axum::routing::patch(org::patch).delete(org::remove), ) + // rant 2026-08-19T20:40:29:管理员模型信息 CRUD + .route( + "/api/admin/models", + get(admin_models::list).post(admin_models::create), + ) + .route( + "/api/admin/models/:id", + axum::routing::patch(admin_models::patch).delete(admin_models::remove), + ) .route("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/api/raise-requests", post(raise::create).get(raise::list)) .route( "/api/admin/raise-requests/:id/approve", @@ -748,6 +758,141 @@ mod tests { assert_eq!(s, StatusCode::BAD_REQUEST, "负数金额应 400: {body}"); } + // --- 管理员模型信息 CRUD(rant 2026-08-19T20:40:29) --- + + #[tokio::test] + async fn admin_models_crud_full_cycle() { + let st = test_state("admmodels"); + let admin_bearer = login_bearer(&st, "admin@aitokenpool.local", "admin1234").await; + let demo_bearer = login_bearer(&st, "demo@aitokenpool.local", "demo1234").await; + // 非 admin → 403 + let (s, body) = get(st.clone(), "/api/admin/models", Some(&demo_bearer)).await; + assert_eq!(s, StatusCode::FORBIDDEN, "非 admin 应 403: {body}"); + // 列表:seed 后有数据且含新字段(context_length/vision) + let (s, body) = get(st.clone(), "/api/admin/models", Some(&admin_bearer)).await; + assert_eq!(s, StatusCode::OK, "列表应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let arr = v.as_array().expect("数组"); + assert!(arr.len() >= 10, "seed 模型 ≥10: {body}"); + let dp = arr + .iter() + .find(|m| m["model"] == "deepseek-v4-pro") + .expect("deepseek-v4-pro 存在"); + assert_eq!( + dp["context_length"], 1048576, + "seed 写入 context_length: {body}" + ); + assert_eq!(dp["vision"], 0, "无 vision 字段默认 0"); + assert!(dp["id"].as_i64().is_some(), "含 id"); + // 新增 + let (s, body) = post( + st.clone(), + "/api/admin/models", + r#"{"provider":"test","model":"test-model-1","currency":"USD","input_per_m":1.5,"output_per_m":3.0,"context_length":128000,"max_output":16384,"vision":1,"cache_hit_input_per_m":0.1}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::OK, "新增应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["model"], "test-model-1"); + assert_eq!(v["vision"], 1); + assert_eq!( + v["context_window"], 128000, + "context_window 与 context_length 对齐" + ); + let new_id = v["id"].as_i64().unwrap(); + // 重复 (provider, model) → 409 + let (s, body) = post( + st.clone(), + "/api/admin/models", + r#"{"provider":"test","model":"test-model-1"}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::CONFLICT, "重复应 409: {body}"); + // 非法输入 → 400(负数价格 / 空 model / 非法 vision) + let (s, _) = post( + st.clone(), + "/api/admin/models", + r#"{"provider":"test","model":"m2","input_per_m":-1}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST, "负数价格应 400"); + let (s, _) = post( + st.clone(), + "/api/admin/models", + r#"{"provider":"test","model":" "}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST, "空 model 应 400"); + let (s, _) = post( + st.clone(), + "/api/admin/models", + r#"{"provider":"test","model":"m3","vision":2}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::BAD_REQUEST, "vision≠0/1 应 400"); + // PATCH 部分更新(改价格 + vision) + let (s, body) = patch( + st.clone(), + &format!("/api/admin/models/{new_id}"), + r#"{"input_per_m":2.5,"vision":0}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::OK, "PATCH 应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["input_per_m"], 2.5); + assert_eq!(v["vision"], 0); + assert_eq!(v["model"], "test-model-1", "未改字段保留"); + // PATCH 改为已存在 (provider, model) → 409(须同时提供 provider+model 才会撞唯一) + let (s, _) = patch( + st.clone(), + &format!("/api/admin/models/{new_id}"), + r#"{"provider":"deepseek","model":"deepseek-v4-pro"}"#, + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::CONFLICT, "改名撞唯一应 409"); + // 删除 + let (s, body) = del( + st.clone(), + &format!("/api/admin/models/{new_id}"), + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::OK, "删除应 200: {body}"); + let (s, _) = del( + st.clone(), + &format!("/api/admin/models/{new_id}"), + Some(&admin_bearer), + ) + .await; + assert_eq!(s, StatusCode::NOT_FOUND, "重复删除应 404"); + } + + #[tokio::test] + async fn market_models_include_new_fields() { + // GET /api/models 响应补 context_length/max_output/vision/cache_hit_input_per_m + let st = test_state("mkfields"); + let demo_bearer = login_bearer(&st, "demo@aitokenpool.local", "demo1234").await; + let (s, body) = get(st.clone(), "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/api/models", Some(&demo_bearer)).await; + assert_eq!(s, StatusCode::OK, "models 应 200: {body}"); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + let arr = v.as_array().expect("数组"); + let dp = arr + .iter() + .find(|m| m["model"] == "deepseek-v4-pro") + .expect("deepseek-v4-pro 存在"); + assert_eq!(dp["context_length"], 1048576); + assert!(dp["max_output"].as_i64().is_some()); + assert!(dp["vision"].as_i64().is_some()); + assert_eq!(dp["cache_hit_input_per_m"], 0.003625, "seed 写入缓存命中价"); + } + #[tokio::test] async fn admin_users_and_usage_lists() { let st = test_state("adminlist"); diff --git a/ui/index.html b/ui/index.html index 795c1a7..5abeb13 100644 --- a/ui/index.html +++ b/ui/index.html @@ -421,6 +421,7 @@

管理视图

+
@@ -495,6 +496,67 @@

添加部门

+ + diff --git a/ui/js/app.js b/ui/js/app.js index c565b13..359962b 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -1716,9 +1716,123 @@ : '
' + EMPTY_ICON + "

" + T("admin.usage.empty.dept") + "

"; } else if (tab === "org") { renderOrg(); + } else if (tab === "models") { + renderAdminModels(); } } + /* --- 模型管理(rant 2026-08-19T20:40:29:管理员模型信息 CRUD) --- */ + + // 模型搜索过滤 + 表格渲染(数据来自 /api/admin/models;零 mock:加载失败 → 空态 + 重试) + function renderAdminModels() { + if (!Live.adminModels) { + $("#model-body").innerHTML = loadErrorRow(7, T("admin.models.loadFail"), T("err.loadFail")); + pulseTbody($("#model-body")); + return; + } + const rawQ = $("#model-search").value || ""; + const q = rawQ.toLowerCase(); + const list = Live.adminModels.filter((m) => !q || + (m.provider || "").toLowerCase().includes(q) || (m.model || "").toLowerCase().includes(q)); + $("#model-body").innerHTML = list.length ? list.map((m, i) => + "" + esc(m.provider) + "" + + "" + esc(m.model) + "" + + '' + D.fmt(m.input_per_m || 0) + "" + + '' + D.fmt(m.output_per_m || 0) + "" + + '' + fmtCtx(m.context_length || m.context_window || 0) + "" + + '' + fmtCtx(m.max_output || 0) + "" + + "" + (m.vision ? '' + T("admin.models.vision.yes") + "" : '' + T("admin.models.vision.no") + "") + "" + + " " + + "" + ).join("") : emptyRow(7, T("admin.models.empty"), T("admin.models.empty.sub")); + pulseTbody($("#model-body")); + } + + // 上下文数字格式化:1048576 → "1M";0 → "—" + function fmtCtx(n) { + if (!n) return "—"; + if (n >= 1000000) return (n / 1000000).toFixed(n % 1000000 === 0 ? 0 : 1) + "M"; + if (n >= 1000) return (n / 1000).toFixed(n % 1000 === 0 ? 0 : 1) + "K"; + return String(n); + } + + // 打开模型表单:i=null 新增;i=索引 编辑(预填) + function openModelForm(i) { + _editingModelId = (i === null) ? null : (Live.adminModels && Live.adminModels[i] ? Live.adminModels[i].id : null); + const m = (i === null || !Live.adminModels) ? null : Live.adminModels[i]; + $("#model-form-title").innerHTML = m ? T("admin.models.form.title.edit") : T("admin.models.form.title.add"); + $("#model-form-provider").value = m ? m.provider : ""; + $("#model-form-model").value = m ? m.model : ""; + $("#model-form-currency").value = m ? m.currency : "USD"; + $("#model-form-in").value = m ? String(m.input_per_m || 0) : "0"; + $("#model-form-out").value = m ? String(m.output_per_m || 0) : "0"; + $("#model-form-ctx").value = m ? String(m.context_length || m.context_window || 0) : "0"; + $("#model-form-outmax").value = m ? String(m.max_output || 0) : "0"; + $("#model-form-vision").checked = m ? !!m.vision : false; + clearFieldError($("#model-form-provider")); + clearFieldError($("#model-form-model")); + $("#model-form-card").hidden = false; + $("#model-form-provider").focus(); + } + + // 提交模型表单(新增 POST / 编辑 PATCH);校验后调真实 API + function confirmModel() { + const editingId = _editingModelId; + const provider = String($("#model-form-provider").value).trim(); + const model = String($("#model-form-model").value).trim(); + const input = Number($("#model-form-in").value); + const output = Number($("#model-form-out").value); + const ctx = Number($("#model-form-ctx").value); + const outmax = Number($("#model-form-outmax").value); + let firstErr = null; + if (!provider) { setFieldError($("#model-form-provider"), T("admin.models.err.provider")); firstErr = firstErr || $("#model-form-provider"); } + else clearFieldError($("#model-form-provider")); + if (!model) { setFieldError($("#model-form-model"), T("admin.models.err.model")); firstErr = firstErr || $("#model-form-model"); } + else clearFieldError($("#model-form-model")); + if (input < 0 || output < 0) { toast(T("admin.models.err.price"), "error"); return; } + if (firstErr) { firstErr.focus(); return; } + const body = { + provider, model, + currency: $("#model-form-currency").value, + input_per_m: input, output_per_m: output, + context_length: ctx || 0, max_output: outmax || 0, + vision: $("#model-form-vision").checked ? 1 : 0, + }; + const btn = $("#model-confirm"); + withLoading(btn, () => { + const req = editingId ? api.patch("/api/admin/models/" + editingId, body) : api.post("/api/admin/models", body); + req.then(async () => { + await loadAdmin(); + $("#model-form-card").hidden = true; + toast(editingId ? T("admin.models.saved") : T("admin.models.added"), "success"); + }).catch((err) => { + toast((err && err.message) ? I18n.mapErr(err.message) : T("admin.models.fail"), "error"); + }); + }); + } + + // 编辑中的模型 id 追踪(新增=null;编辑=行 id) + let _editingModelId = null; + + // 打开编辑表单时记录 id(入口:表格「编辑」按钮) + function editModelRow(i) { + if (!Live.adminModels) return; + openModelForm(i); + } + + // 删除模型(行内二次确认 → DELETE) + function deleteModel(i) { + if (!Live.adminModels) return; + const m = Live.adminModels[i]; + if (!m) return; + api.del("/api/admin/models/" + m.id).then(async () => { + await loadAdmin(); + toast(T("admin.models.deleted", { model: m.model }), "success"); + }).catch((err) => { + toast((err && err.message) ? I18n.mapErr(err.message) : T("admin.models.fail"), "error"); + }); + } + // P2-B/P2-C:拉取管理员数据(users + usage + departments + raise-requests;登录且 role=admin 时) async function loadAdmin() { if (!loggedIn()) return; @@ -1726,6 +1840,7 @@ try { await liveLoad("adminUsage", "/api/admin/usage"); } catch (e) { Live.adminUsage = null; } try { await liveLoad("departments", "/api/admin/departments"); } catch (e) { Live.departments = null; } try { await liveLoad("raiseRequests", "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/api/raise-requests"); } catch (e) { Live.raiseRequests = null; } + try { await liveLoad("adminModels", "/api/admin/models"); } catch (e) { Live.adminModels = null; } renderAdmin(); } @@ -2177,6 +2292,7 @@ raiseRequests: null, // P2-C GET /api/raise-requests(admin 视角全部) opsRuntime: null, // P2-C GET /api/ops/runtime opsUsers: null, // P2-C GET /api/ops/users + adminModels: null, // rant 20:40:29 GET /api/admin/models(管理表格数据源) }; function loggedIn() { return !!api.getToken() && !isGuest; } @@ -2752,6 +2868,23 @@ renderAdmin(); } }); + + // 模型管理(rant 2026-08-19T20:40:29):搜索 / 添加 / 编辑 / 删除 / 表单 + wireSearch($("#model-search"), renderAdmin); + + $("#add-model-btn").addEventListener("click", () => openModelForm(null)); + $("#model-confirm").addEventListener("click", (e) => withLoading(e.currentTarget, confirmModel)); + $("#model-cancel").addEventListener("click", () => { $("#model-form-card").hidden = true; }); + $("#model-form-provider").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); $("#model-confirm").click(); } }); + $("#model-form-model").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); $("#model-confirm").click(); } }); + $("#model-form-card").addEventListener("keydown", (e) => { if (e.key === "Escape") { $("#model-form-card").hidden = true; } }); + + $("#model-body").addEventListener("click", (e) => { + const em = e.target.closest("[data-model-edit]"); + if (em) { editModelRow(Number(em.dataset.modelEdit)); return; } + const dl = e.target.closest("[data-model-del]"); + if (dl) { confirmInline(dl, () => deleteModel(Number(dl.dataset.modelDel)), T("admin.models.del.confirm")); return; } + }); } /* ---------------- 初始化 ---------------- */ diff --git a/ui/js/i18n.js b/ui/js/i18n.js index 5dd15e9..08b3289 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -390,6 +390,49 @@ "admin.tab.employees": "成员管理", "admin.tab.usage": "用量报表", "admin.tab.org": "组织管理", + "admin.tab.models": "模型管理", + "admin.models.search.ph": "搜索厂商 / 模型…", + "admin.models.add": "添加模型", + "admin.models.list": "模型列表", + "admin.models.sub": "厂商 / 模型 / 价格 / 上下文 / 读图——增删改查即时生效;模型删除后该 model 的调用按 0 计费。", + "admin.models.col.provider": "厂商", + "admin.models.col.model": "模型", + "admin.models.col.in": "输入价", + "admin.models.col.out": "输出价", + "admin.models.col.ctx": "上下文", + "admin.models.col.outmax": "最大输出", + "admin.models.col.vision": "读图", + "admin.models.edit": "编辑", + "admin.models.del": "删除", + "admin.models.del.confirm": "确认删除?", + "admin.models.empty": "没有匹配的模型", + "admin.models.empty.sub": "调整搜索条件,或点击右上角「添加模型」", + "admin.models.loadFail": "模型列表加载失败", + "admin.models.vision.yes": "支持", + "admin.models.vision.no": "不支持", + "admin.models.form.title.add": "添加模型", + "admin.models.form.title.edit": "编辑模型", + "admin.models.form.provider": "厂商", + "admin.models.form.provider.ph": "如:deepseek", + "admin.models.form.model": "模型名", + "admin.models.form.model.ph": "如:deepseek-v4-pro", + "admin.models.form.currency": "币种", + "admin.models.form.in": "输入价(/百万 token)", + "admin.models.form.in.ph": "0.435", + "admin.models.form.out": "输出价(/百万 token)", + "admin.models.form.out.ph": "0.87", + "admin.models.form.ctx": "上下文窗口", + "admin.models.form.outmax": "最大输出", + "admin.models.form.vision": "支持读图(vision)", + "admin.models.form.confirm": "确认", + "admin.models.form.cancel": "取消", + "admin.models.err.provider": "厂商不能为空", + "admin.models.err.model": "模型名不能为空", + "admin.models.err.price": "价格不能为负数", + "admin.models.added": "模型已添加", + "admin.models.saved": "模型已保存", + "admin.models.deleted": "模型 {model} 已删除", + "admin.models.fail": "操作失败,请重试", "admin.emp.col.member": "成员", "admin.emp.col.dept": "部门", "admin.emp.col.quota": "配额 / 已用", @@ -1014,6 +1057,49 @@ "admin.tab.employees": "Members", "admin.tab.usage": "Usage", "admin.tab.org": "Organization", + "admin.tab.models": "Models", + "admin.models.search.ph": "Search provider / model…", + "admin.models.add": "Add model", + "admin.models.list": "Model list", + "admin.models.sub": "Provider / model / price / context / vision — CRUD takes effect immediately; calls for a deleted model bill at 0.", + "admin.models.col.provider": "Provider", + "admin.models.col.model": "Model", + "admin.models.col.in": "Input $", + "admin.models.col.out": "Output $", + "admin.models.col.ctx": "Context", + "admin.models.col.outmax": "Max output", + "admin.models.col.vision": "Vision", + "admin.models.edit": "Edit", + "admin.models.del": "Delete", + "admin.models.del.confirm": "Delete?", + "admin.models.empty": "No matching models", + "admin.models.empty.sub": "Adjust the search, or click “Add model”", + "admin.models.loadFail": "Failed to load models", + "admin.models.vision.yes": "Yes", + "admin.models.vision.no": "No", + "admin.models.form.title.add": "Add model", + "admin.models.form.title.edit": "Edit model", + "admin.models.form.provider": "Provider", + "admin.models.form.provider.ph": "e.g. deepseek", + "admin.models.form.model": "Model", + "admin.models.form.model.ph": "e.g. deepseek-v4-pro", + "admin.models.form.currency": "Currency", + "admin.models.form.in": "Input price (/M tokens)", + "admin.models.form.in.ph": "0.435", + "admin.models.form.out": "Output price (/M tokens)", + "admin.models.form.out.ph": "0.87", + "admin.models.form.ctx": "Context window", + "admin.models.form.outmax": "Max output", + "admin.models.form.vision": "Supports vision", + "admin.models.form.confirm": "Confirm", + "admin.models.form.cancel": "Cancel", + "admin.models.err.provider": "Provider is required", + "admin.models.err.model": "Model name is required", + "admin.models.err.price": "Price cannot be negative", + "admin.models.added": "Model added", + "admin.models.saved": "Model saved", + "admin.models.deleted": "Model {model} deleted", + "admin.models.fail": "Operation failed, please retry", "admin.emp.col.member": "Member", "admin.emp.col.dept": "Department", "admin.emp.col.quota": "Quota / Used",