3 Commits
Author SHA1 Message Date
CangShui 5ac6ee7975 feat: intl site (workbuddy.ai) support + credential hot-reload + build script
- login -intl: sign in via www.workbuddy.ai (browser-based, OneID email/SSO flow)
- per-credential edition routing for refresh/chat upstreams (cn | intl)
- mixed cn/intl account pool with round-robin and 429 cooldown fallback
- English rate-limit message parsing (reset time extraction)
- credential hot-reload: auto discover/add/update/remove credential files while serving (-reload-interval, default 5s, 0=off)
- exclude workbuddy-status.json from credential auto-discovery
- status/monitor/snapshot show per-account site label
- add build.ps1 cross-platform build script; harden .gitignore
2026-09-05 05:32:09 +08:00
CangShui 20f7fdded3 Update README.md 2026-09-04 16:37:40 +08:00
CangShui 4d5d315125 Update README.md 2026-09-04 12:22:15 +08:00
5 changed files with 727 additions and 83 deletions
+4 -7
View File
@@ -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
+61 -8
View File
@@ -1,14 +1,24 @@
# WorkBuddy Local Gateway
<img width="1734" height="924" alt="image" src="https://github.com/user-attachments/assets/d25431ed-0240-464a-a65d-70aaf3909477" />
<img width="797" height="231" alt="image" src="https://github.com/user-attachments/assets/b179efab-1caa-4bf6-a679-d6b6f299cbd6" />
基于腾讯 **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`。
@@ -29,12 +39,37 @@
# Linux / macOS
./workbuddy-gateway-linux-amd64 login
./workbuddy-gateway-darwin-arm64 login
#指定保存凭据到某个目录(多账号模式下建议使用)
workbuddy-gateway login -auth /opt/workbuddy-gateway/workbuddy001.json
```
终端将打印 ASCII 二维码与浏览器直达链接,扫码后凭据自动保存到当前目录 `workbuddy.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
@@ -119,6 +154,7 @@ workbuddy-gateway status
# 输出示例:
# --- 账号 #1 ---
# 凭据文件: workbuddy-2.json
# 站点: 国内站 (copilot.tencent.com)
# 用户昵称: tester
# 用户 UID: uid-xxx
# 账号状态: ❌ 授权失效(禁止调度)
@@ -136,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 退出):
@@ -284,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)
@@ -359,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 <ip> · -port <port> · -auth <path> · -auth-dir <dir> · -api-key <key> · -proxy <url> · -verbose
选项: -addr <ip> · -port <port> · -auth <path> · -auth-dir <dir> · -intl · -reload-interval <sec> · -api-key <key> · -proxy <url> · -verbose
(-intl 仅 login 生效:登录国际站 www.workbuddy.ai;-reload-interval 默认 5,0 关闭热加载)
monitor: -interval <sec> · -journal <svc> · -logfile <path> · -lines <n>
```
@@ -375,4 +428,4 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o workbuddy-gateway .
## 免责声明
本项目仅用于个人学习与技术研究。腾讯 CodeBuddy 的接口协议与风控策略可能随时变化;请遵守腾讯服务条款,自行承担使用风险。本仓库不包含任何官方未公开的密钥或凭据。
本项目仅用于个人学习与技术研究。腾讯 CodeBuddy(含国内站与国际站 workbuddy.ai)的接口协议与风控策略可能随时变化;请遵守腾讯服务条款,自行承担使用风险。本仓库不包含任何官方未公开的密钥或凭据。
+142
View File
@@ -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
+337 -65
View File
@@ -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 <path> 凭据文件路径;支持逗号分隔多个文件实现多账号
(默认: 自动发现当前目录下所有 workbuddy*.json)
-auth-dir <dir> 凭据目录:自动加载目录下所有 workbuddy*.json 作为账号池
-intl login 专用:登录国际站 www.workbuddy.ai(浏览器内完成登录)
-reload-interval <sec>
账号池热加载扫描间隔(默认 5 秒,0 关闭):运行期自动发现
新增/更新/删除的凭据文件,免重启生效
-api-key <key> 设置后,调用网关必须携带 Bearer <key> 鉴权
-proxy <url> 设置上游转发代理 (例如 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 {
+183 -3
View File
@@ -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()