Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
818d0c223e | ||
|
|
f96a874d2a | ||
|
|
e82b5f4212 | ||
|
|
946f540cb1 |
@@ -5,6 +5,8 @@ dist/workbuddy.json
|
||||
dist/*.json
|
||||
last-*.json
|
||||
*.json.bak
|
||||
*.json.disabled
|
||||
.workbuddy-*.json
|
||||
|
||||
# 本地编译产物
|
||||
workbuddy-gateway.exe
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# WorkBuddy Local Gateway
|
||||
<img width="1734" height="924" alt="image" src="https://github.com/user-attachments/assets/d25431ed-0240-464a-a65d-70aaf3909477" />
|
||||
|
||||
基于腾讯 **CodeBuddy / 混元(Hunyuan)** 协议开发的**纯 Go、零 CGO 依赖、跨平台单二进制**本地 AI 代理网关。无 Web UI,仅通过命令行(CLI)完成扫码登录、凭据续期与服务控制。
|
||||
|
||||
@@ -6,11 +7,14 @@
|
||||
|
||||
- **模型完全透传**:客户端(OpenAI SDK、Cursor、Claude Code、DSH 等)传什么 `model`(如 `hy4-preview`、`hy3-preview-agent`、`hy3`、`deepseek-v4-pro`、`glm-5.2`…),网关原样透传至腾讯上游,无白名单限制。
|
||||
- **纯 CLI 控制**:终端内嵌 ASCII 二维码,微信 / 企业微信扫码登录;`status` / `refresh` / `serve` 子命令完成全部管理。
|
||||
- **后台自动续期**:运行期间每 5 分钟检查 Token,距过期不足 15 分钟自动刷新并持久化到 `workbuddy.json`。
|
||||
- **多账号池 + 轮询负载均衡**:支持同时挂载多个 CodeBuddy 账号(`-auth` 逗号分隔或 `-auth-dir` 目录),请求按轮询(round-robin)均匀分配到各账号,保持多账号额度使用一致。
|
||||
- **429 频率限制自动冷却**:任一账号触发上游频率限制(HTTP 429 / code 6004)时,自动解析消息中的重置时间(如 `将在 2026-09-04 07:48:15 UTC+8 重置`),将该账号屏蔽至重置时间;冷却期间自动改用其他账号代偿,冷却到期自动恢复。
|
||||
- **授权失效自动禁用**:账号授权过期、被撤销或令牌刷新失败(HTTP 401/403 / invalid token / 登录已过期)时,自动将该账号**禁止调度并删除凭据文件**,同时写入持久化失效标记;`status` / 启动日志会明确提示该账号失效原因与重新登录命令,重新 `login` 后自动恢复调度。
|
||||
- **后台自动续期**:运行期间每 5 分钟检查所有账号 Token,距过期不足 15 分钟自动刷新并持久化回各自凭据文件。
|
||||
- **OpenAI 兼容协议**:`POST /v1/chat/completions`(SSE 流式 + 非流式聚合)、`GET /v1/models`、`GET /health`。
|
||||
- **深度思考透传规则**:仅当客户端显式请求 `reasoning_effort` 时转发;绝不强制注入,避免触发上游内容安全策略。
|
||||
- **反审查净化**:自动改写 Claude Code 等框架被上游逐字拉黑的固定 Prompt 语句。
|
||||
- **单并发串行化**:同一账号请求自动排队,避免并发双发触发上游风控。
|
||||
- **单账号串行化**:同一账号请求自动排队,避免并发双发触发上游风控;不同账号之间可并行。
|
||||
|
||||
## 快速上手
|
||||
|
||||
@@ -53,6 +57,79 @@ workbuddy-gateway serve -proxy http://127.0.0.1:7890
|
||||
workbuddy-gateway serve -api-key sk-localsecret
|
||||
```
|
||||
|
||||
## 多账号池与 429 自动冷却
|
||||
|
||||
### 配置多个账号
|
||||
|
||||
网关支持同时挂载多个 CodeBuddy 账号,请求按**轮询(round-robin)**方式均匀分发,保持各账号额度消耗一致。两种配置方式:
|
||||
|
||||
```bash
|
||||
# 方式一:-auth 逗号分隔多个凭据文件
|
||||
workbuddy-gateway serve -auth workbuddy.json,workbuddy-2.json,workbuddy-3.json
|
||||
|
||||
# 方式二:-auth-dir 指定凭据目录(自动加载目录下所有 workbuddy*.json)
|
||||
mkdir -p auths
|
||||
workbuddy-gateway login -auth auths/workbuddy-1.json # 依次为每个账号扫码登录
|
||||
workbuddy-gateway login -auth auths/workbuddy-2.json
|
||||
workbuddy-gateway serve -auth-dir ./auths
|
||||
```
|
||||
|
||||
### 429 频率限制自动冷却
|
||||
|
||||
当某个账号触发上游频率限制(HTTP 429,消息形如 `您的使用量已超出频率限制,将在 2026-09-04 07:48:15 UTC+8 重置`)时,网关会:
|
||||
|
||||
1. **自动解析消息中的重置时间**,立即将该账号屏蔽(冷却)至该时间点;
|
||||
2. **自动改用下一个可用账号重试**当前请求(代偿),无需客户端干预;
|
||||
3. 冷却期间该账号不参与轮询,**冷却到期后自动恢复**;
|
||||
4. 若消息中无法解析重置时间,默认冷却 60 秒后重试;
|
||||
5. 当所有账号均处于冷却状态时,返回 HTTP 429 并附上最早解封时间。
|
||||
|
||||
```bash
|
||||
# 查看各账号状态(含冷却状态与解封时间)
|
||||
workbuddy-gateway status
|
||||
|
||||
# 手动刷新所有账号令牌
|
||||
workbuddy-gateway refresh
|
||||
```
|
||||
|
||||
### 授权失效自动禁用
|
||||
|
||||
当账号出现以下任一情况时,网关会**自动禁用该账号调度并删除其凭据文件**:
|
||||
|
||||
- 令牌刷新失败且返回授权类错误(HTTP 401/403、`invalid token`、`unauthorized`、`登录已过期` 等);
|
||||
- 上游请求返回 401/403(token 被撤销或已过期);
|
||||
- 凭据缺少 RefreshToken 且已无法刷新。
|
||||
|
||||
处理流程:
|
||||
|
||||
1. 将该账号标记为 **❌ 授权失效**,立即移出轮询调度;
|
||||
2. **删除对应的凭据文件**(如 `workbuddy.json`),并写入持久化失效标记(`workbuddy.json.disabled`);
|
||||
3. 控制台(`status` / 启动日志 / 运行日志)明确显示失效原因,并给出重新登录命令;
|
||||
4. 失效期间其余账号正常代偿;**重新执行 login 后自动清除失效标记并恢复调度**。
|
||||
|
||||
```bash
|
||||
# 查看失效账号与原因
|
||||
workbuddy-gateway status
|
||||
# 输出示例:
|
||||
# --- 账号 #1 ---
|
||||
# 凭据文件: workbuddy-2.json
|
||||
# 用户昵称: tester
|
||||
# 用户 UID: uid-xxx
|
||||
# 账号状态: ❌ 授权失效(禁止调度)
|
||||
# 失效原因: 令牌刷新失败 (HTTP 401): invalid token
|
||||
# 处理建议: ⚠️ 凭据文件已删除,请重新执行: workbuddy-gateway login -auth workbuddy-2.json
|
||||
|
||||
# 失效账号重新登录后自动恢复
|
||||
workbuddy-gateway login -auth workbuddy-2.json
|
||||
workbuddy-gateway status # 该账号恢复为 ✅ 可用
|
||||
```
|
||||
|
||||
### 与单账号模式的兼容性
|
||||
|
||||
- 不传 `-auth` 时默认使用 `./workbuddy.json`,行为与旧版完全一致;
|
||||
- 只有一个账号时,请求始终使用该账号,429 冷却逻辑同样生效(冷却期间请求将返回 429 提示);
|
||||
- 账号之间使用独立的串行锁:同一账号请求严格排队,不同账号可并行,兼顾风控与吞吐。
|
||||
|
||||
## 客户端接入
|
||||
|
||||
网关启动后服务地址为 `http://127.0.0.1:8317/v1`。
|
||||
@@ -95,7 +172,7 @@ llm-pi-ai:
|
||||
|
||||
### Windows
|
||||
|
||||
1. 下载 `dist/workbuddy-gateway-windows-amd64.exe`。
|
||||
1. 从 [Releases](https://github.com/CangShui/workbuddy-gateway/releases) 下载 `workbuddy-gateway-windows-amd64.exe`。
|
||||
2. 在 PowerShell / CMD 中进入文件所在目录:
|
||||
```powershell
|
||||
.\workbuddy-gateway-windows-amd64.exe login
|
||||
@@ -105,11 +182,13 @@ llm-pi-ai:
|
||||
|
||||
### Linux (amd64 / arm64)
|
||||
|
||||
1. 下载对应架构二进制,赋执行权限并放入 PATH:
|
||||
1. 从 [Releases](https://github.com/CangShui/workbuddy-gateway/releases) 下载对应架构二进制,赋执行权限并放入 PATH:
|
||||
```bash
|
||||
# x86_64
|
||||
wget https://github.com/CangShui/workbuddy-gateway/releases/latest/download/workbuddy-gateway-linux-amd64
|
||||
sudo install -m 755 workbuddy-gateway-linux-amd64 /usr/local/bin/workbuddy-gateway
|
||||
# ARM64 (树莓派 / 飞腾 / Apple silicon 云主机等)
|
||||
wget https://github.com/CangShui/workbuddy-gateway/releases/latest/download/workbuddy-gateway-linux-arm64
|
||||
sudo install -m 755 workbuddy-gateway-linux-arm64 /usr/local/bin/workbuddy-gateway
|
||||
```
|
||||
2. 登录并启动:
|
||||
@@ -178,7 +257,7 @@ ExecStart=/opt/workbuddy-gateway/workbuddy-gateway serve -addr 0.0.0.0 -port 831
|
||||
|
||||
### macOS (Apple Silicon / Intel)
|
||||
|
||||
1. 下载 `workbuddy-gateway-darwin-arm64`(M 系列)或 `workbuddy-gateway-darwin-amd64`(Intel)。
|
||||
1. 从 [Releases](https://github.com/CangShui/workbuddy-gateway/releases) 下载 `workbuddy-gateway-darwin-arm64`(M 系列)或 `workbuddy-gateway-darwin-amd64`(Intel)。
|
||||
2. 首次运行需移除隔离属性:
|
||||
```bash
|
||||
chmod +x workbuddy-gateway-darwin-arm64
|
||||
|
||||
@@ -16,6 +16,8 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -27,7 +29,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
version = "1.1.0"
|
||||
version = "1.3.0"
|
||||
upstreamBase = "https://copilot.tencent.com"
|
||||
clientUA = "CLI/2.143.1 CodeBuddy/2.143.1"
|
||||
originReferer = "https://www.codebuddy.cn"
|
||||
@@ -96,18 +98,49 @@ type Config struct {
|
||||
Addr string
|
||||
Port int
|
||||
AuthFile string
|
||||
AuthDir string
|
||||
APIKey string
|
||||
ProxyURL string
|
||||
Verbose bool
|
||||
HttpClient *http.Client
|
||||
}
|
||||
|
||||
// Account 表示一个 CodeBuddy 账号凭据及其运行时状态。
|
||||
type Account struct {
|
||||
Path string // 凭据文件路径
|
||||
Auth *StoredAuth // 凭据内容(失效标记恢复时可能为 nil)
|
||||
CooldownUntil time.Time // 冷却截止时间(429 频率限制后自动屏蔽到该时间)
|
||||
CooldownMsg string // 触发冷却的原因/上游提示
|
||||
Disabled bool // 授权失效(token 过期且无法刷新/已撤销):禁止调度
|
||||
DisabledReason string // 失效原因,用于控制台展示
|
||||
Nickname string // 失效标记持久化字段:昵称(Auth 为 nil 时使用)
|
||||
UID string // 失效标记持久化字段:UID
|
||||
lock sync.Mutex // 单账号串行锁(防止同账号并发触发 11128)
|
||||
}
|
||||
|
||||
// disabledMarker 是授权失效账号的持久化标记(凭据文件删除后用于控制台提示重新登录)。
|
||||
type disabledMarker struct {
|
||||
Path string `json:"path"`
|
||||
Reason string `json:"reason"`
|
||||
DisabledAt int64 `json:"disabledAt"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
}
|
||||
|
||||
// markerPath 返回与凭据文件同目录的失效标记文件路径。
|
||||
func markerPath(authPath string) string {
|
||||
return authPath + ".disabled"
|
||||
}
|
||||
|
||||
var (
|
||||
cfg Config
|
||||
authLock sync.RWMutex
|
||||
currAuth *StoredAuth
|
||||
reqCounter uint64
|
||||
upstreamMutex sync.Mutex // 限制同一账号到腾讯上游的单并发排队,杜绝触发 11128 风控
|
||||
cfg Config
|
||||
authLock sync.RWMutex
|
||||
currAuth *StoredAuth
|
||||
reqCounter uint64
|
||||
|
||||
accountMu sync.Mutex // 账号池保护锁
|
||||
accounts []*Account // 多账号池(单账号时长度为 1,行为与旧版完全一致)
|
||||
rrIndex int // 轮询游标
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -137,7 +170,8 @@ func main() {
|
||||
fs := flag.NewFlagSet(command, flag.ExitOnError)
|
||||
fs.StringVar(&cfg.Addr, "addr", "127.0.0.1", "网关监听地址")
|
||||
fs.IntVar(&cfg.Port, "port", 8317, "网关监听端口")
|
||||
fs.StringVar(&cfg.AuthFile, "auth", "workbuddy.json", "凭据存储文件路径")
|
||||
fs.StringVar(&cfg.AuthFile, "auth", "workbuddy.json", "凭据存储文件路径(支持逗号分隔多个文件实现多账号)")
|
||||
fs.StringVar(&cfg.AuthDir, "auth-dir", "", "凭据目录:自动加载目录下所有 workbuddy*.json 作为多账号池")
|
||||
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, "输出详细调试日志")
|
||||
@@ -167,6 +201,8 @@ func main() {
|
||||
func printHelp() {
|
||||
fmt.Println(`WorkBuddy Local Gateway - 轻量化跨平台本地 AI 网关
|
||||
将腾讯 CodeBuddy / 混元反代为标准 OpenAI 协议,支持直接透传任意模型到上游。
|
||||
支持多账号池:轮询使用多个账号均衡额度;账号触发 429 频率限制后自动冷却屏蔽,
|
||||
由其余账号代偿,冷却到期自动恢复。
|
||||
|
||||
用法:
|
||||
workbuddy-gateway [command] [options]
|
||||
@@ -174,19 +210,31 @@ func printHelp() {
|
||||
命令:
|
||||
serve 启动本地网关 (默认操作)
|
||||
login 微信/企业微信扫码登录,获取/更新凭据
|
||||
status 查看当前账号凭据状态与过期时间
|
||||
refresh 手动立即刷新访问令牌 (Access Token)
|
||||
status 查看账号池状态(含冷却状态与过期时间)
|
||||
refresh 手动立即刷新所有账号访问令牌 (Access Token)
|
||||
version 查看版本信息
|
||||
help 查看帮助说明
|
||||
|
||||
参数选项:
|
||||
-addr <ip> 网关监听地址 (默认: 127.0.0.1)
|
||||
-port <port> 网关监听端口 (默认: 8317)
|
||||
-auth <path> 凭据存储文件路径 (默认: ./workbuddy.json)
|
||||
-auth <path> 凭据文件路径;支持逗号分隔多个文件实现多账号
|
||||
(默认: ./workbuddy.json)
|
||||
-auth-dir <dir> 凭据目录:自动加载目录下所有 workbuddy*.json 作为账号池
|
||||
-api-key <key> 设置后,调用网关必须携带 Bearer <key> 鉴权
|
||||
-proxy <url> 设置上游转发代理 (例如 http://127.0.0.1:7890 或 socks5://...)
|
||||
-verbose 输出详细调试日志 (请求/响应体)
|
||||
|
||||
多账号说明:
|
||||
# 登录第二个账号(保存到不同文件)
|
||||
workbuddy-gateway login -auth workbuddy-2.json
|
||||
|
||||
# 启动时指定多个凭据文件(轮询 + 429 自动冷却代偿)
|
||||
workbuddy-gateway serve -auth workbuddy.json,workbuddy-2.json
|
||||
|
||||
# 或使用目录模式:目录内所有 workbuddy*.json 自动组成账号池
|
||||
workbuddy-gateway serve -auth-dir ./auths
|
||||
|
||||
示例:
|
||||
# 首次使用扫码登录
|
||||
workbuddy-gateway login
|
||||
@@ -263,7 +311,326 @@ func saveAuth(sa *StoredAuth) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(cfg.AuthFile, data, 0600)
|
||||
if err := os.WriteFile(cfg.AuthFile, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
clearDisabledMarker(cfg.AuthFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveAuthTo 将凭据写入指定路径(多账号模式使用)。
|
||||
// 写入成功后清除该路径的失效标记(表示账号已重新登录)。
|
||||
func saveAuthTo(path string, sa *StoredAuth) error {
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "" && dir != "." {
|
||||
_ = os.MkdirAll(dir, 0755)
|
||||
}
|
||||
data, err := json.MarshalIndent(sa, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
clearDisabledMarker(path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadAccountFile 从指定路径读取并解析凭据(不修改全局缓存)。
|
||||
func loadAccountFile(path string) (*StoredAuth, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取凭据文件失败 (%s): %w", path, err)
|
||||
}
|
||||
var sa StoredAuth
|
||||
if err := json.Unmarshal(data, &sa); err != nil {
|
||||
return nil, fmt.Errorf("解析凭据文件失败 (%s): %w", path, err)
|
||||
}
|
||||
if sa.Auth.AccessToken == "" {
|
||||
return nil, fmt.Errorf("凭据文件缺少 AccessToken (%s)", path)
|
||||
}
|
||||
return &sa, nil
|
||||
}
|
||||
|
||||
// loadAccounts 根据 -auth / -auth-dir 配置构建账号池。
|
||||
// -auth 支持逗号分隔多个凭据文件;-auth-dir 自动加载目录下所有 workbuddy*.json。
|
||||
// 单账号时池长度为 1,行为与旧版完全一致。
|
||||
func loadAccounts() error {
|
||||
accountMu.Lock()
|
||||
defer accountMu.Unlock()
|
||||
|
||||
accounts = nil
|
||||
rrIndex = 0
|
||||
|
||||
var paths []string
|
||||
if cfg.AuthDir != "" {
|
||||
entries, err := os.ReadDir(cfg.AuthDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取凭据目录失败 (%s): %w", cfg.AuthDir, err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, "workbuddy") && strings.HasSuffix(name, ".json") {
|
||||
paths = append(paths, filepath.Join(cfg.AuthDir, name))
|
||||
}
|
||||
}
|
||||
sort.Strings(paths)
|
||||
} else {
|
||||
for _, p := range strings.Split(cfg.AuthFile, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
sa, err := loadAccountFile(p)
|
||||
if err != nil {
|
||||
log.Printf("[Auth] 跳过无效凭据文件 %s: %v", p, err)
|
||||
continue
|
||||
}
|
||||
accounts = append(accounts, &Account{Path: p, Auth: sa})
|
||||
}
|
||||
|
||||
// 恢复持久化的失效账号标记(凭据文件已删除,仅供控制台提示重新登录)
|
||||
loadDisabledMarkers()
|
||||
|
||||
if len(accounts) == 0 {
|
||||
return fmt.Errorf("未找到有效凭据,请先执行 login 命令扫码登录")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nextAccount 轮询选择下一个未处于冷却状态且未被禁用的账号。
|
||||
// 若全部账号不可用,返回错误并附带说明。
|
||||
func nextAccount() (*Account, error) {
|
||||
accountMu.Lock()
|
||||
defer accountMu.Unlock()
|
||||
|
||||
if len(accounts) == 0 {
|
||||
return nil, fmt.Errorf("账号池为空")
|
||||
}
|
||||
now := time.Now()
|
||||
disabledCount := 0
|
||||
cooldownCount := 0
|
||||
for i := 0; i < len(accounts); i++ {
|
||||
idx := (rrIndex + i) % len(accounts)
|
||||
acc := accounts[idx]
|
||||
if acc.Disabled {
|
||||
disabledCount++
|
||||
continue
|
||||
}
|
||||
if acc.CooldownUntil.After(now) {
|
||||
cooldownCount++
|
||||
continue
|
||||
}
|
||||
rrIndex = (idx + 1) % len(accounts)
|
||||
return acc, nil
|
||||
}
|
||||
// 全部不可用:优先报告失效账号
|
||||
for _, a := range accounts {
|
||||
if a.Disabled {
|
||||
return nil, fmt.Errorf("账号 %s 已失效(授权过期或已撤销,凭据文件已删除),请重新执行 login 登录", a.Path)
|
||||
}
|
||||
}
|
||||
// 全部冷却:返回最早解封时间
|
||||
earliest := accounts[0].CooldownUntil
|
||||
for _, a := range accounts {
|
||||
if a.CooldownUntil.Before(earliest) {
|
||||
earliest = a.CooldownUntil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("所有账号均处于冷却状态,最早解封时间: %s", earliest.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
// markCooldown 将账号屏蔽至指定时间。
|
||||
func markCooldown(acc *Account, until time.Time, msg string) {
|
||||
accountMu.Lock()
|
||||
acc.CooldownUntil = until
|
||||
acc.CooldownMsg = msg
|
||||
accountMu.Unlock()
|
||||
log.Printf("[Cooldown] 账号 %s 触发频率限制,自动屏蔽至 %s (提示: %s)",
|
||||
acc.Path, until.Format("2006-01-02 15:04:05"), msg)
|
||||
}
|
||||
|
||||
// disableAccount 将账号标记为失效(授权过期/撤销),禁止调度并删除凭据文件,
|
||||
// 同时写入持久化失效标记,便于控制台提示用户重新登录。
|
||||
func disableAccount(acc *Account, reason string) {
|
||||
accountMu.Lock()
|
||||
acc.Disabled = true
|
||||
acc.DisabledReason = reason
|
||||
acc.CooldownUntil = time.Time{}
|
||||
path := acc.Path
|
||||
nickname := ""
|
||||
uid := ""
|
||||
if acc.Auth != nil {
|
||||
nickname = acc.Auth.Account.Nickname
|
||||
uid = acc.Auth.Account.UID
|
||||
acc.Nickname = nickname
|
||||
acc.UID = uid
|
||||
}
|
||||
accountMu.Unlock()
|
||||
|
||||
// 写持久化失效标记(凭据删除后仍能在 status 中提示重新登录)
|
||||
marker := disabledMarker{
|
||||
Path: path,
|
||||
Reason: reason,
|
||||
DisabledAt: time.Now().Unix(),
|
||||
Nickname: nickname,
|
||||
UID: uid,
|
||||
}
|
||||
if data, err := json.MarshalIndent(marker, "", " "); err == nil {
|
||||
if werr := os.WriteFile(markerPath(path), data, 0600); werr != nil {
|
||||
log.Printf("[Auth] 账号 %s 失效标记写入失败: %v", path, werr)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除失效的凭据文件,方便用户下次重新登录
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[Auth] 账号 %s 凭据文件删除失败: %v", path, err)
|
||||
}
|
||||
log.Printf("[Auth] ⚠️ 账号 %s 授权失效,已禁止调度并删除凭据文件: %s", path, reason)
|
||||
}
|
||||
|
||||
// loadDisabledMarkers 扫描失效标记文件,将其恢复为账号池中的失效账号(Auth 为 nil)。
|
||||
func loadDisabledMarkers() {
|
||||
// 收集标记文件路径:
|
||||
// - AuthDir 模式:目录下 *.json.disabled 即为标记文件
|
||||
// - 单文件模式:<凭据路径>.disabled 为标记文件
|
||||
var markerPaths []string
|
||||
if cfg.AuthDir != "" {
|
||||
entries, err := os.ReadDir(cfg.AuthDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasSuffix(name, ".json.disabled") {
|
||||
markerPaths = append(markerPaths, filepath.Join(cfg.AuthDir, name))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, p := range strings.Split(cfg.AuthFile, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
markerPaths = append(markerPaths, markerPath(p))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, mp := range markerPaths {
|
||||
data, err := os.ReadFile(mp)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var m disabledMarker
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
continue
|
||||
}
|
||||
// 避免与已加载的有效账号重复
|
||||
dup := false
|
||||
for _, a := range accounts {
|
||||
if a.Path == m.Path {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if dup {
|
||||
continue
|
||||
}
|
||||
accounts = append(accounts, &Account{
|
||||
Path: m.Path,
|
||||
Disabled: true,
|
||||
DisabledReason: m.Reason,
|
||||
Nickname: m.Nickname,
|
||||
UID: m.UID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// clearDisabledMarker 在重新登录成功后清除失效标记。
|
||||
func clearDisabledMarker(path string) {
|
||||
mp := markerPath(path)
|
||||
if err := os.Remove(mp); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[Auth] 失效标记清除失败 (%s): %v", mp, err)
|
||||
}
|
||||
}
|
||||
|
||||
// isAuthFailure 判断上游响应是否为授权失效(401/403 / invalid token / 登录过期等)。
|
||||
func isAuthFailure(statusCode int, body string) bool {
|
||||
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
||||
return true
|
||||
}
|
||||
low := strings.ToLower(body)
|
||||
if strings.Contains(low, "invalid token") ||
|
||||
strings.Contains(low, "unauthorized") ||
|
||||
strings.Contains(low, "登录已过期") ||
|
||||
strings.Contains(low, "登录失效") ||
|
||||
strings.Contains(low, "token 已失效") ||
|
||||
strings.Contains(low, "authentication required") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var resetTimeRe = regexp.MustCompile(`将在\s*(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s*(UTC[+-]\d+(?::\d{2})?)?`)
|
||||
|
||||
// parseResetTime 从上游 429 错误消息中解析频率限制重置时间。
|
||||
// 示例: "您的使用量已超出频率限制,将在 2026-09-04 07:48:15 UTC+8 重置"
|
||||
func parseResetTime(s string) (time.Time, bool) {
|
||||
m := resetTimeRe.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
offset := 8 * 3600 // 默认按 UTC+8 解析
|
||||
if m[3] != "" {
|
||||
zone := m[3]
|
||||
zone = strings.TrimPrefix(zone, "UTC")
|
||||
zone = strings.TrimPrefix(zone, "utc")
|
||||
var h, mi int
|
||||
if _, err := fmt.Sscanf(zone, "%d:%d", &h, &mi); err == nil {
|
||||
offset = h*3600 + mi*60
|
||||
} else if _, err := fmt.Sscanf(zone, "%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)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
|
||||
// isRateLimited 判断上游响应是否属于频率限制(429 或 code 6004 / 频率限制提示)。
|
||||
func isRateLimited(statusCode int, body string) bool {
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(body, `"code":6004`) || strings.Contains(body, "频率限制") || strings.Contains(body, "frequency limit") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ensureValidTokenFor 对指定账号检查并刷新令牌(多账号版)。
|
||||
// 失效账号(Disabled 或 Auth 为 nil)直接跳过,不参与调度。
|
||||
func ensureValidTokenFor(acc *Account) error {
|
||||
if acc == nil || acc.Disabled || acc.Auth == nil {
|
||||
return nil
|
||||
}
|
||||
if time.Now().Unix() > acc.Auth.Auth.ExpiresAt-900 {
|
||||
log.Printf("[Auth] 账号 %s 访问令牌即将/已经过期,正在自动刷新...", acc.Path)
|
||||
return doRefreshTokenFor(acc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureValidToken() error {
|
||||
@@ -280,9 +647,42 @@ func ensureValidToken() error {
|
||||
}
|
||||
|
||||
func doRefreshToken(sa *StoredAuth) error {
|
||||
if sa.Auth.RefreshToken == "" {
|
||||
if sa == nil || sa.Auth.RefreshToken == "" {
|
||||
return fmt.Errorf("无法刷新:缺少 RefreshToken")
|
||||
}
|
||||
if _, err := refreshTokenPayload(sa); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := saveAuth(sa); err != nil {
|
||||
return fmt.Errorf("写回凭据失败: %w", err)
|
||||
}
|
||||
log.Printf("[Auth] Token 刷新成功!新过期时间: %s", time.Unix(sa.Auth.ExpiresAt, 0).Format("2006-01-02 15:04:05"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// doRefreshTokenFor 刷新指定账号的令牌并保存回其凭据文件(多账号版)。
|
||||
// 若刷新因授权失效失败(401/403/refresh token 无效),自动禁用该账号并删除凭据文件。
|
||||
func doRefreshTokenFor(acc *Account) error {
|
||||
if acc == nil || acc.Disabled || acc.Auth == nil || acc.Auth.Auth.RefreshToken == "" {
|
||||
return fmt.Errorf("无法刷新:账号缺少 RefreshToken 或已失效")
|
||||
}
|
||||
status, err := refreshTokenPayload(acc.Auth)
|
||||
if err != nil {
|
||||
if isAuthFailure(status, err.Error()) {
|
||||
disableAccount(acc, fmt.Sprintf("令牌刷新失败 (HTTP %d): %v", status, err))
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := saveAuthTo(acc.Path, acc.Auth); err != nil {
|
||||
return fmt.Errorf("写回凭据失败: %w", err)
|
||||
}
|
||||
log.Printf("[Auth] 账号 %s Token 刷新成功!新过期时间: %s", acc.Path, time.Unix(acc.Auth.Auth.ExpiresAt, 0).Format("2006-01-02 15:04:05"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshTokenPayload 调用上游刷新接口并更新内存中的令牌字段(不落盘)。
|
||||
// 返回上游 HTTP 状态码(成功或失败时均为实际状态;网络错误为 0)。
|
||||
func refreshTokenPayload(sa *StoredAuth) (int, error) {
|
||||
headers := func(r *http.Request) {
|
||||
commonHeaders(r)
|
||||
r.Header.Set("X-Refresh-Token", sa.Auth.RefreshToken)
|
||||
@@ -294,11 +694,11 @@ func doRefreshToken(sa *StoredAuth) error {
|
||||
|
||||
data, status, err := doJSON(cfg.HttpClient, http.MethodPost, endpointTokenRefresh, headers, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("上游刷新拒绝 (HTTP %d): %w", status, err)
|
||||
return status, fmt.Errorf("上游刷新拒绝 (HTTP %d): %w", status, err)
|
||||
}
|
||||
var tok tokenData
|
||||
if err := json.Unmarshal(data, &tok); err != nil || tok.AccessToken == "" {
|
||||
return fmt.Errorf("解析新 Token 失败: %w", err)
|
||||
return status, fmt.Errorf("解析新 Token 失败: %w", err)
|
||||
}
|
||||
|
||||
sa.Auth.AccessToken = tok.AccessToken
|
||||
@@ -311,12 +711,7 @@ func doRefreshToken(sa *StoredAuth) error {
|
||||
if tok.ExpiresIn > 0 {
|
||||
sa.Auth.ExpiresAt = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix()
|
||||
}
|
||||
|
||||
if err := saveAuth(sa); err != nil {
|
||||
return fmt.Errorf("写回凭据失败: %w", err)
|
||||
}
|
||||
log.Printf("[Auth] Token 刷新成功!新过期时间: %s", time.Unix(sa.Auth.ExpiresAt, 0).Format("2006-01-02 15:04:05"))
|
||||
return nil
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -324,41 +719,96 @@ func doRefreshToken(sa *StoredAuth) error {
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func runStatus() {
|
||||
sa, err := loadAuth()
|
||||
if err != nil {
|
||||
if err := loadAccounts(); err != nil {
|
||||
fmt.Printf("未找到有效凭据: %v\n请先执行: workbuddy-gateway login 扫码登录。\n", err)
|
||||
return
|
||||
}
|
||||
expTime := time.Unix(sa.Auth.ExpiresAt, 0)
|
||||
remaining := time.Until(expTime)
|
||||
statusStr := "有效"
|
||||
if remaining <= 0 {
|
||||
statusStr = "已过期"
|
||||
}
|
||||
|
||||
fmt.Println("================== WorkBuddy 凭据状态 ==================")
|
||||
fmt.Printf("凭据存储文件: %s\n", cfg.AuthFile)
|
||||
fmt.Printf("用户昵称: %s\n", sa.Account.Nickname)
|
||||
fmt.Printf("用户 UID: %s\n", sa.Account.UID)
|
||||
fmt.Printf("企业 ID: %s\n", ifEmpty(sa.Account.EnterpriseID, "(个人账号)"))
|
||||
fmt.Printf("认证域名: %s\n", ifEmpty(sa.Auth.Domain, "www.codebuddy.cn"))
|
||||
fmt.Printf("Token 状态: %s\n", statusStr)
|
||||
fmt.Printf("过期时间: %s (剩余 %v)\n", expTime.Format("2006-01-02 15:04:05"), remaining.Round(time.Minute))
|
||||
fmt.Println("=======================================================")
|
||||
accountMu.Lock()
|
||||
defer accountMu.Unlock()
|
||||
|
||||
fmt.Println("================== WorkBuddy 账号池状态 ==================")
|
||||
fmt.Printf("账号总数: %d\n", len(accounts))
|
||||
now := time.Now()
|
||||
for i, acc := range accounts {
|
||||
fmt.Printf("\n--- 账号 #%d ---\n", i+1)
|
||||
fmt.Printf("凭据文件: %s\n", acc.Path)
|
||||
|
||||
if acc.Disabled {
|
||||
// 失效账号(Auth 可能为 nil:凭据文件已删除,信息来自失效标记)
|
||||
nickname := acc.Nickname
|
||||
uid := acc.UID
|
||||
if acc.Auth != nil {
|
||||
nickname = acc.Auth.Account.Nickname
|
||||
uid = acc.Auth.Account.UID
|
||||
}
|
||||
fmt.Printf("用户昵称: %s\n", ifEmpty(nickname, "(未知)"))
|
||||
fmt.Printf("用户 UID: %s\n", ifEmpty(uid, "(未知)"))
|
||||
fmt.Printf("账号状态: ❌ 授权失效(禁止调度)\n")
|
||||
fmt.Printf("失效原因: %s\n", truncate(acc.DisabledReason, 200))
|
||||
fmt.Printf("处理建议: ⚠️ 凭据文件已删除,请重新执行: workbuddy-gateway login -auth %s\n", acc.Path)
|
||||
continue
|
||||
}
|
||||
|
||||
if acc.Auth == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
expTime := time.Unix(acc.Auth.Auth.ExpiresAt, 0)
|
||||
remaining := time.Until(expTime)
|
||||
statusStr := "有效"
|
||||
if remaining <= 0 {
|
||||
statusStr = "已过期"
|
||||
}
|
||||
|
||||
fmt.Printf("用户昵称: %s\n", acc.Auth.Account.Nickname)
|
||||
fmt.Printf("用户 UID: %s\n", acc.Auth.Account.UID)
|
||||
fmt.Printf("企业 ID: %s\n", ifEmpty(acc.Auth.Account.EnterpriseID, "(个人账号)"))
|
||||
fmt.Printf("认证域名: %s\n", ifEmpty(acc.Auth.Auth.Domain, "www.codebuddy.cn"))
|
||||
if acc.CooldownUntil.After(now) {
|
||||
fmt.Printf("冷却状态: 🔒 冷却中 (解封: %s, 剩余 %v)\n",
|
||||
acc.CooldownUntil.Format("2006-01-02 15:04:05"),
|
||||
time.Until(acc.CooldownUntil).Round(time.Minute))
|
||||
fmt.Printf("冷却原因: %s\n", truncate(acc.CooldownMsg, 120))
|
||||
} else {
|
||||
fmt.Printf("冷却状态: ✅ 可用\n")
|
||||
}
|
||||
fmt.Printf("Token 状态: %s\n", statusStr)
|
||||
fmt.Printf("过期时间: %s (剩余 %v)\n", expTime.Format("2006-01-02 15:04:05"), remaining.Round(time.Minute))
|
||||
}
|
||||
fmt.Println("\n=======================================================")
|
||||
}
|
||||
|
||||
func runRefresh() {
|
||||
sa, err := loadAuth()
|
||||
if err != nil {
|
||||
if err := loadAccounts(); err != nil {
|
||||
fmt.Printf("读取凭据失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("正在向上游请求刷新令牌...")
|
||||
if err := doRefreshToken(sa); err != nil {
|
||||
fmt.Printf("刷新失败: %v\n", err)
|
||||
} else {
|
||||
fmt.Println("刷新成功并已持久化保存!")
|
||||
accountMu.Lock()
|
||||
defer accountMu.Unlock()
|
||||
if len(accounts) == 0 {
|
||||
fmt.Println("账号池为空")
|
||||
return
|
||||
}
|
||||
ok := 0
|
||||
fail := 0
|
||||
skipped := 0
|
||||
for i, acc := range accounts {
|
||||
fmt.Printf("正在刷新账号 #%d (%s)... ", i+1, acc.Path)
|
||||
if acc.Disabled || acc.Auth == nil {
|
||||
fmt.Printf("⏭️ 跳过(授权失效,请重新登录)\n")
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if err := doRefreshTokenFor(acc); err != nil {
|
||||
fmt.Printf("❌ %v\n", err)
|
||||
fail++
|
||||
} else {
|
||||
fmt.Println("✅")
|
||||
ok++
|
||||
}
|
||||
}
|
||||
fmt.Printf("\n刷新完成: 成功 %d 个,失败 %d 个,跳过 %d 个(授权失效)\n", ok, fail, skipped)
|
||||
}
|
||||
|
||||
func runLogin() {
|
||||
@@ -458,13 +908,32 @@ func runLogin() {
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func runServe() {
|
||||
sa, err := loadAuth()
|
||||
if err != nil {
|
||||
fmt.Printf("警告: 未检测到有效凭据 (%v)。\n请先执行: workbuddy-gateway login 扫码登录,或确保 %s 存在。\n\n", err, cfg.AuthFile)
|
||||
if err := loadAccounts(); err != nil {
|
||||
fmt.Printf("警告: 未检测到有效凭据 (%v)。\n请先执行: workbuddy-gateway login 扫码登录,或确保凭据文件存在。\n\n", err)
|
||||
} else {
|
||||
_ = ensureValidToken()
|
||||
fmt.Printf("已就绪账号: %s (UID: %s, 有效期至: %s)\n",
|
||||
sa.Account.Nickname, sa.Account.UID, time.Unix(sa.Auth.ExpiresAt, 0).Format("2006-01-02 15:04:05"))
|
||||
accountMu.Lock()
|
||||
accCount := len(accounts)
|
||||
activeCount := 0
|
||||
disabledCount := 0
|
||||
for _, acc := range accounts {
|
||||
if acc.Disabled || acc.Auth == nil {
|
||||
disabledCount++
|
||||
continue
|
||||
}
|
||||
activeCount++
|
||||
_ = ensureValidTokenFor(acc)
|
||||
}
|
||||
accountMu.Unlock()
|
||||
fmt.Printf("已就绪账号池: %d 个账号 (有效 %d, 失效 %d)\n", accCount, activeCount, disabledCount)
|
||||
for i, acc := range accounts {
|
||||
if acc.Disabled || acc.Auth == nil {
|
||||
fmt.Printf(" #%d ❌ %s (授权失效,凭据文件已删除,请重新登录)\n", i+1, acc.Path)
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" #%d ✅ %s (UID: %s, 有效期至: %s)\n",
|
||||
i+1, acc.Auth.Account.Nickname, acc.Auth.Account.UID,
|
||||
time.Unix(acc.Auth.Auth.ExpiresAt, 0).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
|
||||
// 启动后台自动刷新协程
|
||||
@@ -525,8 +994,14 @@ func backgroundTokenRefresher() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if err := ensureValidToken(); err != nil {
|
||||
log.Printf("[BackgroundAuth] 自动检查/续期令牌异常: %v", err)
|
||||
accountMu.Lock()
|
||||
accs := make([]*Account, len(accounts))
|
||||
copy(accs, accounts)
|
||||
accountMu.Unlock()
|
||||
for _, acc := range accs {
|
||||
if err := ensureValidTokenFor(acc); err != nil {
|
||||
log.Printf("[BackgroundAuth] 账号 %s 自动检查/续期令牌异常: %v", acc.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -575,13 +1050,6 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
reqID := atomic.AddUint64(&reqCounter, 1)
|
||||
startTime := time.Now()
|
||||
|
||||
sa, err := loadAuth()
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusUnauthorized, "no_auth", "未找到有效登录凭据,请先执行 login 命令扫码登录")
|
||||
return
|
||||
}
|
||||
_ = ensureValidToken()
|
||||
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusBadRequest, "read_error", "读取请求体失败")
|
||||
@@ -626,83 +1094,147 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[#%d] POST /v1/chat/completions -> Upstream [Model: %s, Stream: %v]", reqID, modelName, isStream)
|
||||
}
|
||||
|
||||
upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, endpointChat, bytes.NewReader(upstreamBytes))
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "req_create_error", err.Error())
|
||||
// 多账号轮询 + 429 自动冷却代偿:
|
||||
// 按轮询顺序尝试账号;命中 429 频率限制时立即将当前账号屏蔽到重置时间,
|
||||
// 并自动改用下一个可用账号重试(代偿),直至成功或所有账号均不可用。
|
||||
accountMu.Lock()
|
||||
poolSize := len(accounts)
|
||||
accountMu.Unlock()
|
||||
if poolSize == 0 {
|
||||
writeOpenAIError(w, http.StatusUnauthorized, "no_auth", "未找到有效登录凭据,请先执行 login 命令扫码登录")
|
||||
return
|
||||
}
|
||||
|
||||
// 注入 CodeBuddy 凭据与指纹 Header
|
||||
backendHeaders(upstreamReq, sa)
|
||||
|
||||
// 限制同一账号向腾讯上游的请求严格单并发串行排队,防止 DSH 同时发起标题生成+主对话触发腾讯 11128 风控
|
||||
upstreamMutex.Lock()
|
||||
defer upstreamMutex.Unlock()
|
||||
|
||||
resp, err := cfg.HttpClient.Do(upstreamReq)
|
||||
if err != nil {
|
||||
log.Printf("[#%d] 上游请求失败: %v", reqID, err)
|
||||
writeOpenAIError(w, http.StatusBadGateway, "upstream_network_error", fmt.Sprintf("网络转发失败: %v", err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 上游非 200 响应处理
|
||||
if resp.StatusCode >= 400 {
|
||||
errBody, _ := io.ReadAll(resp.Body)
|
||||
errStr := string(errBody)
|
||||
log.Printf("[#%d] 上游返回 HTTP %d: %s (耗时 %v)", reqID, resp.StatusCode, errStr, time.Since(startTime))
|
||||
writeOpenAIError(w, resp.StatusCode, "upstream_error", fmt.Sprintf("upstream %d: %s", resp.StatusCode, errStr))
|
||||
return
|
||||
}
|
||||
|
||||
if isStream {
|
||||
// 客户端需要流式响应
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "streaming_unsupported", "服务器不支持流式响应 Flush")
|
||||
return
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
cleanData := stripDataPrefix(line)
|
||||
if cleanData == "" {
|
||||
continue
|
||||
}
|
||||
if cleanData == "[DONE]" {
|
||||
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
break
|
||||
}
|
||||
cleanedChunk := cleanChunkJSON(cleanData)
|
||||
if cleanedChunk != "" {
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", cleanedChunk)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
log.Printf("[#%d] 流式输出完成 (耗时 %v)", reqID, time.Since(startTime))
|
||||
} else {
|
||||
// 客户端需要完整 JSON 响应,聚合 SSE 数据流
|
||||
completionJSON, err := aggregateCompletion(resp.Body, modelName)
|
||||
var lastRateErr string
|
||||
var lastAuthErr string
|
||||
for attempt := 0; attempt < poolSize; attempt++ {
|
||||
acc, err := nextAccount()
|
||||
if err != nil {
|
||||
log.Printf("[#%d] 聚合响应失败: %v", reqID, err)
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "aggregate_error", "聚合上游流式响应失败: "+err.Error())
|
||||
// 所有账号均不可用(冷却或失效)
|
||||
msg := fmt.Sprintf("无可用账号: %v", err)
|
||||
if lastAuthErr != "" {
|
||||
msg += " | 最近一次授权失效: " + truncate(lastAuthErr, 200)
|
||||
}
|
||||
if lastRateErr != "" {
|
||||
msg += " | 最近一次频率限制: " + truncate(lastRateErr, 200)
|
||||
}
|
||||
log.Printf("[#%d] %s", reqID, msg)
|
||||
writeOpenAIError(w, http.StatusServiceUnavailable, "no_available_account", msg)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(completionJSON)
|
||||
log.Printf("[#%d] 非流式响应完成 (耗时 %v)", reqID, time.Since(startTime))
|
||||
_ = ensureValidTokenFor(acc)
|
||||
// 令牌刷新时若发现授权失效会禁用账号;若被禁用则跳过换下一个
|
||||
accountMu.Lock()
|
||||
disabled := acc.Disabled
|
||||
accountMu.Unlock()
|
||||
if disabled {
|
||||
lastAuthErr = acc.DisabledReason
|
||||
continue
|
||||
}
|
||||
|
||||
upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, endpointChat, bytes.NewReader(upstreamBytes))
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "req_create_error", err.Error())
|
||||
return
|
||||
}
|
||||
// 注入 CodeBuddy 凭据与指纹 Header
|
||||
backendHeaders(upstreamReq, acc.Auth)
|
||||
|
||||
// 限制同一账号向腾讯上游的请求严格单并发串行排队,防止 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)
|
||||
writeOpenAIError(w, http.StatusBadGateway, "upstream_network_error", fmt.Sprintf("网络转发失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// 上游非 200 响应处理
|
||||
if resp.StatusCode >= 400 {
|
||||
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))
|
||||
|
||||
if isRateLimited(resp.StatusCode, errStr) {
|
||||
// 429 频率限制:解析重置时间并屏蔽该账号,交由其他账号代偿
|
||||
until, ok := parseResetTime(errStr)
|
||||
if !ok {
|
||||
until = time.Now().Add(60 * time.Second) // 无法解析时默认冷却 60 秒
|
||||
}
|
||||
markCooldown(acc, until, errStr)
|
||||
lastRateErr = errStr
|
||||
continue // 尝试下一个账号
|
||||
}
|
||||
|
||||
if isAuthFailure(resp.StatusCode, errStr) {
|
||||
// 授权失效(401/403 / token 无效 / 登录过期):禁用该账号并删除凭据文件,
|
||||
// 自动改用下一个可用账号,控制台提示用户重新登录
|
||||
disableAccount(acc, fmt.Sprintf("上游鉴权失败 (HTTP %d): %s", resp.StatusCode, truncate(errStr, 200)))
|
||||
lastAuthErr = errStr
|
||||
continue // 尝试下一个账号
|
||||
}
|
||||
|
||||
writeOpenAIError(w, resp.StatusCode, "upstream_error", fmt.Sprintf("upstream %d: %s", resp.StatusCode, errStr))
|
||||
return
|
||||
}
|
||||
|
||||
if isStream {
|
||||
// 客户端需要流式响应
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
resp.Body.Close()
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "streaming_unsupported", "服务器不支持流式响应 Flush")
|
||||
return
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
cleanData := stripDataPrefix(line)
|
||||
if cleanData == "" {
|
||||
continue
|
||||
}
|
||||
if cleanData == "[DONE]" {
|
||||
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
break
|
||||
}
|
||||
cleanedChunk := cleanChunkJSON(cleanData)
|
||||
if cleanedChunk != "" {
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\n", cleanedChunk)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
resp.Body.Close()
|
||||
log.Printf("[#%d] 流式输出完成 (账号 %s, 耗时 %v)", reqID, acc.Path, time.Since(startTime))
|
||||
} else {
|
||||
// 客户端需要完整 JSON 响应,聚合 SSE 数据流
|
||||
completionJSON, err := aggregateCompletion(resp.Body, modelName)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
log.Printf("[#%d] 聚合响应失败: %v", reqID, err)
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "aggregate_error", "聚合上游流式响应失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
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))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 理论上不可达(poolSize 次尝试后未成功即已在循环内返回)
|
||||
writeOpenAIError(w, http.StatusTooManyRequests, "all_accounts_cooldown", "所有账号均处于冷却状态")
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 验证 429 消息中的重置时间解析
|
||||
func TestParseResetTime(t *testing.T) {
|
||||
msg := `{"code":429,"message":"upstream 429: {\"code\":6004,\"msg\":\"您的使用量已超出频率限制,将在 2026-09-04 07:48:15 UTC+8 重置,您也可以切换其他模型继续使用。\",\"requestId\":\"149e3299-ddf3-4ad1-aaa8-cc752c37630a\"}","type":"upstream_error"}`
|
||||
got, ok := parseResetTime(msg)
|
||||
if !ok {
|
||||
t.Fatal("parseResetTime should succeed")
|
||||
}
|
||||
want := time.Date(2026, 9, 4, 7, 48, 15, 0, time.FixedZone("UTC+8", 8*3600))
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
t.Logf("parsed reset time: %v", got)
|
||||
}
|
||||
|
||||
// 验证无法解析时返回 false
|
||||
func TestParseResetTimeInvalid(t *testing.T) {
|
||||
if _, ok := parseResetTime("some random error"); ok {
|
||||
t.Fatal("should not parse random string")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证限流识别(429 状态码 / code 6004 / 频率限制关键词)
|
||||
func TestIsRateLimited(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{429, "{}", true},
|
||||
{400, `{"code":6004,"msg":"x"}`, true},
|
||||
{400, "频率限制", true},
|
||||
{400, "frequency limit", true},
|
||||
{400, "some other error", false},
|
||||
{500, "{}", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isRateLimited(c.status, c.body); got != c.want {
|
||||
t.Errorf("isRateLimited(%d, %q) = %v, want %v", c.status, c.body, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证轮询选择:两个账号交替返回
|
||||
func TestNextAccountRoundRobin(t *testing.T) {
|
||||
accountMu.Lock()
|
||||
accounts = []*Account{
|
||||
{Path: "a.json", Auth: &StoredAuth{}},
|
||||
{Path: "b.json", Auth: &StoredAuth{}},
|
||||
}
|
||||
rrIndex = 0
|
||||
accountMu.Unlock()
|
||||
|
||||
a1, _ := nextAccount()
|
||||
a2, _ := nextAccount()
|
||||
a3, _ := nextAccount()
|
||||
if a1.Path != "a.json" || a2.Path != "b.json" || a3.Path != "a.json" {
|
||||
t.Fatalf("round robin failed: %s %s %s", a1.Path, a2.Path, a3.Path)
|
||||
}
|
||||
t.Logf("round-robin order: %s %s %s", a1.Path, a2.Path, a3.Path)
|
||||
}
|
||||
|
||||
// 验证冷却屏蔽:冷却中的账号被跳过,由另一账号代偿
|
||||
func TestNextAccountSkipsCooldown(t *testing.T) {
|
||||
accountMu.Lock()
|
||||
accounts = []*Account{
|
||||
{Path: "a.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(time.Hour)},
|
||||
{Path: "b.json", Auth: &StoredAuth{}},
|
||||
}
|
||||
rrIndex = 0
|
||||
accountMu.Unlock()
|
||||
|
||||
a, _ := nextAccount()
|
||||
if a.Path != "b.json" {
|
||||
t.Fatalf("expected b.json (only non-cooldown), got %s", a.Path)
|
||||
}
|
||||
t.Logf("cooldown skip works, selected %s", a.Path)
|
||||
}
|
||||
|
||||
// 验证全部冷却时返回错误
|
||||
func TestNextAccountAllCooldown(t *testing.T) {
|
||||
accountMu.Lock()
|
||||
accounts = []*Account{
|
||||
{Path: "a.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(time.Hour)},
|
||||
{Path: "b.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(2 * time.Hour)},
|
||||
}
|
||||
rrIndex = 0
|
||||
accountMu.Unlock()
|
||||
|
||||
_, err := nextAccount()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when all accounts in cooldown")
|
||||
}
|
||||
t.Logf("all-cooldown error: %v", err)
|
||||
}
|
||||
|
||||
// 验证冷却到期后自动恢复
|
||||
func TestNextAccountCooldownExpiry(t *testing.T) {
|
||||
accountMu.Lock()
|
||||
accounts = []*Account{
|
||||
{Path: "a.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(-time.Minute)},
|
||||
{Path: "b.json", Auth: &StoredAuth{}, CooldownUntil: time.Now().Add(time.Hour)},
|
||||
}
|
||||
rrIndex = 0
|
||||
accountMu.Unlock()
|
||||
|
||||
a, _ := nextAccount()
|
||||
if a.Path != "a.json" {
|
||||
t.Fatalf("expected a.json (cooldown expired), got %s", a.Path)
|
||||
}
|
||||
t.Logf("expired cooldown recovers, selected %s", a.Path)
|
||||
}
|
||||
|
||||
// 验证授权失效识别(401/403 / invalid token / 登录过期等)
|
||||
func TestIsAuthFailure(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
body string
|
||||
want bool
|
||||
}{
|
||||
{401, "{}", true},
|
||||
{403, "{}", true},
|
||||
{400, `{"msg":"invalid token"}`, true},
|
||||
{400, "unauthorized", true},
|
||||
{400, "登录已过期", true},
|
||||
{400, "登录失效,请重新登录", true},
|
||||
{400, "some other error", false},
|
||||
{429, "频率限制", false},
|
||||
{500, "{}", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := isAuthFailure(c.status, c.body); got != c.want {
|
||||
t.Errorf("isAuthFailure(%d, %q) = %v, want %v", c.status, c.body, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验证轮询跳过已失效(Disabled)账号
|
||||
func TestNextAccountSkipsDisabled(t *testing.T) {
|
||||
accountMu.Lock()
|
||||
accounts = []*Account{
|
||||
{Path: "a.json", Auth: &StoredAuth{}, Disabled: true, DisabledReason: "revoked"},
|
||||
{Path: "b.json", Auth: &StoredAuth{}},
|
||||
}
|
||||
rrIndex = 0
|
||||
accountMu.Unlock()
|
||||
|
||||
a, _ := nextAccount()
|
||||
if a.Path != "b.json" {
|
||||
t.Fatalf("expected b.json (only non-disabled), got %s", a.Path)
|
||||
}
|
||||
t.Logf("disabled skip works, selected %s", a.Path)
|
||||
}
|
||||
|
||||
// 验证全部失效时返回错误并提示重新登录
|
||||
func TestNextAccountAllDisabled(t *testing.T) {
|
||||
accountMu.Lock()
|
||||
accounts = []*Account{
|
||||
{Path: "a.json", Auth: &StoredAuth{}, Disabled: true, DisabledReason: "expired"},
|
||||
{Path: "b.json", Auth: &StoredAuth{}, Disabled: true, DisabledReason: "revoked"},
|
||||
}
|
||||
rrIndex = 0
|
||||
accountMu.Unlock()
|
||||
|
||||
_, err := nextAccount()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when all accounts disabled")
|
||||
}
|
||||
t.Logf("all-disabled error: %v", err)
|
||||
}
|
||||
|
||||
// 验证 disableAccount:标记失效、删除凭据文件、写入失效标记文件
|
||||
func TestDisableAccount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
authPath := dir + "/workbuddy-test.json"
|
||||
// 写一个假的凭据文件
|
||||
if err := os.WriteFile(authPath, []byte(`{"auth":{"accessToken":"x"}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
acc := &Account{Path: authPath, Auth: &StoredAuth{
|
||||
Auth: StoredTokens{AccessToken: "x"},
|
||||
Account: StoredAccount{Nickname: "tester", UID: "uid-1"},
|
||||
}}
|
||||
|
||||
disableAccount(acc, "令牌刷新失败 (HTTP 401): invalid token")
|
||||
|
||||
accountMu.Lock()
|
||||
disabled := acc.Disabled
|
||||
reason := acc.DisabledReason
|
||||
accountMu.Unlock()
|
||||
if !disabled {
|
||||
t.Fatal("account should be marked disabled")
|
||||
}
|
||||
if reason == "" {
|
||||
t.Fatal("disabled reason should be recorded")
|
||||
}
|
||||
// 凭据文件应被删除
|
||||
if _, err := os.Stat(authPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("credential file should be deleted, stat err=%v", err)
|
||||
}
|
||||
// 失效标记文件应存在
|
||||
if _, err := os.Stat(markerPath(authPath)); err != nil {
|
||||
t.Fatalf("marker file should exist: %v", err)
|
||||
}
|
||||
t.Logf("disableAccount works: disabled=%v reason=%q", disabled, reason)
|
||||
}
|
||||
|
||||
// 验证失效标记文件可被恢复为失效账号(Auth 为 nil),并提示重新登录
|
||||
func TestLoadDisabledMarkers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
authPath := dir + "/workbuddy-test.json"
|
||||
|
||||
// 构造失效标记文件
|
||||
marker := disabledMarker{
|
||||
Path: authPath,
|
||||
Reason: "授权失效(令牌刷新失败 HTTP 401)",
|
||||
DisabledAt: time.Now().Unix(),
|
||||
Nickname: "tester",
|
||||
UID: "uid-1",
|
||||
}
|
||||
data, _ := json.Marshal(marker)
|
||||
if err := os.WriteFile(markerPath(authPath), data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 模拟 -auth 指定该路径
|
||||
oldAuthFile := cfg.AuthFile
|
||||
oldAuthDir := cfg.AuthDir
|
||||
defer func() {
|
||||
cfg.AuthFile = oldAuthFile
|
||||
cfg.AuthDir = oldAuthDir
|
||||
}()
|
||||
cfg.AuthFile = authPath
|
||||
cfg.AuthDir = ""
|
||||
|
||||
accounts = nil
|
||||
loadDisabledMarkers()
|
||||
|
||||
if len(accounts) != 1 {
|
||||
t.Fatalf("expected 1 disabled account, got %d", len(accounts))
|
||||
}
|
||||
acc := accounts[0]
|
||||
if !acc.Disabled || acc.Auth != nil {
|
||||
t.Fatalf("expected disabled account with nil Auth, got disabled=%v authNil=%v", acc.Disabled, acc.Auth == nil)
|
||||
}
|
||||
if acc.Nickname != "tester" || acc.UID != "uid-1" {
|
||||
t.Fatalf("marker nickname/uid not restored: %+v", acc)
|
||||
}
|
||||
t.Logf("loadDisabledMarkers restores: %s (nickname=%s)", acc.Path, acc.Nickname)
|
||||
}
|
||||
|
||||
// 验证登录成功后清除失效标记
|
||||
func TestClearDisabledMarker(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
authPath := dir + "/workbuddy-test.json"
|
||||
marker := disabledMarker{Path: authPath, Reason: "revoked"}
|
||||
data, _ := json.Marshal(marker)
|
||||
if err := os.WriteFile(markerPath(authPath), data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clearDisabledMarker(authPath)
|
||||
if _, err := os.Stat(markerPath(authPath)); !os.IsNotExist(err) {
|
||||
t.Fatalf("marker file should be removed, stat err=%v", err)
|
||||
}
|
||||
t.Log("clearDisabledMarker works")
|
||||
}
|
||||
Reference in New Issue
Block a user