Skip to content

Commit 48cd306

Browse files
committed
传递真实 token 用量统计并重做统计面板;修复代码审查发现的缺陷
改动目的: - 网关在流结束前会发送 usage 终帧(空 choices + usage),此前被整体丢弃, 导致无法统计真实 token 消耗与实际扣费额度 - 修复代码审查遗留及新增发现的缺陷:登录退避溢出、PAT 明文回显、 改密后会话未失效、SSE 无空闲超时、流提前截断被当作成功、 持久化无 fsync、并发竞态、工具调用累积上限可被绕过等 主要变更: - 用量传递:解析网关 usage 终帧(prompt/completion/total tokens、 cached_tokens、reasoning_tokens、credits),流式响应按 stream_options.include_usage 约定在 [DONE] 前回传 usage 帧, 非流式响应透传 usage;两路径均经 UsageSink 接入统计 - 统计与 UI:stats 新增 token 总量与 Credits 累计(RecordUsage), 移除流式/同步拆分计数;管理面板统计卡改为总请求/成功率、 Token 总数/Credits、总输入/缓存命中、总输出/平均输出, 并修复窄屏下统计网格的响应式布局 - 安全修复:登录退避 1<<63 溢出导致锁死失效(钳制到 30s 上限); 修改密码后全量失效会话;QODER_ADMIN_PASSWORD 模式下禁止面板改密; PAT 接口不再回显明文;clientIP 改用 net.SplitHostPort 支持 IPv6 - 健壮性修复:SSE 空闲超时 5 分钟;EOF 前未见 [DONE] 视为截断错误; store 写入增加 fsync 防崩溃截断;catalog 拉取单飞防并发堆叠; expireTime 缺失时按 30 分钟 TTL 兜底,避免每次请求都续期; identity 快照加锁消除数据竞争;工具调用累积硬上限不可绕过且保留 上游 index;finish_reason 透传;UTF-8 安全截断(truncateRunes); 动态目录缺省模型优先廉价档位(flash/lite/plus) - 构建/清理:baseprompt.json 删除,请求模板内联进 bridge 包; CI 新增 go vet + go test -race 测试作业并在构建前执行; 发布产物与 Docker 镜像不再打包模板文件 影响范围: - 涉及 transform、bridge、stats、admin、auth、store、models 全部核心 模块及对应测试,新增大量端到端与回归测试 - API 行为变化:流式响应末尾新增 usage 帧、非流式响应新增 usage 字段, 对 OpenAI 兼容客户端无破坏性影响 - 管理面板统计口径变化:移除流式/同步拆分,新增 token/Credits 指标; /admin/api/pat 不再返回明文 PAT
1 parent 021ad54 commit 48cd306

19 files changed

Lines changed: 1062 additions & 194 deletions

‎.github/workflows/release.yml‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,30 @@ permissions:
1111
packages: write
1212

1313
jobs:
14+
# --- Lint + test (with race detector) before any build ---
15+
test:
16+
name: Test
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v4
21+
22+
- name: Setup Go
23+
uses: actions/setup-go@v5
24+
with:
25+
go-version: '1.22'
26+
27+
- name: Vet
28+
run: go vet ./...
29+
30+
- name: Test
31+
run: go test -race ./...
32+
1433
# --- Build binaries for Windows / macOS / Linux (amd64 + arm64) ---
1534
build-binaries:
1635
name: Build ${{ matrix.os }}-${{ matrix.arch }}
1736
runs-on: ubuntu-latest
37+
needs: [test]
1838
strategy:
1939
matrix:
2040
include:
@@ -66,15 +86,14 @@ jobs:
6686
run: |
6787
mkdir -p dist
6888
go build -trimpath -ldflags="-s -w" -o dist/qoder2api-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }} .
69-
cp baseprompt.json dist/
7089
7190
- name: Create archive
7291
run: |
7392
cd dist
7493
if [ "${{ matrix.os }}" = "windows" ]; then
75-
zip -r qoder2api-${{ matrix.os }}-${{ matrix.arch }}.zip qoder2api-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }} baseprompt.json
94+
zip -r qoder2api-${{ matrix.os }}-${{ matrix.arch }}.zip qoder2api-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}
7695
else
77-
tar -czf qoder2api-${{ matrix.os }}-${{ matrix.arch }}.tar.gz qoder2api-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }} baseprompt.json
96+
tar -czf qoder2api-${{ matrix.os }}-${{ matrix.arch }}.tar.gz qoder2api-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}
7897
fi
7998
8099
- name: Upload artifact

‎.gitignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,8 @@ data.json
88
# OS files
99
.DS_Store
1010
Thumbs.db
11+
probe/
12+
13+
# Editor local config
14+
.obsidian/
15+
.vscode/

‎Dockerfile‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ FROM alpine:3.20
1010
RUN apk add --no-cache ca-certificates
1111
WORKDIR /app
1212
COPY --from=builder /build/qoder2api .
13-
COPY baseprompt.json .
1413
ENV QODER_HOST=0.0.0.0 \
1514
QODER_PORT=10081
1615
EXPOSE 10081

‎README.md‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
## 功能特性
88

99
- **OpenAI API 兼容** —— 支持 `/v1/chat/completions` 和 `/v1/models` 端点,可无缝替换 OpenAI API
10-
- **流式响应** —— 支持 SSE 流式输出
10+
- **流式响应** —— 支持 SSE 流式输出,末尾附带 usage 帧(含输入 / 输出 / 缓存命中 / 思考 token 与实际扣费额度,遵循 `stream_options.include_usage` 终帧格式)
11+
- **真实用量统计** —— `/v1/chat/completions` 响应携带网关真实 token 用量:`usage.prompt_tokens` / `completion_tokens` / `total_tokens`、`prompt_tokens_details.cached_tokens`、`completion_tokens_details.reasoning_tokens`,以及 Qoder 扩展字段 `credits`(实际扣费额度)/ `original_credits`(折扣前额度)
1112
- **多模态支持** —— 支持图片输入
1213
- **Tool Calls** —— 支持函数调用
1314
- **模型动态加载** —— 自动从网关获取可用模型列表
14-
- **Web 管理面板** —— 浅色排版风格中文界面(支持深浅模式),支持密码登录、API 密钥管理、PAT 配置、模型查看、请求统计(总量 / 成功失败 / 按模型 / 近 24 小时趋势)
15+
- **Web 管理面板** —— 浅色排版风格中文界面(支持深浅模式),支持密码登录、API 密钥管理、PAT 配置、模型查看、请求统计(总量 / 成功失败 / 总输入 / 总输出 / 缓存命中 / 实际扣费额度 / 按模型 / 近 24 小时趋势)
1516
- **单文件部署** —— 编译为单一二进制文件,零外部依赖
1617

1718
## 支持的模型
@@ -71,7 +72,7 @@ docker run -d -p 10081:10081 -v qoder2api-data:/app/data -e QODER_DATA_PATH=/app
7172
| `QODER_ADMIN_PASSWORD` | `password` | 管理面板密码(覆盖 data.json 中的值) |
7273
| `QODER_SIGNATURE_SECRET` | 内置值 | 请求签名密钥 |
7374

74-
> 请求统计(`stats` 字段)随 data.json 持久化:服务每 30 秒批量写入一次,并在收到 `SIGINT`/`SIGTERM` 优雅关闭时执行最终落盘;仅统计通过密钥鉴权且请求体合法的调用,客户端主动断开不计入失败。
75+
> 请求统计(`stats` 字段)随 data.json 持久化:服务每 30 秒批量写入一次,并在收到 `SIGINT`/`SIGTERM` 优雅关闭时执行最终落盘;仅统计通过密钥鉴权且请求体合法的调用,客户端主动断开不计入失败。令牌用量(总输入 / 总输出 / 缓存命中 / 实际扣费额度)从网关 usage 帧自动累计,流中断无 usage 帧时仅计次不计量。
7576
7677
### 配置文件 (data.json)
7778

@@ -137,7 +138,7 @@ curl http://localhost:10081/v1/models \
137138
| `/admin/api/keys` | GET/POST/DELETE | API 密钥管理 |
138139
| `/admin/api/pat` | GET/POST | PAT 令牌管理 |
139140
| `/admin/api/models` | GET | 获取模型列表 |
140-
| `/admin/api/stats` | GET | 请求统计(总量 / 按模型 / 近 24 小时趋势) |
141+
| `/admin/api/stats` | GET | 请求统计(总量 / 按模型 / 近 24 小时趋势 / token 用量 / 扣费额度) |
141142
| `/admin/api/config` | GET/POST | 服务器配置 |
142143
| `/admin/api/password` | POST | 修改管理密码 |
143144

@@ -147,7 +148,7 @@ curl http://localhost:10081/v1/models \
147148

148149
本项目面向内网 / 个人使用,已内置以下加固措施,**部署到公网前请务必修改默认密码并使用 HTTPS 反代**:
149150

150-
- **HTTP 超时**:`ReadHeaderTimeout=10s`、`IdleTimeout=120s`,抵御 slowloris 类慢速连接耗尽;`WriteTimeout` 不设以兼容长连接 SSE 流式响应。
151+
- **HTTP 超时**:`ReadHeaderTimeout=10s`、`IdleTimeout=120s`,抵御 slowloris 类慢速连接耗尽;`WriteTimeout` 不设以兼容长连接 SSE 流式响应,流内超过 5 分钟无数据(空闲)自动断开,防止网关连接卡死。
151152
- **请求体上限**:`/v1/chat/completions` 限制 10 MiB,管理 API 限制 64 KiB,防止超大 payload 耗尽内存。
152153
- **登录限流**:管理面板登录按客户端 IP 计数,失败后指数退避(1s → 30s 封顶),锁定期间返回 `429` + `Retry-After`。
153154
- **恒定时间比较**:API Key 校验与管理密码比对均使用 `crypto/subtle`,避免时序侧信道泄露。
@@ -160,14 +161,13 @@ curl http://localhost:10081/v1/models \
160161

161162
```
162163
Qoder-2API-Go/
163-
├── main.go # 程序入口
164+
├── main.go # 程序入口(请求模板内联在 bridge 包中)
164165
├── auth/ # 认证模块
165166
├── bridge/ # API 桥接模块
166167
├── models/ # 模型管理
167168
├── store/ # 数据持久化
168169
├── transform/ # 数据转换
169170
├── admin/ # Web 管理面板
170-
├── baseprompt.json # Qoder 请求模板
171171
├── Dockerfile # Docker 构建文件
172172
├── docker-compose.yaml # Docker Compose 配置
173173
└── .github/workflows/ # GitHub Actions 自动构建

‎admin/admin.go‎

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import (
99
"encoding/hex"
1010
"encoding/json"
1111
"errors"
12+
"net"
1213
"net/http"
1314
"os"
1415
"strconv"
15-
"strings"
1616
"sync"
1717
"time"
1818

@@ -174,7 +174,15 @@ func (l *loginLimiter) fail(ip string) {
174174
}
175175
st.failures++
176176
st.lastFail = time.Now()
177-
backoff := time.Duration(1<<uint(st.failures-1)) * time.Second
177+
// Compute the backoff in plain integers and clamp before converting to
178+
// time.Duration: 1<<63 seconds overflows int64 nanoseconds and yields a
179+
// negative duration, which would silently disable the lockout after
180+
// enough consecutive failures.
181+
shift := st.failures - 1
182+
if shift > 5 { // 1<<5 s == 32s > loginMaxBackoff
183+
shift = 5
184+
}
185+
backoff := time.Second << uint(shift)
178186
if backoff > loginMaxBackoff {
179187
backoff = loginMaxBackoff
180188
}
@@ -212,8 +220,9 @@ func passwordsEqual(a, b string) bool {
212220

213221
func clientIP(r *http.Request) string {
214222
host := r.RemoteAddr
215-
if i := strings.LastIndex(host, ":"); i >= 0 {
216-
host = host[:i]
223+
// Trim the port, handling both "1.2.3.4:5678" and "[::1]:5678".
224+
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
225+
return host
217226
}
218227
return host
219228
}
@@ -331,6 +340,10 @@ func (a *Admin) handlePassword(w http.ResponseWriter, r *http.Request) {
331340
writeJSONError(w, 400, "请求格式错误")
332341
return
333342
}
343+
if os.Getenv("QODER_ADMIN_PASSWORD") != "" {
344+
writeJSONError(w, 400, "密码由环境变量 QODER_ADMIN_PASSWORD 管理,无法在面板中修改")
345+
return
346+
}
334347
if !passwordsEqual(body.CurrentPassword, a.getPassword()) {
335348
writeJSONError(w, 401, "当前密码错误")
336349
return
@@ -343,6 +356,11 @@ func (a *Admin) handlePassword(w http.ResponseWriter, r *http.Request) {
343356
writeJSONError(w, 500, err.Error())
344357
return
345358
}
359+
// Invalidate every existing session so a (possibly stolen) cookie from
360+
// before the password change cannot be replayed.
361+
a.sessionMu.Lock()
362+
a.sessions = make(map[string]time.Time)
363+
a.sessionMu.Unlock()
346364
writeJSON(w, 200, map[string]string{"status": "ok"})
347365
}
348366

@@ -447,9 +465,10 @@ func (a *Admin) handleConfig(w http.ResponseWriter, r *http.Request) {
447465
func (a *Admin) handlePAT(w http.ResponseWriter, r *http.Request) {
448466
switch r.Method {
449467
case http.MethodGet:
450-
pat := a.store.GetPAT()
451-
masked := maskPAT(pat)
452-
writeJSON(w, 200, map[string]string{"pat": pat, "pat_masked": masked})
468+
// Only the masked form is exposed; the plaintext PAT never leaves
469+
// the server. The UI edits it as a whole new value, never in place.
470+
masked := maskPAT(a.store.GetPAT())
471+
writeJSON(w, 200, map[string]string{"pat_masked": masked})
453472
case http.MethodPost:
454473
var body struct {
455474
PAT string `json:"pat"`

‎admin/admin_test.go‎

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"net/http/httptest"
88
"strings"
99
"testing"
10+
"time"
1011

1112
"qoder2api/models"
1213
"qoder2api/stats"
@@ -105,13 +106,17 @@ func TestPATSetAndGet(t *testing.T) {
105106
if w.Code != 200 {
106107
t.Errorf("expected 200, got %d", w.Code)
107108
}
109+
// The plaintext PAT must never appear in the response; only the masked form.
110+
if strings.Contains(w.Body.String(), "pt-test123") {
111+
t.Error("response must not leak the plaintext PAT")
112+
}
108113
var resp map[string]string
109114
json.Unmarshal(w.Body.Bytes(), &resp)
110-
if resp["pat"] != "pt-test123" {
111-
t.Errorf("expected 'pt-test123', got %q", resp["pat"])
115+
if _, ok := resp["pat"]; ok {
116+
t.Error("response must not contain a 'pat' field")
112117
}
113-
if resp["pat_masked"] == "" {
114-
t.Error("expected non-empty pat_masked")
118+
if resp["pat_masked"] != "pt-t****t123" {
119+
t.Errorf("expected masked PAT, got %q", resp["pat_masked"])
115120
}
116121
}
117122

@@ -213,9 +218,9 @@ func TestStatsEndpoint(t *testing.T) {
213218
return models.DefaultCatalog().Keys()
214219
}
215220
rec := stats.NewRecorder(nil)
216-
rec.Record("Qwen3.7-Max", true, true)
217-
rec.Record("Qwen3.7-Max", false, true)
218-
rec.Record("DeepSeek-V4-Pro", true, false)
221+
rec.Record("Qwen3.7-Max", true)
222+
rec.Record("Qwen3.7-Max", false)
223+
rec.Record("DeepSeek-V4-Pro", true)
219224
a := New(s, mf, rec)
220225

221226
w := doRequest(t, a.handleStats, "GET", "/admin/api/stats", nil)
@@ -320,3 +325,58 @@ func TestAddKeyDuplicateReturns409(t *testing.T) {
320325
t.Errorf("expected 409 for duplicate key, got %d", w.Code)
321326
}
322327
}
328+
329+
// --- Regression tests for review fixes ---
330+
331+
// Sustained brute-force attempts must stay locked out: the old shift-based
332+
// backoff overflowed int64 nanoseconds after ~34 consecutive failures and
333+
// silently disabled the lockout entirely.
334+
func TestLoginLimiterStaysLockedAfterManyFailures(t *testing.T) {
335+
l := newLoginLimiter()
336+
for i := 0; i < 100; i++ {
337+
l.fail("1.2.3.4")
338+
}
339+
if locked, _ := l.check("1.2.3.4"); !locked {
340+
t.Fatal("attacker unlocked after 100 consecutive failures")
341+
}
342+
// And a capped wait is reported, not a bogus negative one.
343+
_, retry := l.check("1.2.3.4")
344+
if retry > 31*time.Second {
345+
t.Errorf("retry hint exceeds cap: %v", retry)
346+
}
347+
}
348+
349+
// Changing the password must invalidate all pre-existing sessions.
350+
func TestPasswordChangeInvalidatesSessions(t *testing.T) {
351+
a, s := newAdmin(t)
352+
if err := s.SetPassword("oldpass"); err != nil {
353+
t.Fatal(err)
354+
}
355+
token, err := a.createSession()
356+
if err != nil {
357+
t.Fatal(err)
358+
}
359+
if !a.isValidSession(token) {
360+
t.Fatal("session should be valid before password change")
361+
}
362+
w := doRequest(t, a.handlePassword, "POST", "/admin/api/password",
363+
map[string]string{"current_password": "oldpass", "new_password": "newpass"})
364+
if w.Code != 200 {
365+
t.Fatalf("password change failed: %d %s", w.Code, w.Body.String())
366+
}
367+
if a.isValidSession(token) {
368+
t.Error("old session must be invalidated after password change")
369+
}
370+
}
371+
372+
func TestClientIPv6(t *testing.T) {
373+
r := httptest.NewRequest("GET", "/", nil)
374+
r.RemoteAddr = "[::1]:8080"
375+
if got := clientIP(r); got != "::1" {
376+
t.Errorf("clientIP([::1]:8080) = %q, want ::1", got)
377+
}
378+
r.RemoteAddr = "1.2.3.4:5678"
379+
if got := clientIP(r); got != "1.2.3.4" {
380+
t.Errorf("clientIP(1.2.3.4:5678) = %q, want 1.2.3.4", got)
381+
}
382+
}

‎admin/webui.go‎

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,10 @@ input[readonly] {
717717
.mobile-nav .nav-item { flex: 0 0 auto; justify-content: center; padding: 8px 14px; }
718718
.mobile-nav .nav-item span.mnav-label { font-size: 12.5px; }
719719
720+
@media (max-width: 1180px) {
721+
.stat-grid { grid-template-columns: repeat(2, 1fr); }
722+
}
723+
720724
@media (max-width: 900px) {
721725
.shell { flex-direction: column; }
722726
.sidebar { display: none; }
@@ -823,22 +827,22 @@ input[readonly] {
823827
<div class="stat-card">
824828
<div class="stat-label"><span class="stat-dot total"></span>总请求</div>
825829
<div class="stat-value num" id="statTotal">0</div>
826-
<div class="stat-sub num" id="statStreams">流式 0 · 同步 0</div>
830+
<div class="stat-sub num" id="statReqSub">成功率 —</div>
827831
</div>
828832
<div class="stat-card">
829-
<div class="stat-label"><span class="stat-dot success"></span>成功</div>
830-
<div class="stat-value num" id="statSuccess">0</div>
831-
<div class="stat-sub">已完成请求</div>
833+
<div class="stat-label"><span class="stat-dot success"></span>Token 总数</div>
834+
<div class="stat-value num" id="statTokens">0</div>
835+
<div class="stat-sub num" id="statCredits">Credits 消耗 0</div>
832836
</div>
833837
<div class="stat-card">
834-
<div class="stat-label"><span class="stat-dot failed"></span>失败</div>
835-
<div class="stat-value num" id="statFailed">0</div>
836-
<div class="stat-sub">请求错误</div>
838+
<div class="stat-label"><span class="stat-dot rate"></span>总输入</div>
839+
<div class="stat-value num" id="statPrompt">0</div>
840+
<div class="stat-sub num" id="statCached">缓存命中 0</div>
837841
</div>
838842
<div class="stat-card">
839-
<div class="stat-label"><span class="stat-dot rate"></span>成功率</div>
840-
<div class="stat-value num" id="statRate">—</div>
841-
<div class="stat-sub">成功 / 总请求</div>
843+
<div class="stat-label"><span class="stat-dot total"></span>总输出</div>
844+
<div class="stat-value num" id="statCompletion">0</div>
845+
<div class="stat-sub num" id="statAvgOut">平均 0 / 次</div>
842846
</div>
843847
</div>
844848
@@ -1164,16 +1168,28 @@ applyTheme(themeDark());
11641168
11651169
function loadStats() {
11661170
api('/stats').then(function(d) {
1171+
var tokens = (d.prompt_tokens || 0) + (d.completion_tokens || 0);
11671172
document.getElementById('statTotal').textContent = fmt(d.total);
1168-
document.getElementById('statSuccess').textContent = fmt(d.success);
1169-
document.getElementById('statFailed').textContent = fmt(d.failed);
1170-
document.getElementById('statRate').textContent = pct(d.success_rate);
1171-
document.getElementById('statStreams').textContent = '流式 ' + fmt(d.streams) + ' · 同步 ' + fmt(d.syncs);
1173+
document.getElementById('statReqSub').textContent = '成功率 ' + pct(d.success_rate);
1174+
document.getElementById('statTokens').textContent = fmt(tokens);
1175+
document.getElementById('statCredits').textContent = 'Credits 消耗 ' + fmtCredits(d.credits);
1176+
document.getElementById('statPrompt').textContent = fmt(d.prompt_tokens);
1177+
document.getElementById('statCached').textContent = '缓存命中 ' + fmt(d.cached_tokens);
1178+
document.getElementById('statCompletion').textContent = fmt(d.completion_tokens);
1179+
var avg = d.total > 0 ? Math.round((d.completion_tokens || 0) / d.total) : 0;
1180+
document.getElementById('statAvgOut').textContent = '平均 ' + fmt(avg) + ' / 次';
11721181
renderChart(d.hourly || []);
11731182
renderStatsTable(d.by_model || []);
11741183
}).catch(function(e) { showToast(e.message, 'error'); });
11751184
}
11761185
1186+
function fmtCredits(v) {
1187+
var n = Number(v || 0);
1188+
if (n === 0) return '0';
1189+
if (n >= 1000) return n.toLocaleString('zh-CN', {maximumFractionDigits: 2});
1190+
return n.toPrecision(6).replace(/\.?0+$/, '');
1191+
}
1192+
11771193
function renderChart(hourly) {
11781194
var max = 0;
11791195
hourly.forEach(function(h) { if (h.total > max) max = h.total; });

0 commit comments

Comments
 (0)