Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "aitokenpool"
version = "0.4.0"
version = "0.4.1"
edition = "2021"
description = "AI Token 共享池 — 企业 key 池 + 公共共享市场"
license = "MIT"
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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` 静态托管,无需单独部署前端。

Expand Down Expand Up @@ -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`(钱包 / 交易 / 仪表盘)
Expand Down
38 changes: 38 additions & 0 deletions src/dao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<serde_json::Value>> {
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<i64> {
let (id, hash) = find_user_by_email(conn, email).ok_or_else(|| anyhow!("用户不存在"))?;
Expand Down
24 changes: 20 additions & 4 deletions src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Connection> {
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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;
}
Expand Down
93 changes: 93 additions & 0 deletions src/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>,
headers: axum::http::HeaderMap,
) -> Result<Json<serde_json::Value>, 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::*;
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ pub fn router() -> Router<AppState> {
.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:共享 / 钱包 / 交易 / 仪表盘
Expand Down
Loading