Skip to content
Draft
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
4 changes: 2 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ API 请求

```bash
# .env 文件
JWT_SECRET=api-hub-v2-jiaguangqi-jwt-secret-2025
JWT_SECRET=<使用 openssl rand -hex 32 生成的独立密钥>
```

⚠️ **重要**: JWT_SECRET 必须固定,否则服务器重启后所有 token 失效
Expand Down Expand Up @@ -305,7 +305,7 @@ npm install

# 3. 配置环境变量
cat > .env << 'EOF'
JWT_SECRET=your-fixed-secret-key
JWT_SECRET=<使用 openssl rand -hex 32 生成的独立密钥>
EOF

# 4. 启动后端服务
Expand Down
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Changelog

本项目的重要变更均记录在此文件中。

## 2.1.0 - 2026-08-10

### 新增

- 支持 OpenAI 兼容的 SSE 流式对话转发。
- 完整透传 `tools`、`tool_choice`、`response_format`、采样参数和 token 限制等智能体参数。
- 客户端中断流式请求时,同步取消上游请求。

### 优化

- 新增渠道时强制校验渠道名称、上游地址和 API Key。
- 新增或修改模型时,校验关联渠道处于启用状态且已配置 API Key。
- 渠道 Key 读取兼容 `key`、`keys` 和 `secret_key` 字段。
- 模型弹窗打开时重置上次选中的渠道,避免错误绑定。

### 修复

- 修复 `stream: true` 时将 SSE 当作 JSON 解析而返回 HTTP 500 的问题。
- 修复上游非 2xx 状态被错误包装为 HTTP 200 的问题。
- 移除模型测试时随机尝试无关渠道的回退逻辑。

### 安全

- 清理 `database.sql` 中的运行数据与凭据,仅保留数据库结构。
- 将架构文档中的固定 JWT 密钥替换为安全生成指引。

### 部署

- 版本号设置为 `2.1.0`。
- 本版本不需要数据库迁移,不新增环境变量。
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ English | [简体中文](./README_zh.md)
- ✅ **API Key Management** - Personal API keys for users
- ✅ **Model Playground** - Online testing experience center
- ✅ **OpenAI-compatible API** - Drop-in replacement for OpenAI API
- ✅ **Streaming & Agent Tools** - SSE streaming plus `tools` / `tool_choice` passthrough
- ✅ **Dynamic IP Configuration** - Auto-detect server IP during deployment

## 🏗️ Architecture
Expand Down
1 change: 1 addition & 0 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- ✅ **API Key 管理** - 为用户提供个人 API Key
- ✅ **模型广场** - 在线体验中心,可直接测试模型
- ✅ **OpenAI 兼容接口** - 完美兼容 OpenAI API 格式
- ✅ **流式与智能体工具** - 支持 SSE 流式输出及 `tools` / `tool_choice` 透传
- ✅ **动态 IP 配置** - 部署时自动检测服务器 IP

## 🏗️ 技术架构
Expand Down
24 changes: 1 addition & 23 deletions database.sql
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
PRAGMA foreign_keys=OFF;
BEGIN TRANSACTION;
-- Schema-only bootstrap: runtime data, user records and credentials are intentionally excluded.
CREATE TABLE inf_users (
id TEXT PRIMARY KEY,
account TEXT UNIQUE NOT NULL,
Expand All @@ -18,8 +19,6 @@ CREATE TABLE inf_users (
invite_code TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
, balance REAL DEFAULT 0);
INSERT INTO inf_users VALUES('d2dd171f-bccc-4eba-af92-ed8f7a1ba06d','admin','系统管理员',NULL,'$2b$10$oCtxdm3WEUY4DAELIhtmaOCSGVav79Xy7Z.6AJnDvUbrte5ARjoCK','inf_admin_default',NULL,NULL,'SUCCESS','admin','ENABLED','default',0,0,NULL,'2026-02-11 07:03:53',0.0);
INSERT INTO inf_users VALUES('ba4f6618-45f5-4b12-b003-02c29e37ffa3','test1','test1','test1@qq.com','$2b$10$q6tBzOKSVWuTMezfmxdgHuNteCzcDqB/gYp9.y24oCQNu0SLpc85q','inf_f6242314-8875-4778-b37a-25d1b1a6299a',NULL,NULL,'SUCCESS','user','ENABLED','default',10000,0,NULL,'2026-02-11 08:57:16',0.0);
CREATE TABLE inf_channels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
Expand All @@ -44,7 +43,6 @@ CREATE TABLE inf_channels (
auto_ban INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO inf_channels VALUES('86a4fd64-e77a-437e-989f-1774bdc5ad7d','云雾',1,'sk-5x6EJaCNn5ROlCvnPoPVQ39iXSchWHQKCmdMFc0YqCVyOIFs','sk-5x6EJaCNn5ROlCvnPoPVQ39iXSchWHQKCmdMFc0YqCVyOIFs','OPENAI_COMPATIBLE','https://yunwu.ai/v1','sk-5x6EJaCNn5ROlCvnPoPVQ39iXSchWHQKCmdMFc0YqCVyOIFs','SYNC',1,10,100,1802,0.0,0,'云雾AI API 提供国内外知名模型',NULL,'default',NULL,1770799105,1,'2026-02-11 07:05:00');
CREATE TABLE inf_abilities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id TEXT NOT NULL,
Expand All @@ -54,7 +52,6 @@ CREATE TABLE inf_abilities (
weight INTEGER DEFAULT 1,
UNIQUE(channel_id, model)
);
INSERT INTO inf_abilities VALUES(5,'86a4fd64-e77a-437e-989f-1774bdc5ad7d','云雾AI API 提供国内外知名模型',1,0,1);
CREATE TABLE inf_models (
id TEXT PRIMARY KEY,
model_id TEXT UNIQUE NOT NULL,
Expand All @@ -67,9 +64,6 @@ CREATE TABLE inf_models (
enabled_status TEXT DEFAULT 'ENABLED',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
, tags TEXT);
INSERT INTO inf_models VALUES('b3385c11-25df-467e-a699-43f7c9959da2','gemini-3-flash-preview','gemini-3-flash-preview','云雾','TEXT',NULL,0.0,0.0,'ENABLED','2026-02-11 08:32:57','文本,多模态,语音');
INSERT INTO inf_models VALUES('01301ea0-d025-41d0-8f11-2f99ef0d6fcc','claude-sonnet-4-20250514','claude-sonnet-4-20250514','云雾','TEXT',NULL,0.0,0.0,'ENABLED','2026-02-11 08:32:57','文本,文生图');
INSERT INTO inf_models VALUES('8e32d917-cc45-4be3-93a0-bb3fb4fa4a32','gpt-4o-mini-2024-07-18','gpt-4o-mini-2024-07-18','云雾','TEXT',NULL,0.0,0.0,'ENABLED','2026-02-11 08:32:57','文本,多模态,语音');
CREATE TABLE inf_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id TEXT,
Expand All @@ -96,7 +90,6 @@ CREATE TABLE inf_settings (
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO inf_settings VALUES('smtp','{"smtp_account":"hpcadmin@163.com","smtp_password":"YAnESUx2YTvCdJnX","smtp_server":"smtp.163.com","smtp_port":"465","pop3_server":"pop.163.com","imap_server":"imap.163.com","smtp_from_name":"AI_API_HUB"}','2026-02-11 10:45:51');
CREATE TABLE inf_payment_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
yipay_url TEXT,
Expand All @@ -121,7 +114,6 @@ CREATE TABLE inf_topup_orders (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
paid_at DATETIME
);
INSERT INTO inf_topup_orders VALUES('e0864e2f-68ec-4559-9dd9-afd4c276394c','d2dd171f-bccc-4eba-af92-ed8f7a1ba06d',50.0,50.0,'alipay','PENDING','TOPUP1770901834099HA3X6S','2026-02-12 13:10:34',NULL);
CREATE TABLE inf_site_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
site_name TEXT DEFAULT 'AI API Hub',
Expand All @@ -133,14 +125,8 @@ CREATE TABLE inf_site_settings (
email_verify INTEGER DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO inf_site_settings VALUES(1,'OGAI API Hub','大模型 API 管理平台','','','© 2024 AI API Hub. All rights reserved.',1,0,'2026-02-11 12:22:43');
CREATE TABLE user_api_keys (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, api_key TEXT UNIQUE NOT NULL, api_key_masked TEXT NOT NULL, status TEXT DEFAULT "ENABLED", usage_count INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME, quota INTEGER DEFAULT 100, used_quota INTEGER DEFAULT 0, expires_at DATETIME, allowed_models TEXT DEFAULT "all");
INSERT INTO user_api_keys VALUES('139f0ac2-3184-4673-ab2f-00f092fb6207','d2dd171f-bccc-4eba-af92-ed8f7a1ba06d','test1','sk-8f5e061c4f2c0124a17ebb3d88ba67afcad4bf1079a0f599','sk-8f5e061******f599','ENABLED',0,'2026-02-12 06:36:43',NULL,100,0,'2027-02-12T06:36:43.335Z','all');
CREATE TABLE api_key_usage_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, api_key_id INTEGER, api_key_masked TEXT, user_id INTEGER, model_id TEXT, input_tokens INTEGER, output_tokens INTEGER, total_tokens INTEGER, cost REAL, request_type TEXT, note TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP);
INSERT INTO api_key_usage_logs VALUES(1,1,'sk-8f5e061******f599',1,'gpt-4o-mini-2024-07-18',1500,800,2300,0.050000000000000002775,NULL,'正常调用','2026-02-12 08:35:21');
INSERT INTO api_key_usage_logs VALUES(2,1,'sk-8f5e061******f599',1,'claude-sonnet-4-20250514',2500,1200,3700,0.080000000000000001665,NULL,'代码生成','2026-02-12 08:35:21');
INSERT INTO api_key_usage_logs VALUES(3,1,'sk-8f5e061******f599',1,'gemini-3-flash-preview',2000,500,2500,0.029999999999999998889,NULL,'数据分析','2026-02-12 08:35:21');
INSERT INTO api_key_usage_logs VALUES(4,1,'sk-8f5e061******f599',1,'gpt-4o-mini-2024-07-18',500,300,800,0.020000000000000000416,NULL,'简单查询','2026-02-12 08:35:21');
CREATE TABLE user_bills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
Expand All @@ -154,14 +140,6 @@ CREATE TABLE user_bills (
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES inf_users(id)
);
INSERT INTO user_bills VALUES(1,'1','consumption','USAGE1',-0.050000000000000002775,0.0,0.0,'API调用 gpt-4o-mini-2024-07-18','1','2026-02-12 08:35:21');
INSERT INTO user_bills VALUES(2,'1','consumption','USAGE2',-0.080000000000000001665,0.0,0.0,'API调用 claude-sonnet-4-20250514','2','2026-02-12 08:35:21');
INSERT INTO user_bills VALUES(3,'1','consumption','USAGE3',-0.029999999999999998889,0.0,0.0,'API调用 gemini-3-flash-preview','3','2026-02-12 08:35:21');
INSERT INTO user_bills VALUES(4,'1','consumption','USAGE4',-0.020000000000000000416,0.0,0.0,'API调用 gpt-4o-mini-2024-07-18','4','2026-02-12 08:35:21');
DELETE FROM sqlite_sequence;
INSERT INTO sqlite_sequence VALUES('inf_abilities',5);
INSERT INTO sqlite_sequence VALUES('api_key_usage_logs',4);
INSERT INTO sqlite_sequence VALUES('user_bills',4);
CREATE INDEX idx_usage_user ON api_key_usage_logs(user_id);
CREATE INDEX idx_bills_user ON user_bills(user_id);
CREATE INDEX idx_bills_type ON user_bills(bill_type);
Expand Down
55 changes: 36 additions & 19 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -942,12 +942,7 @@ <h3 class="text-xl font-medium mb-6">安全设置</h3>
</div>

<script>
// API_URL 配置:优先使用 window.API_HUB_CONFIG,否则自动推断
const API_URL = (typeof window.API_HUB_CONFIG !== 'undefined' && window.API_HUB_CONFIG.apiUrl)
? window.API_HUB_CONFIG.apiUrl
: (window.location.hostname === 'localhost'
? 'http://localhost:8080'
: `http://${window.location.hostname}:8080`);
const API_URL = window.location.protocol + '//' + window.location.hostname + ':8080';
let token = localStorage.getItem('token') || '';
let currentModal = null;

Expand Down Expand Up @@ -2221,17 +2216,17 @@ <h3 class="text-xl font-bold text-green-600">✓ API Key 创建成功</h3>
document.querySelectorAll('[id^="tab-"]').forEach(el => {
el.classList.remove('border-[#0055FF]', 'bg-[#F4F5F7]');
el.classList.add('border-transparent');
el.querySelector('.font-mono').classList.remove('text-[#0055FF]');
el.querySelector('.font-mono').classList.add('text-[#1A1D21]/60');
const fontMono1 = el.querySelector('.font-mono'); if (fontMono1) fontMono1.classList.remove('text-[#0055FF]');
const fontMono2 = el.querySelector('.font-mono'); if (fontMono2) fontMono2.classList.add('text-[#1A1D21]/60');
});

// Show selected tab
document.getElementById('content-' + tab).classList.remove('hidden');
const tabEl = document.getElementById('tab-' + tab);
tabEl.classList.add('border-[#0055FF]', 'bg-[#F4F5F7]');
tabEl.classList.remove('border-transparent');
tabEl.querySelector('.font-mono').classList.add('text-[#0055FF]');
tabEl.querySelector('.font-mono').classList.remove('text-[#1A1D21]/60');
const tabFontMono1 = tabEl.querySelector('.font-mono'); if (tabFontMono1) tabFontMono1.classList.add('text-[#0055FF]');
const tabFontMono2 = tabEl.querySelector('.font-mono'); if (tabFontMono2) tabFontMono2.classList.remove('text-[#1A1D21]/60');

// Load settings when switching to specific tabs
if (tab === 'basic') {
Expand Down Expand Up @@ -2571,15 +2566,15 @@ <h3 class="text-xl font-bold text-green-600">✓ API Key 创建成功</h3>
<div class="space-y-4">
<div>
<label class="font-mono text-xs uppercase text-[#1A1D21]/60">渠道名称</label>
<input type="text" id="chName" class="w-full bg-white border border-[#D1D5DB] px-3 py-2 mt-1 focus:outline-none focus:border-[#0055FF]">
<input type="text" id="chName" required class="w-full bg-white border border-[#D1D5DB] px-3 py-2 mt-1 focus:outline-none focus:border-[#0055FF]">
</div>
<div>
<label class="font-mono text-xs uppercase text-[#1A1D21]/60">Base URL</label>
<input type="text" id="chUrl" class="w-full bg-white border border-[#D1D5DB] px-3 py-2 mt-1 focus:outline-none focus:border-[#0055FF]">
<input type="url" id="chUrl" required class="w-full bg-white border border-[#D1D5DB] px-3 py-2 mt-1 focus:outline-none focus:border-[#0055FF]">
</div>
<div>
<label class="font-mono text-xs uppercase text-[#1A1D21]/60">API Key</label>
<input type="text" id="chKey" class="w-full bg-white border border-[#D1D5DB] px-3 py-2 mt-1 focus:outline-none focus:border-[#0055FF]">
<input type="password" id="chKey" required autocomplete="new-password" placeholder="请输入上游 API Key" class="w-full bg-white border border-[#D1D5DB] px-3 py-2 mt-1 focus:outline-none focus:border-[#0055FF]">
</div>
<div>
<label class="font-mono text-xs uppercase text-[#1A1D21]/60">渠道描述</label>
Expand Down Expand Up @@ -2612,6 +2607,7 @@ <h3 class="text-xl font-bold text-green-600">✓ API Key 创建成功</h3>
let currentSourceChannel = null;

async function showModelModal() {
currentSourceChannel = null;
document.getElementById('modalTitle').textContent = '新建模型';
document.getElementById('modalBody').innerHTML = `
<div class="space-y-4">
Expand Down Expand Up @@ -2667,7 +2663,9 @@ <h3 class="text-xl font-bold text-green-600">✓ API Key 创建成功</h3>
headers: { 'Authorization': 'Bearer ' + token }
});
const data = await res.json();
modelChannelCache = (data.data || []).filter(c => c.response_time > 0);
modelChannelCache = (data.data || []).filter(c =>
c.response_time > 0 && (c.key_list?.some(key => key.trim()) || String(c.key || '').trim())
);

const select = document.getElementById('modelChannelSelect');
select.innerHTML = '<option value="">-- 选择已测试通过的渠道 --</option>' +
Expand Down Expand Up @@ -2756,25 +2754,44 @@ <h3 class="text-xl font-bold text-green-600">✓ API Key 创建成功</h3>
async function saveModal() {
if (currentModal === 'channel' || currentModal === 'channel-edit') {
const data = {
name: document.getElementById('chName').value,
base_url: document.getElementById('chUrl').value,
key: document.getElementById('chKey').value,
name: document.getElementById('chName').value.trim(),
base_url: document.getElementById('chUrl').value.trim(),
key: document.getElementById('chKey').value.trim(),
models: document.getElementById('chDesc').value,
weight: parseInt(document.getElementById('chWeight').value) || 1,
rate: parseFloat(document.getElementById('chRate').value) || 1.0
};
if (!data.name || !data.base_url || !data.key) {
alert('渠道名称、Base URL 和 API Key 均为必填项');
return;
}
try {
new URL(data.base_url);
} catch {
alert('Base URL 格式不正确');
return;
}
const url = currentModal === 'channel-edit' ? `${API_URL}/api/admin/channels/${currentChannelId}` : `${API_URL}/api/admin/channels`;
const method = currentModal === 'channel-edit' ? 'PUT' : 'POST';
await fetch(url, {
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify(data)
});
const result = await response.json();
if (!response.ok) {
alert(result.error || '保存渠道失败');
return;
}
closeModal();
loadChannels();
} else if (currentModal === 'model') {
// Batch publish selected models from channel
if (selectedModelIndices.size > 0 && fetchedModels.length > 0) {
if (!currentSourceChannel || !(currentSourceChannel.key_list?.some(key => key.trim()) || String(currentSourceChannel.key || '').trim())) {
alert('请选择已配置 API Key 的可用渠道');
return;
}
const inputPrice = parseFloat(document.getElementById('mIn').value) || 0;
const outputPrice = parseFloat(document.getElementById('mOut').value) || 0;
const sourceChannelName = currentSourceChannel ? currentSourceChannel.name : 'Manual';
Expand Down Expand Up @@ -2893,7 +2910,7 @@ <h3 class="text-xl font-bold text-green-600">✓ API Key 创建成功</h3>
</div>
<div>
<label class="font-mono text-xs uppercase text-[#1A1D21]/60">API Key</label>
<input type="text" id="chKey" value="${channel.key}" class="w-full bg-white border border-[#D1D5DB] px-3 py-2 mt-1 focus:outline-none focus:border-[#0055FF]">
<input type="password" id="chKey" required autocomplete="current-password" value="${channel.key}" class="w-full bg-white border border-[#D1D5DB] px-3 py-2 mt-1 focus:outline-none focus:border-[#0055FF]">
</div>
<div>
<label class="font-mono text-xs uppercase text-[#1A1D21]/60">渠道描述</label>
Expand Down
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
{
"name": "api-hub-v2",
"version": "2.1.0",
"description": "OpenAI-compatible API hub with channel, model and API key management",
"main": "server.js",
"scripts": {
"start": "node server.js",
"check": "node --check server.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
Expand Down
Loading