diff --git a/.gitignore b/.gitignore
index b80ea6d..4091ed4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,19 +1,16 @@
# 凭据与敏感数据(切勿提交!)
-workbuddy.json
+workbuddy*.json
workbuddy-console.json
-dist/workbuddy.json
-dist/*.json
last-*.json
*.json.bak
*.json.disabled
+*.disabled
.workbuddy-*.json
-workbuddy-status.json
# 本地编译产物
+workbuddy-gateway
workbuddy-gateway.exe
-dist/*.exe
-dist/workbuddy-gateway-linux-*
-dist/workbuddy-gateway-darwin-*
+dist/
# 日志与临时文件
*.log
diff --git a/README.md b/README.md
index 3250a92..6678c77 100644
--- a/README.md
+++ b/README.md
@@ -2,14 +2,23 @@
-基于腾讯 **CodeBuddy / 混元(Hunyuan)** 协议开发的**纯 Go、零 CGO 依赖、跨平台单二进制**本地 AI 代理网关。无 Web UI,仅通过命令行(CLI)完成扫码登录、凭据续期与服务控制。
+基于腾讯 **CodeBuddy / 混元(Hunyuan)** 协议开发的**纯 Go、零 CGO 依赖、跨平台单二进制**本地 AI 代理网关。无 Web UI,仅通过命令行(CLI)完成登录、凭据续期与服务控制。
+
+**同时支持两个上游站点**(同一套协议,凭据按站点隔离,账号池可混挂轮询):
+
+| 站点 | 上游 | 登录方式 | 登录命令 |
+|---|---|---|---|
+| 国内站 | `copilot.tencent.com` | 微信 / 企业微信扫码 | `login` |
+| 国际站 | `www.workbuddy.ai` | 浏览器内登录(邮箱 / 验证码 / SSO 等) | `login -intl` |
## 核心特性
+- **国内 / 国际双站反代**:国际站 `www.workbuddy.ai` 与国内站走同一套 `/v2/plugin/*` 协议,凭据文件通过 `edition` 字段区分站点,刷新 / 对话自动路由到各自上游。
- **模型完全透传**:客户端(OpenAI SDK、Cursor、Claude Code、DSH 等)传什么 `model`(如 `hy4-preview`、`hy3-preview-agent`、`hy3`、`deepseek-v4-pro`、`glm-5.2`…),网关原样透传至腾讯上游,无白名单限制。
-- **纯 CLI 控制**:终端内嵌 ASCII 二维码,微信 / 企业微信扫码登录;`status` / `refresh` / `serve` 子命令完成全部管理。
-- **多账号池 + 轮询负载均衡**:支持同时挂载多个 CodeBuddy 账号(`-auth` 逗号分隔或 `-auth-dir` 目录),请求按轮询(round-robin)均匀分配到各账号,保持多账号额度使用一致。
-- **429 频率限制自动冷却**:任一账号触发上游频率限制(HTTP 429 / code 6004)时,自动解析消息中的重置时间(如 `将在 2026-09-04 07:48:15 UTC+8 重置`),将该账号屏蔽至重置时间;冷却期间自动改用其他账号代偿,冷却到期自动恢复。
+- **纯 CLI 控制**:终端内嵌 ASCII 二维码,国内站微信 / 企业微信扫码登录;`status` / `refresh` / `serve` 子命令完成全部管理。
+- **多账号池 + 轮询负载均衡**:支持同时挂载多个 CodeBuddy 账号(`-auth` 逗号分隔或 `-auth-dir` 目录),请求按轮询(round-robin)均匀分配到各账号,保持多账号额度使用一致;**国内站与国际站账号可混挂在同一池中**。
+- **凭据热加载(免重启)**:`serve` 运行期间自动扫描凭据来源(默认每 5 秒,`-reload-interval` 可调):新增凭据文件自动入池、重新登录/手动更新凭据原地生效、删除凭据自动移出,全程无需重启服务。
+- **429 频率限制自动冷却**:任一账号触发上游频率限制(HTTP 429 / code 6004,中英文消息均可识别)时,自动解析消息中的重置时间(中文如 `将在 2026-09-04 07:48:15 UTC+8 重置`,英文如 `will reset at 2026-09-05 01:57:00 UTC+8`),将该账号屏蔽至重置时间;冷却期间自动改用其他账号代偿,冷却到期自动恢复。
- **授权失效自动禁用**:账号授权过期、被撤销或令牌刷新失败(HTTP 401/403 / invalid token / 登录已过期)时,自动将该账号**禁止调度并删除凭据文件**,同时写入持久化失效标记;`status` / 启动日志会明确提示该账号失效原因与重新登录命令,重新 `login` 后自动恢复调度。
- **后台自动续期**:运行期间每 5 分钟检查所有账号 Token,距过期不足 15 分钟自动刷新并持久化回各自凭据文件。
- **OpenAI 兼容协议**:`POST /v1/chat/completions`(SSE 流式 + 非流式聚合)、`GET /v1/models`、`GET /health`。
@@ -39,6 +48,28 @@ workbuddy-gateway login -auth /opt/workbuddy-gateway/workbuddy001.json
> 提示:建议在 **CodeBuddy 控制台(网页端)** 扫码登录获取更高权限评级的 Token;若使用 `login` 命令扫码,账号渠道可能受限(`azp=invite`)。
+### 1b. 登录国际站(workbuddy.ai)
+
+```bash
+# 登录国际站账号(保存到指定文件以便与国内站账号区分)
+workbuddy-gateway login -intl
+workbuddy-gateway login -intl -auth workbuddy-intl.json
+```
+
+国际站登录流程与国内站不同:网关生成登录链接后,**需要你在浏览器里完成登录**(支持邮箱、验证码、SSO 等方式),流程如下:
+
+1. 终端打印二维码与直达链接(手机扫码或电脑打开均可);
+2. 在浏览器中完成登录,页面显示 **Login Successful** 即可(无需理会跳转 App 的提示);
+3. 网关自动轮询拿到 Token 并保存到凭据文件(`edition: "intl"`),等待窗口 15 分钟。
+
+启动服务无需任何额外参数——凭据文件内已记录站点标识,`serve` / `refresh` 会自动路由到对应上游;**登录成功后新账号由凭据热加载自动加入运行中的账号池,无需重启服务**:
+
+```bash
+workbuddy-gateway serve # 自动发现目录下所有 workbuddy*.json(含国际站账号,混挂轮询)
+```
+
+> 说明:国际站 `www.workbuddy.ai` 与国内站 `copilot.tencent.com` 是同一协议的两套部署(登录 platform 为 `workbuddy-ai`,等待授权时轮询返回 code 11217)。国际站账号是否可用各模型、额度与风控策略由腾讯国际站侧决定。
+
### 2. 查看凭据状态
```bash
@@ -123,6 +154,7 @@ workbuddy-gateway status
# 输出示例:
# --- 账号 #1 ---
# 凭据文件: workbuddy-2.json
+# 站点: 国内站 (copilot.tencent.com)
# 用户昵称: tester
# 用户 UID: uid-xxx
# 账号状态: ❌ 授权失效(禁止调度)
@@ -140,6 +172,22 @@ workbuddy-gateway status # 该账号恢复为 ✅ 可用
- 只有一个账号时,请求始终使用该账号,429 冷却逻辑同样生效(冷却期间请求将返回 429 提示);
- 账号之间使用独立的串行锁:同一账号请求严格排队,不同账号可并行,兼顾风控与吞吐。
+### 凭据热加载(免重启增减账号)
+
+`serve` 运行期间默认每 5 秒扫描一次凭据来源(`-reload-interval` 可调,设为 `0` 关闭),自动完成:
+
+- **新增账号**:把新的 `workbuddy*.json` 放进凭据目录(或对运行中的网关执行 `login -auth 新文件.json`),几秒内自动加入轮询池;
+- **重新登录 / 更新凭据**:对已有凭据文件重新 `login` 后,网关原地替换凭据并自动恢复调度(含曾因授权失效被禁用的账号);
+- **删除账号**:删除凭据文件即自动移出轮询池(已写入失效标记的账号保留提示,重新登录后自动恢复)。
+
+日志示例:
+
+```text
+[Reload] 发现新账号凭据 workbuddy4.json(国际站),已自动加入账号池
+[Reload] 账号 workbuddy2.json 重新登录成功,已自动恢复调度
+[Reload] 凭据文件 workbuddy3.json 已删除,已移出账号池
+```
+
## 前台实时监控 (monitor)
网关以 systemd / 后台方式运行时,可用 **`monitor` 命令在前台实时查看所有账号的最新状态与最近日志**(Ctrl+C 退出):
@@ -288,7 +336,7 @@ sudo /opt/workbuddy-gateway/workbuddy-gateway login -auth /opt/workbuddy-gateway
> 💡 **多账号自动发现**:服务通过 `WorkingDirectory` 固定在 `/opt/workbuddy-gateway`,
> 无需修改 ExecStart——把多个凭据文件(`workbuddy.json`、`workbuddy2.json`…)放进该目录,
-> 重启服务即自动加载全部账号组成轮询池。确认方式:
+> 即可自动组成轮询池;凭据热加载会让新增/更新/删除的凭据文件免重启生效。确认方式:
> ```bash
> sudo journalctl -u workbuddy-gateway | grep 账号池
> # 输出示例: 已就绪账号池: 2 个账号 (有效 2, 失效 0)
@@ -363,7 +411,8 @@ ExecStart=/opt/workbuddy-gateway/workbuddy-gateway serve -addr 0.0.0.0 -port 831
```
命令: serve | login | status | refresh | monitor | version | help
-选项: -addr · -port · -auth · -auth-dir · -api-key · -proxy · -verbose
+选项: -addr · -port · -auth · -auth-dir · -intl · -reload-interval · -api-key · -proxy · -verbose
+ (-intl 仅 login 生效:登录国际站 www.workbuddy.ai;-reload-interval 默认 5,0 关闭热加载)
monitor: -interval · -journal · -logfile · -lines
```
@@ -379,4 +428,4 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o workbuddy-gateway .
## 免责声明
-本项目仅用于个人学习与技术研究。腾讯 CodeBuddy 的接口协议与风控策略可能随时变化;请遵守腾讯服务条款,自行承担使用风险。本仓库不包含任何官方未公开的密钥或凭据。
+本项目仅用于个人学习与技术研究。腾讯 CodeBuddy(含国内站与国际站 workbuddy.ai)的接口协议与风控策略可能随时变化;请遵守腾讯服务条款,自行承担使用风险。本仓库不包含任何官方未公开的密钥或凭据。
diff --git a/build.ps1 b/build.ps1
new file mode 100644
index 0000000..0e35189
--- /dev/null
+++ b/build.ps1
@@ -0,0 +1,142 @@
+<#
+.SYNOPSIS
+ WorkBuddy Local Gateway 一键交叉编译脚本(纯 Go、零 CGO 依赖,产物为静态单二进制)
+
+.DESCRIPTION
+ 默认构建全部 5 个平台产物到 dist\ 目录:
+ workbuddy-gateway-windows-amd64.exe
+ workbuddy-gateway-linux-amd64
+ workbuddy-gateway-linux-arm64
+ workbuddy-gateway-darwin-amd64
+ workbuddy-gateway-darwin-arm64
+
+ 构建前默认执行 go vet + go test,任何一步失败即中止。
+
+.PARAMETER Only
+ 仅构建指定平台:windows | linux | darwin
+
+.PARAMETER OutputDir
+ 输出目录(默认 dist)
+
+.PARAMETER Clean
+ 构建前清空输出目录
+
+.PARAMETER SkipTests
+ 跳过构建前的 go vet / go test
+
+.EXAMPLE
+ .\build.ps1 # 全平台构建
+ .\build.ps1 -Only windows # 只构建 windows
+ .\build.ps1 -Clean -SkipTests
+#>
+param(
+ [ValidateSet('', 'windows', 'linux', 'darwin')]
+ [string]$Only = '',
+ [string]$OutputDir = 'dist',
+ [switch]$Clean,
+ [switch]$SkipTests
+)
+
+$ErrorActionPreference = 'Stop'
+Set-Location -LiteralPath $PSScriptRoot
+
+# ---------------------------------------------------------------------------
+# 0. 环境检查
+# ---------------------------------------------------------------------------
+$go = Get-Command go -ErrorAction SilentlyContinue
+if (-not $go) {
+ Write-Host '[错误] 未检测到 Go 工具链,请先安装 Go 1.20+:https://go.dev/dl/' -ForegroundColor Red
+ exit 1
+}
+
+# 从 main.go 读取版本号(仅用于展示;版本定义在 main.go 的 version 常量中)
+$version = 'dev'
+if (Test-Path 'main.go') {
+ $m = Select-String -Path 'main.go' -Pattern 'version\s*=\s*"([^"]+)"' | Select-Object -First 1
+ if ($m) { $version = $m.Matches[0].Groups[1].Value }
+}
+
+Write-Host '================ WorkBuddy Local Gateway 构建 ================' -ForegroundColor Cyan
+Write-Host ("工具链: {0}" -f (go version))
+Write-Host ("目标版本: v{0}" -f $version)
+
+# ---------------------------------------------------------------------------
+# 1. 代码检查与单元测试
+# ---------------------------------------------------------------------------
+if (-not $SkipTests) {
+ Write-Host "`n[1/3] go vet + go test ..." -ForegroundColor Cyan
+ go vet .
+ if ($LASTEXITCODE -ne 0) { Write-Host '[错误] go vet 未通过' -ForegroundColor Red; exit 1 }
+ go test ./...
+ if ($LASTEXITCODE -ne 0) { Write-Host '[错误] go test 未通过' -ForegroundColor Red; exit 1 }
+}
+else {
+ Write-Host "`n[1/3] 已跳过 go vet / go test (-SkipTests)" -ForegroundColor DarkGray
+}
+
+# ---------------------------------------------------------------------------
+# 2. 交叉编译
+# ---------------------------------------------------------------------------
+$targets = @(
+ @{ GOOS = 'windows'; GOARCH = 'amd64'; Ext = '.exe' }
+ @{ GOOS = 'linux'; GOARCH = 'amd64'; Ext = '' }
+ @{ GOOS = 'linux'; GOARCH = 'arm64'; Ext = '' }
+ @{ GOOS = 'darwin'; GOARCH = 'amd64'; Ext = '' }
+ @{ GOOS = 'darwin'; GOARCH = 'arm64'; Ext = '' }
+)
+if ($Only) { $targets = @($targets | Where-Object { $_.GOOS -eq $Only }) }
+
+if (-not (Test-Path -LiteralPath $OutputDir)) {
+ New-Item -ItemType Directory -Path $OutputDir | Out-Null
+}
+elseif ($Clean) {
+ Remove-Item (Join-Path $OutputDir '*') -Recurse -Force
+ Write-Host ("已清空输出目录: {0}" -f $OutputDir) -ForegroundColor DarkGray
+}
+
+# 保存并临时覆盖构建环境变量,结束后恢复
+$oldEnv = @{ GOOS = $env:GOOS; GOARCH = $env:GOARCH; CGO_ENABLED = $env:CGO_ENABLED }
+$env:CGO_ENABLED = '0' # 强制零 CGO,保证产物可任意交叉分发
+
+$failed = @()
+$total = $targets.Count
+$i = 0
+foreach ($t in $targets) {
+ $i++
+ $name = "workbuddy-gateway-$($t.GOOS)-$($t.GOARCH)$($t.Ext)"
+ Write-Host ("`n[2/3] ({0}/{1}) building {2} ..." -f $i, $total, $name) -ForegroundColor Cyan
+ $env:GOOS = $t.GOOS
+ $env:GOARCH = $t.GOARCH
+ go build -trimpath -ldflags '-s -w' -o (Join-Path $OutputDir $name) .
+ if ($LASTEXITCODE -ne 0) {
+ $failed += $name
+ Write-Host " [FAIL] $name" -ForegroundColor Red
+ }
+ else {
+ Write-Host " [OK] $name" -ForegroundColor Green
+ }
+}
+
+# 恢复原环境变量
+foreach ($k in @('GOOS', 'GOARCH', 'CGO_ENABLED')) {
+ if ($oldEnv[$k]) { Set-Item -Path "env:$k" -Value $oldEnv[$k] }
+ else { Remove-Item -Path "env:$k" -ErrorAction SilentlyContinue }
+}
+
+# ---------------------------------------------------------------------------
+# 3. 结果汇总
+# ---------------------------------------------------------------------------
+Write-Host "`n================ 构建结果 ================" -ForegroundColor Cyan
+if ($failed.Count -gt 0) {
+ Write-Host ("失败 {0} 个: {1}" -f $failed.Count, ($failed -join ', ')) -ForegroundColor Red
+ exit 1
+}
+
+Get-ChildItem -LiteralPath $OutputDir -File |
+ Sort-Object Name |
+ Format-Table @(
+ @{ Label = 'Artifact'; Expression = { ' ' + $_.Name } },
+ @{ Label = 'Size'; Expression = { '{0:N1} MB' -f ($_.Length / 1MB) } }
+ ) -AutoSize | Out-Host
+
+Write-Host ("全部构建成功 共 {0} 个产物,输出目录: {1}" -f $total, (Join-Path $PSScriptRoot $OutputDir)) -ForegroundColor Green
diff --git a/main.go b/main.go
index 25e40b2..4bd7c1a 100644
--- a/main.go
+++ b/main.go
@@ -31,23 +31,80 @@ import (
)
const (
- version = "1.5.0"
- upstreamBase = "https://copilot.tencent.com"
- clientUA = "CLI/2.143.1 CodeBuddy/2.143.1"
- originReferer = "https://www.codebuddy.cn"
-
- endpointAuthState = upstreamBase + "/v2/plugin/auth/state?platform=VSCode"
- endpointLoginAcct = upstreamBase + "/v2/plugin/login/account?state="
- endpointAuthToken = upstreamBase + "/v2/plugin/auth/token?state="
- endpointTokenRefresh = upstreamBase + "/v2/plugin/auth/token/refresh"
- endpointChat = upstreamBase + "/v2/chat/completions"
-
- loginTTL = 5 * time.Minute
+ version = "1.7.0"
// 状态快照文件名:serve 后台周期写入,monitor 前台命令实时读取展示
statusSnapshotFile = "workbuddy-status.json"
)
+// -----------------------------------------------------------------------------
+// 上游站点 Profile(国内站 / 国际站)
+//
+// 国际站 www.workbuddy.ai 与国内站 copilot.tencent.com 走同一套 /v2/plugin/* 协议
+// (实测:auth/state、auth/token、login/account、auth/token/refresh、chat/completions
+// 的路径与响应包络完全一致),差异仅在:
+// - 上游域名与 Web Origin(国际站位于腾讯 EdgeOne 国际 CDN)
+// - 登录 platform 参数(workbuddy-ai 而非 VSCode),登录在浏览器内完成(邮箱/验证码/SSO)
+// - 等待授权期间 auth/token 轮询返回 code 11217 (login ing...)
+// 每个账号凭据文件通过 edition 字段记录所属站点,账号池支持国内/国际混挂轮询。
+// -----------------------------------------------------------------------------
+
+type upstreamProfile struct {
+ Key string // 存储于凭据文件 edition 字段的站点标识
+ Label string // 控制台展示名
+ Base string // 上游 API 基础地址
+ Origin string // Origin/Referer 伪装来源(各站 Web 控制台)
+ Platform string // auth/state 的 platform 参数
+ ClientUA string // User-Agent
+ ClientID string // X-Client-ID
+ ClientVer string // X-Client-Version
+ Product string // X-Product
+ LoginTTL time.Duration // login 命令等待授权完成的超时
+}
+
+var (
+ profileCN = upstreamProfile{
+ Key: "cn", Label: "国内站",
+ Base: "https://copilot.tencent.com", Origin: "https://www.codebuddy.cn",
+ Platform: "VSCode", ClientUA: "CLI/2.143.1 CodeBuddy/2.143.1",
+ ClientID: "codebuddy-cli", ClientVer: "2.143.1", Product: "SaaS",
+ LoginTTL: 5 * time.Minute,
+ }
+ profileINTL = upstreamProfile{
+ Key: "intl", Label: "国际站",
+ Base: "https://www.workbuddy.ai", Origin: "https://www.workbuddy.ai",
+ Platform: "workbuddy-ai", ClientUA: "CLI/2.143.1 CodeBuddy/2.143.1",
+ ClientID: "codebuddy-cli", ClientVer: "2.143.1", Product: "SaaS",
+ LoginTTL: 15 * time.Minute, // 浏览器内登录(邮箱/验证码/SSO)比扫码慢,放宽超时
+ }
+)
+
+// profileForEdition 根据凭据文件中的 edition 标识返回上游站点参数;空值/未知值回退国内站。
+func profileForEdition(edition string) *upstreamProfile {
+ switch strings.ToLower(strings.TrimSpace(edition)) {
+ case "intl", "international", "global", "workbuddy.ai":
+ return &profileINTL
+ default:
+ return &profileCN
+ }
+}
+
+func (p *upstreamProfile) authStateURL() string {
+ return p.Base + "/v2/plugin/auth/state?platform=" + url.QueryEscape(p.Platform)
+}
+
+func (p *upstreamProfile) loginAcctURL(state string) string {
+ return p.Base + "/v2/plugin/login/account?state=" + url.QueryEscape(state)
+}
+
+func (p *upstreamProfile) authTokenURL(state string) string {
+ return p.Base + "/v2/plugin/auth/token?state=" + url.QueryEscape(state)
+}
+
+func (p *upstreamProfile) tokenRefreshURL() string { return p.Base + "/v2/plugin/auth/token/refresh" }
+
+func (p *upstreamProfile) chatURL() string { return p.Base + "/v2/chat/completions" }
+
// -----------------------------------------------------------------------------
// 数据结构定义
// -----------------------------------------------------------------------------
@@ -55,6 +112,7 @@ const (
type StoredAuth struct {
Auth StoredTokens `json:"auth"`
Account StoredAccount `json:"account"`
+ Edition string `json:"edition,omitempty"` // 站点标识:cn(国内站,默认)| intl(国际站 www.workbuddy.ai)
}
type StoredTokens struct {
@@ -105,9 +163,11 @@ type Config struct {
AuthFile string
AuthDir string
AuthExplicit bool // 用户是否显式指定了 -auth(未指定时自动扫描目录下所有 workbuddy*.json)
+ LoginIntl bool // login -intl:登录国际站 (www.workbuddy.ai,浏览器内完成登录)
APIKey string
ProxyURL string
Verbose bool
+ ReloadInterval int // 账号池热加载扫描间隔(秒),0 关闭
MonitorInterval int // monitor 状态刷新间隔(秒)
LogFile string // monitor 附加展示的日志文件路径
JournalService string // monitor 附加展示的 systemd 服务名(journalctl -u)
@@ -125,9 +185,19 @@ type Account struct {
DisabledReason string // 失效原因,用于控制台展示
Nickname string // 失效标记持久化字段:昵称(Auth 为 nil 时使用)
UID string // 失效标记持久化字段:UID
+ Edition string // 站点标识(cn/intl):失效标记恢复时 Auth 为 nil 也可见
+ fingerprint string // 凭据文件变更指纹(mtime+size,凭据热加载用)
lock sync.Mutex // 单账号串行锁(防止同账号并发触发 11128)
}
+// Profile 返回该账号对应的上游站点参数(国内站/国际站)。
+func (acc *Account) Profile() *upstreamProfile {
+ if acc.Auth != nil {
+ return profileForEdition(acc.Auth.Edition)
+ }
+ return profileForEdition(acc.Edition)
+}
+
// disabledMarker 是授权失效账号的持久化标记(凭据文件删除后用于控制台提示重新登录)。
type disabledMarker struct {
Path string `json:"path"`
@@ -135,6 +205,7 @@ type disabledMarker struct {
DisabledAt int64 `json:"disabledAt"`
Nickname string `json:"nickname,omitempty"`
UID string `json:"uid,omitempty"`
+ Edition string `json:"edition,omitempty"` // 站点标识(cn/intl),用于控制台展示
}
// markerPath 返回与凭据文件同目录的失效标记文件路径。
@@ -185,7 +256,9 @@ func main() {
fs.StringVar(&cfg.APIKey, "api-key", "", "可选:访问网关所需的 API Key (客户端 Bearer 校验)")
fs.StringVar(&cfg.ProxyURL, "proxy", "", "可选:上游请求代理 (如 http://127.0.0.1:7890)")
fs.BoolVar(&cfg.Verbose, "verbose", false, "输出详细调试日志")
+ fs.BoolVar(&cfg.LoginIntl, "intl", false, "login 专用:登录国际站 (www.workbuddy.ai,浏览器内完成登录);默认登录国内站")
fs.IntVar(&cfg.MonitorInterval, "interval", 3, "monitor 状态刷新间隔(秒)")
+ fs.IntVar(&cfg.ReloadInterval, "reload-interval", 5, "账号池热加载扫描间隔(秒),0 关闭:运行期自动发现新增/更新/删除的凭据文件,免重启")
fs.StringVar(&cfg.LogFile, "logfile", "", "monitor 附加跟随的日志文件路径(如 -logfile /var/log/workbuddy-gateway.log)")
fs.StringVar(&cfg.JournalService, "journal", "", "monitor 附加跟随的 systemd 服务名(Linux 下用 journalctl -u <服务> -f 跟随)")
fs.IntVar(&cfg.LogLines, "lines", 5, "monitor 每次刷新展示的最近日志行数")
@@ -234,8 +307,8 @@ func printHelp() {
命令:
serve 启动本地网关 (默认操作)
- login 微信/企业微信扫码登录,获取/更新凭据
- status 查看账号池状态(含冷却状态与过期时间)
+ login 登录并获取/更新凭据:国内站微信扫码;-intl 登录国际站 (浏览器内完成)
+ status 查看账号池状态(含站点、冷却状态与过期时间)
refresh 手动立即刷新所有账号访问令牌 (Access Token)
monitor 前台实时监控:周期刷新展示账号状态 + 最近日志 (Ctrl+C 退出)
version 查看版本信息
@@ -247,6 +320,10 @@ func printHelp() {
-auth 凭据文件路径;支持逗号分隔多个文件实现多账号
(默认: 自动发现当前目录下所有 workbuddy*.json)
-auth-dir 凭据目录:自动加载目录下所有 workbuddy*.json 作为账号池
+ -intl login 专用:登录国际站 www.workbuddy.ai(浏览器内完成登录)
+ -reload-interval
+ 账号池热加载扫描间隔(默认 5 秒,0 关闭):运行期自动发现
+ 新增/更新/删除的凭据文件,免重启生效
-api-key 设置后,调用网关必须携带 Bearer 鉴权
-proxy 设置上游转发代理 (例如 http://127.0.0.1:7890 或 socks5://...)
-verbose 输出详细调试日志 (请求/响应体)
@@ -261,19 +338,27 @@ monitor 选项:
# 登录第二个账号(保存到不同文件)
workbuddy-gateway login -auth workbuddy2.json
+ # 登录国际站账号(www.workbuddy.ai,浏览器内完成登录)
+ workbuddy-gateway login -intl -auth workbuddy-intl.json
+
# 自动发现:把多个凭据文件放进工作目录即可自动多账号(无需任何参数)
workbuddy-gateway serve # 自动加载 ./workbuddy*.json
- # 启动时指定多个凭据文件(轮询 + 429 自动冷却代偿)
+ # 启动时指定多个凭据文件(轮询 + 429 自动冷却代偿;国内/国际可混挂)
workbuddy-gateway serve -auth workbuddy.json,workbuddy2.json
# 或使用目录模式:目录内所有 workbuddy*.json 自动组成账号池
workbuddy-gateway serve -auth-dir ./auths
+ # 运行期新增/更新/删除凭据文件会自动热加载(默认每 5 秒),无需重启 serve
+
示例:
- # 首次使用扫码登录
+ # 首次使用扫码登录(国内站)
workbuddy-gateway login
+ # 登录国际站(www.workbuddy.ai)
+ workbuddy-gateway login -intl
+
# 启动本地网关 (监听 127.0.0.1:8317)
workbuddy-gateway serve
@@ -387,6 +472,15 @@ func loadAccountFile(path string) (*StoredAuth, error) {
return &sa, nil
}
+// isCredentialFile 判断自动发现模式下文件名是否为凭据文件。
+// 排除运行时产物:workbuddy-status.json(monitor 状态快照)等。
+func isCredentialFile(name string) bool {
+ if !strings.HasPrefix(name, "workbuddy") || !strings.HasSuffix(name, ".json") {
+ return false
+ }
+ return name != statusSnapshotFile
+}
+
// collectConfiguredAuthPaths 解析应加载的凭据路径列表:
// 1) -auth-dir 指定目录 → 目录下所有 workbuddy*.json
// 2) 显式 -auth → 逗号分隔的凭据文件列表(保持用户顺序)
@@ -399,13 +493,10 @@ func collectConfiguredAuthPaths() []string {
entries, err := os.ReadDir(cfg.AuthDir)
if err == nil {
for _, e := range entries {
- if e.IsDir() {
+ if e.IsDir() || !isCredentialFile(e.Name()) {
continue
}
- name := e.Name()
- if strings.HasPrefix(name, "workbuddy") && strings.HasSuffix(name, ".json") {
- paths = append(paths, filepath.Join(cfg.AuthDir, name))
- }
+ paths = append(paths, filepath.Join(cfg.AuthDir, e.Name()))
}
sort.Strings(paths)
}
@@ -426,13 +517,10 @@ func collectConfiguredAuthPaths() []string {
entries, err := os.ReadDir(".")
if err == nil {
for _, e := range entries {
- if e.IsDir() {
+ if e.IsDir() || !isCredentialFile(e.Name()) {
continue
}
- name := e.Name()
- if strings.HasPrefix(name, "workbuddy") && strings.HasSuffix(name, ".json") {
- paths = append(paths, name)
- }
+ paths = append(paths, e.Name())
}
sort.Strings(paths)
}
@@ -459,7 +547,11 @@ func loadAccounts() error {
log.Printf("[Auth] 跳过无效凭据文件 %s: %v", p, err)
continue
}
- accounts = append(accounts, &Account{Path: p, Auth: sa})
+ fp := ""
+ if st, statErr := os.Stat(p); statErr == nil {
+ fp = accountFingerprint(st)
+ }
+ accounts = append(accounts, &Account{Path: p, Auth: sa, Edition: profileForEdition(sa.Edition).Key, fingerprint: fp})
}
// 恢复持久化的失效账号标记(凭据文件已删除,仅供控制台提示重新登录)
@@ -471,6 +563,124 @@ func loadAccounts() error {
return nil
}
+// -----------------------------------------------------------------------------
+// 凭据热加载:serve 运行期间自动发现凭据文件的新增/更新/删除,免重启收敛账号池
+// -----------------------------------------------------------------------------
+
+// accountFingerprint 基于文件 mtime+size 生成凭据文件变更指纹。
+func accountFingerprint(st os.FileInfo) string {
+ return fmt.Sprintf("%d:%d", st.ModTime().UnixNano(), st.Size())
+}
+
+// reloadAccounts 将磁盘上的凭据文件与内存账号池原地收敛(热加载):
+// - 新增凭据文件 → 自动加入账号池;
+// - 凭据内容变化(重新登录/手动更新)→ 原地替换凭据、清除冷却/失效状态并恢复调度;
+// - 凭据文件被删除 → 移出账号池(已写入失效标记的幻影账号保留,用于提示重新登录)。
+//
+// 返回本次是否发生变更。调用方需自行处理 accountMu 之外的快照刷新。
+func reloadAccounts() bool {
+ accountMu.Lock()
+ defer accountMu.Unlock()
+
+ changed := false
+ seen := make(map[string]bool)
+
+ for _, p := range collectConfiguredAuthPaths() {
+ st, err := os.Stat(p)
+ if err != nil {
+ continue
+ }
+ seen[p] = true
+ fp := accountFingerprint(st)
+
+ var existing *Account
+ for _, acc := range accounts {
+ if acc.Path == p {
+ existing = acc
+ break
+ }
+ }
+
+ if existing == nil {
+ // 新凭据文件:加载并加入账号池(文件可能正在写入,失败则下轮重试)
+ sa, err := loadAccountFile(p)
+ if err != nil {
+ log.Printf("[Reload] 凭据文件 %s 暂不可解析,下轮重试: %v", p, err)
+ continue
+ }
+ accounts = append(accounts, &Account{
+ Path: p,
+ Auth: sa,
+ Edition: profileForEdition(sa.Edition).Key,
+ fingerprint: fp,
+ })
+ log.Printf("[Reload] 发现新账号凭据 %s(%s),已自动加入账号池", p, profileForEdition(sa.Edition).Label)
+ changed = true
+ continue
+ }
+
+ if existing.fingerprint == fp {
+ continue // 未变化
+ }
+
+ // 内容变化:原地替换凭据(重新登录或手动更新)
+ sa, err := loadAccountFile(p)
+ if err != nil {
+ // 不更新指纹,下一轮重试(正常写入窗口极短,几乎必在下轮成功)
+ log.Printf("[Reload] 凭据文件 %s 变更但暂不可解析,保留旧凭据: %v", p, err)
+ continue
+ }
+ recovered := existing.Disabled
+ existing.Auth = sa
+ existing.Edition = profileForEdition(sa.Edition).Key
+ existing.Disabled = false
+ existing.DisabledReason = ""
+ existing.CooldownUntil = time.Time{}
+ existing.CooldownMsg = ""
+ existing.fingerprint = fp
+ clearDisabledMarker(p)
+ if recovered {
+ log.Printf("[Reload] 账号 %s 重新登录成功,已自动恢复调度", p)
+ } else {
+ log.Printf("[Reload] 账号 %s 凭据已更新(热加载,无需重启)", p)
+ }
+ changed = true
+ }
+
+ // 收敛:移除已删除的凭据(失效幻影账号保留)
+ kept := make([]*Account, 0, len(accounts))
+ for _, acc := range accounts {
+ if !seen[acc.Path] && !acc.Disabled {
+ log.Printf("[Reload] 凭据文件 %s 已删除,已移出账号池", acc.Path)
+ changed = true
+ continue
+ }
+ kept = append(kept, acc)
+ }
+ accounts = kept
+ if len(accounts) > 0 {
+ rrIndex %= len(accounts)
+ } else {
+ rrIndex = 0
+ }
+ return changed
+}
+
+// accountReloaderLoop serve 后台凭据热加载协程:周期扫描凭据变化并原地收敛账号池。
+func accountReloaderLoop() {
+ interval := time.Duration(cfg.ReloadInterval) * time.Second
+ if interval <= 0 {
+ return
+ }
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for range ticker.C {
+ if reloadAccounts() {
+ writeStatusSnapshot() // 立即刷新 monitor 状态文件
+ }
+ }
+}
+
// nextAccount 轮询选择下一个未处于冷却状态且未被禁用的账号。
// 若全部账号不可用,返回错误并附带说明。
func nextAccount() (*Account, error) {
@@ -534,11 +744,14 @@ func disableAccount(acc *Account, reason string) {
path := acc.Path
nickname := ""
uid := ""
+ edition := ""
if acc.Auth != nil {
nickname = acc.Auth.Account.Nickname
uid = acc.Auth.Account.UID
+ edition = profileForEdition(acc.Auth.Edition).Key
acc.Nickname = nickname
acc.UID = uid
+ acc.Edition = edition
}
accountMu.Unlock()
@@ -549,6 +762,7 @@ func disableAccount(acc *Account, reason string) {
DisabledAt: time.Now().Unix(),
Nickname: nickname,
UID: uid,
+ Edition: edition,
}
if data, err := json.MarshalIndent(marker, "", " "); err == nil {
if werr := os.WriteFile(markerPath(path), data, 0600); werr != nil {
@@ -635,6 +849,7 @@ func loadDisabledMarkers() {
DisabledReason: m.Reason,
Nickname: m.Nickname,
UID: m.UID,
+ Edition: m.Edition,
})
}
}
@@ -666,34 +881,47 @@ func isAuthFailure(statusCode int, body string) bool {
var resetTimeRe = regexp.MustCompile(`将在\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s*(UTC[+-]\d+(?::\d{2})?)?`)
+// resetTimeGenericRe 兜底匹配任意「日期 + 时间(可选 UTC 偏移)」片段,
+// 用于国际站等英文限流消息(如 "will reset at 2026-09-05 01:57:00 UTC+8")。
+var resetTimeGenericRe = regexp.MustCompile(`(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\s*(UTC[+-]\d+(?::\d{2})?))?`)
+
// parseResetTime 从上游 429 错误消息中解析频率限制重置时间。
-// 示例: "您的使用量已超出频率限制,将在 2026-09-04 07:48:15 UTC+8 重置"
+// 国内站示例: "您的使用量已超出频率限制,将在 2026-09-04 07:48:15 UTC+8 重置"
+// 国际站示例: "Your usage has exceeded the rate limit. It will reset at 2026-09-05 01:57:00 UTC+8."
func parseResetTime(s string) (time.Time, bool) {
m := resetTimeRe.FindStringSubmatch(s)
+ if m == nil {
+ m = resetTimeGenericRe.FindStringSubmatch(s)
+ }
if m == nil {
return time.Time{}, false
}
+ return parseDateTimeTZ(m[1], m[2], m[3])
+}
+
+// parseDateTimeTZ 按「日期 时间 (可选 UTC 偏移)」解析时间;未提供时区时默认按 UTC+8。
+func parseDateTimeTZ(date, clock, zone string) (time.Time, bool) {
offset := 8 * 3600 // 默认按 UTC+8 解析
- if m[3] != "" {
- zone := m[3]
- zone = strings.TrimPrefix(zone, "UTC")
- zone = strings.TrimPrefix(zone, "utc")
+ if zone != "" {
+ z := zone
+ z = strings.TrimPrefix(z, "UTC")
+ z = strings.TrimPrefix(z, "utc")
var h, mi int
- if _, err := fmt.Sscanf(zone, "%d:%d", &h, &mi); err == nil {
+ if _, err := fmt.Sscanf(z, "%d:%d", &h, &mi); err == nil {
offset = h*3600 + mi*60
- } else if _, err := fmt.Sscanf(zone, "%d", &h); err == nil {
+ } else if _, err := fmt.Sscanf(z, "%d", &h); err == nil {
offset = h * 3600
}
}
loc := time.FixedZone("UTC", offset)
- t, err := time.ParseInLocation("2006-01-02 15:04:05", m[1]+" "+m[2], loc)
+ t, err := time.ParseInLocation("2006-01-02 15:04:05", date+" "+clock, loc)
if err != nil {
return time.Time{}, false
}
return t, true
}
-// isRateLimited 判断上游响应是否属于频率限制(429 或 code 6004 / 频率限制提示)。
+// isRateLimited 判断上游响应是否属于频率限制(429 或 code 6004 / 频率限制提示,含中英文)。
func isRateLimited(statusCode int, body string) bool {
if statusCode == http.StatusTooManyRequests {
return true
@@ -701,6 +929,10 @@ func isRateLimited(statusCode int, body string) bool {
if strings.Contains(body, `"code":6004`) || strings.Contains(body, "频率限制") || strings.Contains(body, "frequency limit") {
return true
}
+ low := strings.ToLower(body)
+ if strings.Contains(low, "rate limit") || strings.Contains(low, "ratelimit") || strings.Contains(low, "too many requests") {
+ return true
+ }
return false
}
@@ -766,10 +998,12 @@ func doRefreshTokenFor(acc *Account) error {
}
// refreshTokenPayload 调用上游刷新接口并更新内存中的令牌字段(不落盘)。
+// 按凭据文件中的 edition 路由到对应站点(国内站/国际站)的刷新接口。
// 返回上游 HTTP 状态码(成功或失败时均为实际状态;网络错误为 0)。
func refreshTokenPayload(sa *StoredAuth) (int, error) {
+ prof := profileForEdition(sa.Edition)
headers := func(r *http.Request) {
- commonHeaders(r)
+ commonHeaders(r, prof)
r.Header.Set("X-Refresh-Token", sa.Auth.RefreshToken)
if sa.Account.EnterpriseID != "" {
r.Header.Set("X-Enterprise-Id", sa.Account.EnterpriseID)
@@ -777,7 +1011,7 @@ func refreshTokenPayload(sa *StoredAuth) (int, error) {
r.Header.Set("X-Auth-Refresh-Source", "workbuddy")
}
- data, status, err := doJSON(cfg.HttpClient, http.MethodPost, endpointTokenRefresh, headers, nil)
+ data, status, err := doJSON(cfg.HttpClient, http.MethodPost, prof.tokenRefreshURL(), headers, nil)
if err != nil {
return status, fmt.Errorf("上游刷新拒绝 (HTTP %d): %w", status, err)
}
@@ -809,6 +1043,8 @@ func formatAccountStatus(acc *Account, idx int, now time.Time) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("\n--- 账号 #%d ---\n", idx))
sb.WriteString(fmt.Sprintf("凭据文件: %s\n", acc.Path))
+ prof := acc.Profile()
+ sb.WriteString(fmt.Sprintf("站点: %s (%s)\n", prof.Label, strings.TrimPrefix(prof.Base, "https://")))
if acc.Disabled {
// 失效账号(Auth 可能为 nil:凭据文件已删除,信息来自失效标记)
@@ -911,6 +1147,7 @@ func runRefresh() {
// accountSnapshot 是写入状态快照文件的单个账号状态。
type accountSnapshot struct {
Path string `json:"path"`
+ Edition string `json:"edition,omitempty"` // 站点标识(cn/intl)
Nickname string `json:"nickname"`
UID string `json:"uid"`
State string `json:"state"` // active | cooldown | disabled
@@ -933,7 +1170,7 @@ func writeStatusSnapshot() {
snap := statusSnapshot{UpdatedAt: time.Now().Unix()}
now := time.Now()
for _, acc := range accounts {
- as := accountSnapshot{Path: acc.Path}
+ as := accountSnapshot{Path: acc.Path, Edition: acc.Profile().Key}
switch {
case acc.Disabled:
as.State = "disabled"
@@ -1114,7 +1351,7 @@ func runMonitor() {
fmt.Printf("📊 账号池: 共 %d 个 | ✅ 可用 %d | 🔒 冷却 %d | ❌ 失效 %d\n",
len(snap.Accounts), active, cooldown, disabled)
for i, a := range snap.Accounts {
- fmt.Printf(" #%d %s (%s)\n", i+1, a.Path, ifEmpty(a.Nickname, "?"))
+ fmt.Printf(" #%d %s (%s · %s)\n", i+1, a.Path, ifEmpty(a.Nickname, "?"), profileForEdition(a.Edition).Label)
switch a.State {
case "active":
fmt.Printf(" ✅ 可用 | Token 有效期至: %s\n",
@@ -1159,7 +1396,16 @@ func runMonitor() {
}
func runLogin() {
- fmt.Println("================ WorkBuddy 扫码登录 ================")
+ prof := &profileCN
+ if cfg.LoginIntl {
+ prof = &profileINTL
+ }
+
+ fmt.Println("================ WorkBuddy 登录 ================")
+ fmt.Printf("目标站点: %s (%s)\n", prof.Label, strings.TrimPrefix(prof.Base, "https://"))
+ if cfg.LoginIntl {
+ fmt.Println("国际站登录将在浏览器中完成(邮箱 / 验证码 / SSO 等),凭据由网关自动接管。")
+ }
fmt.Println("正在生成登录凭据与二维码...")
loginClient := &http.Client{
@@ -1167,7 +1413,13 @@ func runLogin() {
Jar: cfg.HttpClient.Jar,
}
- data, _, err := doJSON(loginClient, http.MethodPost, endpointAuthState, nil, bytes.NewReader([]byte("{}")))
+ // 轮询 auth/token 时与官方客户端一致,显式声明无 Authorization
+ pollHeaders := func(r *http.Request) {
+ commonHeaders(r, prof)
+ r.Header.Set("X-No-Authorization", "1")
+ }
+
+ data, _, err := doJSON(loginClient, http.MethodPost, prof.authStateURL(), nil, bytes.NewReader([]byte("{}")))
if err != nil {
fmt.Printf("获取登录状态失败: %v\n", err)
return
@@ -1182,15 +1434,19 @@ func runLogin() {
// 终端字符二维码
qr, err := qrcode.New(st.AuthURL, qrcode.Medium)
if err == nil {
- fmt.Println("\n请使用 微信 或 企业微信 扫描下方二维码登录:")
+ if cfg.LoginIntl {
+ fmt.Println("\n请用手机扫描下方二维码,并在浏览器中完成登录(邮箱 / 验证码 / SSO 等):")
+ } else {
+ fmt.Println("\n请使用 微信 或 企业微信 扫描下方二维码登录:")
+ }
fmt.Println(qr.ToSmallString(false))
}
fmt.Println("如无法扫码,也可在浏览器中直接打开以下链接:")
fmt.Printf("👉 %s\n\n", st.AuthURL)
- fmt.Println("等待扫码授权中 (按 Ctrl+C 可取消)...")
+ fmt.Println("等待登录授权完成 (按 Ctrl+C 可取消)...")
- deadline := time.Now().Add(loginTTL)
+ deadline := time.Now().Add(prof.LoginTTL)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
@@ -1201,7 +1457,7 @@ func runLogin() {
fmt.Println("\n登录已超时,请重新执行 login 命令。")
return
}
- tokRaw, _, errTok := doJSON(loginClient, http.MethodGet, endpointAuthToken+st.State, nil, nil)
+ tokRaw, _, errTok := doJSON(loginClient, http.MethodGet, prof.authTokenURL(st.State), pollHeaders, nil)
if errTok != nil {
continue
}
@@ -1213,14 +1469,15 @@ func runLogin() {
// 登录成功,拉取账号信息
var acct accountData
acctHeaders := func(r *http.Request) {
- commonHeaders(r)
+ commonHeaders(r, prof)
r.Header.Set("Authorization", "Bearer "+tok.AccessToken)
}
- if acctRaw, _, errAcct := doJSON(loginClient, http.MethodGet, endpointLoginAcct+st.State, acctHeaders, nil); errAcct == nil {
+ if acctRaw, _, errAcct := doJSON(loginClient, http.MethodGet, prof.loginAcctURL(st.State), acctHeaders, nil); errAcct == nil {
_ = json.Unmarshal(acctRaw, &acct)
}
sa := &StoredAuth{
+ Edition: prof.Key,
Auth: StoredTokens{
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
@@ -1240,6 +1497,7 @@ func runLogin() {
}
fmt.Println("\n🎉 登录成功!")
+ fmt.Printf("站点: %s (%s)\n", prof.Label, strings.TrimPrefix(prof.Base, "https://"))
fmt.Printf("欢迎,%s (UID: %s)\n", sa.Account.Nickname, sa.Account.UID)
fmt.Printf("凭据已成功保存至: %s\n", cfg.AuthFile)
fmt.Printf("令牌有效期至: %s\n", time.Unix(sa.Auth.ExpiresAt, 0).Format("2006-01-02 15:04:05"))
@@ -1274,6 +1532,11 @@ func runServe() {
// 启动状态快照协程(monitor 命令实时读取展示)
go statusSnapshotLoop()
+ // 启动凭据热加载协程(新增/更新/删除凭据文件免重启生效)
+ if cfg.ReloadInterval > 0 {
+ go accountReloaderLoop()
+ }
+
mux := http.NewServeMux()
mux.HandleFunc("/v1/chat/completions", handleChatCompletions)
mux.HandleFunc("/chat/completions", handleChatCompletions)
@@ -1297,6 +1560,11 @@ func runServe() {
fmt.Printf(" Chat 接口地址: http://%s/v1/chat/completions\n", listenAddr)
fmt.Printf(" Models 接口: http://%s/v1/models\n", listenAddr)
fmt.Printf(" 模型转发策略: 【完全透传】客户端请求的任意 model 原样中继至上游\n")
+ if cfg.ReloadInterval > 0 {
+ fmt.Printf(" 凭据热加载: 每 %ds 自动扫描,新增/更新/删除凭据免重启生效\n", cfg.ReloadInterval)
+ } else {
+ fmt.Printf(" 凭据热加载: 已关闭 (-reload-interval 0)\n")
+ }
if cfg.APIKey != "" {
fmt.Printf(" API 鉴权: 已启用 (Bearer %s)\n", cfg.APIKey)
} else {
@@ -1501,20 +1769,23 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
continue
}
- upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, endpointChat, bytes.NewReader(upstreamBytes))
+ // 按账号所属站点(国内站/国际站)路由上游与指纹 Header
+ prof := acc.Profile()
+
+ upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, prof.chatURL(), bytes.NewReader(upstreamBytes))
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, "req_create_error", err.Error())
return
}
// 注入 CodeBuddy 凭据与指纹 Header
- backendHeaders(upstreamReq, acc.Auth)
+ backendHeaders(upstreamReq, acc.Auth, prof)
// 限制同一账号向腾讯上游的请求严格单并发串行排队,防止 DSH 同时发起标题生成+主对话触发腾讯 11128 风控
acc.lock.Lock()
resp, err := cfg.HttpClient.Do(upstreamReq)
acc.lock.Unlock()
if err != nil {
- log.Printf("[#%d] 账号 %s 上游请求失败: %v", reqID, acc.Path, err)
+ log.Printf("[#%d] 账号 %s [%s] 上游请求失败: %v", reqID, acc.Path, prof.Label, err)
writeOpenAIError(w, http.StatusBadGateway, "upstream_network_error", fmt.Sprintf("网络转发失败: %v", err))
return
}
@@ -1524,7 +1795,7 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
errBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
errStr := string(errBody)
- log.Printf("[#%d] 账号 %s 上游返回 HTTP %d: %s (耗时 %v)", reqID, acc.Path, resp.StatusCode, errStr, time.Since(startTime))
+ log.Printf("[#%d] 账号 %s [%s] 上游返回 HTTP %d: %s (耗时 %v)", reqID, acc.Path, prof.Label, resp.StatusCode, errStr, time.Since(startTime))
if isRateLimited(resp.StatusCode, errStr) {
// 429 频率限制:解析重置时间并屏蔽该账号,交由其他账号代偿
@@ -1584,7 +1855,7 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
}
resp.Body.Close()
- log.Printf("[#%d] 流式输出完成 (账号 %s, 耗时 %v)", reqID, acc.Path, time.Since(startTime))
+ log.Printf("[#%d] 流式输出完成 (账号 %s [%s], 耗时 %v)", reqID, acc.Path, prof.Label, time.Since(startTime))
} else {
// 客户端需要完整 JSON 响应,聚合 SSE 数据流
completionJSON, err := aggregateCompletion(resp.Body, modelName)
@@ -1597,7 +1868,7 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(completionJSON)
- log.Printf("[#%d] 非流式响应完成 (账号 %s, 耗时 %v)", reqID, acc.Path, time.Since(startTime))
+ log.Printf("[#%d] 非流式响应完成 (账号 %s [%s], 耗时 %v)", reqID, acc.Path, prof.Label, time.Since(startTime))
}
return
}
@@ -1704,14 +1975,14 @@ func sanitizeBlockedTemplates(s string) string {
return s
}
-// backendHeaders 设置 CodeBuddy 上游专用指纹与鉴权 Header
-func backendHeaders(req *http.Request, sa *StoredAuth) {
- commonHeaders(req)
+// backendHeaders 设置 CodeBuddy 上游专用指纹与鉴权 Header(按站点 Profile 生成)
+func backendHeaders(req *http.Request, sa *StoredAuth, prof *upstreamProfile) {
+ commonHeaders(req, prof)
reqID := uuid.New().String()
req.Header.Set("X-Request-ID", reqID)
req.Header.Set("X-Trace-ID", reqID)
- req.Header.Set("X-Client-ID", "codebuddy-cli")
- req.Header.Set("X-Client-Version", "2.143.1")
+ req.Header.Set("X-Client-ID", prof.ClientID)
+ req.Header.Set("X-Client-Version", prof.ClientVer)
if sa != nil && sa.Auth.AccessToken != "" {
req.Header.Set("Authorization", "Bearer "+sa.Auth.AccessToken)
@@ -1736,16 +2007,17 @@ func backendHeaders(req *http.Request, sa *StoredAuth) {
} else {
req.Header.Set("X-No-Department-Info", "1")
}
- req.Header.Set("X-Product", "SaaS")
+ req.Header.Set("X-Product", prof.Product)
}
-func commonHeaders(req *http.Request) {
+// commonHeaders 按站点 Profile 设置通用伪装 Header(Origin/Referer/UA 因站点而异)。
+func commonHeaders(req *http.Request, prof *upstreamProfile) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
- req.Header.Set("Origin", originReferer)
- req.Header.Set("Referer", originReferer+"/")
- req.Header.Set("User-Agent", clientUA)
+ req.Header.Set("Origin", prof.Origin)
+ req.Header.Set("Referer", prof.Origin+"/")
+ req.Header.Set("User-Agent", prof.ClientUA)
}
func doJSON(client *http.Client, method, fullURL string, headers func(*http.Request), body io.Reader) (json.RawMessage, int, error) {
@@ -1756,7 +2028,7 @@ func doJSON(client *http.Client, method, fullURL string, headers func(*http.Requ
if headers != nil {
headers(req)
} else {
- commonHeaders(req)
+ commonHeaders(req, &profileCN) // 兜底默认(当前所有调用方均显式传入站点 Profile)
}
resp, err := client.Do(req)
if err != nil {
diff --git a/main_test.go b/main_test.go
index b98a61c..d30ac49 100644
--- a/main_test.go
+++ b/main_test.go
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"os"
+ "path/filepath"
"testing"
"time"
)
@@ -28,7 +29,99 @@ func TestParseResetTimeInvalid(t *testing.T) {
}
}
-// 验证限流识别(429 状态码 / code 6004 / 频率限制关键词)
+// 验证国际站英文限流消息的重置时间解析(通用兜底正则)
+func TestParseResetTimeEnglish(t *testing.T) {
+ cases := []string{
+ `{"code":6004,"msg":"Your usage has exceeded the rate limit. It will reset at 2026-09-05 01:57:00 UTC+8."}`,
+ `upstream 429: rate limit exceeded, reset at 2026-09-05 01:57:00`,
+ `{"msg":"quota exceeded, will reset on 2026-09-05T01:57:00 UTC+8"}`,
+ }
+ want := time.Date(2026, 9, 5, 1, 57, 0, 0, time.FixedZone("UTC+8", 8*3600))
+ for _, msg := range cases {
+ got, ok := parseResetTime(msg)
+ if !ok {
+ t.Fatalf("parseResetTime(%q) should succeed", msg)
+ }
+ if !got.Equal(want) {
+ t.Fatalf("parseResetTime(%q) = %v, want %v", msg, got, want)
+ }
+ }
+}
+
+// 验证站点 Profile 选择:空值/未知回退国内站,intl 系列值命中国际站
+func TestProfileForEdition(t *testing.T) {
+ cases := []struct {
+ edition string
+ wantKey string
+ }{
+ {"", "cn"},
+ {"cn", "cn"},
+ {"unknown", "cn"},
+ {"intl", "intl"},
+ {"INTL", "intl"},
+ {" workbuddy.ai ", "intl"},
+ {"international", "intl"},
+ }
+ for _, c := range cases {
+ if got := profileForEdition(c.edition).Key; got != c.wantKey {
+ t.Errorf("profileForEdition(%q).Key = %s, want %s", c.edition, got, c.wantKey)
+ }
+ }
+ if p := profileForEdition("intl"); p.Base != "https://www.workbuddy.ai" || p.Origin != "https://www.workbuddy.ai" {
+ t.Errorf("intl profile base/origin unexpected: %+v", p)
+ }
+ if p := profileForEdition("cn"); p.Base != "https://copilot.tencent.com" || p.Platform != "VSCode" {
+ t.Errorf("cn profile base/platform unexpected: %+v", p)
+ }
+}
+
+// 验证各站点上游 URL 构建与旧版常量完全一致(国内站回归)+ 国际站正确
+func TestUpstreamProfileURLs(t *testing.T) {
+ cn := profileForEdition("cn")
+ if got := cn.authStateURL(); got != "https://copilot.tencent.com/v2/plugin/auth/state?platform=VSCode" {
+ t.Errorf("cn authStateURL = %s", got)
+ }
+ if got := cn.chatURL(); got != "https://copilot.tencent.com/v2/chat/completions" {
+ t.Errorf("cn chatURL = %s", got)
+ }
+ if got := cn.tokenRefreshURL(); got != "https://copilot.tencent.com/v2/plugin/auth/token/refresh" {
+ t.Errorf("cn tokenRefreshURL = %s", got)
+ }
+
+ itl := profileForEdition("intl")
+ if got := itl.authStateURL(); got != "https://www.workbuddy.ai/v2/plugin/auth/state?platform=workbuddy-ai" {
+ t.Errorf("intl authStateURL = %s", got)
+ }
+ if got := itl.authTokenURL("abc-123"); got != "https://www.workbuddy.ai/v2/plugin/auth/token?state=abc-123" {
+ t.Errorf("intl authTokenURL = %s", got)
+ }
+ if got := itl.loginAcctURL("abc-123"); got != "https://www.workbuddy.ai/v2/plugin/login/account?state=abc-123" {
+ t.Errorf("intl loginAcctURL = %s", got)
+ }
+ if got := itl.chatURL(); got != "https://www.workbuddy.ai/v2/chat/completions" {
+ t.Errorf("intl chatURL = %s", got)
+ }
+}
+
+// 验证账号站点路由:优先取凭据内 edition;失效标记恢复(Auth 为 nil)时取账号上的 edition
+func TestAccountProfile(t *testing.T) {
+ acc := &Account{Auth: &StoredAuth{Edition: "intl"}}
+ if acc.Profile().Key != "intl" {
+ t.Fatalf("expected intl via Auth.Edition, got %s", acc.Profile().Key)
+ }
+ // 失效标记恢复场景:凭据文件已删除
+ acc2 := &Account{Edition: "intl"}
+ if acc2.Profile().Key != "intl" {
+ t.Fatalf("expected intl via Account.Edition, got %s", acc2.Profile().Key)
+ }
+ // 旧版凭据(无 edition 字段)回退国内站
+ acc3 := &Account{Auth: &StoredAuth{}}
+ if acc3.Profile().Key != "cn" {
+ t.Fatalf("expected cn fallback, got %s", acc3.Profile().Key)
+ }
+}
+
+// 验证限流识别(429 状态码 / code 6004 / 频率限制关键词,含中英文)
func TestIsRateLimited(t *testing.T) {
cases := []struct {
status int
@@ -39,6 +132,10 @@ func TestIsRateLimited(t *testing.T) {
{400, `{"code":6004,"msg":"x"}`, true},
{400, "频率限制", true},
{400, "frequency limit", true},
+ {400, "rate limit exceeded", true},
+ {400, "Rate Limit Exceeded", true},
+ {400, "ratelimit", true},
+ {400, "Too Many Requests", true},
{400, "some other error", false},
{500, "{}", false},
}
@@ -290,8 +387,8 @@ func TestCollectConfiguredAuthPathsAutoDiscover(t *testing.T) {
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
- // 模拟目录内多个凭据文件 + 非凭据文件 + 失效标记
- for _, name := range []string{"workbuddy.json", "workbuddy2.json", "workbuddy-3.json", "other.json", "workbuddy.json.disabled"} {
+ // 模拟目录内多个凭据文件 + 非凭据文件 + 失效标记 + 运行时状态快照(应被排除)
+ for _, name := range []string{"workbuddy.json", "workbuddy2.json", "workbuddy-3.json", "other.json", "workbuddy.json.disabled", "workbuddy-status.json"} {
if err := os.WriteFile(name, []byte(`{"auth":{"accessToken":"x"}}`), 0600); err != nil {
t.Fatal(err)
}
@@ -355,6 +452,89 @@ func TestCollectConfiguredAuthPathsAutoDiscoverFallback(t *testing.T) {
t.Logf("fallback path: %v", paths)
}
+// 验证凭据热加载:新增 / 更新 / 删除凭据文件均原地收敛账号池,无需重启
+func TestReloadAccounts(t *testing.T) {
+ dir := t.TempDir()
+
+ oldAuthFile := cfg.AuthFile
+ oldAuthDir := cfg.AuthDir
+ oldAuthExplicit := cfg.AuthExplicit
+ defer func() {
+ cfg.AuthFile = oldAuthFile
+ cfg.AuthDir = oldAuthDir
+ cfg.AuthExplicit = oldAuthExplicit
+ accountMu.Lock()
+ accounts = nil
+ rrIndex = 0
+ accountMu.Unlock()
+ }()
+ cfg.AuthFile = "workbuddy.json"
+ cfg.AuthDir = dir
+ cfg.AuthExplicit = false
+
+ accountMu.Lock()
+ accounts = nil
+ rrIndex = 0
+ accountMu.Unlock()
+
+ pa := filepath.Join(dir, "workbuddy-a.json")
+
+ // 1) 新增凭据文件 → 自动入池
+ if err := os.WriteFile(pa, []byte(`{"auth":{"accessToken":"a1"},"account":{"nickname":"A"}}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if !reloadAccounts() {
+ t.Fatal("expected changed=true after adding new credential file")
+ }
+ if len(accounts) != 1 || accounts[0].Auth.Auth.AccessToken != "a1" {
+ t.Fatalf("expected 1 account with a1, got %+v", accounts)
+ }
+ t.Logf("hot-add works: %s joined the pool", accounts[0].Path)
+
+ // 2) 内容变更(含站点切换 cn -> intl)→ 原地替换凭据
+ if err := os.WriteFile(pa, []byte(`{"auth":{"accessToken":"a2-longer"},"account":{"nickname":"A2"},"edition":"intl"}`), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if !reloadAccounts() {
+ t.Fatal("expected changed=true after credential content change")
+ }
+ if len(accounts) != 1 || accounts[0].Auth.Auth.AccessToken != "a2-longer" {
+ t.Fatalf("expected hot-reloaded a2 credential, got %+v", accounts)
+ }
+ if accounts[0].Profile().Key != "intl" {
+ t.Fatalf("expected intl profile after reload, got %s", accounts[0].Profile().Key)
+ }
+ t.Logf("hot-update works: edition now %s", accounts[0].Profile().Key)
+
+ // 3) 失效幻影账号:文件被删但账号 Disabled → 保留(用于提示重新登录)
+ accountMu.Lock()
+ accounts[0].Disabled = true
+ accountMu.Unlock()
+ if err := os.Remove(pa); err != nil {
+ t.Fatal(err)
+ }
+ if reloadAccounts() {
+ t.Fatal("disabled phantom account should be kept, expected no change")
+ }
+ if len(accounts) != 1 || !accounts[0].Disabled {
+ t.Fatalf("disabled phantom should be kept, got %d accounts", len(accounts))
+ }
+ t.Log("disabled phantom kept after file deletion")
+
+ // 4) 非失效账号文件被删 → 移出账号池
+ accountMu.Lock()
+ accounts[0].Disabled = false
+ accounts[0].Auth = &StoredAuth{Auth: StoredTokens{AccessToken: "x"}}
+ accountMu.Unlock()
+ if !reloadAccounts() {
+ t.Fatal("expected changed=true after credential file deletion")
+ }
+ if len(accounts) != 0 {
+ t.Fatalf("expected empty pool after deletion, got %d", len(accounts))
+ }
+ t.Log("hot-remove works: pool emptied after file deletion")
+}
+
// 验证 tailLines 读取文件末尾 N 行
func TestTailLines(t *testing.T) {
dir := t.TempDir()