Skip to content

Commit 4272ed6

Browse files
authored
Merge pull request #3 from Jerry-Luo/main
支持配置思考等级和支持qoder官方quota查询
1 parent 0991ff7 commit 4272ed6

15 files changed

Lines changed: 2421 additions & 88 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ work/
1414
# Editor local config
1515
.obsidian/
1616
.vscode/
17+
qoder2api

‎README.md‎

Lines changed: 127 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,24 @@
1212
- **多模态支持** —— 支持图片输入
1313
- **Tool Calls** —— 支持函数调用
1414
- **模型动态加载** —— 自动从网关获取可用模型列表
15-
- **Web 管理面板** —— 浅色排版风格中文界面(支持深浅模式),支持密码登录、API 密钥管理、PAT 配置、模型查看、请求统计(总量 / 成功失败 / 总输入 / 总输出 / 缓存命中 / 实际扣费额度 / 按模型 / 近 24 小时趋势)
15+
- **Web 管理面板** —— 浅色排版风格中文界面(支持深浅模式),支持密码登录、API 密钥管理、PAT 配置、模型查看、请求统计(总量 / 成功失败 / 总输入 / 总输出 / 缓存命中 / **本周期扣费额度与重置倒计时** / 按模型 / 近 24 小时趋势)
16+
- **订阅周期感知** —— 从网关 `/algo/api/v3/user/status` 读取真实订阅信息(套餐、组织、`nextResetAt` 重置时间、`isQuotaExceeded` 超额标记),使面板额度按计费周期统计而非终身累计
1617
- **单文件部署** —— 编译为单一二进制文件,零外部依赖
1718

1819
## 支持的模型
1920

20-
| 显示名称 | 内部 Key |
21+
| 显示名称 | 内部 Key |
2122
| ----------------- | -------------- |
22-
| Qwen3.8-Max | qmodel_38max |
23-
| Qwen3.7-Max | qmodel_latest |
24-
| Qwen3.7-Plus | qmodel |
25-
| Qwen3.6-Flash | q36fmodel |
26-
| DeepSeek-V4-Pro | dmodel |
27-
| DeepSeek-V4-Flash | dfmodel |
28-
| GLM-5.3 | gmodel |
29-
| GLM-5.2 | gm51model |
30-
| Kimi-K2.7-Code | kmodel |
31-
| MiniMax-M2.7 | mmodel |
23+
| Qwen3.8-Max | qmodel_38max |
24+
| Qwen3.7-Max | qmodel_latest |
25+
| Qwen3.7-Plus | qmodel |
26+
| Qwen3.6-Flash | q36fmodel |
27+
| DeepSeek-V4-Pro | dmodel |
28+
| DeepSeek-V4-Flash | dfmodel |
29+
| GLM-5.3 | gmodel |
30+
| GLM-5.2 | gm51model |
31+
| Kimi-K2.7-Code | kmodel |
32+
| MiniMax-M2.7 | mmodel |
3233

3334
> 以上为内置默认列表(catalog-v6,2026-08-15),实际可用模型以网关动态返回为准。
3435
>
@@ -68,7 +69,7 @@ docker run -d -p 10081:10081 -v qoder2api-data:/app/data -e QODER_DATA_PATH=/app
6869
### 环境变量
6970

7071
| 变量名 | 默认值 | 说明 |
71-
|-------|--------|------|
72+
| ------- | -------- | ------ |
7273
| `QODER_HOST` | `0.0.0.0` | 监听地址 |
7374
| `QODER_PORT` | `10081` | 监听端口 |
7475
| `QODER_DATA_PATH` | `data.json` | 数据文件路径 |
@@ -150,23 +151,134 @@ curl http://localhost:10081/v1/models \
150151
-H "Authorization: Bearer sk-xxxxxxxxxxxxxxxx"
151152
```
152153

154+
### 5. 控制思考强度(`reasoning_effort`)
155+
156+
请求体支持 OpenAI 风格的 `reasoning_effort`。有效档位写入网关请求的 `parameters` 对象(与官方客户端一致),**无需**额外的 device token、Bearer token 或模型服务域名配置;未携带该字段的请求保持原有行为。
157+
158+
```json
159+
{
160+
"model": "Qwen3.8-Max",
161+
"messages": [{"role": "user", "content": "你好"}],
162+
"reasoning_effort": "xhigh"
163+
}
164+
```
165+
166+
桥接层从动态模型目录的 `thinking_config` 读取校验信息:档位列表来自 `thinking_config.enabled.efforts`(对象 map),`none` 是否允许则取决于 `thinking_config.disabled` 是否声明。有效值为 `none`、`low`、`medium`、`high`、`xhigh`、`max`,`minimal` 会映射为 `low`。不受当前模型支持的档位会被省略,模型继续使用默认思考强度。
167+
168+
其中 `none` 的判据是**就紧不就松**:只有在模型目录明确声明 `thinking_config.disabled` 时才转发;目录元数据缺失(模型没有 `thinking_config` 或其为 `null`)时同样省略,因为上游对未声明支持的模型会以一个 HTTP 400 拒绝整个请求。其余档位在元数据缺失时保持原有的直接透传行为。
169+
170+
> **注意**:档位必须放在 `parameters` 内才会生效。网关不读取请求体顶层的 `reasoning_effort`,写在该位置会被静默忽略,模型一律按默认强度思考。此外 `parameters` 还承载 `max_tokens`、`tool_choice` 等字段,桥接层会自动组装,调用方无需关心。
171+
172+
#### `none` 档:完全关闭思考
173+
174+
`none` 是二值开关而非强度档位,需要额外配合才能真正关闭思考。当模型目录声明了 `thinking_config.disabled` 时,桥接层在档位解析为 `none` 时会同时做三件事(与官方客户端行为一致):
175+
176+
1. `parameters.reasoning_effort` 置为 `"none"`
177+
2. `parameters.max_thinking_tokens` 显式置为 `0`(该字段用指针承载,以保证 `0` 不被 JSON 序列化省略)
178+
3. `model_config.is_reasoning` 与 `chat_context.extra.modelConfig.is_reasoning` 均置为 `false`
179+
180+
其余档位不会写入 `max_thinking_tokens`:官方客户端仅在技能 / 子代理覆盖场景才把档位换算为思考预算,主交互路径只发送档位字符串。
181+
182+
#### `is_reasoning` 与 `max_tokens` 的来源
183+
184+
两项能力信息来自网关模型目录,与官方客户端的解析方式一致:
185+
186+
- `is_reasoning`:取目录的 `is_reasoning` 字段,缺失时为 `false`。动态目录不可用时回退为 `true`,以保持桥接层原有的"默认开启思考"行为,避免静默关闭所有模型的思考能力。
187+
- `max_tokens`:取目录的 `max_output_tokens`,缺失或非法(非正整数)时回退为 `32000`,与官方客户端的 `LS()` 归一化结果相同。
188+
189+
**pi 客户端配置示例**:Qoder 上游不接受 OpenAI 的 `developer` 角色,而 pi 会用该角色承载代理指令;因此必须在 `~/.pi/agent/models.json` 的 `qoder-local` provider 上设置 `supportsDeveloperRole: false`,使 pi 改用 `system` 角色。为模型设置 `reasoning: true`,并将 pi 的档位映射到模型目录实际支持的值:
190+
191+
```json
192+
{
193+
"providers": {
194+
"qoder-local": {
195+
"compat": {
196+
"supportsDeveloperRole": false
197+
}
198+
}
199+
}
200+
}
201+
```
202+
203+
以下模型配置适用于支持 `low` / `medium` / `xhigh` 的模型(`max` 映射到目录实际支持的最高档位,避免发送被上游丢弃的无效值):
204+
205+
```json
206+
{
207+
"id": "Qwen3.8-Max",
208+
"reasoning": true,
209+
"thinkingLevelMap": {
210+
"off": "none",
211+
"minimal": "low",
212+
"low": "low",
213+
"medium": "medium",
214+
"high": "xhigh",
215+
"xhigh": "xhigh",
216+
"max": "xhigh"
217+
}
218+
}
219+
```
220+
153221
## API 端点
154222

155223
| 端点 | 方法 | 说明 |
156-
|------|------|------|
224+
| ------ | ------ | ------ |
157225
| `/v1/chat/completions` | POST | 聊天补全(兼容 OpenAI 格式) |
158226
| `/v1/models` | GET | 获取模型列表 |
159227
| `/admin` | GET | Web 管理面板 |
160228
| `/admin/api/login` | POST | 管理面板登录 |
161229
| `/admin/api/keys` | GET/POST/DELETE | API 密钥管理 |
162230
| `/admin/api/pat` | GET/POST | PAT 令牌管理 |
163231
| `/admin/api/models` | GET | 获取模型列表 |
164-
| `/admin/api/stats` | GET | 请求统计(总量 / 按模型 / 近 24 小时趋势 / token 用量 / 扣费额度) |
232+
| `/admin/api/stats` | GET | 请求统计(总量 / 按模型 / 近 24 小时趋势 / token 用量 / 扣费额度 / 计费周期与套餐信息) |
165233
| `/admin/api/config` | GET/POST | 服务器配置 |
166234
| `/admin/api/password` | POST | 修改管理密码 |
167235

168236
**错误响应约定**:登录失败限流返回 `429`(附 `Retry-After`);创建重复 API Key 返回 `409`;端口配置越界(非 1–65535)返回 `400`;未鉴权或会话过期返回 `401`。
169237

238+
## 额度与计费周期
239+
240+
管理面板使用 Qoder 官方客户端同源的额度接口,展示当前计费周期的权威用量,而不是仅统计本服务启动后的局部消耗:
241+
242+
```text
243+
套餐额度 2,939 / 3,000 剩余 61 可用
244+
加购额度 0 / 1,000 剩余 1,000 可用
245+
组织资源包 120 / 4,000 剩余 3,880 可用
246+
合计剩余 4,941 · 09-25 重置
247+
```
248+
249+
> 免费账号的套餐池为 `userQuota.total = 0`(套餐本身不含额度),此时面板首行直接展示真正持有额度的池子(通常是加购额度),而不是渲染成「套餐 0 / 0 · 已用尽」。`total`/`cap` 为 0 的池子视为不存在,不会出现在额度明细卡中。
250+
251+
### 数据来源
252+
253+
主数据来自 `GET https://openapi.qoder.com.cn/api/v2/quota/usage`,使用现有 jobToken 交换返回的 `securityOauthToken` 作为 Bearer。该接口与官方客户端 `/usage` 面板相同,提供:
254+
255+
| 字段 | 含义 | 用途 |
256+
| ------ | ------ | ------ |
257+
| `userQuota.total/used/remaining` | 套餐总额、已用及剩余 Credits | 面板额度行(`total = 0` 时该池不参与展示) |
258+
| `userQuota.percentage` | 套餐使用比例 | 管理 API 输出及详细信息 |
259+
| `addOnQuota` | 加购额度包(存在时) | 管理 API 输出及额度明细卡 |
260+
| `orgResourcePackage` | 组织资源包容量、已用、剩余及可用状态 | 管理 API 输出及额度明细卡 |
261+
| `expiresAt` | 当前额度周期结束时刻(仅兜底) | 重置日期与倒计时 |
262+
| `isQuotaExceeded` | 官方客户端采用的超额判定 | 面板红色告警徽标 |
263+
264+
网关 `POST /algo/api/v3/user/status?Encode=1` 仍负责提供 `plan`、`userTag`、组织名称和真实 `userType`。它的 `quota: 0` 是精简字段,**不能解释为套餐没有数字上限**;额度必须以 OpenAPI 的 `userQuota` 为准。
265+
266+
### 统计与降级口径
267+
268+
- **官方额度优先**:面板的套餐用量来自账号级 OpenAPI,包含 IDE、CLI 和本服务产生的全部消耗,也能覆盖服务启动前的当期用量。
269+
- **额度来源分别展示**:套餐额度、加购额度和组织资源包在额度明细卡中分别成行展示,并汇总各额度来源返回的剩余 Credits;组织资源包的“可用/不可用”状态以 OpenAPI 返回值为准。
270+
- **本地累计仅作降级**:usage 帧中 `billable=false` 的请求仍计入 token,但不计入本地 credits;OpenAPI 从未成功时,面板才显示本服务观察到的周期消耗,并明确标注口径。
271+
- **周期边界权威同步**:重置时刻以网关 `/user/status` 的 `nextResetAt` 为权威,OpenAPI `expiresAt` 仅在其缺失时兜底;且 `expiresAt` 必须小于 `2100-01-01` 才会被采纳——无期限套餐会把它填成 `9999-12-31` 哨兵值,该值会被忽略。本地周期起点按自然月回退推导,断网期间仍可完成一次性滚动。
272+
- **累计值仍保留**:`stats.credits` 保存本服务历史累计扣费,鼠标悬停在额度行可查看。
273+
274+
### 刷新时机
275+
276+
主程序启动后立即获取一次额度,此后每分钟在后台刷新。管理面板的 15 秒轮询完全读取内存和持久化快照,不直接调用上游;临时网络故障会保留最后一次成功结果。
277+
278+
### `userType` 修复
279+
280+
`jobToken` 响应中**不含** `userType` 字段,此前 bridge 一律回退为 `personal_standard`,导致团队账号的签名会话载荷层级错误。现改为在构建会话**之前**从 `/user/status` 取真实层级(该值被 AES 签入 bearer 载荷,事后无法修改)。状态接口临时不可用时,按「上次已知层级 → 空值 → 历史默认值」顺序降级,避免续期时把团队账号静默降级。
281+
170282
## 安全
171283

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

‎admin/admin_test.go‎

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,9 +288,53 @@ func TestServeIndex(t *testing.T) {
288288
if w.Code != 200 {
289289
t.Errorf("expected 200, got %d", w.Code)
290290
}
291-
if !strings.Contains(w.Body.String(), "<!DOCTYPE html>") {
291+
body := w.Body.String()
292+
if !strings.Contains(body, "<!DOCTYPE html>") {
292293
t.Error("expected HTML doctype")
293294
}
295+
for _, want := range []string{`id="quotaCard"`, `id="quotaTable"`, "renderQuotaDetails(d)", "组织资源包"} {
296+
if !strings.Contains(body, want) {
297+
t.Errorf("expected quota detail UI marker %q", want)
298+
}
299+
}
300+
}
301+
302+
// The panel's headline must reflect the allowance the account actually holds.
303+
// A free account reports userQuota.total=0 while the usable budget sits in
304+
// addOnQuota; keying the headline off userQuota rendered "套餐 8 / 0 · 剩 0"
305+
// for an account that still had credits, and hid the pool that really mattered.
306+
func TestQuotaSourceSelectionIgnoresEmptyPools(t *testing.T) {
307+
start := strings.Index(indexHTML, "function quotaPools(acct) {")
308+
if start < 0 {
309+
t.Fatal("quotaPools not found in the embedded UI")
310+
}
311+
end := strings.Index(indexHTML[start:], "\n/*\n * Prefer the authoritative")
312+
if end < 0 {
313+
t.Fatal("could not delimit quotaPools")
314+
}
315+
pools := indexHTML[start : start+end]
316+
317+
// A zero-total pool carries no allowance and must not be offered as one.
318+
for _, guard := range []string{
319+
"acct.user_quota && Number(acct.user_quota.total || 0) > 0",
320+
"acct.add_on_quota && Number(acct.add_on_quota.total || 0) > 0",
321+
"acct.org_resource_package && Number(acct.org_resource_package.cap || 0) > 0",
322+
} {
323+
if !strings.Contains(pools, guard) {
324+
t.Errorf("quotaPools must skip zero-capacity pools; missing guard %q", guard)
325+
}
326+
}
327+
328+
// Both renderers must share that selection instead of hand-rolling their
329+
// own, otherwise the headline and the detail card can disagree.
330+
renderCredits := indexHTML[strings.Index(indexHTML, "function renderCredits(d) {"):]
331+
renderCredits = renderCredits[:strings.Index(renderCredits, "function renderQuotaDetails(d) {")]
332+
if !strings.Contains(renderCredits, "quotaPools(acct)") {
333+
t.Error("renderCredits must select its pools via quotaPools(acct)")
334+
}
335+
if strings.Contains(renderCredits, "acct.user_quota ||") {
336+
t.Error("renderCredits must not hardcode user_quota as the headline source")
337+
}
294338
}
295339

296340
// Repeated wrong passwords trigger per-IP rate limiting (HTTP 429).
@@ -505,3 +549,96 @@ func TestConfigPartialInvalidTimeoutAppliesNothing(t *testing.T) {
505549
t.Errorf("hot-apply must not run on rejected input, got %s", timeouts.Header)
506550
}
507551
}
552+
553+
// The billing-cycle fields and account metadata must reach the panel through
554+
// the stats endpoint, so the UI can render cycle-relative credits without any
555+
// network call of its own.
556+
func TestStatsEndpointExposesBillingCycle(t *testing.T) {
557+
reset := time.Date(2026, time.September, 25, 0, 0, 0, 0, time.Local)
558+
rec := stats.NewRecorder(nil)
559+
rec.SetBillingCycle(reset.UnixMilli())
560+
rec.SetAccount(&stats.Account{
561+
Plan: "PLAN_TIER_TEAM", Tag: "Teams", OrgName: "Example Org",
562+
TotalUsagePercentage: 0.98,
563+
UserQuota: &stats.Quota{Total: 3000, Used: 2939, Remaining: 61, Percentage: 0.98, Unit: "credits"},
564+
OrgResourcePackage: &stats.OrgResourcePackage{Cap: 4000, Available: false, Unit: "credits"},
565+
})
566+
rec.RecordUsage(&stats.Usage{PromptTokens: 5, Credits: 1.25})
567+
568+
a := New(tempStore(t), func(ctx context.Context) []string { return nil }, rec)
569+
w := doRequest(t, a.handleStats, "GET", "/admin/api/stats", nil)
570+
571+
var resp struct {
572+
Credits float64 `json:"credits"`
573+
CycleCredits float64 `json:"cycle_credits"`
574+
CycleStartMs int64 `json:"cycle_start_ms"`
575+
NextResetMs int64 `json:"next_reset_ms"`
576+
Account *struct {
577+
Plan string `json:"plan"`
578+
Tag string `json:"tag"`
579+
OrgName string `json:"org_name"`
580+
TotalUsagePercentage float64 `json:"total_usage_percentage"`
581+
UserQuota *struct {
582+
Total float64 `json:"total"`
583+
Used float64 `json:"used"`
584+
Remaining float64 `json:"remaining"`
585+
} `json:"user_quota"`
586+
OrgResourcePackage *struct {
587+
Cap float64 `json:"cap"`
588+
Available bool `json:"available"`
589+
} `json:"org_resource_package"`
590+
} `json:"account"`
591+
}
592+
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
593+
t.Fatalf("bad response JSON: %v", err)
594+
}
595+
if resp.NextResetMs != reset.UnixMilli() {
596+
t.Errorf("expected next_reset_ms %d, got %d", reset.UnixMilli(), resp.NextResetMs)
597+
}
598+
wantStart := time.Date(2026, time.August, 25, 0, 0, 0, 0, time.Local).UnixMilli()
599+
if resp.CycleStartMs != wantStart {
600+
t.Errorf("expected cycle_start_ms %d (one calendar month back), got %d", wantStart, resp.CycleStartMs)
601+
}
602+
if resp.CycleCredits < 1.25-1e-9 || resp.CycleCredits > 1.25+1e-9 {
603+
t.Errorf("expected cycle_credits 1.25, got %v", resp.CycleCredits)
604+
}
605+
if resp.Account == nil || resp.Account.Tag != "Teams" || resp.Account.Plan != "PLAN_TIER_TEAM" {
606+
t.Errorf("expected account metadata in the response, got %+v", resp.Account)
607+
}
608+
if resp.Account.UserQuota == nil || resp.Account.UserQuota.Total != 3000 || resp.Account.UserQuota.Used != 2939 || resp.Account.UserQuota.Remaining != 61 {
609+
t.Errorf("expected authoritative user quota in the response, got %+v", resp.Account.UserQuota)
610+
}
611+
if resp.Account.OrgResourcePackage == nil || resp.Account.OrgResourcePackage.Cap != 4000 || resp.Account.OrgResourcePackage.Available {
612+
t.Errorf("expected organization resource package in the response, got %+v", resp.Account.OrgResourcePackage)
613+
}
614+
}
615+
616+
// With no account status ever resolved the cycle fields stay zero and the
617+
// account is omitted, which is what makes the UI fall back to the lifetime
618+
// total instead of rendering a bogus "resetting soon".
619+
func TestStatsEndpointBillingCycleUnknown(t *testing.T) {
620+
rec := stats.NewRecorder(nil)
621+
rec.RecordUsage(&stats.Usage{Credits: 2})
622+
623+
a := New(tempStore(t), func(ctx context.Context) []string { return nil }, rec)
624+
w := doRequest(t, a.handleStats, "GET", "/admin/api/stats", nil)
625+
626+
var resp struct {
627+
Credits float64 `json:"credits"`
628+
NextResetMs int64 `json:"next_reset_ms"`
629+
CycleCredits float64 `json:"cycle_credits"`
630+
Account map[string]any `json:"account"`
631+
}
632+
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
633+
t.Fatalf("bad response JSON: %v", err)
634+
}
635+
if resp.NextResetMs != 0 {
636+
t.Errorf("expected no reset boundary, got %d", resp.NextResetMs)
637+
}
638+
if resp.Credits < 2-1e-9 || resp.Credits > 2+1e-9 {
639+
t.Errorf("lifetime credits must still accrue with no cycle known, got %v", resp.Credits)
640+
}
641+
if resp.Account != nil {
642+
t.Errorf("expected account to be omitted, got %+v", resp.Account)
643+
}
644+
}

0 commit comments

Comments
 (0)