From 169e65d8b2309a413dc9499fe13f79d9f62740bb Mon Sep 17 00:00:00 2001 From: CangShui Date: Fri, 4 Sep 2026 00:03:58 +0800 Subject: [PATCH] Initial release v1.1.0: WorkBuddy Local Gateway - Pure Go, zero CGO, single binary, cross-platform (Windows/Linux/macOS) - Full model passthrough to Tencent CodeBuddy upstream - CLI login with ASCII QR, auto token refresh - OpenAI-compatible API with SSE streaming + non-stream aggregation - Reasoning-effort passthrough only when client explicitly requests - No credentials, secrets, IPs or sensitive domains in repo --- .gitignore | 19 + README.md | 240 ++++++++++++ go.mod | 7 + go.sum | 4 + main.go | 1039 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1309 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dfb126 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# 凭据与敏感数据(切勿提交!) +workbuddy.json +workbuddy-console.json +dist/workbuddy.json +dist/*.json +last-*.json +*.json.bak + +# 本地编译产物 +workbuddy-gateway.exe +dist/*.exe +dist/workbuddy-gateway-linux-* +dist/workbuddy-gateway-darwin-* + +# 日志与临时文件 +*.log +*.tmp +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..c036465 --- /dev/null +++ b/README.md @@ -0,0 +1,240 @@ +# WorkBuddy Local Gateway + +基于腾讯 **CodeBuddy / 混元(Hunyuan)** 协议开发的**纯 Go、零 CGO 依赖、跨平台单二进制**本地 AI 代理网关。无 Web UI,仅通过命令行(CLI)完成扫码登录、凭据续期与服务控制。 + +## 核心特性 + +- **模型完全透传**:客户端(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`。 +- **OpenAI 兼容协议**:`POST /v1/chat/completions`(SSE 流式 + 非流式聚合)、`GET /v1/models`、`GET /health`。 +- **深度思考透传规则**:仅当客户端显式请求 `reasoning_effort` 时转发;绝不强制注入,避免触发上游内容安全策略。 +- **反审查净化**:自动改写 Claude Code 等框架被上游逐字拉黑的固定 Prompt 语句。 +- **单并发串行化**:同一账号请求自动排队,避免并发双发触发上游风控。 + +## 快速上手 + +### 1. 扫码登录 + +首次使用或凭据失效时执行(需微信 / 企业微信扫码): + +```bash +# Windows +.\workbuddy-gateway-windows-amd64.exe login + +# Linux / macOS +./workbuddy-gateway-linux-amd64 login +./workbuddy-gateway-darwin-arm64 login +``` + +终端将打印 ASCII 二维码与浏览器直达链接,扫码后凭据自动保存到当前目录 `workbuddy.json`(请勿提交到代码仓库)。 + +> 提示:建议在 **CodeBuddy 控制台(网页端)** 扫码登录获取更高权限评级的 Token;若使用 `login` 命令扫码,账号渠道可能受限(`azp=invite`)。 + +### 2. 查看凭据状态 + +```bash +workbuddy-gateway status +``` + +### 3. 启动本地网关 + +```bash +# 默认监听 127.0.0.1:8317 +workbuddy-gateway serve + +# 自定义端口 / 地址 / 详细日志 +workbuddy-gateway serve -port 8317 -verbose + +# 通过出口代理转发(可选,降低上游风控概率) +workbuddy-gateway serve -proxy http://127.0.0.1:7890 + +# 开启客户端鉴权 +workbuddy-gateway serve -api-key sk-localsecret +``` + +## 客户端接入 + +网关启动后服务地址为 `http://127.0.0.1:8317/v1`。 + +```bash +curl -N -s http://127.0.0.1:8317/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d '{"model":"hy4-preview","messages":[{"role":"user","content":"你好"}],"stream":true}' +``` + +Python OpenAI SDK: + +```python +from openai import OpenAI + +client = OpenAI(base_url="http://127.0.0.1:8317/v1", api_key="none") +resp = client.chat.completions.create( + model="hy4-preview", + messages=[{"role": "user", "content": "写一个快速排序"}], +) +print(resp.choices[0].message.content) +``` + +DSH (`~/.dsh/settings.yaml`): + +```yaml +llm-pi-ai: + providers: + workbuddy-local: + baseURL: http://127.0.0.1:8317/v1 + apiKeyEnv: LOCAL_API_KEY # 任意字符串即可 + api: openai-completions + models: + - id: hy4-preview + contextWindow: 1000000 + maxTokens: 128000 +``` + +## 各平台使用方法 + +### Windows + +1. 下载 `dist/workbuddy-gateway-windows-amd64.exe`。 +2. 在 PowerShell / CMD 中进入文件所在目录: + ```powershell + .\workbuddy-gateway-windows-amd64.exe login + .\workbuddy-gateway-windows-amd64.exe serve -port 8317 + ``` +3. 如需开机自启:`Win+R` → `shell:startup`,将 exe 的快捷方式放入启动文件夹即可(命令行加 `serve` 参数需通过快捷方式"目标"追加)。 + +### Linux (amd64 / arm64) + +1. 下载对应架构二进制,赋执行权限并放入 PATH: + ```bash + # x86_64 + sudo install -m 755 workbuddy-gateway-linux-amd64 /usr/local/bin/workbuddy-gateway + # ARM64 (树莓派 / 飞腾 / Apple silicon 云主机等) + sudo install -m 755 workbuddy-gateway-linux-arm64 /usr/local/bin/workbuddy-gateway + ``` +2. 登录并启动: + ```bash + workbuddy-gateway login # 终端二维码扫码(可用 tmux 保持) + workbuddy-gateway serve -addr 127.0.0.1 -port 8317 + ``` + +#### Linux 注册为 systemd 服务(推荐,开机自启 + 崩溃自动拉起) + +创建 `/etc/systemd/system/workbuddy-gateway.service`: + +```ini +[Unit] +Description=WorkBuddy Local Gateway (CodeBuddy/Hunyuan OpenAI-compatible proxy) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +# 二进制与 workbuddy.json 所在目录;请按实际部署路径修改 +WorkingDirectory=/opt/workbuddy-gateway +ExecStart=/opt/workbuddy-gateway/workbuddy-gateway serve -addr 127.0.0.1 -port 8317 +Restart=on-failure +RestartSec=5 +User=root +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=false + +[Install] +WantedBy=multi-user.target +``` + +部署文件: + +```bash +sudo mkdir -p /opt/workbuddy-gateway +sudo cp workbuddy-gateway /opt/workbuddy-gateway/ # 对应架构的二进制 +# 首次登录(会生成 workbuddy.json) +sudo /opt/workbuddy-gateway/workbuddy-gateway login +``` + +启用并启动服务: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now workbuddy-gateway +sudo systemctl status workbuddy-gateway # 查看状态 +sudo journalctl -u workbuddy-gateway -f # 查看实时日志 +``` + +常用运维命令: + +```bash +sudo systemctl restart workbuddy-gateway # 重启(如更换凭据后) +sudo systemctl stop workbuddy-gateway # 停止 +sudo systemctl disable workbuddy-gateway # 取消开机自启 +``` + +如需对外开放(例如给局域网其他设备使用),将 `-addr` 改为 `0.0.0.0`,**并务必**配合 `-api-key` 设置访问密钥: + +```bash +ExecStart=/opt/workbuddy-gateway/workbuddy-gateway serve -addr 0.0.0.0 -port 8317 -api-key sk-changeme +``` + +### macOS (Apple Silicon / Intel) + +1. 下载 `workbuddy-gateway-darwin-arm64`(M 系列)或 `workbuddy-gateway-darwin-amd64`(Intel)。 +2. 首次运行需移除隔离属性: + ```bash + chmod +x workbuddy-gateway-darwin-arm64 + xattr -d com.apple.quarantine workbuddy-gateway-darwin-arm64 2>/dev/null || true + ``` +3. 登录与启动: + ```bash + ./workbuddy-gateway-darwin-arm64 login + ./workbuddy-gateway-darwin-arm64 serve + ``` +4. 开机自启(launchd):创建 `~/Library/LaunchAgents/com.workbuddy.gateway.plist`: + ```xml + + + + + Labelcom.workbuddy.gateway + ProgramArguments + + /path/to/workbuddy-gateway-darwin-arm64 + serve + -port8317 + + RunAtLoad + KeepAlive + WorkingDirectory/path/to/workbuddy-gateway-dir + + + ``` + ```bash + launchctl load ~/Library/LaunchAgents/com.workbuddy.gateway.plist + ``` + +## 安全提示 + +- `workbuddy.json` 包含真实 CodeBuddy 访问凭据(Access Token / Refresh Token),**严禁提交到 Git 仓库或公开分享**;本仓库 `.gitignore` 已将其排除。 +- 网关默认只监听 `127.0.0.1`。需要局域网 / 公网访问时请改用 `-addr 0.0.0.0` 并配合 `-api-key` 鉴权,或置于反向代理(如 nginx)之后。 +- 若不再需要某账号的授权,请删除对应 `workbuddy.json` 并在 CodeBuddy 控制台撤销应用授权。 + +## 命令行速查 + +``` +命令: serve | login | status | refresh | version | help +选项: -addr · -port · -auth · -api-key · -proxy · -verbose +``` + +## 从源码构建 + +需要 Go 1.20+: + +```bash +git clone https://github.com/CangShui/workbuddy-gateway.git +cd workbuddy-gateway +CGO_ENABLED=0 go build -ldflags="-s -w" -o workbuddy-gateway . +``` + +## 免责声明 + +本项目仅用于个人学习与技术研究。腾讯 CodeBuddy 的接口协议与风控策略可能随时变化;请遵守腾讯服务条款,自行承担使用风险。本仓库不包含任何官方未公开的密钥或凭据。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a41efe3 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module workbuddy-gateway + +go 1.26.5 + +require github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + +require github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3310967 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= diff --git a/main.go b/main.go new file mode 100644 index 0000000..ee645fb --- /dev/null +++ b/main.go @@ -0,0 +1,1039 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "os/signal" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/google/uuid" + "github.com/skip2/go-qrcode" +) + +const ( + version = "1.1.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 +) + +// ----------------------------------------------------------------------------- +// 数据结构定义 +// ----------------------------------------------------------------------------- + +type StoredAuth struct { + Auth StoredTokens `json:"auth"` + Account StoredAccount `json:"account"` +} + +type StoredTokens struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresAt int64 `json:"expiresAt"` + Domain string `json:"domain"` +} + +type StoredAccount struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` +} + +type apiEnvelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +type tokenData struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ExpiresIn int64 `json:"expiresIn"` + RefreshExpiresIn int64 `json:"refreshExpiresIn"` + Domain string `json:"domain"` +} + +type accountData struct { + UID string `json:"uid"` + EnterpriseID string `json:"enterpriseId"` + Nickname string `json:"nickname"` +} + +type authStateData struct { + State string `json:"state"` + AuthURL string `json:"authUrl"` +} + +// ----------------------------------------------------------------------------- +// 全局状态与配置 +// ----------------------------------------------------------------------------- + +type Config struct { + Addr string + Port int + AuthFile string + APIKey string + ProxyURL string + Verbose bool + HttpClient *http.Client +} + +var ( + cfg Config + authLock sync.RWMutex + currAuth *StoredAuth + reqCounter uint64 + upstreamMutex sync.Mutex // 限制同一账号到腾讯上游的单并发排队,杜绝触发 11128 风控 +) + +// ----------------------------------------------------------------------------- +// 主入口与命令行控制 +// ----------------------------------------------------------------------------- + +func main() { + if len(os.Args) > 1 { + first := os.Args[1] + if first == "help" || first == "-h" || first == "--help" { + printHelp() + return + } + if first == "version" || first == "-v" || first == "--version" { + fmt.Printf("WorkBuddy Local Gateway v%s\n", version) + return + } + } + + command := "serve" + args := os.Args[1:] + if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") { + command = os.Args[1] + args = os.Args[2:] + } + + 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.APIKey, "api-key", "", "可选:访问网关所需的 API Key (客户端 Bearer 校验)") + fs.StringVar(&cfg.ProxyURL, "proxy", "", "可选:上游请求代理 (如 http://127.0.0.1:7890)") + fs.BoolVar(&cfg.Verbose, "verbose", false, "输出详细调试日志") + _ = fs.Parse(args) + + // 初始化 HTTP 客户端 + initHTTPClient() + + switch command { + case "serve", "run", "start": + runServe() + case "login": + runLogin() + case "status": + runStatus() + case "refresh": + runRefresh() + case "version", "-v", "--version": + fmt.Printf("WorkBuddy Local Gateway v%s\n", version) + default: + fmt.Printf("未知命令: %s\n\n", command) + printHelp() + os.Exit(1) + } +} + +func printHelp() { + fmt.Println(`WorkBuddy Local Gateway - 轻量化跨平台本地 AI 网关 +将腾讯 CodeBuddy / 混元反代为标准 OpenAI 协议,支持直接透传任意模型到上游。 + +用法: + workbuddy-gateway [command] [options] + +命令: + serve 启动本地网关 (默认操作) + login 微信/企业微信扫码登录,获取/更新凭据 + status 查看当前账号凭据状态与过期时间 + refresh 手动立即刷新访问令牌 (Access Token) + version 查看版本信息 + help 查看帮助说明 + +参数选项: + -addr 网关监听地址 (默认: 127.0.0.1) + -port 网关监听端口 (默认: 8317) + -auth 凭据存储文件路径 (默认: ./workbuddy.json) + -api-key 设置后,调用网关必须携带 Bearer 鉴权 + -proxy 设置上游转发代理 (例如 http://127.0.0.1:7890 或 socks5://...) + -verbose 输出详细调试日志 (请求/响应体) + +示例: + # 首次使用扫码登录 + workbuddy-gateway login + + # 启动本地网关 (监听 127.0.0.1:8317) + workbuddy-gateway serve + + # 启动网关并指定端口和代理 + workbuddy-gateway serve -port 9000 -proxy http://127.0.0.1:7890`) +} + +func initHTTPClient() { + jar, _ := cookiejar.New(nil) + transport := &http.Transport{ + MaxIdleConns: 50, + IdleConnTimeout: 90 * time.Second, + MaxIdleConnsPerHost: 10, + TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, + } + if cfg.ProxyURL != "" { + pURL, err := url.Parse(cfg.ProxyURL) + if err != nil { + log.Fatalf("错误: 无效的代理地址 %s: %v", cfg.ProxyURL, err) + } + transport.Proxy = http.ProxyURL(pURL) + } + cfg.HttpClient = &http.Client{ + Timeout: 180 * time.Second, + Transport: transport, + Jar: jar, + } +} + +// ----------------------------------------------------------------------------- +// 凭据加载、保存与自动续期 +// ----------------------------------------------------------------------------- + +func loadAuth() (*StoredAuth, error) { + authLock.RLock() + if currAuth != nil { + defer authLock.RUnlock() + return currAuth, nil + } + authLock.RUnlock() + + data, err := os.ReadFile(cfg.AuthFile) + if err != nil { + return nil, fmt.Errorf("读取凭据文件失败 (%s): %w", cfg.AuthFile, err) + } + var sa StoredAuth + if err := json.Unmarshal(data, &sa); err != nil { + return nil, fmt.Errorf("解析凭据文件失败: %w", err) + } + if sa.Auth.AccessToken == "" { + return nil, fmt.Errorf("凭据文件中缺少 AccessToken") + } + + authLock.Lock() + currAuth = &sa + authLock.Unlock() + return &sa, nil +} + +func saveAuth(sa *StoredAuth) error { + authLock.Lock() + currAuth = sa + authLock.Unlock() + + dir := filepath.Dir(cfg.AuthFile) + if dir != "" && dir != "." { + _ = os.MkdirAll(dir, 0755) + } + data, err := json.MarshalIndent(sa, "", " ") + if err != nil { + return err + } + return os.WriteFile(cfg.AuthFile, data, 0600) +} + +func ensureValidToken() error { + sa, err := loadAuth() + if err != nil { + return err + } + // 如果距过期不足 15 分钟,则自动刷新 + if time.Now().Unix() > sa.Auth.ExpiresAt-900 { + log.Printf("[Auth] 访问令牌即将或已经过期 (ExpiresAt=%s),正在自动刷新...", time.Unix(sa.Auth.ExpiresAt, 0).Format("2006-01-02 15:04:05")) + return doRefreshToken(sa) + } + return nil +} + +func doRefreshToken(sa *StoredAuth) error { + if sa.Auth.RefreshToken == "" { + return fmt.Errorf("无法刷新:缺少 RefreshToken") + } + headers := func(r *http.Request) { + commonHeaders(r) + r.Header.Set("X-Refresh-Token", sa.Auth.RefreshToken) + if sa.Account.EnterpriseID != "" { + r.Header.Set("X-Enterprise-Id", sa.Account.EnterpriseID) + } + r.Header.Set("X-Auth-Refresh-Source", "workbuddy") + } + + data, status, err := doJSON(cfg.HttpClient, http.MethodPost, endpointTokenRefresh, headers, nil) + if err != nil { + return 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) + } + + sa.Auth.AccessToken = tok.AccessToken + if tok.RefreshToken != "" { + sa.Auth.RefreshToken = tok.RefreshToken + } + if tok.Domain != "" { + sa.Auth.Domain = tok.Domain + } + 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 +} + +// ----------------------------------------------------------------------------- +// CLI 子命令实现: login, status, refresh +// ----------------------------------------------------------------------------- + +func runStatus() { + sa, err := loadAuth() + if 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("=======================================================") +} + +func runRefresh() { + sa, err := loadAuth() + if err != nil { + fmt.Printf("读取凭据失败: %v\n", err) + return + } + fmt.Println("正在向上游请求刷新令牌...") + if err := doRefreshToken(sa); err != nil { + fmt.Printf("刷新失败: %v\n", err) + } else { + fmt.Println("刷新成功并已持久化保存!") + } +} + +func runLogin() { + fmt.Println("================ WorkBuddy 扫码登录 ================") + fmt.Println("正在生成登录凭据与二维码...") + + loginClient := &http.Client{ + Timeout: 30 * time.Second, + Jar: cfg.HttpClient.Jar, + } + + data, _, err := doJSON(loginClient, http.MethodPost, endpointAuthState, nil, bytes.NewReader([]byte("{}"))) + if err != nil { + fmt.Printf("获取登录状态失败: %v\n", err) + return + } + var st authStateData + _ = json.Unmarshal(data, &st) + if st.State == "" || st.AuthURL == "" { + fmt.Println("上游返回的登录状态信息异常,请重试。") + return + } + + // 终端字符二维码 + qr, err := qrcode.New(st.AuthURL, qrcode.Medium) + if err == nil { + fmt.Println("\n请使用 微信 或 企业微信 扫描下方二维码登录:") + fmt.Println(qr.ToSmallString(false)) + } + + fmt.Println("如无法扫码,也可在浏览器中直接打开以下链接:") + fmt.Printf("👉 %s\n\n", st.AuthURL) + fmt.Println("等待扫码授权中 (按 Ctrl+C 可取消)...") + + deadline := time.Now().Add(loginTTL) + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if time.Now().After(deadline) { + fmt.Println("\n登录已超时,请重新执行 login 命令。") + return + } + tokRaw, _, errTok := doJSON(loginClient, http.MethodGet, endpointAuthToken+st.State, nil, nil) + if errTok != nil { + continue + } + var tok tokenData + if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" { + continue + } + + // 登录成功,拉取账号信息 + var acct accountData + acctHeaders := func(r *http.Request) { + commonHeaders(r) + r.Header.Set("Authorization", "Bearer "+tok.AccessToken) + } + if acctRaw, _, errAcct := doJSON(loginClient, http.MethodGet, endpointLoginAcct+st.State, acctHeaders, nil); errAcct == nil { + _ = json.Unmarshal(acctRaw, &acct) + } + + sa := &StoredAuth{ + Auth: StoredTokens{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + ExpiresAt: time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second).Unix(), + Domain: tok.Domain, + }, + Account: StoredAccount{ + UID: acct.UID, + EnterpriseID: acct.EnterpriseID, + Nickname: acct.Nickname, + }, + } + + if err := saveAuth(sa); err != nil { + fmt.Printf("\n登录成功但保存凭据失败: %v\n", err) + return + } + + fmt.Println("\n🎉 登录成功!") + 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")) + fmt.Println("\n现在您可以运行以下命令启动网关服务:") + fmt.Println(" workbuddy-gateway serve") + return + } + } +} + +// ----------------------------------------------------------------------------- +// 本地 HTTP API 网关服务 (OpenAI 协议兼容) +// ----------------------------------------------------------------------------- + +func runServe() { + sa, err := loadAuth() + if err != nil { + fmt.Printf("警告: 未检测到有效凭据 (%v)。\n请先执行: workbuddy-gateway login 扫码登录,或确保 %s 存在。\n\n", err, cfg.AuthFile) + } 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")) + } + + // 启动后台自动刷新协程 + go backgroundTokenRefresher() + + mux := http.NewServeMux() + mux.HandleFunc("/v1/chat/completions", handleChatCompletions) + mux.HandleFunc("/chat/completions", handleChatCompletions) + mux.HandleFunc("/v1/models", handleModels) + mux.HandleFunc("/models", handleModels) + mux.HandleFunc("/health", handleHealth) + mux.HandleFunc("/ping", handleHealth) + mux.HandleFunc("/", handleIndex) + + listenAddr := fmt.Sprintf("%s:%d", cfg.Addr, cfg.Port) + server := &http.Server{ + Addr: listenAddr, + Handler: corsMiddleware(authMiddleware(mux)), + ReadTimeout: 120 * time.Second, + WriteTimeout: 300 * time.Second, + } + + fmt.Println("================================================================") + fmt.Printf("🚀 WorkBuddy 本地网关已启动!\n") + fmt.Printf(" 服务监听地址: http://%s\n", listenAddr) + 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.APIKey != "" { + fmt.Printf(" API 鉴权: 已启用 (Bearer %s)\n", cfg.APIKey) + } else { + fmt.Printf(" API 鉴权: 未启用 (任何客户端均可直连)\n") + } + if cfg.ProxyURL != "" { + fmt.Printf(" 上游出口代理: %s\n", cfg.ProxyURL) + } + fmt.Println("================================================================") + fmt.Println("等待客户端请求中 (按 Ctrl+C 安全停止)...") + + stopChan := make(chan os.Signal, 1) + signal.Notify(stopChan, os.Interrupt, syscall.SIGTERM) + + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("HTTP 服务异常退出: %v", err) + } + }() + + <-stopChan + fmt.Println("\n正在关闭网关服务...") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(ctx) + fmt.Println("网关已安全停止。") +} + +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) + } + } +} + +// ----------------------------------------------------------------------------- +// 中间件: CORS 与可选 APIKey 校验 +// ----------------------------------------------------------------------------- + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "*") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + next.ServeHTTP(w, r) + }) +} + +func authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if cfg.APIKey != "" && r.URL.Path != "/health" && r.URL.Path != "/ping" && r.URL.Path != "/" { + authHeader := r.Header.Get("Authorization") + token := strings.TrimPrefix(authHeader, "Bearer ") + if token != cfg.APIKey { + writeOpenAIError(w, http.StatusUnauthorized, "invalid_api_key", "未提供有效 API 密钥") + return + } + } + next.ServeHTTP(w, r) + }) +} + +// ----------------------------------------------------------------------------- +// 路由处理: /v1/chat/completions (支持任意 model 透传) +// ----------------------------------------------------------------------------- + +func handleChatCompletions(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeOpenAIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "仅支持 POST 请求") + return + } + + 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", "读取请求体失败") + return + } + defer r.Body.Close() + + var reqObj map[string]any + if err := json.Unmarshal(bodyBytes, &reqObj); err != nil { + writeOpenAIError(w, http.StatusBadRequest, "invalid_json", "无效的 JSON 请求体") + return + } + + // 核心特性:完全透传 model 字段 + // 客户端传什么 model,我们就透传什么 model 给上游,不做任何硬编码限制! + modelName, _ := reqObj["model"].(string) + if modelName == "" { + modelName = "hy4-preview" // 默认保底 + reqObj["model"] = modelName + } + + isStream, _ := reqObj["stream"].(bool) + + // 腾讯上游强制要求 stream 必须为 true,非流式会被拦截 (code 11101) + reqObj["stream"] = true + + // 深度思考 (Thinking) 自动适配:混元系列如果未关闭思考,自动赋予 high 档位保证深度思考输出 + applyThinkingRules(reqObj, modelName) + + // 模板净化:改写 Claude Code 等框架被腾讯官方逐字拉黑的固定 prompt 语句 + sanitizeMessages(reqObj) + + upstreamBytes, err := json.Marshal(reqObj) + if err != nil { + writeOpenAIError(w, http.StatusInternalServerError, "encode_error", "序列化请求失败") + return + } + + if cfg.Verbose { + log.Printf("[#%d][Req] Model: %s | Stream: %v | BodyLen: %d", reqID, modelName, isStream, len(upstreamBytes)) + } else { + 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()) + 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) + 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] 非流式响应完成 (耗时 %v)", reqID, time.Since(startTime)) + } +} + +// ----------------------------------------------------------------------------- +// 路由处理: /v1/models (列出常见模型供客户端自动补全) +// ----------------------------------------------------------------------------- + +func handleModels(w http.ResponseWriter, r *http.Request) { + // 动态内置常见模型,用户直接传入任何未列出的 model 也会直接透传到上游 + modelsList := []map[string]any{ + {"id": "hy4-preview", "object": "model", "owned_by": "workbuddy", "permission": []any{}}, + {"id": "hy3-preview-agent", "object": "model", "owned_by": "workbuddy", "permission": []any{}}, + {"id": "hy3-preview", "object": "model", "owned_by": "workbuddy", "permission": []any{}}, + {"id": "hy3", "object": "model", "owned_by": "workbuddy", "permission": []any{}}, + {"id": "glm-5.2", "object": "model", "owned_by": "workbuddy", "permission": []any{}}, + {"id": "glm-5.1", "object": "model", "owned_by": "workbuddy", "permission": []any{}}, + {"id": "kimi-k2.7", "object": "model", "owned_by": "workbuddy", "permission": []any{}}, + {"id": "deepseek-v4-pro", "object": "model", "owned_by": "workbuddy", "permission": []any{}}, + {"id": "deepseek-v4-flash", "object": "model", "owned_by": "workbuddy", "permission": []any{}}, + {"id": "minimax-m3-pay", "object": "model", "owned_by": "workbuddy", "permission": []any{}}, + } + resp := map[string]any{ + "object": "list", + "data": modelsList, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "healthy", + "timestamp": time.Now().Unix(), + "version": version, + }) +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = fmt.Fprintf(w, "WorkBuddy Local Gateway v%s is running.\n\nEndpoints:\n- POST /v1/chat/completions\n- GET /v1/models\n- GET /health\n", version) +} + +// ----------------------------------------------------------------------------- +// 请求转译与净化工具函数 +// ----------------------------------------------------------------------------- + +func applyThinkingRules(obj map[string]any, modelName string) { + // 遵循 CodeBuddy 规范:仅当客户端显式设置了 reasoning_effort 时才传递与规范化 + // 绝不可强行对普通请求注入 reasoning_effort,否则极易触发腾讯内容与安全策略拦截 (code 11128) + currEff, exists := obj["reasoning_effort"].(string) + if !exists || currEff == "" || currEff == "off" || currEff == "none" { + delete(obj, "reasoning_effort") + delete(obj, "reasoning_summary") + return + } + // 客户端显式请求思考时,设置 auto + obj["reasoning_summary"] = "auto" +} + +func sanitizeMessages(obj map[string]any) { + messages, ok := obj["messages"].([]any) + if !ok { + return + } + for _, m := range messages { + msg, ok := m.(map[string]any) + if !ok { + continue + } + switch c := msg["content"].(type) { + case string: + msg["content"] = sanitizeBlockedTemplates(c) + case []any: + for _, partAny := range c { + part, ok := partAny.(map[string]any) + if !ok { + continue + } + if t, ok := part["text"].(string); ok { + part["text"] = sanitizeBlockedTemplates(t) + } + } + } + } +} + +func sanitizeBlockedTemplates(s string) string { + s = strings.ReplaceAll(s, + "You are Claude Code, Anthropic's official CLI for Claude.", + "You are Claude Code, Anthropic's official CLI tool for Claude.") + s = strings.ReplaceAll(s, + "Main branch (you will usually use this for PRs)", + "Default branch (you will usually use this for PRs)") + return s +} + +// backendHeaders 设置 CodeBuddy 上游专用指纹与鉴权 Header +func backendHeaders(req *http.Request, sa *StoredAuth) { + commonHeaders(req) + 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") + + if sa != nil && sa.Auth.AccessToken != "" { + req.Header.Set("Authorization", "Bearer "+sa.Auth.AccessToken) + } else { + req.Header.Set("X-No-Authorization", "1") + } + if sa != nil && sa.Account.UID != "" { + req.Header.Set("X-User-Id", sa.Account.UID) + } else { + req.Header.Set("X-No-User-Id", "1") + } + if sa != nil && sa.Account.EnterpriseID != "" { + req.Header.Set("X-Enterprise-Id", sa.Account.EnterpriseID) + } else { + req.Header.Set("X-No-Enterprise-Id", "1") + } + if sa != nil && sa.Auth.RefreshToken != "" { + req.Header.Set("X-Refresh-Token", sa.Auth.RefreshToken) + } + if sa != nil && sa.Auth.Domain != "" { + req.Header.Set("X-Domain", sa.Auth.Domain) + } else { + req.Header.Set("X-No-Department-Info", "1") + } + req.Header.Set("X-Product", "SaaS") +} + +func commonHeaders(req *http.Request) { + 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) +} + +func doJSON(client *http.Client, method, fullURL string, headers func(*http.Request), body io.Reader) (json.RawMessage, int, error) { + req, err := http.NewRequest(method, fullURL, body) + if err != nil { + return nil, 0, err + } + if headers != nil { + headers(req) + } else { + commonHeaders(req) + } + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d: %s", resp.StatusCode, string(raw)) + } + var env apiEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + return nil, resp.StatusCode, fmt.Errorf("解析上游 JSON 失败: %w", err) + } + if env.Code != 0 { + return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg) + } + return env.Data, resp.StatusCode, nil +} + +// ----------------------------------------------------------------------------- +// 流式聚合与格式清理 +// ----------------------------------------------------------------------------- + +func aggregateCompletion(r io.Reader, model string) ([]byte, error) { + var content, reasoning, role, respModel, respID, finish string + var created int64 + var usage map[string]any + var toolCalls []map[string]any + + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + for scanner.Scan() { + data := stripDataPrefix(scanner.Text()) + if data == "" || data == "[DONE]" { + continue + } + var chunk map[string]any + if json.Unmarshal([]byte(data), &chunk) != nil { + continue + } + if v, ok := chunk["id"].(string); ok && v != "" { + respID = v + } + if v, ok := chunk["model"].(string); ok && v != "" { + respModel = v + } + if v, ok := chunk["created"].(float64); ok { + created = int64(v) + } + if v, ok := chunk["usage"].(map[string]any); ok { + usage = v + } + choices, _ := chunk["choices"].([]any) + for _, c := range choices { + choice, _ := c.(map[string]any) + if delta, ok := choice["delta"].(map[string]any); ok { + if v, ok := delta["role"].(string); ok && v != "" { + role = v + } + if v, ok := delta["content"].(string); ok { + content += v + } + if v, ok := delta["reasoning_content"].(string); ok { + reasoning += v + } + if tcs, ok := delta["tool_calls"].([]any); ok { + for _, tc := range tcs { + if call, ok := tc.(map[string]any); ok { + toolCalls = append(toolCalls, call) + } + } + } + } + if v, ok := choice["finish_reason"].(string); ok && v != "" { + finish = v + } + } + } + + message := map[string]any{"role": ifEmpty(role, "assistant"), "content": content} + if reasoning != "" { + message["reasoning_content"] = reasoning + } + if len(toolCalls) > 0 { + message["tool_calls"] = toolCalls + } + if created == 0 { + created = time.Now().Unix() + } + result := map[string]any{ + "id": ifEmpty(respID, fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano())), + "object": "chat.completion", + "created": created, + "model": ifEmpty(respModel, model), + "choices": []map[string]any{{ + "index": 0, + "message": message, + "finish_reason": ifEmpty(finish, "stop"), + }}, + } + if usage != nil { + result["usage"] = usage + } + return json.Marshal(result) +} + +func cleanChunkJSON(s string) string { + var obj map[string]any + if json.Unmarshal([]byte(s), &obj) != nil { + return s + } + if choices, ok := obj["choices"].([]any); ok { + for _, c := range choices { + choice, ok := c.(map[string]any) + if !ok { + continue + } + if delta, ok := choice["delta"].(map[string]any); ok { + for k, v := range delta { + if isEmptyValue(v) { + delete(delta, k) + } + } + } + } + } + out, err := json.Marshal(obj) + if err != nil { + return s + } + return string(out) +} + +func isEmptyValue(v any) bool { + switch x := v.(type) { + case nil: + return true + case string: + return x == "" + case []any: + return len(x) == 0 + case map[string]any: + return len(x) == 0 + } + return false +} + +func stripDataPrefix(s string) string { + s = strings.TrimSpace(s) + for strings.HasPrefix(s, "data:") { + s = strings.TrimSpace(strings.TrimPrefix(s, "data:")) + } + return s +} + +func writeOpenAIError(w http.ResponseWriter, statusCode int, errType, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "message": message, + "type": errType, + "code": statusCode, + }, + }) +} + +func ifEmpty(val, fallback string) string { + if strings.TrimSpace(val) == "" { + return fallback + } + return val +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +}