From 29a46f4dd15fcdb7dbf7d992715c2197a8541b25 Mon Sep 17 00:00:00 2001 From: argszero Date: Tue, 18 Aug 2026 18:14:30 +0800 Subject: [PATCH] feat(gateway): GET /v1/models OpenAI-compatible model list endpoint (v0.4.1) OpenAI-compatible GET /v1/models (alias /models) with optional auth: - no Bearer -> plain model list (id/object/created/owned_by/display_name/context_window) - with Bearer -> adds available_keys per model - models table gains context_window column (ensure_column migration for existing DBs) - seed_models reads context_window from data/models.example.json Closes rant 2026-08-18T18:10:18 (GET /v1/models missed in P3-A #83). --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 7 ++-- src/dao.rs | 38 +++++++++++++++++++ src/db.rs | 24 ++++++++++-- src/gateway.rs | 93 +++++++++++++++++++++++++++++++++++++++++++++++ src/routes/mod.rs | 2 + 7 files changed, 159 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 39e081b..0c99acb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "aitokenpool" -version = "0.4.0" +version = "0.4.1" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 42db8ba..890857f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aitokenpool" -version = "0.4.0" +version = "0.4.1" edition = "2021" description = "AI Token 共享池 — 企业 key 池 + 公共共享市场" license = "MIT" diff --git a/README.md b/README.md index 3962ed1..558760a 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke ## 状态 -后端 + 前端已全部实现,当前版本 **v0.4.0**(92/92 测试全绿): +后端 + 前端已全部实现,当前版本 **v0.4.1**(94/94 测试全绿): - ✅ **P0-A(v0.2.0)**:后端骨架 + TOML 配置(`Config::validate`:points_per_unit>0 / plan→provider 存在 / endpoints≥1 / protocol 枚举)+ SQLite 数据层(幂等迁移 v4 + demo/admin/ops 种子)+ 认证(argon2 + Bearer API Key)+ API Key 端点 - ✅ **P0-B(v0.2.1)**:网关转发(OpenAI Chat Completions + Anthropic Messages)+ 路由故障转移(粘性 / 健康冷却 / 3 次切换上限)+ 计量账本(点数计算、90/10 分成、事务性 settle) @@ -27,6 +27,7 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke - ✅ **P2-B(v0.3.2)**:各页面数据对接真实 API——市场 / 共享 / 交易 / 仪表盘 / API Key 管理全接后端 - ✅ **P2-C(v0.3.3)**:部门/成员管理 + 加额审批 + 用量报表(users/models/departments 三组聚合)+ 运营者视图(runtime/credits/users) - ✅ **P3-A(v0.4.0)**:网关三协议互转——OpenAI Chat / OpenAI Responses / Anthropic 任一端点可调用只暴露其他协议的 plan(自动转换,同协议透传零损耗)+ 新增 `/v1/responses` 端点 +- ✅ **P3-A 补充(v0.4.1)**:`GET /v1/models` OpenAI 兼容模型列表(认证可选,带 Bearer 附加 available_keys;`/models` 别名) `ui/` 已由纯静态原型升级为**对接真实 API**(登录、钱包、市场、共享、交易、设置、管理、运营全部真实数据),由后端 `ServeDir` 静态托管,无需单独部署前端。 @@ -69,10 +70,10 @@ open http://localhost:8080/ # 浏览器访问 ## API 端点(Bearer 认证) -- `GET /healthz` → `{"status":"ok","version":"0.4.0"}` +- `GET /healthz` → `{"status":"ok","version":"0.4.1"}` - `POST /api/auth/login` → `{api_key}`;`GET /api/me` → `{id,email,name,role}` - `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 流式同协议透传) +- `POST /v1/chat/completions` / `POST /anthropic/v1/messages` / `POST /v1/responses`(网关,三协议互转;SSE 流式同协议透传);`GET /v1/models`(OpenAI 兼容模型列表,认证可选) - `GET /api/models`(模型市场) - `POST|GET /api/sharings` + `PATCH /api/sharings/:id`(上架 / 列表 / 暂停 / 恢复 / 删除) - `GET /api/wallet` / `GET /api/transactions?type=` / `GET /api/dashboard`(钱包 / 交易 / 仪表盘) diff --git a/src/dao.rs b/src/dao.rs index 6486761..c5a8556 100644 --- a/src/dao.rs +++ b/src/dao.rs @@ -186,6 +186,44 @@ pub fn touch_api_key(conn: &Connection, key: &str) -> Result<()> { Ok(()) } +/// OpenAI 兼容模型列表(GET /v1/models,rant 2026-08-18T18:10:18): +/// id=model 名、display_name=model 名、context_window 来自 models.context_window; +/// with_availability=true 时附加 available_keys(带 Bearer 时)。 +pub fn list_models_openai( + conn: &Connection, + with_availability: bool, +) -> Result> { + let avail_sub = if with_availability { + ",\n (SELECT COUNT(*) FROM keys k WHERE k.model = m.model AND k.status = 'on') AS avail" + } else { + "" + }; + let sql = format!( + "SELECT m.model, m.context_window{avail_sub} \ + FROM models m ORDER BY m.provider, m.model" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map([], |r| { + let mut v = serde_json::json!({ + "id": r.get::<_, String>(0)?, + "object": "model", + "created": 0, + "owned_by": "aitokenpool", + "display_name": r.get::<_, String>(0)?, + "context_window": r.get::<_, i64>(1)?, + }); + if with_availability { + v["available_keys"] = serde_json::json!(r.get::<_, i64>(2)?); + } + Ok(v) + })?; + let mut out = Vec::new(); + for r in rows { + out.push(r?); + } + Ok(out) +} + /// 校验口令(供登录用) pub fn verify_user_password(conn: &Connection, email: &str, pw: &str) -> Result { let (id, hash) = find_user_by_email(conn, email).ok_or_else(|| anyhow!("用户不存在"))?; diff --git a/src/db.rs b/src/db.rs index 32a915e..020645c 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 = 4; +pub const SCHEMA_VERSION: i64 = 5; /// 打开(或创建)数据库并执行幂等迁移 + dev 种子 pub fn open(path: &str) -> Result { @@ -189,6 +189,14 @@ pub fn migrate(conn: &Connection) -> Result<()> { reviewed_at TEXT );", )?; + // v5(rant 2026-08-18T18:10:18):models.context_window(OpenAI 兼容 /v1/models 用, + // 默认 1048576;seed 时以 context_length 覆盖) + ensure_column( + conn, + "models", + "context_window", + "context_window INTEGER NOT NULL DEFAULT 1048576", + )?; // schema_version:INSERT OR REPLACE 保证幂等 let v: i64 = conn .query_row("SELECT version FROM schema_version", [], |r| r.get(0)) @@ -366,14 +374,22 @@ 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, updated_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, datetime('now')) \ + "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')) \ 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, \ updated_at = datetime('now')", - rusqlite::params![provider, model, currency, input, output], + rusqlite::params![ + provider, + model, + currency, + input, + output, + m["context_length"].as_i64().unwrap_or(1048576) + ], )?; n += 1; } diff --git a/src/gateway.rs b/src/gateway.rs index 4fbdeaa..38abe15 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -659,6 +659,27 @@ pub async fn plans( Ok(Json(list)) } +/// GET /v1/models(OpenAI 兼容模型列表,rant 2026-08-18T18:10:18) +/// +/// 认证可选:无 token → 纯 OpenAI 列表;带有效 Bearer → data[] 附加 available_keys。 +/// 与 /api/models 的关系:/api/models 保留(市场页专用,含价格 input_per_m/output_per_m); +/// /v1/models 为 OpenAI 标准格式(OpenAI SDK / 工具用)。 +pub async fn v1_models( + State(st): State, + headers: axum::http::HeaderMap, +) -> Result, ApiErr> { + let conn = st.db.lock().map_err(|_| internal("db lock poisoned"))?; + // 可选认证:header 有合法 Bearer → 附加 available_keys;无/非法 → 纯列表 + let with_availability = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|key| dao::find_api_key_user_and_id(&conn, key).is_some()) + .unwrap_or(false); + let data = dao::list_models_openai(&conn, with_availability).map_err(internal)?; + Ok(Json(serde_json::json!({ "object": "list", "data": data }))) +} + #[cfg(test)] mod tests { use super::*; @@ -1239,6 +1260,78 @@ mod tests { up.abort(); } + /// P3-A 补充(rant 2026-08-18T18:10:18):GET /v1/models 无认证 → OpenAI 列表格式 + #[tokio::test] + async fn v1_models_public_list_format() { + let st = test_state("v1m", "test-plan", "http://127.0.0.1:9"); + for uri in ["/v1/models", "/models"] { + let resp = router() + .with_state(st.clone()) + .oneshot( + Request::builder() + .method("GET") + .uri(uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "uri={uri}"); + let bytes = axum::body::to_bytes(resp.into_body(), 4 * 1024 * 1024) + .await + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["object"], "list", "uri={uri}"); + let data = v["data"].as_array().expect("data 数组"); + assert!(!data.is_empty(), "models 已 seed"); + let first = &data[0]; + assert_eq!(first["object"], "model"); + assert!(first.get("id").is_some()); + assert_eq!(first["created"], 0); + assert_eq!(first["owned_by"], "aitokenpool"); + assert_eq!( + first["display_name"], first["id"], + "display_name 与 id 一致" + ); + assert!(first.get("context_window").is_some()); + // 无认证 → 不附加 available_keys + assert!( + first.get("available_keys").is_none(), + "无认证不应有 available_keys" + ); + } + } + + /// P3-A 补充:带有效 Bearer → data[].available_keys 存在 + #[tokio::test] + async fn v1_models_with_token_adds_available_keys() { + let st = test_state("v1mk", "test-plan", "http://127.0.0.1:9"); + let key = login_key(st.clone()).await; + let resp = router() + .with_state(st.clone()) + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/models") + .header("authorization", format!("Bearer {key}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), 4 * 1024 * 1024) + .await + .unwrap(); + let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let data = v["data"].as_array().unwrap(); + assert!(!data.is_empty()); + assert!( + data[0].get("available_keys").is_some(), + "带 token 附加 available_keys" + ); + } + #[tokio::test] async fn models_endpoint_lists_with_availability() { let st = test_state("mk", "test-plan", "http://127.0.0.1:9"); diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 61f2e90..b5d0668 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -174,6 +174,8 @@ pub fn router() -> Router { .route("/v1/chat/completions", post(gateway::chat_completions)) .route("/anthropic/v1/messages", post(gateway::anthropic_messages)) .route("/v1/responses", post(gateway::responses)) + .route("/v1/models", get(gateway::v1_models)) + .route("/models", get(gateway::v1_models)) .route("/api/models", get(gateway::models)) .route("/api/plans", get(gateway::plans)) // P0-C:共享 / 钱包 / 交易 / 仪表盘