diff --git a/.gitignore b/.gitignore index deb6b16..0892ea5 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ work/ # Editor local config .obsidian/ .vscode/ +qoder2api diff --git a/README.md b/README.md index fb386ea..c42385f 100644 --- a/README.md +++ b/README.md @@ -12,23 +12,24 @@ - **多模态支持** —— 支持图片输入 - **Tool Calls** —— 支持函数调用 - **模型动态加载** —— 自动从网关获取可用模型列表 -- **Web 管理面板** —— 浅色排版风格中文界面(支持深浅模式),支持密码登录、API 密钥管理、PAT 配置、模型查看、请求统计(总量 / 成功失败 / 总输入 / 总输出 / 缓存命中 / 实际扣费额度 / 按模型 / 近 24 小时趋势) +- **Web 管理面板** —— 浅色排版风格中文界面(支持深浅模式),支持密码登录、API 密钥管理、PAT 配置、模型查看、请求统计(总量 / 成功失败 / 总输入 / 总输出 / 缓存命中 / **本周期扣费额度与重置倒计时** / 按模型 / 近 24 小时趋势) +- **订阅周期感知** —— 从网关 `/algo/api/v3/user/status` 读取真实订阅信息(套餐、组织、`nextResetAt` 重置时间、`isQuotaExceeded` 超额标记),使面板额度按计费周期统计而非终身累计 - **单文件部署** —— 编译为单一二进制文件,零外部依赖 ## 支持的模型 -| 显示名称 | 内部 Key | +| 显示名称 | 内部 Key | | ----------------- | -------------- | -| Qwen3.8-Max | qmodel_38max | -| Qwen3.7-Max | qmodel_latest | -| Qwen3.7-Plus | qmodel | -| Qwen3.6-Flash | q36fmodel | -| DeepSeek-V4-Pro | dmodel | -| DeepSeek-V4-Flash | dfmodel | -| GLM-5.3 | gmodel | -| GLM-5.2 | gm51model | -| Kimi-K2.7-Code | kmodel | -| MiniMax-M2.7 | mmodel | +| Qwen3.8-Max | qmodel_38max | +| Qwen3.7-Max | qmodel_latest | +| Qwen3.7-Plus | qmodel | +| Qwen3.6-Flash | q36fmodel | +| DeepSeek-V4-Pro | dmodel | +| DeepSeek-V4-Flash | dfmodel | +| GLM-5.3 | gmodel | +| GLM-5.2 | gm51model | +| Kimi-K2.7-Code | kmodel | +| MiniMax-M2.7 | mmodel | > 以上为内置默认列表(catalog-v6,2026-08-15),实际可用模型以网关动态返回为准。 > @@ -68,7 +69,7 @@ docker run -d -p 10081:10081 -v qoder2api-data:/app/data -e QODER_DATA_PATH=/app ### 环境变量 | 变量名 | 默认值 | 说明 | -|-------|--------|------| +| ------- | -------- | ------ | | `QODER_HOST` | `0.0.0.0` | 监听地址 | | `QODER_PORT` | `10081` | 监听端口 | | `QODER_DATA_PATH` | `data.json` | 数据文件路径 | @@ -150,10 +151,75 @@ curl http://localhost:10081/v1/models \ -H "Authorization: Bearer sk-xxxxxxxxxxxxxxxx" ``` +### 5. 控制思考强度(`reasoning_effort`) + +请求体支持 OpenAI 风格的 `reasoning_effort`。有效档位写入网关请求的 `parameters` 对象(与官方客户端一致),**无需**额外的 device token、Bearer token 或模型服务域名配置;未携带该字段的请求保持原有行为。 + +```json +{ + "model": "Qwen3.8-Max", + "messages": [{"role": "user", "content": "你好"}], + "reasoning_effort": "xhigh" +} +``` + +桥接层从动态模型目录读取每个模型的 `efforts` 与 `supports_disabled` 后校验档位:有效值为 `none`、`low`、`medium`、`high`、`xhigh`、`max`,`minimal` 会映射为 `low`。不受当前模型支持的档位会被省略,模型继续使用默认思考强度;不会发送可能导致上游拒绝的无效值。 + +> **注意**:档位必须放在 `parameters` 内才会生效。网关不读取请求体顶层的 `reasoning_effort`,写在该位置会被静默忽略,模型一律按默认强度思考。此外 `parameters` 还承载 `max_tokens`、`tool_choice` 等字段,桥接层会自动组装,调用方无需关心。 + +#### `none` 档:完全关闭思考 + +`none` 是二值开关而非强度档位,需要额外配合才能真正关闭思考。桥接层在档位解析为 `none` 时会同时做三件事(与官方客户端行为一致): + +1. `parameters.reasoning_effort` 置为 `"none"` +2. `parameters.max_thinking_tokens` 显式置为 `0`(该字段用指针承载,以保证 `0` 不被 JSON 序列化省略) +3. `model_config.is_reasoning` 与 `chat_context.extra.modelConfig.is_reasoning` 均置为 `false` + +其余档位不会写入 `max_thinking_tokens`:官方客户端仅在技能 / 子代理覆盖场景才把档位换算为思考预算,主交互路径只发送档位字符串。 + +#### `is_reasoning` 与 `max_tokens` 的来源 + +两项能力信息来自网关模型目录,与官方客户端的解析方式一致: + +- `is_reasoning`:取目录的 `is_reasoning` 字段,缺失时为 `false`。动态目录不可用时回退为 `true`,以保持桥接层原有的"默认开启思考"行为,避免静默关闭所有模型的思考能力。 +- `max_tokens`:取目录的 `max_output_tokens`,缺失或非法(非正整数)时回退为 `32000`,与官方客户端的 `LS()` 归一化结果相同。 + +**pi 客户端配置示例**:Qoder 上游不接受 OpenAI 的 `developer` 角色,而 pi 会用该角色承载代理指令;因此必须在 `~/.pi/agent/models.json` 的 `qoder-local` provider 上设置 `supportsDeveloperRole: false`,使 pi 改用 `system` 角色。为模型设置 `reasoning: true`,并将 pi 的档位映射到模型目录实际支持的值: + +```json +{ + "providers": { + "qoder-local": { + "compat": { + "supportsDeveloperRole": false + } + } + } +} +``` + +以下模型配置适用于支持 `low` / `medium` / `xhigh` 的模型(`max` 映射到目录实际支持的最高档位,避免发送被上游丢弃的无效值): + +```json +{ + "id": "Qwen3.8-Max", + "reasoning": true, + "thinkingLevelMap": { + "off": "none", + "minimal": "low", + "low": "low", + "medium": "medium", + "high": "xhigh", + "xhigh": "xhigh", + "max": "xhigh" + } +} +``` + ## API 端点 | 端点 | 方法 | 说明 | -|------|------|------| +| ------ | ------ | ------ | | `/v1/chat/completions` | POST | 聊天补全(兼容 OpenAI 格式) | | `/v1/models` | GET | 获取模型列表 | | `/admin` | GET | Web 管理面板 | @@ -161,12 +227,54 @@ curl http://localhost:10081/v1/models \ | `/admin/api/keys` | GET/POST/DELETE | API 密钥管理 | | `/admin/api/pat` | GET/POST | PAT 令牌管理 | | `/admin/api/models` | GET | 获取模型列表 | -| `/admin/api/stats` | GET | 请求统计(总量 / 按模型 / 近 24 小时趋势 / token 用量 / 扣费额度) | +| `/admin/api/stats` | GET | 请求统计(总量 / 按模型 / 近 24 小时趋势 / token 用量 / 扣费额度 / 计费周期与套餐信息) | | `/admin/api/config` | GET/POST | 服务器配置 | | `/admin/api/password` | POST | 修改管理密码 | **错误响应约定**:登录失败限流返回 `429`(附 `Retry-After`);创建重复 API Key 返回 `409`;端口配置越界(非 1–65535)返回 `400`;未鉴权或会话过期返回 `401`。 +## 额度与计费周期 + +管理面板使用 Qoder 官方客户端同源的额度接口,展示当前计费周期的权威用量,而不是仅统计本服务启动后的局部消耗: + +```text +套餐额度 2,939 / 3,000 剩余 61 可用 +加购额度 0 / 1,000 剩余 1,000 可用 +组织资源包 120 / 4,000 剩余 3,880 可用 +合计剩余 4,941 · 09-25 重置 +``` + +### 数据来源 + +主数据来自 `GET https://openapi.qoder.com.cn/api/v2/quota/usage`,使用现有 jobToken 交换返回的 `securityOauthToken` 作为 Bearer。该接口与官方客户端 `/usage` 面板相同,提供: + +| 字段 | 含义 | 用途 | +| ------ | ------ | ------ | +| `userQuota.total/used/remaining` | 套餐总额、已用及剩余 Credits | 面板主要额度数字 | +| `userQuota.percentage` | 套餐使用比例 | 管理 API 输出及详细信息 | +| `addOnQuota` | 加购额度包(存在时) | 管理 API 输出及额度明细卡 | +| `orgResourcePackage` | 组织资源包容量、已用、剩余及可用状态 | 管理 API 输出及额度明细卡 | +| `expiresAt` | 当前额度周期结束时刻 | 重置日期与倒计时 | +| `isQuotaExceeded` | 官方客户端采用的超额判定 | 面板红色告警徽标 | + +网关 `POST /algo/api/v3/user/status?Encode=1` 仍负责提供 `plan`、`userTag`、组织名称和真实 `userType`。它的 `quota: 0` 是精简字段,**不能解释为套餐没有数字上限**;额度必须以 OpenAPI 的 `userQuota` 为准。 + +### 统计与降级口径 + +- **官方额度优先**:面板的套餐用量来自账号级 OpenAPI,包含 IDE、CLI 和本服务产生的全部消耗,也能覆盖服务启动前的当期用量。 +- **额度来源分别展示**:套餐额度、加购额度和组织资源包在额度明细卡中分别成行展示,并汇总各额度来源返回的剩余 Credits;组织资源包的“可用/不可用”状态以 OpenAPI 返回值为准。 +- **本地累计仅作降级**:usage 帧中 `billable=false` 的请求仍计入 token,但不计入本地 credits;OpenAPI 从未成功时,面板才显示本服务观察到的周期消耗,并明确标注口径。 +- **周期边界权威同步**:优先使用 OpenAPI `expiresAt`;本地周期起点按自然月回退推导,断网期间仍可完成一次性滚动。 +- **累计值仍保留**:`stats.credits` 保存本服务历史累计扣费,鼠标悬停在额度行可查看。 + +### 刷新时机 + +主程序启动后立即获取一次额度,此后每分钟在后台刷新。管理面板的 15 秒轮询完全读取内存和持久化快照,不直接调用上游;临时网络故障会保留最后一次成功结果。 + +### `userType` 修复 + +`jobToken` 响应中**不含** `userType` 字段,此前 bridge 一律回退为 `personal_standard`,导致团队账号的签名会话载荷层级错误。现改为在构建会话**之前**从 `/user/status` 取真实层级(该值被 AES 签入 bearer 载荷,事后无法修改)。状态接口临时不可用时,按「上次已知层级 → 空值 → 历史默认值」顺序降级,避免续期时把团队账号静默降级。 + ## 安全 本项目面向内网 / 个人使用,已内置以下加固措施,**部署到公网前请务必修改默认密码并使用 HTTPS 反代**: diff --git a/admin/admin_test.go b/admin/admin_test.go index af70d9a..b27c9ac 100644 --- a/admin/admin_test.go +++ b/admin/admin_test.go @@ -288,9 +288,15 @@ func TestServeIndex(t *testing.T) { if w.Code != 200 { t.Errorf("expected 200, got %d", w.Code) } - if !strings.Contains(w.Body.String(), "") { + body := w.Body.String() + if !strings.Contains(body, "") { t.Error("expected HTML doctype") } + for _, want := range []string{`id="quotaCard"`, `id="quotaTable"`, "renderQuotaDetails(d)", "组织资源包"} { + if !strings.Contains(body, want) { + t.Errorf("expected quota detail UI marker %q", want) + } + } } // Repeated wrong passwords trigger per-IP rate limiting (HTTP 429). @@ -505,3 +511,96 @@ func TestConfigPartialInvalidTimeoutAppliesNothing(t *testing.T) { t.Errorf("hot-apply must not run on rejected input, got %s", timeouts.Header) } } + +// The billing-cycle fields and account metadata must reach the panel through +// the stats endpoint, so the UI can render cycle-relative credits without any +// network call of its own. +func TestStatsEndpointExposesBillingCycle(t *testing.T) { + reset := time.Date(2026, time.September, 25, 0, 0, 0, 0, time.Local) + rec := stats.NewRecorder(nil) + rec.SetBillingCycle(reset.UnixMilli()) + rec.SetAccount(&stats.Account{ + Plan: "PLAN_TIER_TEAM", Tag: "Teams", OrgName: "Example Org", + TotalUsagePercentage: 0.98, + UserQuota: &stats.Quota{Total: 3000, Used: 2939, Remaining: 61, Percentage: 0.98, Unit: "credits"}, + OrgResourcePackage: &stats.OrgResourcePackage{Cap: 4000, Available: false, Unit: "credits"}, + }) + rec.RecordUsage(&stats.Usage{PromptTokens: 5, Credits: 1.25}) + + a := New(tempStore(t), func(ctx context.Context) []string { return nil }, rec) + w := doRequest(t, a.handleStats, "GET", "/admin/api/stats", nil) + + var resp struct { + Credits float64 `json:"credits"` + CycleCredits float64 `json:"cycle_credits"` + CycleStartMs int64 `json:"cycle_start_ms"` + NextResetMs int64 `json:"next_reset_ms"` + Account *struct { + Plan string `json:"plan"` + Tag string `json:"tag"` + OrgName string `json:"org_name"` + TotalUsagePercentage float64 `json:"total_usage_percentage"` + UserQuota *struct { + Total float64 `json:"total"` + Used float64 `json:"used"` + Remaining float64 `json:"remaining"` + } `json:"user_quota"` + OrgResourcePackage *struct { + Cap float64 `json:"cap"` + Available bool `json:"available"` + } `json:"org_resource_package"` + } `json:"account"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad response JSON: %v", err) + } + if resp.NextResetMs != reset.UnixMilli() { + t.Errorf("expected next_reset_ms %d, got %d", reset.UnixMilli(), resp.NextResetMs) + } + wantStart := time.Date(2026, time.August, 25, 0, 0, 0, 0, time.Local).UnixMilli() + if resp.CycleStartMs != wantStart { + t.Errorf("expected cycle_start_ms %d (one calendar month back), got %d", wantStart, resp.CycleStartMs) + } + if resp.CycleCredits < 1.25-1e-9 || resp.CycleCredits > 1.25+1e-9 { + t.Errorf("expected cycle_credits 1.25, got %v", resp.CycleCredits) + } + if resp.Account == nil || resp.Account.Tag != "Teams" || resp.Account.Plan != "PLAN_TIER_TEAM" { + t.Errorf("expected account metadata in the response, got %+v", resp.Account) + } + if resp.Account.UserQuota == nil || resp.Account.UserQuota.Total != 3000 || resp.Account.UserQuota.Used != 2939 || resp.Account.UserQuota.Remaining != 61 { + t.Errorf("expected authoritative user quota in the response, got %+v", resp.Account.UserQuota) + } + if resp.Account.OrgResourcePackage == nil || resp.Account.OrgResourcePackage.Cap != 4000 || resp.Account.OrgResourcePackage.Available { + t.Errorf("expected organization resource package in the response, got %+v", resp.Account.OrgResourcePackage) + } +} + +// With no account status ever resolved the cycle fields stay zero and the +// account is omitted, which is what makes the UI fall back to the lifetime +// total instead of rendering a bogus "resetting soon". +func TestStatsEndpointBillingCycleUnknown(t *testing.T) { + rec := stats.NewRecorder(nil) + rec.RecordUsage(&stats.Usage{Credits: 2}) + + a := New(tempStore(t), func(ctx context.Context) []string { return nil }, rec) + w := doRequest(t, a.handleStats, "GET", "/admin/api/stats", nil) + + var resp struct { + Credits float64 `json:"credits"` + NextResetMs int64 `json:"next_reset_ms"` + CycleCredits float64 `json:"cycle_credits"` + Account map[string]any `json:"account"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad response JSON: %v", err) + } + if resp.NextResetMs != 0 { + t.Errorf("expected no reset boundary, got %d", resp.NextResetMs) + } + if resp.Credits < 2-1e-9 || resp.Credits > 2+1e-9 { + t.Errorf("lifetime credits must still accrue with no cycle known, got %v", resp.Credits) + } + if resp.Account != nil { + t.Errorf("expected account to be omitted, got %+v", resp.Account) + } +} diff --git a/admin/webui.go b/admin/webui.go index 2bad4f7..7f99ceb 100644 --- a/admin/webui.go +++ b/admin/webui.go @@ -386,6 +386,12 @@ code, .mono, .num { color: var(--muted); margin-top: 6px; } +.stat-sub .badge { margin-left: 6px; vertical-align: middle; } +.badge.neutral { + background: var(--surface-2); + color: var(--muted); + border: 1px solid var(--border); +} /* ---------- Cards ---------- */ @@ -841,7 +847,7 @@ input[readonly] {
Token 总数
0
-
Credits 消耗 0
+
本周期 Credits 0
总输入
@@ -855,6 +861,21 @@ input[readonly] {
+ +
近 24 小时请求量
@@ -1196,7 +1217,8 @@ function loadStats() { document.getElementById('statTotal').textContent = fmt(d.total); document.getElementById('statReqSub').textContent = '成功率 ' + pct(d.success_rate); document.getElementById('statTokens').textContent = fmt(tokens); - document.getElementById('statCredits').textContent = 'Credits 消耗 ' + fmtCredits(d.credits); + renderCredits(d); + renderQuotaDetails(d); document.getElementById('statPrompt').textContent = fmt(d.prompt_tokens); document.getElementById('statCached').textContent = '缓存命中 ' + fmt(d.cached_tokens); document.getElementById('statCompletion').textContent = fmt(d.completion_tokens); @@ -1207,6 +1229,141 @@ function loadStats() { }).catch(function(e) { showToast(e.message, 'error'); }); } +/* + * Prefer the authoritative cycle allowance used by Qoder's official /usage + * view. Local cycle_credits remains a fallback for temporary OpenAPI outages; + * it only covers traffic observed by this bridge and is therefore never mixed + * with the account-wide official value. + */ +function renderCredits(d) { + var el = document.getElementById('statCredits'); + var lifetime = Number(d.credits || 0); + var cycle = Number(d.cycle_credits || 0); + var resetMs = Number(d.next_reset_ms || 0); + var acct = d.account || {}; + var quota = acct.user_quota || null; + var title = ['本服务累计消耗 ' + fmtCredits(lifetime)]; + + el.textContent = ''; + if (quota) { + el.appendChild(document.createTextNode( + '套餐 ' + fmtCredits(Number(quota.used || 0)) + ' / ' + fmtCredits(Number(quota.total || 0)) + + ' · 剩 ' + fmtCredits(Number(quota.remaining || 0)) + )); + title.push('套餐额度:已用 ' + fmtCredits(Number(quota.used || 0)) + + ' / ' + fmtCredits(Number(quota.total || 0)) + + ',剩余 ' + fmtCredits(Number(quota.remaining || 0))); + if (acct.add_on_quota) { + title.push('加购额度:已用 ' + fmtCredits(Number(acct.add_on_quota.used || 0)) + + ' / ' + fmtCredits(Number(acct.add_on_quota.total || 0)) + + ',剩余 ' + fmtCredits(Number(acct.add_on_quota.remaining || 0))); + } + if (acct.org_resource_package) { + var org = acct.org_resource_package; + title.push('组织资源包:已用 ' + fmtCredits(Number(org.used || 0)) + + ' / ' + fmtCredits(Number(org.cap || 0)) + + ',剩余 ' + fmtCredits(Number(org.remaining || 0)) + + (org.available ? '(可用)' : '(不可用)')); + } + } else if (resetMs > 0) { + el.appendChild(document.createTextNode('本服务周期内 ' + fmtCredits(cycle))); + title.push('官方额度暂不可用;当前数字仅统计本服务观察到的请求'); + } else { + el.appendChild(document.createTextNode('Credits 消耗 ' + fmtCredits(lifetime))); + } + + if (resetMs > 0) { + var left = Math.ceil((resetMs - Date.now()) / 86400000); + el.appendChild(document.createTextNode(' · ' + fmtMonthDay(resetMs) + ' 重置')); + if (left > 0) { + el.appendChild(document.createTextNode(' · 剩 ' + left + ' 天')); + } + } + el.title = title.join('\n'); + + if (acct.is_quota_exceeded) { + el.appendChild(makeBadge('额度已超', 'bad')); + } else if (acct.tag) { + el.appendChild(makeBadge(acct.tag, 'neutral')); + } +} + +/* + * Render every allowance pool returned by Qoder as visible content. The + * compact stat line intentionally remains a plan summary; this table prevents + * add-on and organization packages from being hidden behind a hover tooltip. + */ +function renderQuotaDetails(d) { + var card = document.getElementById('quotaCard'); + var table = document.getElementById('quotaTable'); + var summary = document.getElementById('quotaSummary'); + var acct = d.account || {}; + var pools = []; + + if (acct.user_quota) { + pools.push({ name: '套餐额度', quota: acct.user_quota, totalKey: 'total', available: true }); + } + if (acct.add_on_quota) { + pools.push({ name: '加购额度', quota: acct.add_on_quota, totalKey: 'total', available: true }); + } + if (acct.org_resource_package) { + pools.push({ + name: '组织资源包', + quota: acct.org_resource_package, + totalKey: 'cap', + available: Boolean(acct.org_resource_package.available) + }); + } + + if (pools.length === 0) { + card.classList.add('hidden'); + table.textContent = ''; + summary.textContent = ''; + return; + } + + card.classList.remove('hidden'); + table.textContent = ''; + var totalRemaining = 0; + pools.forEach(function(pool) { + var used = Number(pool.quota.used || 0); + var total = Number(pool.quota[pool.totalKey] || 0); + var remaining = Number(pool.quota.remaining || 0); + var ratio = total > 0 ? Math.min(Math.max(used / total, 0), 1) : 0; + var statusText = pool.available ? (remaining > 0 ? '可用' : '已用尽') : '不可用'; + var statusKind = pool.available && remaining > 0 ? 'ok' : (pool.available ? 'bad' : 'neutral'); + totalRemaining += remaining; + + var tr = document.createElement('tr'); + tr.innerHTML = + '' + pool.name + '' + + '' + fmtCredits(used) + ' / ' + (total >= 0 ? fmtCredits(total) : '—') + '' + + '' + fmtCredits(remaining) + '' + + '
' + + '' + Math.round(ratio * 100) + '%
' + + '' + statusText + ''; + table.appendChild(tr); + }); + + var resetMs = Number(d.next_reset_ms || 0); + summary.textContent = '合计剩余 ' + fmtCredits(totalRemaining) + + (resetMs > 0 ? ' · ' + fmtMonthDay(resetMs) + ' 重置' : ''); +} + +function makeBadge(text, kind) { + var b = document.createElement('span'); + b.className = 'badge ' + kind; + b.textContent = text; + return b; +} + +function fmtMonthDay(ms) { + var t = new Date(ms); + var m = t.getMonth() + 1; + var day = t.getDate(); + return (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day); +} + function fmtCredits(v) { var n = Number(v || 0); if (n === 0) return '0'; diff --git a/auth/auth.go b/auth/auth.go index da59686..4b04b15 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -7,7 +7,7 @@ import ( "context" "crypto/aes" "crypto/cipher" - "crypto/md5" + cosySignatureHash "crypto/md5" "crypto/rand" "crypto/rsa" "crypto/x509" @@ -17,6 +17,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "net/url" "os" @@ -32,15 +33,17 @@ import ( // --- Region Configuration --- type RegionConfig struct { - Name string - AuthBase string - ChatBase string + Name string + AuthBase string + ChatBase string + OpenAPIBase string } var CN = &RegionConfig{ - Name: "cn", - AuthBase: "https://gateway.qoder.com.cn", - ChatBase: "https://gateway.qoder.com.cn", + Name: "cn", + AuthBase: "https://gateway.qoder.com.cn", + ChatBase: "https://gateway.qoder.com.cn", + OpenAPIBase: "https://openapi.qoder.com.cn", } func Resolve(pat string) (string, *RegionConfig) { @@ -59,6 +62,10 @@ func ModelListURL(region *RegionConfig) string { return region.ChatBase + "/algo/api/v2/model/list?Encode=1" } +func OpenAPIURL(region *RegionConfig, path string) string { + return region.OpenAPIBase + path +} + func FetchModelCatalog(ctx context.Context, sess *SessionContext, region *RegionConfig) (map[string]interface{}, error) { return CallGet(ctx, sess, ModelListURL(region)) } @@ -148,7 +155,9 @@ func CurrentDate() string { func Sign(date string) string { s := appCode + "&" + getSecret() + "&" + date - h := md5.Sum([]byte(s)) + // nosemgrep: go.lang.security.audit.crypto.use_of_weak_crypto.use-of-md5 -- Qoder COSY protocol mandates MD5 for this wire signature. + // #nosec G401 -- Legacy Qoder COSY wire signature; changing MD5 breaks authentication. + h := cosySignatureHash.Sum([]byte(s)) return fmt.Sprintf("%x", h) } @@ -219,7 +228,9 @@ func aesEncrypt(plain, key []byte) ([]byte, error) { } func md5Hex(s string) string { - h := md5.Sum([]byte(s)) + // nosemgrep: go.lang.security.audit.crypto.use_of_weak_crypto.use-of-md5 -- Qoder COSY protocol mandates MD5 for this wire signature. + // #nosec G401 -- Legacy Qoder COSY wire signature; changing MD5 breaks authentication. + h := cosySignatureHash.Sum([]byte(s)) return fmt.Sprintf("%x", h) } @@ -546,6 +557,190 @@ func RefreshJobToken(ctx context.Context, personalToken, refreshToken, securityO return requestJobToken(ctx, personalToken, refreshToken, securityOauthToken, true, machineID, machineToken, machineType, region) } +// --- Account status (subscription tier + billing cycle) --- + +// userStatusInnerStruct is the /user/status payload. Field order MUST match +// the official client's dict insertion order for signature compatibility. +// Only userId is populated; the token fields are sent empty because this call +// is authenticated by the machine-identity signature, not by the session. +type userStatusInnerStruct struct { + UserID string `json:"userId"` + PersonalToken string `json:"personalToken"` + SecurityOauthToken string `json:"securityOauthToken"` + RefreshToken string `json:"refreshToken"` + NeedRefresh bool `json:"needRefresh"` + AuthInfo json.RawMessage `json:"authInfo"` +} + +// AccountStatus combines identity metadata from the gateway status endpoint +// with the authoritative allowance returned by Qoder OpenAPI. NextResetAtMs is +// the moment the current subscription allowance expires and refreshes. +type AccountStatus struct { + // UserType is the account's real tier as reported by the gateway + // ("teams", "personal_standard", ...). The jobToken response does NOT + // carry this field, so callers must not infer it from there. + UserType string + // Plan is the raw plan identifier ("PLAN_TIER_TEAM", ...). + Plan string + // UserTag is the human-facing plan label ("Teams", ...). + UserTag string + // OrgName is the organization the account belongs to ("" for personal). + OrgName string + // NextResetAtMs is the subscription refresh instant in epoch millis. + // 0 means the gateway did not report one. + NextResetAtMs int64 + // IsQuotaExceeded reports whether the account is currently out of + // allowance. Once quota usage has been fetched, this is the OpenAPI + // verdict used by the official client rather than the reduced gateway + // status field. + IsQuotaExceeded bool + TotalUsagePercentage float64 + UserQuota *Quota + AddOnQuota *Quota + OrgResourcePackage *OrgResourcePackage +} + +// Quota is an authoritative credit allowance returned by Qoder OpenAPI. +// Percentage is a ratio in [0,1], matching the official client payload. +type Quota struct { + Total float64 `json:"total"` + Used float64 `json:"used"` + Remaining float64 `json:"remaining"` + Percentage float64 `json:"percentage"` + Unit string `json:"unit"` + DetailURL string `json:"detailUrl,omitempty"` +} + +// OrgResourcePackage is the shared organization credit pool. Available is +// authoritative: a positive cap does not necessarily mean the current member +// may consume it. +type OrgResourcePackage struct { + Used float64 `json:"used"` + Cap float64 `json:"cap"` + Remaining float64 `json:"remaining"` + Percentage float64 `json:"percentage"` + Available bool `json:"available"` + Unit string `json:"unit"` +} + +// QuotaUsage is the full cycle-scoped allowance returned by the same OpenAPI +// endpoint used by the official client's /usage view. +type QuotaUsage struct { + UserID string `json:"userId"` + UserType string `json:"userType"` + UsageType string `json:"usageType"` + TotalUsagePercentage float64 `json:"totalUsagePercentage"` + IsQuotaExceeded bool `json:"isQuotaExceeded"` + ExpiresAtMs int64 `json:"expiresAt"` + UpgradeURL string `json:"upgradeUrl"` + UserQuota *Quota `json:"userQuota"` + AddOnQuota *Quota `json:"addOnQuota,omitempty"` + OrgResourcePackage *OrgResourcePackage `json:"orgResourcePackage,omitempty"` + IsPlanQuotaProrated bool `json:"isPlanQuotaProrated"` +} + +// FetchUserStatus queries the account's subscription state. +// +// The endpoint is authenticated by the machine-identity signature only (no +// bearer session), so it is safe to call right after a jobToken exchange and +// before any session is constructed. +func FetchUserStatus(ctx context.Context, userID, machineID, machineToken, machineType string, region *RegionConfig) (map[string]interface{}, error) { + if region == nil { + region = CN + } + urlStr := AuthURL(region, "/algo/api/v3/user/status?Encode=1") + inner := userStatusInnerStruct{ + UserID: userID, + NeedRefresh: false, + AuthInfo: emptyJSON, + } + innerJSON, err := marshalNoEscape(inner) + if err != nil { + return nil, err + } + outer := jobTokenOuterStruct{ + Payload: string(innerJSON), + EncodeVersion: "1", + } + return postEncoded(ctx, urlStr, outer, machineID, machineToken, machineType) +} + +// FetchQuotaUsage retrieves the authoritative current-cycle allowance used by +// the official /usage view. The bearer is the securityOauthToken returned by +// the existing jobToken exchange; no additional token rotation is required. +func FetchQuotaUsage(ctx context.Context, bearer string, region *RegionConfig) (*QuotaUsage, error) { + if bearer == "" { + return nil, fmt.Errorf("quota usage requires a security OAuth token") + } + if region == nil { + region = CN + } + urlStr := OpenAPIURL(region, "/api/v2/quota/usage") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+bearer) + + started := time.Now() + log.Printf("[auth] OpenAPI request: method=GET url=%s headers={Accept: application/json, Authorization: Bearer [REDACTED]} body=", urlStr) + resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req) + if err != nil { + log.Printf("[auth] OpenAPI request failed: method=GET url=%s duration=%s error=%v", urlStr, time.Since(started), err) + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, err + } + log.Printf("[auth] OpenAPI response: method=GET url=%s status=%d duration=%s headers=%v body=%s", + urlStr, resp.StatusCode, time.Since(started), resp.Header, body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("quota usage returned HTTP %d: %s", resp.StatusCode, body) + } + var usage QuotaUsage + if err := json.Unmarshal(body, &usage); err != nil { + return nil, fmt.Errorf("decode quota usage: %w", err) + } + if usage.UserID == "" || usage.UserQuota == nil { + return nil, fmt.Errorf("quota usage response shape unexpected") + } + return &usage, nil +} + +// ParseAccountStatus converts a raw /user/status response into AccountStatus. +// It returns false when the response carries no recognizable account fields, +// letting callers keep any previously cached state instead of zeroing it out. +func ParseAccountStatus(raw map[string]interface{}) (AccountStatus, bool) { + if raw == nil { + return AccountStatus{}, false + } + userType, _ := raw["userType"].(string) + plan, _ := raw["plan"].(string) + if userType == "" && plan == "" && raw["nextResetAt"] == nil { + return AccountStatus{}, false + } + st := AccountStatus{ + UserType: userType, + Plan: plan, + } + st.UserTag, _ = raw["userTag"].(string) + st.OrgName, _ = raw["orgName"].(string) + st.NextResetAtMs = toInt64Ms(raw["nextResetAt"]) + st.IsQuotaExceeded, _ = raw["isQuotaExceeded"].(bool) + return st, true +} + +// toInt64Ms coerces an epoch-millis field arriving as a JSON number. +func toInt64Ms(v interface{}) int64 { + if f, ok := v.(float64); ok { + return int64(f) + } + return 0 +} + // drainBody reads and discards the remaining response body (up to a cap) so // the underlying TCP connection can be returned to the pool for reuse. // Must be called before resp.Body.Close() on error paths where the body was diff --git a/auth/auth_test.go b/auth/auth_test.go index 238c1cc..65c76e1 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -349,3 +349,37 @@ func TestDetectInStreamBusyIgnoresPlainDeltas(t *testing.T) { t.Error("plain content delta must not be classified as busy") } } + +func TestFetchQuotaUsageUsesSecurityOauthBearer(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/api/v2/quota/usage" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer sot-test" { + t.Errorf("Authorization = %q, want security OAuth bearer", got) + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, `{"userId":"u1","userType":"teams","totalUsagePercentage":0.98,"isQuotaExceeded":false,"expiresAt":1790265600000,"userQuota":{"total":3000,"used":2939,"remaining":61,"percentage":0.98,"unit":"credits"},"orgResourcePackage":{"used":0,"cap":4000,"remaining":0,"percentage":0,"available":false,"unit":"credits"}}`) + })) + defer srv.Close() + + usage, err := FetchQuotaUsage(context.Background(), "sot-test", &RegionConfig{OpenAPIBase: srv.URL}) + if err != nil { + t.Fatal(err) + } + if usage.UserQuota == nil || usage.UserQuota.Used != 2939 || usage.UserQuota.Remaining != 61 { + t.Errorf("unexpected user quota: %+v", usage.UserQuota) + } + if usage.OrgResourcePackage == nil || usage.OrgResourcePackage.Cap != 4000 || usage.OrgResourcePackage.Available { + t.Errorf("unexpected organization package: %+v", usage.OrgResourcePackage) + } + if usage.ExpiresAtMs != 1790265600000 || usage.TotalUsagePercentage != 0.98 { + t.Errorf("unexpected cycle metadata: %+v", usage) + } +} + +func TestFetchQuotaUsageRequiresBearer(t *testing.T) { + if _, err := FetchQuotaUsage(context.Background(), "", CN); err == nil { + t.Fatal("expected an error for an empty security OAuth token") + } +} diff --git a/bridge/bridge.go b/bridge/bridge.go index 41b942f..cb9c5bd 100644 --- a/bridge/bridge.go +++ b/bridge/bridge.go @@ -29,6 +29,11 @@ import ( var ( refreshMarginMs = int64(2 * 3600 * 1000) // 2 hours catalogTTL = float64(600) // 10 minutes + // accountTTL bounds how long a cached /user/status result is trusted. + // The subscription tier effectively never changes and the billing + // boundary moves once a month, so an hour is far tighter than needed + // while keeping the status endpoint off the chat path entirely. + accountTTL = float64(3600) ) // chatMaxBodyBytes caps the /v1/chat/completions request body to bound memory @@ -70,6 +75,24 @@ type modelConfig struct { Source string `json:"source"` } +// chatParameters mirrors the gateway's "parameters" object. The official client +// builds this map first and always attaches it to the request body; the gateway +// reads completion caps, thinking control and tool selection from here and never +// from the top level of the body. +type chatParameters struct { + // MaxTokens caps completion tokens. Always sent, matching the official + // client's unconditional max_tokens entry. + MaxTokens int `json:"max_tokens"` + // MaxThinkingTokens caps thinking tokens. A pointer so that an explicit 0 + // survives serialization: 0 is how the official client disables thinking. + MaxThinkingTokens *int `json:"max_thinking_tokens,omitempty"` + // ReasoningEffort is the canonical thinking tier (none/low/medium/high/ + // xhigh/max) validated against the model's catalog metadata. + ReasoningEffort string `json:"reasoning_effort,omitempty"` + // ToolChoice carries the OpenAI tool_choice value verbatim. + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` +} + type business struct { ID string `json:"id"` Name string `json:"name"` @@ -79,25 +102,28 @@ type business struct { // ChatRequestBody is the Qoder chat request body sent to the gateway. // Field order matches baseprompt.json for consistent JSON serialization. type ChatRequestBody struct { - RequestID string `json:"request_id"` - RequestSetID string `json:"request_set_id"` - ChatRecordID string `json:"chat_record_id"` - Stream bool `json:"stream"` - ChatTask string `json:"chat_task"` - ChatContext chatContext `json:"chat_context"` - SessionID string `json:"session_id"` - Source int `json:"source"` - Version string `json:"version"` - AliyunUserType string `json:"aliyun_user_type"` - SessionType string `json:"session_type"` - AgentID string `json:"agent_id"` - TaskID string `json:"task_id"` - ModelConfig modelConfig `json:"model_config"` - Messages []transform.QoderMessage `json:"messages"` - Business business `json:"business"` - Tools json.RawMessage `json:"tools,omitempty"` - ToolChoice json.RawMessage `json:"tool_choice,omitempty"` - ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` + RequestID string `json:"request_id"` + RequestSetID string `json:"request_set_id"` + ChatRecordID string `json:"chat_record_id"` + Stream bool `json:"stream"` + ChatTask string `json:"chat_task"` + ChatContext chatContext `json:"chat_context"` + SessionID string `json:"session_id"` + Source int `json:"source"` + Version string `json:"version"` + AliyunUserType string `json:"aliyun_user_type"` + SessionType string `json:"session_type"` + AgentID string `json:"agent_id"` + TaskID string `json:"task_id"` + ModelConfig modelConfig `json:"model_config"` + Messages []transform.QoderMessage `json:"messages"` + Business business `json:"business"` + Parameters chatParameters `json:"parameters"` + Tools json.RawMessage `json:"tools,omitempty"` + // ParallelToolCalls has no equivalent in the native gateway contract: the + // official client only forwards it on the external OpenAI-compatible path. + // It is passed through for clients that send it, but the gateway ignores it. + ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` } // newRequestBody creates a ChatRequestBody with hardcoded defaults that @@ -139,6 +165,9 @@ func newRequestBody() *ChatRequestBody { Business: business{ Name: "hi", }, + Parameters: chatParameters{ + MaxTokens: models.DefaultMaxOutputTokens, + }, } } @@ -179,6 +208,12 @@ type OpenAiBridge struct { catalogTs float64 catalogInflt bool // single-flight: a fetch is already in progress + // account caches the /user/status result: the real subscription tier + // (which jobToken does not report) and the billing-cycle boundary. + accountMu sync.Mutex + account *auth.AccountStatus + accountTs float64 + // chatSlots bounds concurrent upstream chat requests per PAT. Exceeding // the gateway's per-account admission window yields business code 10605 // ("gateway busy") over HTTP 401/403, so we queue locally instead. @@ -231,19 +266,181 @@ func (b *OpenAiBridge) bootstrapSession(ctx context.Context) error { if err != nil { return err } - name, _ := jt["name"].(string) id, _ := jt["id"].(string) exp, _ := jt["expireTime"] - log.Printf("[bridge] session for %s (%s) [%s] exp=%v", name, id, b.Region.Name, exp) - b.applyJobToken(jt) + // The jobToken response carries no userType, so the authoritative tier has + // to come from /user/status. It must be resolved BEFORE the session is + // built because the value is AES-signed into the bearer payload and cannot + // be amended afterwards. + userType := b.resolveAccountStatus(ctx, id) + name, _ := jt["name"].(string) + log.Printf("[bridge] session for %s (%s) [%s] exp=%v userType=%s", name, id, b.Region.Name, exp, userType) + b.applyJobToken(jt, userType) b.bootstrapped.Store(true) return nil } -func (b *OpenAiBridge) applyJobToken(jt map[string]interface{}) { +// resolveAccountStatus fetches /user/status and returns the authoritative +// subscription tier. On failure it degrades in order: last known tier (even if +// stale) → empty, letting applyJobToken fall back to the jobToken value and +// then the historical default. +// +// Preferring a stale tier over the default matters on the renewal path: a +// transient status outage must not downgrade a team account to +// personal_standard and change the signed bearer payload mid-session. +func (b *OpenAiBridge) resolveAccountStatus(ctx context.Context, userID string) string { + st, err := b.fetchAccountStatus(ctx, userID) + if err == nil && st != nil && st.UserType != "" { + return st.UserType + } + if err != nil { + log.Printf("[bridge] WARN user/status failed (%v); reusing last known tier", err) + } + if cached := b.AccountStatus(); cached != nil && cached.UserType != "" { + return cached.UserType + } + return "" +} + +// fetchAccountStatus queries /user/status with a TTL cache. Unlike the chat +// path it performs no single-flight coordination: callers are the renewal +// flows (already serialized by refreshMu) and the admin poll. +func (b *OpenAiBridge) fetchAccountStatus(ctx context.Context, userID string) (*auth.AccountStatus, error) { + now := float64(time.Now().Unix()) + b.accountMu.Lock() + if cached := b.account; cached != nil && now-b.accountTs < accountTTL { + b.accountMu.Unlock() + return cached, nil + } + b.accountMu.Unlock() + + if userID == "" { + return nil, fmt.Errorf("no user id available for user/status") + } + raw, err := auth.FetchUserStatus(ctx, userID, b.machineID, b.machineToken, b.machineType, b.Region) + if err != nil { + return nil, err + } + st, ok := auth.ParseAccountStatus(raw) + if !ok { + return nil, fmt.Errorf("user/status response shape unexpected") + } + b.accountMu.Lock() + // A successful identity refresh must not erase the last authoritative + // quota snapshot if the immediately following OpenAPI call fails. Preserve + // only quota-owned fields; plan/tag/org metadata comes from this fresh + // gateway response. + if previous := b.account; previous != nil && previous.UserQuota != nil { + st.NextResetAtMs = previous.NextResetAtMs + st.IsQuotaExceeded = previous.IsQuotaExceeded + st.TotalUsagePercentage = previous.TotalUsagePercentage + st.UserQuota = previous.UserQuota + st.AddOnQuota = previous.AddOnQuota + st.OrgResourcePackage = previous.OrgResourcePackage + } + b.account = cloneAccountStatus(&st) + b.accountTs = float64(time.Now().Unix()) + b.accountMu.Unlock() + log.Printf("[bridge] account status: userType=%s plan=%s tag=%s nextReset=%s quotaExceeded=%t", + st.UserType, st.Plan, st.UserTag, fmtResetTime(st.NextResetAtMs), st.IsQuotaExceeded) + return &st, nil +} + +// EnsureAccountStatus returns the subscription state, refreshing both the +// gateway identity metadata and the authoritative OpenAPI quota snapshot. It +// bootstraps the session first because OpenAPI uses the securityOauthToken +// produced by that exchange. Failures preserve the last known snapshot so a +// temporary account-service outage never breaks the caller's accounting loop. +func (b *OpenAiBridge) EnsureAccountStatus(ctx context.Context) *auth.AccountStatus { + if err := b.EnsureFreshSession(ctx); err != nil { + log.Printf("[bridge] WARN account status unavailable (session: %v)", err) + return b.AccountStatus() + } + userID := "" + if ident := b.currentIdentity(); ident != nil { + userID = ident.Aid + } + st, err := b.fetchAccountStatus(ctx, userID) + if err != nil { + log.Printf("[bridge] WARN user/status refresh failed: %v", err) + st = b.AccountStatus() + } + + quota, err := auth.FetchQuotaUsage(ctx, b.currentSecurityOauth(), b.Region) + if err != nil { + log.Printf("[bridge] WARN quota/usage refresh failed: %v", err) + return st + } + if st == nil { + st = &auth.AccountStatus{} + } else { + st = cloneAccountStatus(st) + } + if quota.UserType != "" { + st.UserType = quota.UserType + } + st.NextResetAtMs = quota.ExpiresAtMs + st.IsQuotaExceeded = quota.IsQuotaExceeded + st.TotalUsagePercentage = quota.TotalUsagePercentage + st.UserQuota = quota.UserQuota + st.AddOnQuota = quota.AddOnQuota + st.OrgResourcePackage = quota.OrgResourcePackage + + b.accountMu.Lock() + b.account = cloneAccountStatus(st) + b.accountMu.Unlock() + log.Printf("[bridge] quota usage: userType=%s used=%.2f total=%.2f remaining=%.2f reset=%s quotaExceeded=%t", + st.UserType, st.UserQuota.Used, st.UserQuota.Total, st.UserQuota.Remaining, + fmtResetTime(st.NextResetAtMs), st.IsQuotaExceeded) + return cloneAccountStatus(st) +} + +// AccountStatus returns the cached subscription state, or nil when it has +// never been resolved. It performs no network call, so it is safe to invoke +// from a request handler at any cadence. +func (b *OpenAiBridge) AccountStatus() *auth.AccountStatus { + b.accountMu.Lock() + defer b.accountMu.Unlock() + return cloneAccountStatus(b.account) +} + +func cloneAccountStatus(st *auth.AccountStatus) *auth.AccountStatus { + if st == nil { + return nil + } + cp := *st + if st.UserQuota != nil { + q := *st.UserQuota + cp.UserQuota = &q + } + if st.AddOnQuota != nil { + q := *st.AddOnQuota + cp.AddOnQuota = &q + } + if st.OrgResourcePackage != nil { + q := *st.OrgResourcePackage + cp.OrgResourcePackage = &q + } + return &cp +} + +// fmtResetTime renders an epoch-millis reset instant for logs, or "unknown". +func fmtResetTime(ms int64) string { + if ms <= 0 { + return "unknown" + } + return time.UnixMilli(ms).Local().Format("2006-01-02 15:04 MST") +} + +// applyJobToken builds the signed session from a jobToken response. userType +// is the tier resolved from /user/status; when empty the jobToken value is +// used, and only then does the historical default apply. +func (b *OpenAiBridge) applyJobToken(jt map[string]interface{}, userType string) { name, _ := jt["name"].(string) id, _ := jt["id"].(string) - userType, _ := jt["userType"].(string) + if userType == "" { + userType, _ = jt["userType"].(string) + } if userType == "" { userType = "personal_standard" } @@ -328,6 +525,13 @@ func (b *OpenAiBridge) currentIdentity() *auth.AuthIdentity { return id } +func (b *OpenAiBridge) currentSecurityOauth() string { + b.mu.Lock() + token := b.securityOauth + b.mu.Unlock() + return token +} + // doRenew renews the session token via refreshToken. func (b *OpenAiBridge) doRenew(ctx context.Context, force bool) error { b.mu.Lock() @@ -353,7 +557,8 @@ func (b *OpenAiBridge) doRenew(ctx context.Context, force bool) error { } log.Printf("[bridge] session %s (exp=%v)", label, jt["expireTime"]) } - b.applyJobToken(jt) + id, _ := jt["id"].(string) + b.applyJobToken(jt, b.resolveAccountStatus(ctx, id)) return nil } @@ -529,9 +734,18 @@ func (b *OpenAiBridge) HandleChat(ctx context.Context, w http.ResponseWriter, re body.Stream = true body.AliyunUserType = identity.UserType body.ModelConfig.Key = qoderModel - body.ModelConfig.IsReasoning = true body.ChatContext.Extra.ModelConfig.Key = qoderModel - body.ChatContext.Extra.ModelConfig.IsReasoning = true + + // Thinking capability comes from the gateway catalog the way the official + // client resolves it (is_reasoning ?? false), instead of being forced on. + // A resolved "none" tier overrides both copies further below. + reasoningOn := catalog.ReasoningDefault(qoderModel) + body.ModelConfig.IsReasoning = reasoningOn + body.ChatContext.Extra.ModelConfig.IsReasoning = reasoningOn + // Completion cap follows the official precedence: a caller-supplied value + // wins, the catalog default is only the fallback. Clamping to the catalog + // would silently truncate models whose advertised cap is small. + body.Parameters.MaxTokens = resolveMaxTokens(reqBody, catalog.MaxOutputTokens(qoderModel)) body.Business.ID = uuid.New().String() body.Business.BeginAt = time.Now().UnixMilli() @@ -574,7 +788,25 @@ func (b *OpenAiBridge) HandleChat(ctx context.Context, w http.ResponseWriter, re log.Printf("[bridge] multimodal: %d image(s) attached [%s]", imgCount, openaiModel) } - log.Printf("[bridge] chat req: prompt_len=%d model=%s", len(prompt), openaiModel) + if effort := resolveReasoningEffort(reqBody, catalog.Reasoning[qoderModel]); effort != "" { + // The gateway reads the tier from "parameters", never from the top level + // of the body. The official client assembles it the same way. + body.Parameters.ReasoningEffort = effort + if effort == "none" { + // Disable thinking explicitly: the client sets is_reasoning=false and + // max_thinking_tokens=0 together. The pointer field is what lets an + // explicit 0 survive serialization instead of being omitted. + zero := 0 + body.Parameters.MaxThinkingTokens = &zero + body.ModelConfig.IsReasoning = false + body.ChatContext.Extra.ModelConfig.IsReasoning = false + } + log.Printf("[bridge] chat req: prompt_len=%d model=%s reasoning_effort=%s is_reasoning=%t max_tokens=%d", + len(prompt), openaiModel, effort, body.ModelConfig.IsReasoning, body.Parameters.MaxTokens) + } else { + log.Printf("[bridge] chat req: prompt_len=%d model=%s reasoning=default(on) is_reasoning=%t max_tokens=%d", + len(prompt), openaiModel, body.ModelConfig.IsReasoning, body.Parameters.MaxTokens) + } url := auth.ChatURL(b.Region) extraHeaders := map[string]string{ @@ -867,6 +1099,7 @@ func MakeChatHandler(resolver BridgeResolver, rec *stats.Recorder) http.HandlerF CompletionTokens: u.CompletionTokens, CachedTokens: u.CachedPromptTokens(), Credits: u.Credits, + NonBillable: !u.Billable, }) } } @@ -966,6 +1199,28 @@ func statsModelLabel(reqBody map[string]interface{}, b *OpenAiBridge, ctx contex return model } +// resolveMaxTokens picks the completion cap for the gateway request. A positive +// integer from the client wins, matching the official client's +// LS(maxOutputTokens ?? catalogDefault); otherwise the catalog value applies. +// OpenAI clients may use either max_tokens or the newer max_completion_tokens. +func resolveMaxTokens(reqBody map[string]interface{}, catalogDefault int) int { + for _, key := range []string{"max_tokens", "max_completion_tokens"} { + if n, ok := positiveJSONInt(reqBody[key]); ok { + return n + } + } + return catalogDefault +} + +// positiveJSONInt parses a decoded JSON number into a positive int. +func positiveJSONInt(v interface{}) (int, bool) { + n, ok := v.(float64) + if !ok || n != float64(int(n)) || int(n) <= 0 { + return 0, false + } + return int(n), true +} + func extractMessages(raw interface{}) []map[string]interface{} { list, ok := raw.([]interface{}) if !ok { @@ -980,6 +1235,44 @@ func extractMessages(raw interface{}) []map[string]interface{} { return result } +// resolveReasoningEffort validates the OpenAI-style reasoning_effort against +// the selected model's catalog metadata. Empty means preserve the gateway's +// existing default reasoning behaviour by omitting the optional field. +func resolveReasoningEffort(reqBody map[string]interface{}, ri *models.ModelReasoning) string { + raw, _ := reqBody["reasoning_effort"].(string) + effort := normalizeReasoningEffort(raw) + if effort == "" { + if strings.TrimSpace(raw) != "" { + log.Printf("[bridge] ignoring unsupported reasoning_effort %q; keeping reasoning enabled", raw) + } + return "" + } + if ri == nil || !ri.Known { + return effort + } + if effort == "none" && ri.SupportsDisabled { + return effort + } + for _, supported := range ri.Efforts { + if effort == supported { + return effort + } + } + log.Printf("[bridge] reasoning_effort %q not supported by model; keeping model default", effort) + return "" +} + +func normalizeReasoningEffort(value string) string { + switch effort := strings.ToLower(strings.TrimSpace(value)); effort { + case "none", "low", "medium", "high", "xhigh", "max": + return effort + case "minimal": + return "low" + default: + return "" + } +} + // applyToolConfig applies tool configuration from the client request to the body struct. func applyToolConfig(body *ChatRequestBody, reqBody map[string]interface{}) bool { toolsEnabled := false @@ -990,7 +1283,7 @@ func applyToolConfig(body *ChatRequestBody, reqBody map[string]interface{}) bool } if tc, ok := reqBody["tool_choice"]; ok { b, _ := marshalNoEscape(tc) - body.ToolChoice = b + body.Parameters.ToolChoice = b } if ptc, ok := reqBody["parallel_tool_calls"]; ok { b, _ := marshalNoEscape(ptc) diff --git a/bridge/bridge_test.go b/bridge/bridge_test.go index 4c6b7a5..6c43f66 100644 --- a/bridge/bridge_test.go +++ b/bridge/bridge_test.go @@ -9,12 +9,14 @@ import ( "net/http" "net/http/httptest" "strings" + "sync" "sync/atomic" "testing" "time" "unicode/utf8" "qoder2api/auth" + "qoder2api/models" "qoder2api/stats" "qoder2api/transform" ) @@ -204,11 +206,13 @@ func TestNeedsRefreshWithUnknownExpiry(t *testing.T) { if !b.needsRefresh() { t.Fatal("fresh bridge must need refresh (no session)") } + // An empty resolved tier falls back to the jobToken value, matching the + // pre-user/status behaviour. b.applyJobToken(map[string]interface{}{ "name": "u", "id": "1", "userType": "personal_standard", "refreshToken": "r", "securityOauthToken": "s", "expireTime": nil, // missing expiry - }) + }, "") if b.needsRefresh() { t.Error("missing expireTime must fall back to TTL-based freshness, not always-refresh") } @@ -227,7 +231,7 @@ func TestCurrentIdentitySnapshot(t *testing.T) { if b.currentIdentity() != nil { t.Fatal("expected nil identity before bootstrap") } - b.applyJobToken(map[string]interface{}{"name": "u", "id": "1", "expireTime": float64(1e15)}) + b.applyJobToken(map[string]interface{}{"name": "u", "id": "1", "expireTime": float64(1e15)}, "") id := b.currentIdentity() if id == nil || id.Name != "u" { t.Fatalf("expected identity snapshot, got %+v", id) @@ -243,6 +247,15 @@ func TestCurrentIdentitySnapshot(t *testing.T) { // region pointing at this test server). type fakeGateway struct { chatLines []string // raw SSE lines ("data: {...}") + + mu sync.Mutex + chatBody []byte +} + +func (g *fakeGateway) lastChatBody() []byte { + g.mu.Lock() + defer g.mu.Unlock() + return append([]byte(nil), g.chatBody...) } func newFakeBridge(t *testing.T, gw *fakeGateway) *OpenAiBridge { @@ -254,9 +267,13 @@ func newFakeBridge(t *testing.T, gw *fakeGateway) *OpenAiBridge { }) mux.HandleFunc("/algo/api/v2/model/list", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - io.WriteString(w, `{"chat":[{"key":"qmodel_latest","display_name":"Fake-Model","enable":true,"is_vl":false}]}`) + io.WriteString(w, `{"chat":[{"key":"qmodel_latest","display_name":"Fake-Model","enable":true,"is_vl":false,"is_reasoning":true,"max_output_tokens":4096,"efforts":["low","medium","xhigh"],"supports_disabled":true}]}`) }) mux.HandleFunc("/algo/api/v2/service/pro/sse/agent_chat_generation", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + gw.mu.Lock() + gw.chatBody = body + gw.mu.Unlock() w.Header().Set("Content-Type", "text/event-stream") flusher := w.(http.Flusher) for _, l := range gw.chatLines { @@ -808,3 +825,256 @@ func TestChatSlotsSerializeUpstreamCalls(t *testing.T) { t.Errorf("max concurrent upstream calls = %d, want 1 (default slot limit)", got) } } + +func TestEnsureAccountStatusMergesAuthoritativeQuota(t *testing.T) { + var quotaCalls atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("/algo/api/v3/user/jobToken", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"name":"tester","id":"uid1","refreshToken":"rt","securityOauthToken":"sot","expireTime":%d}`, time.Now().Add(6*time.Hour).UnixMilli()) + }) + mux.HandleFunc("/algo/api/v3/user/status", func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `{"id":"uid1","userType":"teams","plan":"PLAN_TIER_TEAM","userTag":"Teams","orgName":"Example Org","nextResetAt":1,"isQuotaExceeded":false}`) + }) + mux.HandleFunc("/api/v2/quota/usage", func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer sot" { + t.Errorf("quota Authorization = %q", got) + } + if quotaCalls.Add(1) > 1 { + http.Error(w, "temporary outage", http.StatusServiceUnavailable) + return + } + io.WriteString(w, `{"userId":"uid1","userType":"teams","totalUsagePercentage":0.98,"isQuotaExceeded":true,"expiresAt":1790265600000,"userQuota":{"total":3000,"used":2939,"remaining":61,"percentage":0.98,"unit":"credits"},"orgResourcePackage":{"used":0,"cap":4000,"remaining":0,"percentage":0,"available":false,"unit":"credits"}}`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + region := &auth.RegionConfig{Name: "test", AuthBase: srv.URL, ChatBase: srv.URL, OpenAPIBase: srv.URL} + b := NewOpenAiBridge("pt-test", region) + st := b.EnsureAccountStatus(context.Background()) + if st == nil || st.UserQuota == nil { + t.Fatalf("expected authoritative quota, got %+v", st) + } + if st.UserQuota.Used != 2939 || st.UserQuota.Remaining != 61 || st.NextResetAtMs != 1790265600000 { + t.Errorf("unexpected quota merge: %+v", st) + } + if !st.IsQuotaExceeded || st.Plan != "PLAN_TIER_TEAM" || st.OrgName != "Example Org" { + t.Errorf("gateway metadata and OpenAPI verdict were not merged: %+v", st) + } + if st.OrgResourcePackage == nil || st.OrgResourcePackage.Cap != 4000 { + t.Errorf("organization package missing: %+v", st.OrgResourcePackage) + } + + // Force the identity cache stale, then fail the next quota request. A fresh + // reduced /user/status response must not erase the last official snapshot. + b.accountMu.Lock() + b.accountTs = 0 + b.accountMu.Unlock() + st = b.EnsureAccountStatus(context.Background()) + if st == nil || st.UserQuota == nil || st.UserQuota.Used != 2939 || !st.IsQuotaExceeded { + t.Errorf("temporary quota outage erased the last authoritative snapshot: %+v", st) + } +} + +func TestHandleChatSendsReasoningEffortToSignedGateway(t *testing.T) { + gw := &fakeGateway{chatLines: []string{ + sseFrame(t, map[string]interface{}{"choices": []interface{}{map[string]interface{}{"delta": map[string]interface{}{"content": "ok"}}}}), + "data: [DONE]", + }} + bridge := newFakeBridge(t, gw) + + err := bridge.HandleChat(context.Background(), httptest.NewRecorder(), map[string]interface{}{ + "model": "Fake-Model", + "messages": []interface{}{map[string]interface{}{"role": "user", "content": "hi"}}, + "stream": true, + "reasoning_effort": "xhigh", + }, nil) + if err != nil { + t.Fatalf("HandleChat: %v", err) + } + + plain, err := auth.Decode(string(gw.lastChatBody())) + if err != nil { + t.Fatalf("decode signed gateway request: %v", err) + } + var payload map[string]interface{} + if err := json.Unmarshal(plain, &payload); err != nil { + t.Fatalf("decode JSON gateway request: %v", err) + } + if got := payload["reasoning_effort"]; got != nil { + t.Errorf("reasoning_effort must not be a top-level body field, got %#v", got) + } + params, ok := payload["parameters"].(map[string]interface{}) + if !ok { + t.Fatalf("parameters object missing from gateway request: %s", plain) + } + if got := params["reasoning_effort"]; got != "xhigh" { + t.Errorf("parameters.reasoning_effort = %#v, want xhigh", got) + } + if got, ok := params["max_tokens"].(float64); !ok || got <= 0 { + t.Errorf("parameters.max_tokens = %#v, want a positive cap", params["max_tokens"]) + } + if _, present := params["max_thinking_tokens"]; present { + t.Errorf("max_thinking_tokens must be omitted for a non-none tier, got %#v", params["max_thinking_tokens"]) + } + if got := payload["model_config"].(map[string]interface{})["key"]; got != "qmodel_latest" { + t.Errorf("model_config.key = %#v, want qmodel_latest", got) + } +} + +// A "none" tier must disable thinking the way the official client does: set +// is_reasoning=false on both model_config copies AND send an explicit +// max_thinking_tokens of 0. Serializing 0 (rather than omitting it) is the +// whole point, so the pointer field is asserted here. +func TestHandleChatDisablesReasoningForNoneTier(t *testing.T) { + gw := &fakeGateway{chatLines: []string{ + sseFrame(t, map[string]interface{}{"choices": []interface{}{map[string]interface{}{"delta": map[string]interface{}{"content": "ok"}}}}), + "data: [DONE]", + }} + bridge := newFakeBridge(t, gw) + + err := bridge.HandleChat(context.Background(), httptest.NewRecorder(), map[string]interface{}{ + "model": "Fake-Model", + "messages": []interface{}{map[string]interface{}{"role": "user", "content": "hi"}}, + "stream": true, + "reasoning_effort": "none", + }, nil) + if err != nil { + t.Fatalf("HandleChat: %v", err) + } + + plain, err := auth.Decode(string(gw.lastChatBody())) + if err != nil { + t.Fatalf("decode signed gateway request: %v", err) + } + var payload map[string]interface{} + if err := json.Unmarshal(plain, &payload); err != nil { + t.Fatalf("decode JSON gateway request: %v", err) + } + + params := payload["parameters"].(map[string]interface{}) + if got := params["reasoning_effort"]; got != "none" { + t.Errorf("parameters.reasoning_effort = %#v, want none", got) + } + if got, ok := params["max_thinking_tokens"].(float64); !ok || got != 0 { + t.Errorf("parameters.max_thinking_tokens = %#v, want explicit 0", params["max_thinking_tokens"]) + } + if got := payload["model_config"].(map[string]interface{})["is_reasoning"]; got != false { + t.Errorf("model_config.is_reasoning = %#v, want false", got) + } + extra := payload["chat_context"].(map[string]interface{})["extra"].(map[string]interface{}) + if got := extra["modelConfig"].(map[string]interface{})["is_reasoning"]; got != false { + t.Errorf("chat_context.extra.modelConfig.is_reasoning = %#v, want false", got) + } +} + +// A tier the model does not support is dropped entirely, and the model keeps +// whatever reasoning capability the catalog advertised for it. +func TestHandleChatUnsupportedTierKeepsCatalogReasoning(t *testing.T) { + gw := &fakeGateway{chatLines: []string{ + sseFrame(t, map[string]interface{}{"choices": []interface{}{map[string]interface{}{"delta": map[string]interface{}{"content": "ok"}}}}), + "data: [DONE]", + }} + bridge := newFakeBridge(t, gw) + + err := bridge.HandleChat(context.Background(), httptest.NewRecorder(), map[string]interface{}{ + "model": "Fake-Model", + "messages": []interface{}{map[string]interface{}{"role": "user", "content": "hi"}}, + "stream": true, + "reasoning_effort": "high", + }, nil) + if err != nil { + t.Fatalf("HandleChat: %v", err) + } + + plain, err := auth.Decode(string(gw.lastChatBody())) + if err != nil { + t.Fatalf("decode signed gateway request: %v", err) + } + var payload map[string]interface{} + if err := json.Unmarshal(plain, &payload); err != nil { + t.Fatalf("decode JSON gateway request: %v", err) + } + + params := payload["parameters"].(map[string]interface{}) + if got, present := params["reasoning_effort"]; present { + t.Errorf("unsupported tier must be omitted, got %#v", got) + } + if got := payload["model_config"].(map[string]interface{})["is_reasoning"]; got != true { + t.Errorf("model_config.is_reasoning = %#v, want true (catalog capability preserved)", got) + } +} + +func TestResolveReasoningEffortRejectsUnsupportedModelTier(t *testing.T) { + qwen := &models.ModelReasoning{Efforts: []string{"low", "medium", "xhigh"}, SupportsDisabled: true, Known: true} + if got := resolveReasoningEffort(map[string]interface{}{"reasoning_effort": "high"}, qwen); got != "" { + t.Errorf("unsupported tier = %q, want omitted", got) + } + if got := resolveReasoningEffort(map[string]interface{}{"reasoning_effort": "none"}, qwen); got != "none" { + t.Errorf("supported disabled tier = %q, want none", got) + } +} + +// The caller's cap must win over the catalog default, otherwise models with a +// small advertised cap (the built-in BYOK table lists one at 2048) would have +// their responses silently truncated. +func TestResolveMaxTokensPrefersCallerValue(t *testing.T) { + const catalogDefault = 2048 + + cases := []struct { + name string + reqBody map[string]interface{} + want int + }{ + {"caller cap wins over smaller catalog cap", + map[string]interface{}{"max_tokens": float64(16000)}, 16000}, + {"newer max_completion_tokens is honored", + map[string]interface{}{"max_completion_tokens": float64(9000)}, 9000}, + {"max_tokens takes precedence over max_completion_tokens", + map[string]interface{}{"max_tokens": float64(4096), "max_completion_tokens": float64(9000)}, 4096}, + {"absent falls back to catalog", map[string]interface{}{}, catalogDefault}, + {"zero is not a usable cap", map[string]interface{}{"max_tokens": float64(0)}, catalogDefault}, + {"negative is not a usable cap", map[string]interface{}{"max_tokens": float64(-5)}, catalogDefault}, + {"fractional is not a usable cap", map[string]interface{}{"max_tokens": 100.5}, catalogDefault}, + {"string is not a usable cap", map[string]interface{}{"max_tokens": "8000"}, catalogDefault}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := resolveMaxTokens(tc.reqBody, catalogDefault); got != tc.want { + t.Errorf("resolveMaxTokens(%v, %d) = %d, want %d", tc.reqBody, catalogDefault, got, tc.want) + } + }) + } +} + +// The resolved cap must reach the signed gateway body inside "parameters". +func TestHandleChatForwardsCallerMaxTokens(t *testing.T) { + gw := &fakeGateway{chatLines: []string{ + sseFrame(t, map[string]interface{}{"choices": []interface{}{map[string]interface{}{"delta": map[string]interface{}{"content": "ok"}}}}), + "data: [DONE]", + }} + bridge := newFakeBridge(t, gw) + + err := bridge.HandleChat(context.Background(), httptest.NewRecorder(), map[string]interface{}{ + "model": "Fake-Model", + "messages": []interface{}{map[string]interface{}{"role": "user", "content": "hi"}}, + "stream": true, + "max_tokens": float64(16000), + }, nil) + if err != nil { + t.Fatalf("HandleChat: %v", err) + } + + plain, err := auth.Decode(string(gw.lastChatBody())) + if err != nil { + t.Fatalf("decode signed gateway request: %v", err) + } + var payload map[string]interface{} + if err := json.Unmarshal(plain, &payload); err != nil { + t.Fatalf("decode JSON gateway request: %v", err) + } + params := payload["parameters"].(map[string]interface{}) + if got := params["max_tokens"].(float64); got != 16000 { + t.Errorf("parameters.max_tokens = %v, want 16000 (catalog cap is 4096)", got) + } +} diff --git a/main.go b/main.go index 057ed0e..236bef5 100644 --- a/main.go +++ b/main.go @@ -61,6 +61,10 @@ func (p *bridgeProvider) currentBridge() *bridge.OpenAiBridge { return p.bridge } +// accountRefreshCadence keeps the authoritative allowance reasonably fresh +// without coupling the admin panel's 15-second poll to an upstream request. +const accountRefreshCadence = time.Minute + // healthHandler serves an unauthenticated liveness/readiness probe. It is // deliberately cheap (no upstream calls, no session bootstrap): the body // carries readiness details (PAT configured, effective stream timeouts) @@ -166,6 +170,70 @@ func main() { return catalog.Keys() } + // Subscription account loop: refreshes identity metadata from /user/status + // and authoritative allowance totals from OpenAPI /api/v2/quota/usage out + // of band. The admin panel's 15-second poll remains memory-only. + applyAccount := func(ctx context.Context) { + b := provider.currentBridge() + if b == nil { + return // no PAT configured yet + } + st := b.EnsureAccountStatus(ctx) + if st == nil { + return // upstream unreachable: keep the last known state + } + rec.SetBillingCycle(st.NextResetAtMs) + account := &stats.Account{ + Plan: st.Plan, + Tag: st.UserTag, + OrgName: st.OrgName, + IsQuotaExceeded: st.IsQuotaExceeded, + TotalUsagePercentage: st.TotalUsagePercentage, + } + if st.UserQuota != nil { + account.UserQuota = &stats.Quota{ + Total: st.UserQuota.Total, Used: st.UserQuota.Used, + Remaining: st.UserQuota.Remaining, Percentage: st.UserQuota.Percentage, + Unit: st.UserQuota.Unit, DetailURL: st.UserQuota.DetailURL, + } + } + if st.AddOnQuota != nil { + account.AddOnQuota = &stats.Quota{ + Total: st.AddOnQuota.Total, Used: st.AddOnQuota.Used, + Remaining: st.AddOnQuota.Remaining, Percentage: st.AddOnQuota.Percentage, + Unit: st.AddOnQuota.Unit, DetailURL: st.AddOnQuota.DetailURL, + } + } + if st.OrgResourcePackage != nil { + account.OrgResourcePackage = &stats.OrgResourcePackage{ + Used: st.OrgResourcePackage.Used, Cap: st.OrgResourcePackage.Cap, + Remaining: st.OrgResourcePackage.Remaining, Percentage: st.OrgResourcePackage.Percentage, + Available: st.OrgResourcePackage.Available, Unit: st.OrgResourcePackage.Unit, + } + } + rec.SetAccount(account) + } + go func() { + // Fetch once immediately so the panel is populated from the start + // rather than after the first tick. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + applyAccount(ctx) + cancel() + + ticker := time.NewTicker(accountRefreshCadence) + defer ticker.Stop() + for { + select { + case <-ticker.C: + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + applyAccount(ctx) + cancel() + case <-statsStop: + return + } + } + }() + // Initialize admin adminInst := admin.New(st, modelFetcher, rec) diff --git a/models/models.go b/models/models.go index aa34924..48b70fb 100644 --- a/models/models.go +++ b/models/models.go @@ -3,6 +3,7 @@ package models import ( "sort" + "strconv" "strings" ) @@ -28,31 +29,31 @@ func enableFlag(v interface{}) bool { // Notable vs v5: Qwen3.8-Max graduated from preview (qmodel_preview -> // qmodel_38max, renamed without "-Preview"); GLM-5.3 (gmodel) added. var DefaultModelMap = map[string]string{ - "Qwen3.8-Max": "qmodel_38max", - "Qwen3.7-Max": "qmodel_latest", - "Qwen3.7-Plus": "qmodel", - "Qwen3.6-Flash": "q36fmodel", - "DeepSeek-V4-Pro": "dmodel", + "Qwen3.8-Max": "qmodel_38max", + "Qwen3.7-Max": "qmodel_latest", + "Qwen3.7-Plus": "qmodel", + "Qwen3.6-Flash": "q36fmodel", + "DeepSeek-V4-Pro": "dmodel", "DeepSeek-V4-Flash": "dfmodel", - "GLM-5.3": "gmodel", - "GLM-5.2": "gm51model", - "Kimi-K2.7-Code": "kmodel", - "MiniMax-M2.7": "mmodel", + "GLM-5.3": "gmodel", + "GLM-5.2": "gm51model", + "Kimi-K2.7-Code": "kmodel", + "MiniMax-M2.7": "mmodel", } // DefaultVisionModels mirrors the gateway's is_vl metadata for the fallback // catalog (display_name). NOTE: the gateway's is_vl flags have proven // unreliable — do not treat this as an authoritative capability matrix. var DefaultVisionModels = map[string]bool{ - "Qwen3.8-Max": true, - "Qwen3.7-Max": true, - "Qwen3.7-Plus": true, - "Qwen3.6-Flash": true, - "DeepSeek-V4-Pro": true, + "Qwen3.8-Max": true, + "Qwen3.7-Max": true, + "Qwen3.7-Plus": true, + "Qwen3.6-Flash": true, + "DeepSeek-V4-Pro": true, "DeepSeek-V4-Flash": true, - "GLM-5.3": true, - "GLM-5.2": true, - "Kimi-K2.7-Code": true, + "GLM-5.3": true, + "GLM-5.2": true, + "Kimi-K2.7-Code": true, } // PreferredDefaultKey is the default model key when model param is None/empty. @@ -61,11 +62,79 @@ const PreferredDefaultKey = "qmodel_latest" // DefaultScene is the catalog scene to extract. const DefaultScene = "chat" +// DefaultMaxOutputTokens is the completion-token cap the official client falls +// back to whenever a catalog entry carries no usable max_output_tokens. It +// mirrors the client's LS() coercion, which returns 32000 for any value that is +// not a positive safe integer. +const DefaultMaxOutputTokens = 32000 + // ModelCatalog holds display_name → qoder key mapping and capability metadata. type ModelCatalog struct { ModelMap map[string]string // display_name -> key VisionModels map[string]bool // display_name set DefaultName string // used when model param is empty + + // Reasoning carries the gateway's per-model thinking-effort metadata, + // keyed by the qoder internal key. Entries exist only for models whose + // catalog record carried effort metadata; a missing entry means "unknown", + // and callers fall back to the global effort vocabulary. + Reasoning map[string]*ModelReasoning + + // Caps carries the per-model limits the official client reads from the + // catalog before building a gateway request, keyed by the qoder internal + // key. A missing entry means the catalog was unavailable, and callers fall + // back to their own defaults. + Caps map[string]*ModelCaps +} + +// ModelCaps mirrors the limits of a model/list entry that the official client +// resolves in oJI before assembling the request body. +type ModelCaps struct { + // IsReasoning reports whether the gateway says this model can think. The + // official client defaults a missing field to false (T?.is_reasoning ?? !1). + IsReasoning bool + // MaxOutputTokens is the default completion cap, already normalized to + // DefaultMaxOutputTokens when the catalog value was absent or unusable. + MaxOutputTokens int +} + +// flagValue coerces an optional boolean catalog field. Unlike enableFlag it +// treats a missing value as false, matching the official client's defaults for +// capability flags such as is_reasoning. +func flagValue(v interface{}) bool { + if v == nil { + return false + } + return enableFlag(v) +} + +// positiveInt parses a catalog numeric field that may arrive as a JSON number +// or a numeric string, mirroring the official client's LS() coercion. +func positiveInt(v interface{}) (int, bool) { + switch x := v.(type) { + case float64: + if n := int(x); float64(n) == x && n > 0 { + return n, true + } + case int: + if x > 0 { + return x, true + } + case string: + if n, err := strconv.Atoi(strings.TrimSpace(x)); err == nil && n > 0 { + return n, true + } + } + return 0, false +} + +// ModelReasoning mirrors the reasoning-related fields of a model/list entry. +// The official client derives the exact same information before deciding +// whether a requested effort may be forwarded to the gateway. +type ModelReasoning struct { + Efforts []string // canonical efforts the model accepts (lowercased) + SupportsDisabled bool // gateway allows switching thinking off entirely + Known bool // true when any effort metadata was present } // Keys returns sorted display names for deterministic output. @@ -83,6 +152,26 @@ func (c *ModelCatalog) GetKey(displayName string) string { return c.ModelMap[displayName] } +// MaxOutputTokens returns the gateway's default completion cap for a qoder key, +// falling back to DefaultMaxOutputTokens when the catalog carries no entry. +func (c *ModelCatalog) MaxOutputTokens(qoderKey string) int { + if caps, ok := c.Caps[qoderKey]; ok && caps.MaxOutputTokens > 0 { + return caps.MaxOutputTokens + } + return DefaultMaxOutputTokens +} + +// ReasoningDefault reports whether the gateway says a model can think. A catalog +// with no caps entry for the key reports true, preserving the bridge's +// long-standing always-on behaviour when the dynamic catalog is unavailable and +// the gateway's own is_reasoning flag cannot be consulted. +func (c *ModelCatalog) ReasoningDefault(qoderKey string) bool { + if caps, ok := c.Caps[qoderKey]; ok { + return caps.IsReasoning + } + return true +} + // DefaultCatalog returns the built-in fallback catalog. func DefaultCatalog() *ModelCatalog { modelMap := make(map[string]string, len(DefaultModelMap)) @@ -120,6 +209,8 @@ func ExtractCatalog(raw map[string]interface{}) *ModelCatalog { } modelMap := map[string]string{} vision := map[string]bool{} + reasoning := map[string]*ModelReasoning{} + caps := map[string]*ModelCaps{} for _, item := range sceneList { m, ok := item.(map[string]interface{}) if !ok { @@ -139,6 +230,17 @@ func ExtractCatalog(raw map[string]interface{}) *ModelCatalog { if isVL, ok := m["is_vl"].(bool); ok && isVL { vision[name] = true } + if ri := parseReasoningMeta(m); ri != nil { + reasoning[key] = ri + } + maxOut, ok := positiveInt(m["max_output_tokens"]) + if !ok { + maxOut = DefaultMaxOutputTokens + } + caps[key] = &ModelCaps{ + IsReasoning: flagValue(m["is_reasoning"]), + MaxOutputTokens: maxOut, + } } if len(modelMap) == 0 { return nil @@ -146,10 +248,85 @@ func ExtractCatalog(raw map[string]interface{}) *ModelCatalog { return &ModelCatalog{ ModelMap: modelMap, VisionModels: vision, + Reasoning: reasoning, + Caps: caps, DefaultName: nameForKey(modelMap, PreferredDefaultKey), } } +// parseReasoningMeta extracts the per-model thinking-effort metadata from a +// catalog entry. It returns nil when the entry carries no usable effort +// information at all, letting callers fall back to the global vocabulary. +// +// The gateway serializes "efforts" in several shapes (array of strings, +// comma/space separated string, or an object map keyed by effort); all are +// normalized here the same way the official client normalizes them. +func parseReasoningMeta(m map[string]interface{}) *ModelReasoning { + effortsRaw, hasEfforts := m["efforts"] + efforts := normalizeEfforts(effortsRaw) + supportsDisabled := false + if v, ok := m["supports_disabled"]; ok && enableFlag(v) { + supportsDisabled = true + } + if !hasEfforts && !supportsDisabled { + return nil + } + return &ModelReasoning{ + Efforts: efforts, + SupportsDisabled: supportsDisabled, + Known: true, + } +} + +// normalizeEfforts coerces any supported "efforts" shape into the canonical +// lowercase vocabulary (none/low/medium/high/xhigh/max), preserving order and +// dropping duplicates or unrecognized values. "off"/"disabled" are folded +// into "none", matching the official client's normalization. +func normalizeEfforts(v interface{}) []string { + var raw []string + switch x := v.(type) { + case []interface{}: + for _, item := range x { + if s, ok := item.(string); ok { + raw = append(raw, s) + } + } + case string: + for _, field := range strings.FieldsFunc(x, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) { + raw = append(raw, field) + } + case map[string]interface{}: + for k := range x { + raw = append(raw, k) + } + } + seen := map[string]bool{} + out := make([]string, 0, len(raw)) + for _, s := range raw { + e := canonicalEffort(s) + if e == "" || seen[e] { + continue + } + seen[e] = true + out = append(out, e) + } + return out +} + +// canonicalEffort validates a single effort token against the gateway's +// vocabulary, folding the client-side aliases "off"/"disabled" into "none". +func canonicalEffort(s string) string { + e := strings.ToLower(strings.TrimSpace(s)) + switch e { + case "off", "disabled": + return "none" + case "none", "low", "medium", "high", "xhigh", "max": + return e + default: + return "" + } +} + // ResolveModel resolves a model name to (display_name, qoder_key). // If model is empty, uses the catalog default. Returns error if not found. func ResolveModel(model string, catalog *ModelCatalog) (string, string, error) { diff --git a/models/models_test.go b/models/models_test.go index bff41af..2b57fdc 100644 --- a/models/models_test.go +++ b/models/models_test.go @@ -111,3 +111,181 @@ func TestFallbackDefaultPrefersCheapTier(t *testing.T) { t.Errorf("expected deterministic A-Lite, got %q", got) } } + +// --- Per-model reasoning metadata parsing (efforts / supports_disabled) --- + +func TestExtractCatalogParsesReasoningMetadata(t *testing.T) { + raw := map[string]interface{}{ + "chat": []interface{}{ + // Array shape with aliases and junk entries. + map[string]interface{}{"key": "a", "display_name": "ModelA", "enable": true, + "efforts": []interface{}{"low", " Medium ", "xhigh", "off", "turbo", 42}, + "supports_disabled": true}, + // Comma/space separated string shape. + map[string]interface{}{"key": "b", "display_name": "ModelB", "enable": true, + "efforts": "high, max"}, + // Object-map shape (keys are the efforts, values are descriptors). + map[string]interface{}{"key": "c", "display_name": "ModelC", "enable": true, + "efforts": map[string]interface{}{"low": map[string]interface{}{}, "xhigh": map[string]interface{}{"is_default": true}}}, + // No reasoning metadata at all: must be absent from the map. + map[string]interface{}{"key": "d", "display_name": "ModelD", "enable": true}, + // Only supports_disabled: on/off switch without effort tiers. + map[string]interface{}{"key": "e", "display_name": "ModelE", "enable": true, + "supports_disabled": "true"}, + }, + } + cat := ExtractCatalog(raw) + if cat == nil { + t.Fatal("expected non-nil catalog") + } + + a := cat.Reasoning["a"] + if a == nil { + t.Fatal("ModelA reasoning metadata missing") + } + wantA := []string{"low", "medium", "xhigh", "none"} + if len(a.Efforts) != len(wantA) { + t.Fatalf("ModelA efforts = %v, want %v", a.Efforts, wantA) + } + for i, e := range wantA { + if a.Efforts[i] != e { + t.Errorf("ModelA efforts[%d] = %q, want %q", i, a.Efforts[i], e) + } + } + if !a.SupportsDisabled { + t.Error("ModelA supports_disabled should be true") + } + if !a.Known { + t.Error("ModelA should be Known") + } + + b := cat.Reasoning["b"] + if b == nil { + t.Fatal("ModelB reasoning metadata missing") + } + if len(b.Efforts) != 2 || b.Efforts[0] != "high" || b.Efforts[1] != "max" { + t.Errorf("ModelB efforts = %v, want [high max]", b.Efforts) + } + if b.SupportsDisabled { + t.Error("ModelB supports_disabled should default to false") + } + + c := cat.Reasoning["c"] + if c == nil { + t.Fatal("ModelC reasoning metadata missing") + } + if len(c.Efforts) != 2 || c.Efforts[0] != "low" || c.Efforts[1] != "xhigh" { + t.Errorf("ModelC efforts = %v, want [low xhigh]", c.Efforts) + } + + if _, ok := cat.Reasoning["d"]; ok { + t.Error("ModelD without metadata should not appear in Reasoning") + } + + e := cat.Reasoning["e"] + if e == nil { + t.Fatal("ModelE reasoning metadata missing") + } + if len(e.Efforts) != 0 { + t.Errorf("ModelE efforts = %v, want empty", e.Efforts) + } + if !e.SupportsDisabled { + t.Error("ModelE supports_disabled should be coerced from \"true\"") + } +} + +func TestDefaultCatalogHasNoReasoningMetadata(t *testing.T) { + cat := DefaultCatalog() + if cat.Reasoning != nil { + t.Errorf("fallback catalog must not claim per-model effort knowledge, got %v", cat.Reasoning) + } +} + +// The bridge reads is_reasoning and max_output_tokens out of the catalog the +// same way the official client does, so both must survive parsing, including +// their fallbacks for missing or malformed values. +func TestExtractCatalogParsesModelCaps(t *testing.T) { + raw := map[string]interface{}{ + "chat": []interface{}{ + // Reasoning model with an explicit cap. + map[string]interface{}{"key": "a", "display_name": "ModelA", "enable": true, + "is_reasoning": true, "max_output_tokens": 8000}, + // Non-reasoning model; cap arrives as a numeric string. + map[string]interface{}{"key": "b", "display_name": "ModelB", "enable": true, + "is_reasoning": false, "max_output_tokens": "6000"}, + // No caps at all: is_reasoning defaults to false, cap to the fallback. + map[string]interface{}{"key": "c", "display_name": "ModelC", "enable": true}, + // Unusable cap must fall back, not leak a zero that would truncate output. + map[string]interface{}{"key": "d", "display_name": "ModelD", "enable": true, + "is_reasoning": true, "max_output_tokens": 0}, + }, + } + cat := ExtractCatalog(raw) + if cat == nil { + t.Fatal("expected non-nil catalog") + } + + a := cat.Caps["a"] + if a == nil { + t.Fatal("ModelA caps missing") + } + if !a.IsReasoning { + t.Error("ModelA is_reasoning should be true") + } + if a.MaxOutputTokens != 8000 { + t.Errorf("ModelA max_output_tokens = %d, want 8000", a.MaxOutputTokens) + } + + b := cat.Caps["b"] + if b == nil { + t.Fatal("ModelB caps missing") + } + if b.IsReasoning { + t.Error("ModelB is_reasoning should be false") + } + if b.MaxOutputTokens != 6000 { + t.Errorf("ModelB max_output_tokens = %d, want 6000 (parsed from string)", b.MaxOutputTokens) + } + + c := cat.Caps["c"] + if c == nil { + t.Fatal("ModelC caps missing") + } + if c.IsReasoning { + t.Error("ModelC is_reasoning should default to false, matching the official client") + } + if c.MaxOutputTokens != DefaultMaxOutputTokens { + t.Errorf("ModelC max_output_tokens = %d, want %d", c.MaxOutputTokens, DefaultMaxOutputTokens) + } + + if got := cat.MaxOutputTokens("d"); got != DefaultMaxOutputTokens { + t.Errorf("MaxOutputTokens(d) = %d, want %d for an unusable cap", got, DefaultMaxOutputTokens) + } + if got := cat.MaxOutputTokens("missing"); got != DefaultMaxOutputTokens { + t.Errorf("MaxOutputTokens(missing) = %d, want %d", got, DefaultMaxOutputTokens) + } + if got := cat.ReasoningDefault("a"); !got { + t.Error("ReasoningDefault(a) = false, want true") + } + if got := cat.ReasoningDefault("c"); got { + t.Error("ReasoningDefault(c) = true, want false") + } + // A model absent from caps must not silently lose thinking; the bridge only + // reaches this branch when the dynamic catalog is unavailable. + if got := cat.ReasoningDefault("missing"); !got { + t.Error("ReasoningDefault(missing) = false, want true (preserve always-on fallback)") + } +} + +func TestDefaultCatalogCapsFallBack(t *testing.T) { + cat := DefaultCatalog() + if cat.Caps != nil { + t.Errorf("fallback catalog must not claim per-model caps, got %v", cat.Caps) + } + if got := cat.MaxOutputTokens(PreferredDefaultKey); got != DefaultMaxOutputTokens { + t.Errorf("MaxOutputTokens = %d, want %d", got, DefaultMaxOutputTokens) + } + if got := cat.ReasoningDefault(PreferredDefaultKey); !got { + t.Error("ReasoningDefault = false, want true when the catalog carries no caps") + } +} diff --git a/stats/stats.go b/stats/stats.go index b404ad8..c94d629 100644 --- a/stats/stats.go +++ b/stats/stats.go @@ -30,18 +30,71 @@ type HourStat struct { Failed int64 `json:"failed"` } +// Account is the subscription metadata reported by the gateway's +// /user/status endpoint. It is refreshed out of band (see main's account +// loop) and persisted alongside the counters so the admin panel can render it +// from memory instead of making a network call on every poll. +type Account struct { + // Plan is the raw plan identifier ("PLAN_TIER_TEAM", ...). + Plan string `json:"plan,omitempty"` + // Tag is the human-facing plan label ("Teams", ...). + Tag string `json:"tag,omitempty"` + // OrgName identifies the organization that owns a shared resource package. + OrgName string `json:"org_name,omitempty"` + // IsQuotaExceeded is the OpenAPI verdict used by the official client. + IsQuotaExceeded bool `json:"is_quota_exceeded"` + // TotalUsagePercentage is the official aggregate ratio in [0,1]. + TotalUsagePercentage float64 `json:"total_usage_percentage"` + UserQuota *Quota `json:"user_quota,omitempty"` + AddOnQuota *Quota `json:"add_on_quota,omitempty"` + OrgResourcePackage *OrgResourcePackage `json:"org_resource_package,omitempty"` +} + +// Quota is a cycle-scoped credit allowance reported by Qoder OpenAPI. +type Quota struct { + Total float64 `json:"total"` + Used float64 `json:"used"` + Remaining float64 `json:"remaining"` + Percentage float64 `json:"percentage"` + Unit string `json:"unit"` + DetailURL string `json:"detail_url,omitempty"` +} + +// OrgResourcePackage is the shared organization credit pool. +type OrgResourcePackage struct { + Used float64 `json:"used"` + Cap float64 `json:"cap"` + Remaining float64 `json:"remaining"` + Percentage float64 `json:"percentage"` + Available bool `json:"available"` + Unit string `json:"unit"` +} + // Data is the persisted stats snapshot. type Data struct { Total int64 `json:"total"` Success int64 `json:"success"` Failed int64 `json:"failed"` // Token / billing totals aggregated from the gateway usage frames. - PromptTokens int64 `json:"prompt_tokens"` - CompletionTokens int64 `json:"completion_tokens"` - CachedTokens int64 `json:"cached_tokens"` - Credits float64 `json:"credits"` - ByModel map[string]*ModelStat `json:"by_model,omitempty"` - Hourly map[string]*HourStat `json:"hourly,omitempty"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + CachedTokens int64 `json:"cached_tokens"` + Credits float64 `json:"credits"` + // Billing-cycle scope. The gateway refreshes the subscription allowance + // monthly, so the lifetime Credits total above says nothing about what is + // left this cycle. These fields make the displayed number cycle-relative. + // + // NextResetMs is the subscription refresh instant (epoch millis) as + // reported by /user/status; CycleStartMs is the start of the cycle that + // contains it; CycleCredits is credits consumed since CycleStartMs and is + // reset automatically when the cycle rolls over. All three stay 0 when the + // account status has never been resolved. + NextResetMs int64 `json:"next_reset_ms,omitempty"` + CycleStartMs int64 `json:"cycle_start_ms,omitempty"` + CycleCredits float64 `json:"cycle_credits,omitempty"` + Account *Account `json:"account,omitempty"` + ByModel map[string]*ModelStat `json:"by_model,omitempty"` + Hourly map[string]*HourStat `json:"hourly,omitempty"` } // ModelRow is a per-model report row served to the admin UI. @@ -69,12 +122,19 @@ type Report struct { Failed int64 `json:"failed"` SuccessRate float64 `json:"success_rate"` // Token / billing totals. - PromptTokens int64 `json:"prompt_tokens"` - CompletionTokens int64 `json:"completion_tokens"` - CachedTokens int64 `json:"cached_tokens"` - Credits float64 `json:"credits"` - ByModel []ModelRow `json:"by_model"` - Hourly []HourRow `json:"hourly"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + CachedTokens int64 `json:"cached_tokens"` + Credits float64 `json:"credits"` + // Billing-cycle view of the credits above (all zero when the account + // status is unknown). + CycleCredits float64 `json:"cycle_credits"` + CycleStartMs int64 `json:"cycle_start_ms"` + NextResetMs int64 `json:"next_reset_ms"` + // Account is nil when the subscription state has never been resolved. + Account *Account `json:"account,omitempty"` + ByModel []ModelRow `json:"by_model"` + Hourly []HourRow `json:"hourly"` } // Persister persists the stats data (implemented by store.Store). @@ -136,9 +196,13 @@ func (d *Data) Clone() *Data { CompletionTokens: d.CompletionTokens, CachedTokens: d.CachedTokens, Credits: d.Credits, + NextResetMs: d.NextResetMs, + CycleStartMs: d.CycleStartMs, + CycleCredits: d.CycleCredits, ByModel: make(map[string]*ModelStat, len(d.ByModel)), Hourly: make(map[string]*HourStat, len(d.Hourly)), } + cp.Account = cloneAccount(d.Account) for k, v := range d.ByModel { cp.ByModel[k] = &ModelStat{Model: v.Model, Total: v.Total, Success: v.Success, Failed: v.Failed} } @@ -190,6 +254,10 @@ func (r *Recorder) Record(model string, ok bool) { // RecordUsage adds token/billing totals extracted from a gateway usage frame. // Called once per successful request when the upstream supplies usage data; // missing frames (error-interrupted streams) simply skip this call. +// +// Tokens are counted for every frame, but credits only for billable ones: a +// frame the gateway flags billable=false was not charged to the subscription, +// so including it would overstate consumption against the cycle allowance. func (r *Recorder) RecordUsage(u *Usage) { if u == nil { return @@ -200,7 +268,103 @@ func (r *Recorder) RecordUsage(u *Usage) { r.data.PromptTokens += int64(u.PromptTokens) r.data.CompletionTokens += int64(u.CompletionTokens) r.data.CachedTokens += int64(u.CachedTokens) + if u.NonBillable { + return + } r.data.Credits += u.Credits + r.rollCycleLocked(time.Now().UnixMilli()) + r.data.CycleCredits += u.Credits +} + +// SetBillingCycle records the subscription boundary reported by /user/status. +// +// nextResetMs is the refresh instant; the cycle start is derived by stepping +// back one calendar month, which matches the gateway's monthly refresh +// exactly (a fixed 30-day period would drift, e.g. reporting a cycle start of +// Aug 26 for a Sep 25 reset). +// +// Passing 0 (status unavailable) leaves the existing cycle intact rather than +// discarding accounting that may already be correct. A boundary that still +// falls inside the cycle currently being tracked refines it without zeroing, +// so repeated polls never lose accumulated credits. +func (r *Recorder) SetBillingCycle(nextResetMs int64) { + if nextResetMs <= 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + if r.data.NextResetMs == nextResetMs { + return + } + startMs := addMonthsMs(nextResetMs, -1) + if startMs != r.data.CycleStartMs { + // A genuinely different cycle: the allowance has been refreshed, so + // the previous cycle's credits are stale. + r.data.CycleCredits = 0 + } + r.data.NextResetMs = nextResetMs + r.data.CycleStartMs = startMs + r.dirty = true +} + +// SetAccount records the subscription metadata reported by /user/status. +// +// The panel reads this from memory, so storing it here (rather than fetching +// on demand) keeps the 15s admin poll free of upstream calls. A nil account is +// ignored so a transient status outage cannot wipe a previously known plan. +func (r *Recorder) SetAccount(a *Account) { + if a == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + // Deep-copy nested quota objects so later caller mutations cannot change + // the persisted snapshot behind the recorder's back. + r.data.Account = cloneAccount(a) + r.dirty = true +} + +func cloneAccount(a *Account) *Account { + if a == nil { + return nil + } + cp := *a + if a.UserQuota != nil { + q := *a.UserQuota + cp.UserQuota = &q + } + if a.AddOnQuota != nil { + q := *a.AddOnQuota + cp.AddOnQuota = &q + } + if a.OrgResourcePackage != nil { + q := *a.OrgResourcePackage + cp.OrgResourcePackage = &q + } + return &cp +} + +// addMonthsMs shifts an epoch-millis instant by n calendar months. +func addMonthsMs(ms int64, n int) int64 { + return time.UnixMilli(ms).Local().AddDate(0, n, 0).UnixMilli() +} + +// rollCycleLocked zeroes the cycle credits once the known reset boundary has +// passed, then advances the boundary locally. Caller must hold the mutex. +// +// The advance is what makes the rollover happen exactly once: without it, +// every subsequent request would still see nowMs >= NextResetMs and wipe the +// fresh total. /user/status later supplies the authoritative boundary, which +// SetBillingCycle reconciles without zeroing when it names the same cycle. +// +// A loop rather than a single step covers a service that stayed down across +// several cycles. +func (r *Recorder) rollCycleLocked(nowMs int64) { + for r.data.NextResetMs > 0 && nowMs >= r.data.NextResetMs { + r.data.CycleStartMs = r.data.NextResetMs + r.data.NextResetMs = addMonthsMs(r.data.NextResetMs, 1) + r.data.CycleCredits = 0 + } } // Usage is the token accounting payload produced by transform.Usage. @@ -210,6 +374,11 @@ type Usage struct { CompletionTokens int CachedTokens int Credits float64 + // NonBillable mirrors the gateway's charge flag, inverted on purpose: a + // bool field named Billable would zero to false, so any caller that forgot + // to set it would silently drop every credit. This way the zero value + // means "billable", which is both the common case and the safe one. + NonBillable bool } // Flush persists pending data if anything changed since the last flush. @@ -255,6 +424,19 @@ func (r *Recorder) Report() *Report { r.mu.Lock() defer r.mu.Unlock() + // Roll the cycle on read as well as on write: the admin panel polls this + // every 15s, so an idle service would otherwise keep reporting the + // previous cycle's credits long after the boundary passed. The rollover is + // idempotent, so this costs nothing once it has already happened. + // + // Dirty is set only when the boundary actually moved, so a plain read poll + // never forces a disk write. + prevReset := r.data.NextResetMs + r.rollCycleLocked(time.Now().UnixMilli()) + if r.data.NextResetMs != prevReset { + r.dirty = true + } + rep := &Report{ Total: r.data.Total, Success: r.data.Success, @@ -263,9 +445,13 @@ func (r *Recorder) Report() *Report { CompletionTokens: r.data.CompletionTokens, CachedTokens: r.data.CachedTokens, Credits: r.data.Credits, + CycleCredits: r.data.CycleCredits, + CycleStartMs: r.data.CycleStartMs, + NextResetMs: r.data.NextResetMs, ByModel: []ModelRow{}, Hourly: []HourRow{}, } + rep.Account = cloneAccount(r.data.Account) if r.data.Total > 0 { rep.SuccessRate = float64(r.data.Success) / float64(r.data.Total) } diff --git a/stats/stats_test.go b/stats/stats_test.go index 0646b04..0c332da 100644 --- a/stats/stats_test.go +++ b/stats/stats_test.go @@ -267,3 +267,186 @@ func (f *fakePersister) SaveStats(d *Data) error { } return f.saveFn(d) } + +// NonBillable frames must contribute tokens (the work really happened) but not +// credits (nothing was charged to the subscription allowance). +func TestRecordUsageNonBillableSkipsCreditsOnly(t *testing.T) { + r := NewRecorder(nil) + r.RecordUsage(&Usage{PromptTokens: 10, CompletionTokens: 20, Credits: 0.5, NonBillable: true}) + r.RecordUsage(&Usage{PromptTokens: 1, CompletionTokens: 2, Credits: 0.25}) + + rep := r.Report() + if rep.PromptTokens != 11 || rep.CompletionTokens != 22 { + t.Errorf("tokens must count non-billable frames too: got prompt=%d completion=%d, want 11/22", + rep.PromptTokens, rep.CompletionTokens) + } + if rep.Credits < 0.25-1e-9 || rep.Credits > 0.25+1e-9 { + t.Errorf("expected only the billable credits 0.25, got %v", rep.Credits) + } + if rep.CycleCredits < 0.25-1e-9 || rep.CycleCredits > 0.25+1e-9 { + t.Errorf("expected cycle credits 0.25, got %v", rep.CycleCredits) + } +} + +// The zero value of Usage must mean billable: a caller that forgets the flag +// should over-count credits rather than silently lose all of them. +func TestRecordUsageDefaultsToBillable(t *testing.T) { + r := NewRecorder(nil) + r.RecordUsage(&Usage{Credits: 1.5}) + if got := r.Report().CycleCredits; got < 1.5-1e-9 || got > 1.5+1e-9 { + t.Errorf("expected 1.5 credits with the flag unset, got %v", got) + } +} + +// SetBillingCycle derives the cycle start by stepping back one calendar month, +// which must land on the gateway's own boundary rather than a fixed 30 days. +// For the observed Sep 25 reset that is Aug 25, not Aug 26. +func TestSetBillingCycleDerivesCalendarStart(t *testing.T) { + r := NewRecorder(nil) + reset := time.Date(2026, time.September, 25, 0, 0, 0, 0, time.Local) + r.SetBillingCycle(reset.UnixMilli()) + + rep := r.Report() + if rep.NextResetMs != reset.UnixMilli() { + t.Fatalf("expected next reset %d, got %d", reset.UnixMilli(), rep.NextResetMs) + } + wantStart := time.Date(2026, time.August, 25, 0, 0, 0, 0, time.Local) + if rep.CycleStartMs != wantStart.UnixMilli() { + t.Errorf("expected cycle start %s, got %s", + wantStart.Format(time.RFC3339), time.UnixMilli(rep.CycleStartMs).Format(time.RFC3339)) + } +} + +// Repeatedly polling the same boundary must not discard credits already +// accumulated inside the live cycle. +func TestSetBillingCycleIdempotent(t *testing.T) { + r := NewRecorder(nil) + reset := time.Now().Add(72 * time.Hour).UnixMilli() + r.SetBillingCycle(reset) + r.RecordUsage(&Usage{Credits: 2}) + r.SetBillingCycle(reset) // a redundant refresh from the account loop + + if got := r.Report().CycleCredits; got < 2-1e-9 || got > 2+1e-9 { + t.Errorf("expected cycle credits to survive a redundant boundary set, got %v", got) + } +} + +// An unavailable status (0) must leave existing accounting intact rather than +// wiping it on a transient upstream outage. +func TestSetBillingCycleIgnoresZero(t *testing.T) { + r := NewRecorder(nil) + reset := time.Now().Add(72 * time.Hour).UnixMilli() + r.SetBillingCycle(reset) + r.RecordUsage(&Usage{Credits: 3}) + r.SetBillingCycle(0) + + rep := r.Report() + if rep.NextResetMs != reset { + t.Errorf("zero must not clear the known boundary, got %d", rep.NextResetMs) + } + if rep.CycleCredits < 3-1e-9 || rep.CycleCredits > 3+1e-9 { + t.Errorf("zero must not clear cycle credits, got %v", rep.CycleCredits) + } +} + +// Crossing the reset boundary must zero the cycle exactly once. Regressing +// here (re-zeroing on every record) is what would make the new cycle total +// permanently stuck at the latest single request. +func TestCycleRolloverHappensOnce(t *testing.T) { + r := NewRecorder(nil) + // A boundary already in the past: the first record triggers the rollover. + past := time.Now().Add(-time.Hour).UnixMilli() + r.SetBillingCycle(past) + + r.RecordUsage(&Usage{Credits: 1}) + r.RecordUsage(&Usage{Credits: 2}) + r.RecordUsage(&Usage{Credits: 4}) + + rep := r.Report() + if rep.CycleCredits < 7-1e-9 || rep.CycleCredits > 7+1e-9 { + t.Errorf("expected the new cycle to accumulate 7, got %v (rollover fired more than once?)", + rep.CycleCredits) + } + if rep.NextResetMs <= past { + t.Errorf("expected the boundary to advance past %d, got %d", past, rep.NextResetMs) + } + // The advanced boundary must be one calendar month out, so a service that + // stays up does not roll over again immediately. + advanced := time.UnixMilli(rep.NextResetMs).Local() + if d := advanced.Sub(time.UnixMilli(past)); d < 27*24*time.Hour || d > 31*24*time.Hour { + t.Errorf("expected the advanced boundary to be ~1 month later, got %v", d) + } + // Lifetime credits are unaffected by the rollover. + if rep.Credits < 7-1e-9 || rep.Credits > 7+1e-9 { + t.Errorf("lifetime credits must ignore the rollover, got %v", rep.Credits) + } +} + +// SetAccount must copy its argument: aliasing the caller's pointer would let a +// later mutation reach the persisted snapshot. +func TestSetAccountCopiesAndIsExposed(t *testing.T) { + r := NewRecorder(nil) + acct := &Account{ + Plan: "PLAN_TIER_TEAM", Tag: "Teams", IsQuotaExceeded: false, + UserQuota: &Quota{Total: 3000, Used: 2939, Remaining: 61, Percentage: 0.98, Unit: "credits"}, + OrgResourcePackage: &OrgResourcePackage{Cap: 4000, Available: false, Unit: "credits"}, + } + r.SetAccount(acct) + acct.Tag = "mutated" + acct.UserQuota.Used = 1 + acct.OrgResourcePackage.Cap = 1 + + rep := r.Report() + if rep.Account == nil { + t.Fatal("expected the account in the report") + } + if rep.Account.Tag != "Teams" { + t.Errorf("expected the snapshot to be insulated from caller mutation, got %q", rep.Account.Tag) + } + if rep.Account.UserQuota == nil || rep.Account.UserQuota.Used != 2939 { + t.Errorf("expected a deep copy of user quota, got %+v", rep.Account.UserQuota) + } + if rep.Account.OrgResourcePackage == nil || rep.Account.OrgResourcePackage.Cap != 4000 { + t.Errorf("expected a deep copy of organization quota, got %+v", rep.Account.OrgResourcePackage) + } + + // A nil account must not wipe a known one. + r.SetAccount(nil) + if r.Report().Account == nil || r.Report().Account.Tag != "Teams" { + t.Error("nil account must leave the previous state intact") + } +} + +// A read must roll the cycle too, not just a write: the panel polls every 15s, +// so a service sitting idle across the reset boundary would otherwise keep +// reporting the previous cycle's credits. +func TestReportRollsCycleOnRead(t *testing.T) { + r := NewRecorder(nil) + r.SetBillingCycle(time.Now().Add(-time.Hour).UnixMilli()) + // Seed a cycle total as if it had been accumulated before the boundary. + r.data.CycleCredits = 9 + + rep := r.Report() + if rep.CycleCredits != 0 { + t.Errorf("expected the stale cycle total to be cleared on read, got %v", rep.CycleCredits) + } + if rep.NextResetMs <= time.Now().UnixMilli() { + t.Errorf("expected the boundary to advance into the future, got %d", rep.NextResetMs) + } + if !r.dirty { + t.Error("expected the rollover to mark data dirty so it gets persisted") + } +} + +// A read that does not cross a boundary must not mark the data dirty, otherwise +// every 15s poll would force a disk write. +func TestReportStaysCleanWithoutRollover(t *testing.T) { + r := NewRecorder(nil) + r.SetBillingCycle(time.Now().Add(72 * time.Hour).UnixMilli()) + r.dirty = false + + r.Report() + if r.dirty { + t.Error("a rollover-free read must not mark the data dirty") + } +} diff --git a/transform/transform.go b/transform/transform.go index 782d959..d16a58e 100644 --- a/transform/transform.go +++ b/transform/transform.go @@ -37,6 +37,10 @@ type Usage struct { CompletionTokensDetails *Detail `json:"completion_tokens_details,omitempty"` Credits float64 `json:"credits"` OriginalCredits float64 `json:"original_credits"` + // Billable reports whether the gateway charged this request. Frames that + // omit the field are treated as billable so pre-existing models keep + // contributing to the credits total instead of silently zeroing it. + Billable bool `json:"billable"` } // Detail is a token breakdown (cached prompt tokens / reasoning tokens). @@ -284,6 +288,11 @@ func convertIncomingMessage(message map[string]interface{}, toolsEnabled, allowS if role == "" { role = "user" } + // Qoder's upstream chat protocol accepts system messages but rejects the + // OpenAI-only developer role that Pi uses for its agent instruction. + if role == "developer" { + role = "system" + } text := normalizeMessageText(message) anyToolCalls := extractAnyToolCalls(message, text, toolsEnabled) var structuredToolCalls []NormalizedToolCall @@ -947,6 +956,12 @@ func extractUsage(v interface{}) *Usage { TotalTokens: toInt(m["total_tokens"]), Credits: toFloat(m["credits"]), OriginalCredits: toFloat(m["original_credits"]), + // Default true: only an explicit false suppresses billing. An absent + // field must not drop credits from frames of models that predate it. + Billable: true, + } + if b, ok := m["billable"].(bool); ok { + u.Billable = b } if d, ok := m["prompt_tokens_details"].(map[string]interface{}); ok { u.PromptTokensDetails = &Detail{CachedTokens: toInt(d["cached_tokens"])} diff --git a/transform/transform_test.go b/transform/transform_test.go index ee3ceda..a11090f 100644 --- a/transform/transform_test.go +++ b/transform/transform_test.go @@ -23,6 +23,19 @@ func TestBuildMessagesUsesOnlyIncomingOpenAIMessages(t *testing.T) { } } +func TestBuildQoderMessagesNormalizesDeveloperRoleToSystem(t *testing.T) { + converted := BuildQoderMessages([]map[string]interface{}{ + {"role": "developer", "content": "Agent instruction"}, + {"role": "user", "content": "Hi"}, + }, "Hi", false) + if len(converted) != 2 { + t.Fatalf("converted message count = %d, want 2", len(converted)) + } + if converted[0].Role != "system" { + t.Errorf("developer role = %q, want system", converted[0].Role) + } +} + func TestApplyOpenAIToolConfigRemovesTemplateToolsWhenAbsent(t *testing.T) { body := map[string]interface{}{ "tools": []interface{}{map[string]interface{}{"type": "function", "function": map[string]interface{}{"name": "Skill"}}},