Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ee094ffa1 | ||
|
|
5ac6ee7975 | ||
|
|
20f7fdded3 | ||
|
|
4d5d315125 | ||
|
|
61aa053816 | ||
|
|
86b70baf29 | ||
|
|
0484db4b3a |
+4
-6
@@ -1,18 +1,16 @@
|
||||
# 凭据与敏感数据(切勿提交!)
|
||||
workbuddy.json
|
||||
workbuddy*.json
|
||||
workbuddy-console.json
|
||||
dist/workbuddy.json
|
||||
dist/*.json
|
||||
last-*.json
|
||||
*.json.bak
|
||||
*.json.disabled
|
||||
*.disabled
|
||||
.workbuddy-*.json
|
||||
|
||||
# 本地编译产物
|
||||
workbuddy-gateway
|
||||
workbuddy-gateway.exe
|
||||
dist/*.exe
|
||||
dist/workbuddy-gateway-linux-*
|
||||
dist/workbuddy-gateway-darwin-*
|
||||
dist/
|
||||
|
||||
# 日志与临时文件
|
||||
*.log
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
# 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`。
|
||||
- **OpenAI 兼容协议**:`POST /v1/chat/completions`(SSE 流式 + 非流式聚合)、`POST /v1/responses`(OpenAI Responses API,支持流式语义事件、非流式与 function tools)、`GET /v1/models`、`GET /health`。
|
||||
- **深度思考透传规则**:仅当客户端显式请求 `reasoning_effort` 时转发;绝不强制注入,避免触发上游内容安全策略。
|
||||
- **反审查净化**:自动改写 Claude Code 等框架被上游逐字拉黑的固定 Prompt 语句。
|
||||
- **会话结构自动归一化**:自动保证首条消息为 `system`,修复部分非 harness 客户端(以 `assistant` 续写或以 `tool` 回传工具结果开头)触发的上游 `first message is not system prompt` (code 11128) 报错;同时兼容 OpenAI 新版 `developer` 角色。
|
||||
- **单账号串行化**:同一账号请求自动排队,避免并发双发触发上游风控;不同账号之间可并行。
|
||||
|
||||
## 快速上手
|
||||
@@ -29,12 +40,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 +155,7 @@ workbuddy-gateway status
|
||||
# 输出示例:
|
||||
# --- 账号 #1 ---
|
||||
# 凭据文件: workbuddy-2.json
|
||||
# 站点: 国内站 (copilot.tencent.com)
|
||||
# 用户昵称: tester
|
||||
# 用户 UID: uid-xxx
|
||||
# 账号状态: ❌ 授权失效(禁止调度)
|
||||
@@ -136,6 +173,64 @@ 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 退出):
|
||||
|
||||
```bash
|
||||
# 基本用法:每 3 秒刷新展示账号池状态(可用/冷却/失效 + Token 有效期)
|
||||
cd /opt/workbuddy-gateway # 必须与 serve 同一工作目录(读取 workbuddy-status.json)
|
||||
workbuddy-gateway monitor
|
||||
|
||||
# 自定义刷新间隔(秒)
|
||||
workbuddy-gateway monitor -interval 2
|
||||
|
||||
# 同时展示 systemd 服务最近日志(Linux)
|
||||
workbuddy-gateway monitor -journal workbuddy-gateway
|
||||
|
||||
# 或展示指定日志文件最近内容
|
||||
workbuddy-gateway monitor -logfile /var/log/workbuddy-gateway.log
|
||||
|
||||
# 控制每次展示的日志行数
|
||||
workbuddy-gateway monitor -journal workbuddy-gateway -lines 8
|
||||
```
|
||||
|
||||
展示内容(实时刷新):
|
||||
|
||||
```text
|
||||
================ WorkBuddy 实时监控 ================
|
||||
按 Ctrl+C 退出 | 状态文件: workbuddy-status.json
|
||||
---------------------------------------------------------------
|
||||
🕐 更新时间: 2026-09-04 09:35:12
|
||||
📊 账号池: 共 2 个 | ✅ 可用 1 | 🔒 冷却 1 | ❌ 失效 0
|
||||
#1 workbuddy.json (Abandon)
|
||||
✅ 可用 | Token 有效期至: 09-10 20:43
|
||||
#2 workbuddy2.json (啊水)
|
||||
🔒 冷却中 至 09-05 01:57 (剩余 16h)
|
||||
原因: 您的使用量已超出频率限制...
|
||||
📜 最近日志 (journalctl -u workbuddy-gateway):
|
||||
9月 04 09:34:31 ... [Cooldown] 账号 workbuddy2.json 触发频率限制...
|
||||
---------------------------------------------------------------
|
||||
```
|
||||
|
||||
> 原理:`serve` 后台每 3 秒(及状态变化时)将账号池实时状态原子写入同目录 `workbuddy-status.json`,`monitor` 前台读取该文件并周期刷新展示;日志通过 `journalctl` 或日志文件补充展示。
|
||||
|
||||
## 客户端接入
|
||||
|
||||
网关启动后服务地址为 `http://127.0.0.1:8317/v1`。
|
||||
@@ -242,7 +337,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)
|
||||
@@ -316,8 +411,10 @@ ExecStart=/opt/workbuddy-gateway/workbuddy-gateway serve -addr 0.0.0.0 -port 831
|
||||
## 命令行速查
|
||||
|
||||
```
|
||||
命令: serve | login | status | refresh | version | help
|
||||
选项: -addr <ip> · -port <port> · -auth <path> · -api-key <key> · -proxy <url> · -verbose
|
||||
命令: serve | login | status | refresh | monitor | version | help
|
||||
选项: -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>
|
||||
```
|
||||
|
||||
## 从源码构建
|
||||
@@ -332,4 +429,4 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -o workbuddy-gateway .
|
||||
|
||||
## 免责声明
|
||||
|
||||
本项目仅用于个人学习与技术研究。腾讯 CodeBuddy 的接口协议与风控策略可能随时变化;请遵守腾讯服务条款,自行承担使用风险。本仓库不包含任何官方未公开的密钥或凭据。
|
||||
本项目仅用于个人学习与技术研究。腾讯 CodeBuddy(含国内站与国际站 workbuddy.ai)的接口协议与风控策略可能随时变化;请遵守腾讯服务条款,自行承担使用风险。本仓库不包含任何官方未公开的密钥或凭据。
|
||||
|
||||
@@ -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
|
||||
+559
-3
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -28,7 +30,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 +133,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 +388,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)
|
||||
}
|
||||
@@ -354,3 +452,461 @@ 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()
|
||||
f := dir + "/test.log"
|
||||
content := "line1\nline2\nline3\nline4\nline5\n"
|
||||
if err := os.WriteFile(f, []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines, err := tailLines(f, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(lines) != 2 || lines[0] != "line4" || lines[1] != "line5" {
|
||||
t.Fatalf("tailLines got %v", lines)
|
||||
}
|
||||
t.Logf("tailLines(2) = %v", lines)
|
||||
}
|
||||
|
||||
// 验证状态快照写入:含 active / cooldown / disabled 三种状态
|
||||
func TestWriteStatusSnapshot(t *testing.T) {
|
||||
oldDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chdir(oldDir)
|
||||
dir := t.TempDir()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
accountMu.Lock()
|
||||
accounts = []*Account{
|
||||
{Path: "a.json", Auth: &StoredAuth{
|
||||
Auth: StoredTokens{AccessToken: "x", ExpiresAt: time.Now().Add(time.Hour).Unix()},
|
||||
Account: StoredAccount{Nickname: "alice", UID: "u1"},
|
||||
}},
|
||||
{Path: "b.json", Auth: &StoredAuth{
|
||||
Auth: StoredTokens{AccessToken: "x", ExpiresAt: time.Now().Add(time.Hour).Unix()},
|
||||
Account: StoredAccount{Nickname: "bob", UID: "u2"},
|
||||
}, CooldownUntil: time.Now().Add(30 * time.Minute), CooldownMsg: "频率限制"},
|
||||
{Path: "c.json", Disabled: true, DisabledReason: "revoked", Nickname: "carol", UID: "u3"},
|
||||
}
|
||||
accountMu.Unlock()
|
||||
|
||||
writeStatusSnapshot()
|
||||
|
||||
data, err := os.ReadFile(statusSnapshotFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snap statusSnapshot
|
||||
if err := json.Unmarshal(data, &snap); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snap.Accounts) != 3 {
|
||||
t.Fatalf("expected 3 accounts in snapshot, got %d", len(snap.Accounts))
|
||||
}
|
||||
states := map[string]bool{}
|
||||
for _, a := range snap.Accounts {
|
||||
states[a.State] = true
|
||||
}
|
||||
if !states["active"] || !states["cooldown"] || !states["disabled"] {
|
||||
t.Fatalf("expected all three states in snapshot, got %v", states)
|
||||
}
|
||||
t.Logf("snapshot states: %v", states)
|
||||
}
|
||||
|
||||
// 构造 messages 便于表驱动测试
|
||||
func msg(role, content string) map[string]any {
|
||||
return map[string]any{"role": role, "content": content}
|
||||
}
|
||||
|
||||
// 提取 messages 各条 role,便于断言
|
||||
func rolesOf(obj map[string]any) []string {
|
||||
messages, _ := obj["messages"].([]any)
|
||||
roles := make([]string, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
roles = append(roles, roleOfMessage(m))
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
// 验证会话结构归一化:修复上游 11128「first message is not system prompt」
|
||||
func TestEnsureLeadingSystemMessage(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
messages []any
|
||||
wantRoles []string
|
||||
wantInject bool // 首条是否应为注入的保底 system
|
||||
}{
|
||||
{
|
||||
name: "首条已是 system 保持原样",
|
||||
messages: []any{msg("system", "You are helpful"), msg("user", "hi")},
|
||||
wantRoles: []string{"system", "user"},
|
||||
},
|
||||
{
|
||||
name: "首条 user 且无 system(国内站宽容/国际站必须,统一注入 system)",
|
||||
messages: []any{msg("user", "hi")},
|
||||
wantRoles: []string{"system", "user"},
|
||||
wantInject: true,
|
||||
},
|
||||
{
|
||||
name: "首条 assistant(续写)注入 system",
|
||||
messages: []any{msg("assistant", "Sure")},
|
||||
wantRoles: []string{"system", "assistant"},
|
||||
wantInject: true,
|
||||
},
|
||||
{
|
||||
name: "首条 tool(仅回传工具结果)注入 system",
|
||||
messages: []any{msg("tool", "result")},
|
||||
wantRoles: []string{"system", "tool"},
|
||||
wantInject: true,
|
||||
},
|
||||
{
|
||||
name: "后续 system 提升到首位",
|
||||
messages: []any{msg("user", "hi"), msg("system", "You are helpful"), msg("user", "bye")},
|
||||
wantRoles: []string{"system", "user", "user"},
|
||||
},
|
||||
{
|
||||
name: "首条 developer 归一化为 system",
|
||||
messages: []any{msg("developer", "You are helpful"), msg("user", "hi")},
|
||||
wantRoles: []string{"system", "user"},
|
||||
},
|
||||
{
|
||||
name: "空 messages 注入 system",
|
||||
messages: []any{},
|
||||
wantRoles: []string{"system"},
|
||||
wantInject: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
obj := map[string]any{"messages": c.messages}
|
||||
ensureLeadingSystemMessage(obj)
|
||||
got := rolesOf(obj)
|
||||
if len(got) != len(c.wantRoles) {
|
||||
t.Fatalf("roles = %v, want %v", got, c.wantRoles)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.wantRoles[i] {
|
||||
t.Fatalf("roles = %v, want %v", got, c.wantRoles)
|
||||
}
|
||||
}
|
||||
messages, _ := obj["messages"].([]any)
|
||||
first, _ := messages[0].(map[string]any)
|
||||
if c.wantInject {
|
||||
if content, _ := first["content"].(string); content != defaultSystemPrompt {
|
||||
t.Fatalf("injected system content = %q, want %q", content, defaultSystemPrompt)
|
||||
}
|
||||
}
|
||||
t.Logf("roles -> %v", got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 验证缺失 / 非法 messages 字段时也能安全注入(不得 panic)
|
||||
func TestEnsureLeadingSystemMessageMissingField(t *testing.T) {
|
||||
obj := map[string]any{}
|
||||
ensureLeadingSystemMessage(obj)
|
||||
messages, ok := obj["messages"].([]any)
|
||||
if !ok || len(messages) != 1 {
|
||||
t.Fatalf("expected 1 injected message, got %#v", obj["messages"])
|
||||
}
|
||||
if roleOfMessage(messages[0]) != "system" {
|
||||
t.Fatalf("expected system first, got %v", roleOfMessage(messages[0]))
|
||||
}
|
||||
t.Log("missing messages field handled")
|
||||
}
|
||||
|
||||
// 验证 developer 角色(GPT-5/Codex)被归一化为 system,避免上游 11128
|
||||
// "Illegal API invocation from an unapproved channel"
|
||||
func TestSanitizeMessagesNormalizesDeveloperRole(t *testing.T) {
|
||||
obj := map[string]any{"messages": []any{
|
||||
msg("system", "You are helpful."),
|
||||
msg("developer", "Be terse."),
|
||||
msg("user", "hi"),
|
||||
}}
|
||||
sanitizeMessages(obj)
|
||||
roles := rolesOf(obj)
|
||||
for _, r := range roles {
|
||||
if r == "developer" {
|
||||
t.Fatalf("developer role should be normalized, got %v", roles)
|
||||
}
|
||||
}
|
||||
if roles[1] != "system" {
|
||||
t.Fatalf("roles = %v, want second = system", roles)
|
||||
}
|
||||
t.Logf("roles normalized: %v", roles)
|
||||
}
|
||||
|
||||
// 验证上游 tool_calls 增量按 index 正确归并为一个完整工具调用
|
||||
func TestApplyToolCallDeltaMerge(t *testing.T) {
|
||||
merged := map[int]*mergedToolCall{}
|
||||
var order []int
|
||||
applyToolCallDelta(merged, &order, []any{
|
||||
map[string]any{"index": float64(0), "id": "call_1", "type": "function",
|
||||
"function": map[string]any{"name": "get_weather", "arguments": ""}},
|
||||
})
|
||||
applyToolCallDelta(merged, &order, []any{
|
||||
map[string]any{"index": float64(0), "function": map[string]any{"arguments": `{"city":`}},
|
||||
})
|
||||
applyToolCallDelta(merged, &order, []any{
|
||||
map[string]any{"index": float64(0), "function": map[string]any{"arguments": `"Beijing"}`}},
|
||||
})
|
||||
if len(order) != 1 || order[0] != 0 {
|
||||
t.Fatalf("order = %v, want [0]", order)
|
||||
}
|
||||
st := merged[0]
|
||||
if st.ID != "call_1" || st.Name != "get_weather" {
|
||||
t.Fatalf("id/name = %q/%q", st.ID, st.Name)
|
||||
}
|
||||
if got := st.Args.String(); got != `{"city":"Beijing"}` {
|
||||
t.Fatalf("args = %q", got)
|
||||
}
|
||||
t.Logf("merged tool call: %s(%s) id=%s", st.Name, st.Args.String(), st.ID)
|
||||
}
|
||||
|
||||
// 验证 aggregateCompletion 正确合并流式 tool_calls(修复旧的按片追加问题)
|
||||
func TestAggregateCompletionToolCalls(t *testing.T) {
|
||||
sse := strings.Join([]string{
|
||||
`data: {"id":"cmpl-1","model":"hy3-preview","created":1700000000,"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]}}]}`,
|
||||
`data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]}}]}`,
|
||||
`data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"Beijing\"}"}}]}}]}`,
|
||||
`data: [DONE]`,
|
||||
}, "\n")
|
||||
|
||||
out, err := aggregateCompletion(strings.NewReader(sse), "hy3-preview")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var chat map[string]any
|
||||
if err := json.Unmarshal(out, &chat); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
choices := chat["choices"].([]any)
|
||||
msg := choices[0].(map[string]any)["message"].(map[string]any)
|
||||
calls, ok := msg["tool_calls"].([]any)
|
||||
if !ok || len(calls) != 1 {
|
||||
t.Fatalf("expected 1 merged tool_call, got %#v", msg["tool_calls"])
|
||||
}
|
||||
call := calls[0].(map[string]any)
|
||||
fn := call["function"].(map[string]any)
|
||||
if fn["name"] != "get_weather" || fn["arguments"] != `{"city":"Beijing"}` || call["id"] != "call_1" {
|
||||
t.Fatalf("bad merged call: %#v", call)
|
||||
}
|
||||
t.Logf("aggregateCompletion merged: %v", call)
|
||||
}
|
||||
|
||||
// 验证 Responses -> Chat Completions 请求转换(instructions/input/tools/tool_choice/参数)
|
||||
func TestResponsesToChatRequest(t *testing.T) {
|
||||
respReq := map[string]any{
|
||||
"model": "hy3-preview",
|
||||
"instructions": "You are helpful.",
|
||||
"max_output_tokens": float64(128),
|
||||
"temperature": float64(0.3),
|
||||
"input": []any{
|
||||
map[string]any{"role": "user", "content": []any{
|
||||
map[string]any{"type": "input_text", "text": "weather?"},
|
||||
}},
|
||||
map[string]any{"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": `{"city":"BJ"}`},
|
||||
map[string]any{"type": "function_call_output", "call_id": "call_1", "output": "sunny"},
|
||||
},
|
||||
"tools": []any{map[string]any{
|
||||
"type": "function", "name": "get_weather", "description": "Get weather",
|
||||
"parameters": map[string]any{"type": "object"},
|
||||
}},
|
||||
"tool_choice": "auto",
|
||||
"reasoning": map[string]any{"effort": "high"},
|
||||
}
|
||||
|
||||
chat, err := responsesToChatRequest(respReq, "hy3-preview")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chat["model"] != "hy3-preview" || chat["max_tokens"] != float64(128) || chat["temperature"] != float64(0.3) {
|
||||
t.Fatalf("bad top-level fields: %#v", chat)
|
||||
}
|
||||
if chat["reasoning_effort"] != "high" {
|
||||
t.Fatalf("reasoning_effort = %v", chat["reasoning_effort"])
|
||||
}
|
||||
messages := chat["messages"].([]any)
|
||||
if len(messages) != 4 {
|
||||
t.Fatalf("expected 4 messages (system+user+assistant+tool), got %d: %#v", len(messages), messages)
|
||||
}
|
||||
if rolesOf(map[string]any{"messages": messages})[0] != "system" {
|
||||
t.Fatalf("first message should be system")
|
||||
}
|
||||
assistant := messages[2].(map[string]any)
|
||||
if assistant["role"] != "assistant" {
|
||||
t.Fatalf("3rd message role = %v", assistant["role"])
|
||||
}
|
||||
toolMsg := messages[3].(map[string]any)
|
||||
if toolMsg["role"] != "tool" || toolMsg["tool_call_id"] != "call_1" || toolMsg["content"] != "sunny" {
|
||||
t.Fatalf("tool message = %#v", toolMsg)
|
||||
}
|
||||
tools := chat["tools"].([]any)
|
||||
fn := tools[0].(map[string]any)["function"].(map[string]any)
|
||||
if fn["name"] != "get_weather" {
|
||||
t.Fatalf("tools not flattened: %#v", tools)
|
||||
}
|
||||
if chat["tool_choice"] != "auto" {
|
||||
t.Fatalf("tool_choice = %v", chat["tool_choice"])
|
||||
}
|
||||
t.Log("responses request converted OK")
|
||||
}
|
||||
|
||||
// 验证 responsesToChatRequest 对纯字符串 input 的处理与空 input 报错
|
||||
func TestResponsesToChatRequestStringInput(t *testing.T) {
|
||||
chat, err := responsesToChatRequest(map[string]any{"model": "x", "input": "hello"}, "x")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
messages := chat["messages"].([]any)
|
||||
if len(messages) != 1 || messages[0].(map[string]any)["content"] != "hello" {
|
||||
t.Fatalf("bad messages: %#v", messages)
|
||||
}
|
||||
if _, err := responsesToChatRequest(map[string]any{"model": "x"}, "x"); err == nil {
|
||||
t.Fatal("empty input should error")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证 chat.completion -> Responses 非流式响应对象转换
|
||||
func TestChatCompletionToResponses(t *testing.T) {
|
||||
chat := map[string]any{
|
||||
"id": "cmpl-123", "created": float64(1700000000), "model": "hy3-preview",
|
||||
"choices": []any{map[string]any{"index": 0, "message": map[string]any{
|
||||
"role": "assistant", "content": "hello", "reasoning_content": "thinking",
|
||||
"tool_calls": []any{map[string]any{
|
||||
"id": "call_1", "type": "function",
|
||||
"function": map[string]any{"name": "get_weather", "arguments": `{"city":"BJ"}`},
|
||||
}},
|
||||
}}},
|
||||
"usage": map[string]any{
|
||||
"prompt_tokens": float64(10), "completion_tokens": float64(5), "total_tokens": float64(15),
|
||||
"prompt_tokens_details": map[string]any{"cached_tokens": float64(2)},
|
||||
"completion_tokens_details": map[string]any{"reasoning_tokens": float64(3)},
|
||||
},
|
||||
}
|
||||
raw, _ := json.Marshal(chat)
|
||||
out, err := chatCompletionToResponses(raw, "hy3-preview")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(out, &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result["object"] != "response" || result["status"] != "completed" {
|
||||
t.Fatalf("bad envelope: %#v", result)
|
||||
}
|
||||
output := result["output"].([]any)
|
||||
if len(output) != 3 {
|
||||
t.Fatalf("expected 3 output items (reasoning+message+function_call), got %d", len(output))
|
||||
}
|
||||
if output[0].(map[string]any)["type"] != "reasoning" {
|
||||
t.Fatalf("output[0] = %#v", output[0])
|
||||
}
|
||||
if output[1].(map[string]any)["type"] != "message" {
|
||||
t.Fatalf("output[1] = %#v", output[1])
|
||||
}
|
||||
fc := output[2].(map[string]any)
|
||||
if fc["type"] != "function_call" || fc["call_id"] != "call_1" || fc["name"] != "get_weather" {
|
||||
t.Fatalf("function_call item bad: %#v", fc)
|
||||
}
|
||||
usage := result["usage"].(map[string]any)
|
||||
if usage["input_tokens"] != float64(10) && usage["input_tokens"] != int64(10) {
|
||||
t.Fatalf("usage input = %#v", usage["input_tokens"])
|
||||
}
|
||||
if usage["total_tokens"] != float64(15) && usage["total_tokens"] != int64(15) {
|
||||
t.Fatalf("usage total = %#v", usage["total_tokens"])
|
||||
}
|
||||
t.Logf("responses object output items: %d", len(output))
|
||||
}
|
||||
|
||||
+699
@@ -0,0 +1,699 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// OpenAI Responses API (/v1/responses) -> 上游 Chat Completions 转译
|
||||
//
|
||||
// 网关只与上游 /v2/chat/completions 通信,此处负责双向协议转换:
|
||||
// - 请求:input / instructions / tools(扁平) / tool_choice / max_output_tokens /
|
||||
// reasoning.effort -> chat messages / tools(嵌套 function) / max_tokens / reasoning_effort
|
||||
// - 非流式响应:chat.completion -> response{object:"response", output:[...]}
|
||||
// - 流式响应:上游 SSE 增量 -> Responses 语义事件(response.output_text.delta、
|
||||
// response.function_call_arguments.delta、response.completed ...)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// handleResponses 处理 POST /v1/responses。
|
||||
func handleResponses(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()
|
||||
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusBadRequest, "read_error", "读取请求体失败")
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
var respReq map[string]any
|
||||
if err := json.Unmarshal(bodyBytes, &respReq); err != nil {
|
||||
writeOpenAIError(w, http.StatusBadRequest, "invalid_json", "无效的 JSON 请求体")
|
||||
return
|
||||
}
|
||||
|
||||
modelName, _ := respReq["model"].(string)
|
||||
if modelName == "" {
|
||||
modelName = "hy4-preview"
|
||||
}
|
||||
isStream, _ := respReq["stream"].(bool)
|
||||
|
||||
chatReq, err := responsesToChatRequest(respReq, modelName)
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
chatReq["stream"] = true // 上游强制流式,非流式由网关本地聚合
|
||||
|
||||
applyThinkingRules(chatReq, modelName)
|
||||
sanitizeMessages(chatReq)
|
||||
ensureLeadingSystemMessage(chatReq)
|
||||
|
||||
upstreamBytes, err := json.Marshal(chatReq)
|
||||
if err != nil {
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "encode_error", "序列化请求失败")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[#%d] POST /v1/responses -> Upstream [Model: %s, Stream: %v]", reqID, modelName, isStream)
|
||||
|
||||
resp, acc, prof, ok := upstreamChat(w, r, reqID, upstreamBytes, startTime)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if isStream {
|
||||
streamResponsesResponse(w, resp, modelName, reqID, acc, prof, startTime)
|
||||
} else {
|
||||
writeResponsesAggregate(w, resp, modelName, reqID, acc, prof, startTime)
|
||||
}
|
||||
}
|
||||
|
||||
// responsesToChatRequest 将 Responses 请求体转换为上游 Chat Completions 请求体。
|
||||
func responsesToChatRequest(respReq map[string]any, modelName string) (map[string]any, error) {
|
||||
chat := map[string]any{"model": modelName}
|
||||
|
||||
messages := []any{}
|
||||
if instructions, ok := respReq["instructions"].(string); ok && strings.TrimSpace(instructions) != "" {
|
||||
messages = append(messages, map[string]any{"role": "system", "content": instructions})
|
||||
}
|
||||
|
||||
switch input := respReq["input"].(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(input) != "" {
|
||||
messages = append(messages, map[string]any{"role": "user", "content": input})
|
||||
}
|
||||
case []any:
|
||||
for _, itemAny := range input {
|
||||
switch item := itemAny.(type) {
|
||||
case string:
|
||||
messages = append(messages, map[string]any{"role": "user", "content": item})
|
||||
case map[string]any:
|
||||
messages = append(messages, convertResponsesInputItem(item)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(messages) == 0 {
|
||||
return nil, fmt.Errorf("input 为空:Responses 请求必须提供 input 或 instructions")
|
||||
}
|
||||
chat["messages"] = messages
|
||||
|
||||
if v, ok := respReq["temperature"]; ok && v != nil {
|
||||
chat["temperature"] = v
|
||||
}
|
||||
if v, ok := respReq["top_p"]; ok && v != nil {
|
||||
chat["top_p"] = v
|
||||
}
|
||||
if v, ok := respReq["max_output_tokens"]; ok && v != nil {
|
||||
chat["max_tokens"] = v
|
||||
}
|
||||
if tools := convertResponsesTools(respReq["tools"]); len(tools) > 0 {
|
||||
chat["tools"] = tools
|
||||
}
|
||||
if tc := convertResponsesToolChoice(respReq["tool_choice"]); tc != nil {
|
||||
chat["tool_choice"] = tc
|
||||
}
|
||||
if reasoning, ok := respReq["reasoning"].(map[string]any); ok {
|
||||
if effort, ok := reasoning["effort"].(string); ok && effort != "" && effort != "none" {
|
||||
chat["reasoning_effort"] = effort
|
||||
}
|
||||
}
|
||||
return chat, nil
|
||||
}
|
||||
|
||||
// convertResponsesInputItem 将单个 Responses input item 转换为 0..1 条 chat 消息。
|
||||
func convertResponsesInputItem(item map[string]any) []any {
|
||||
switch typ, _ := item["type"].(string); typ {
|
||||
case "function_call":
|
||||
callID, _ := item["call_id"].(string)
|
||||
if callID == "" {
|
||||
callID, _ = item["id"].(string)
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
args, _ := item["arguments"].(string)
|
||||
return []any{map[string]any{
|
||||
"role": "assistant",
|
||||
"content": nil,
|
||||
"tool_calls": []any{map[string]any{
|
||||
"id": ifEmpty(callID, "call_"+compactUUID()),
|
||||
"type": "function",
|
||||
"function": map[string]any{"name": name, "arguments": args},
|
||||
}},
|
||||
}}
|
||||
case "function_call_output":
|
||||
callID, _ := item["call_id"].(string)
|
||||
return []any{map[string]any{
|
||||
"role": "tool",
|
||||
"tool_call_id": callID,
|
||||
"content": stringifyToolOutput(item["output"]),
|
||||
}}
|
||||
case "reasoning":
|
||||
// 上游无法接收 reasoning item,忽略(历史上下文不影响后续对话)
|
||||
return nil
|
||||
}
|
||||
|
||||
role, _ := item["role"].(string)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
return []any{map[string]any{"role": role, "content": convertResponsesContent(item["content"])}}
|
||||
}
|
||||
|
||||
// convertResponsesContent 将 Responses content(string 或 parts 数组)转换为 chat content。
|
||||
func convertResponsesContent(content any) any {
|
||||
switch c := content.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return c
|
||||
case []any:
|
||||
parts := make([]any, 0, len(c))
|
||||
for _, pAny := range c {
|
||||
p, ok := pAny.(map[string]any)
|
||||
if !ok {
|
||||
if s, ok := pAny.(string); ok {
|
||||
parts = append(parts, map[string]any{"type": "text", "text": s})
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch typ, _ := p["type"].(string); typ {
|
||||
case "input_text", "output_text", "text":
|
||||
if t, ok := p["text"].(string); ok {
|
||||
parts = append(parts, map[string]any{"type": "text", "text": t})
|
||||
}
|
||||
case "refusal":
|
||||
if t, ok := p["refusal"].(string); ok {
|
||||
parts = append(parts, map[string]any{"type": "text", "text": t})
|
||||
}
|
||||
case "input_image":
|
||||
url, _ := p["image_url"].(string)
|
||||
if url != "" {
|
||||
parts = append(parts, map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return parts
|
||||
default:
|
||||
return fmt.Sprintf("%v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// stringifyToolOutput 将 function_call_output 的 output 统一转为字符串。
|
||||
func stringifyToolOutput(output any) string {
|
||||
switch o := output.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return o
|
||||
default:
|
||||
if b, err := json.Marshal(o); err == nil {
|
||||
return string(b)
|
||||
}
|
||||
return fmt.Sprintf("%v", o)
|
||||
}
|
||||
}
|
||||
|
||||
// convertResponsesTools 将 Responses 扁平 function 工具转换为 chat 嵌套 function 工具。
|
||||
func convertResponsesTools(toolsAny any) []any {
|
||||
arr, ok := toolsAny.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]any, 0, len(arr))
|
||||
for _, tAny := range arr {
|
||||
t, ok := tAny.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typ, _ := t["type"].(string)
|
||||
if typ != "" && typ != "function" {
|
||||
continue // 仅支持 function 工具
|
||||
}
|
||||
name, _ := t["name"].(string)
|
||||
desc, _ := t["description"].(string)
|
||||
params := t["parameters"]
|
||||
if name == "" {
|
||||
// 兼容旧式嵌套 {type:"function", function:{...}}
|
||||
if fn, ok := t["function"].(map[string]any); ok {
|
||||
name, _ = fn["name"].(string)
|
||||
desc, _ = fn["description"].(string)
|
||||
params = fn["parameters"]
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
fn := map[string]any{"name": name}
|
||||
if desc != "" {
|
||||
fn["description"] = desc
|
||||
}
|
||||
if params != nil {
|
||||
fn["parameters"] = params
|
||||
}
|
||||
out = append(out, map[string]any{"type": "function", "function": fn})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// convertResponsesToolChoice 将 Responses tool_choice 转换为 chat tool_choice。
|
||||
func convertResponsesToolChoice(tc any) any {
|
||||
switch v := tc.(type) {
|
||||
case string:
|
||||
if v == "auto" || v == "none" || v == "required" {
|
||||
return v
|
||||
}
|
||||
case map[string]any:
|
||||
if typ, _ := v["type"].(string); typ == "function" {
|
||||
name, _ := v["name"].(string)
|
||||
if name == "" {
|
||||
if fn, ok := v["function"].(map[string]any); ok {
|
||||
name, _ = fn["name"].(string)
|
||||
}
|
||||
}
|
||||
if name != "" {
|
||||
return map[string]any{"type": "function", "function": map[string]any{"name": name}}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeResponsesAggregate 聚合上游 SSE 后转换为 Responses 非流式响应。
|
||||
func writeResponsesAggregate(w http.ResponseWriter, resp *http.Response, modelName string, reqID uint64, acc *Account, prof *upstreamProfile, startTime time.Time) {
|
||||
defer resp.Body.Close()
|
||||
completionJSON, err := aggregateCompletion(resp.Body, modelName)
|
||||
if err != nil {
|
||||
log.Printf("[#%d] 聚合响应失败: %v", reqID, err)
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "aggregate_error", "聚合上游流式响应失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := chatCompletionToResponses(completionJSON, modelName)
|
||||
if err != nil {
|
||||
log.Printf("[#%d] Responses 转换失败: %v", reqID, err)
|
||||
writeOpenAIError(w, http.StatusInternalServerError, "translate_error", "转换 Responses 响应失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(out)
|
||||
log.Printf("[#%d] Responses 非流式响应完成 (账号 %s [%s], 耗时 %v)", reqID, acc.Path, prof.Label, time.Since(startTime))
|
||||
}
|
||||
|
||||
// chatCompletionToResponses 将 chat.completion JSON 转换为 Responses 响应对象。
|
||||
func chatCompletionToResponses(chatJSON []byte, modelName string) ([]byte, error) {
|
||||
var chat map[string]any
|
||||
if err := json.Unmarshal(chatJSON, &chat); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var content, reasoning string
|
||||
var toolCalls []any
|
||||
if choices, ok := chat["choices"].([]any); ok && len(choices) > 0 {
|
||||
if choice, ok := choices[0].(map[string]any); ok {
|
||||
if msg, ok := choice["message"].(map[string]any); ok {
|
||||
content, _ = msg["content"].(string)
|
||||
reasoning, _ = msg["reasoning_content"].(string)
|
||||
toolCalls, _ = msg["tool_calls"].([]any)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := []any{}
|
||||
if reasoning != "" {
|
||||
output = append(output, map[string]any{
|
||||
"id": "rs_" + compactUUID(),
|
||||
"type": "reasoning",
|
||||
"status": "completed",
|
||||
"summary": []any{map[string]any{"type": "summary_text", "text": reasoning}},
|
||||
})
|
||||
}
|
||||
if content != "" || len(toolCalls) == 0 {
|
||||
output = append(output, map[string]any{
|
||||
"id": "msg_" + compactUUID(),
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": []any{
|
||||
map[string]any{"type": "output_text", "text": content, "annotations": []any{}},
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, tcAny := range toolCalls {
|
||||
tc, ok := tcAny.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
callID, _ := tc["id"].(string)
|
||||
name, args := "", ""
|
||||
if fn, ok := tc["function"].(map[string]any); ok {
|
||||
name, _ = fn["name"].(string)
|
||||
args, _ = fn["arguments"].(string)
|
||||
}
|
||||
output = append(output, map[string]any{
|
||||
"id": "fc_" + compactUUID(),
|
||||
"type": "function_call",
|
||||
"status": "completed",
|
||||
"call_id": ifEmpty(callID, "call_"+compactUUID()),
|
||||
"name": name,
|
||||
"arguments": args,
|
||||
})
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
created := now
|
||||
if v, ok := chat["created"].(float64); ok && v > 0 && int64(v) <= now {
|
||||
created = int64(v)
|
||||
}
|
||||
respID, _ := chat["id"].(string)
|
||||
if respID == "" {
|
||||
respID = "resp_" + compactUUID()
|
||||
} else {
|
||||
respID = "resp_" + strings.TrimPrefix(respID, "chatcmpl-")
|
||||
}
|
||||
|
||||
result := buildResponsesEnvelope(respID, modelName, created)
|
||||
result["status"] = "completed"
|
||||
result["output"] = output
|
||||
result["completed_at"] = now
|
||||
if usage, ok := chat["usage"].(map[string]any); ok {
|
||||
result["usage"] = toResponsesUsage(usage)
|
||||
}
|
||||
return json.Marshal(result)
|
||||
}
|
||||
|
||||
// buildResponsesEnvelope 构造 Responses 响应骨架(含规范要求的常见字段)。
|
||||
func buildResponsesEnvelope(id, model string, createdAt int64) map[string]any {
|
||||
return map[string]any{
|
||||
"id": id,
|
||||
"object": "response",
|
||||
"created_at": createdAt,
|
||||
"completed_at": nil,
|
||||
"status": "in_progress",
|
||||
"error": nil,
|
||||
"incomplete_details": nil,
|
||||
"instructions": nil,
|
||||
"max_output_tokens": nil,
|
||||
"model": model,
|
||||
"output": []any{},
|
||||
"parallel_tool_calls": true,
|
||||
"previous_response_id": nil,
|
||||
"reasoning": map[string]any{"effort": nil, "summary": nil},
|
||||
"store": true,
|
||||
"temperature": 1.0,
|
||||
"text": map[string]any{"format": map[string]any{"type": "text"}},
|
||||
"tool_choice": "auto",
|
||||
"tools": []any{},
|
||||
"top_p": 1.0,
|
||||
"truncation": "disabled",
|
||||
"usage": nil,
|
||||
"metadata": map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
// toResponsesUsage 将 chat usage 转换为 Responses usage 结构。
|
||||
func toResponsesUsage(u map[string]any) map[string]any {
|
||||
in := numOr0(u["prompt_tokens"])
|
||||
out := numOr0(u["completion_tokens"])
|
||||
total := numOr0(u["total_tokens"])
|
||||
if total == 0 {
|
||||
total = in + out
|
||||
}
|
||||
cached, reasoningTokens := 0.0, 0.0
|
||||
if d, ok := u["prompt_tokens_details"].(map[string]any); ok {
|
||||
cached = numOr0(d["cached_tokens"])
|
||||
}
|
||||
if d, ok := u["completion_tokens_details"].(map[string]any); ok {
|
||||
reasoningTokens = numOr0(d["reasoning_tokens"])
|
||||
}
|
||||
return map[string]any{
|
||||
"input_tokens": int64(in),
|
||||
"input_tokens_details": map[string]any{"cached_tokens": int64(cached)},
|
||||
"output_tokens": int64(out),
|
||||
"output_tokens_details": map[string]any{"reasoning_tokens": int64(reasoningTokens)},
|
||||
"total_tokens": int64(total),
|
||||
}
|
||||
}
|
||||
|
||||
func numOr0(v any) float64 {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// streamResponsesResponse 将上游 Chat Completions SSE 实时转译为 Responses 语义事件流。
|
||||
func streamResponsesResponse(w http.ResponseWriter, resp *http.Response, modelName string, reqID uint64, acc *Account, prof *upstreamProfile, startTime time.Time) {
|
||||
defer resp.Body.Close()
|
||||
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
|
||||
}
|
||||
|
||||
seq := 0
|
||||
emit := func(eventType string, data map[string]any) {
|
||||
data["type"] = eventType
|
||||
data["sequence_number"] = seq
|
||||
seq++
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, b)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
created := time.Now().Unix()
|
||||
respID := "resp_" + compactUUID()
|
||||
envelope := buildResponsesEnvelope(respID, modelName, created)
|
||||
emit("response.created", map[string]any{"response": envelope})
|
||||
emit("response.in_progress", map[string]any{"response": envelope})
|
||||
|
||||
nextIndex := 0
|
||||
idxToItem := map[int]map[string]any{}
|
||||
|
||||
// reasoning item 状态
|
||||
reasoningOpen := false
|
||||
reasoningIndex := -1
|
||||
reasoningItemID := ""
|
||||
var reasoningSB strings.Builder
|
||||
|
||||
// message item 状态
|
||||
msgOpen := false
|
||||
msgIndex := -1
|
||||
msgItemID := ""
|
||||
var msgSB strings.Builder
|
||||
|
||||
// function_call item 状态
|
||||
toolCalls := map[int]*mergedToolCall{}
|
||||
var toolOrder []int
|
||||
tcItemID := map[int]string{}
|
||||
tcIndex := map[int]int{}
|
||||
|
||||
openReasoning := func() {
|
||||
if reasoningOpen {
|
||||
return
|
||||
}
|
||||
reasoningOpen = true
|
||||
reasoningIndex = nextIndex
|
||||
nextIndex++
|
||||
reasoningItemID = "rs_" + compactUUID()
|
||||
emit("response.output_item.added", map[string]any{
|
||||
"output_index": reasoningIndex,
|
||||
"item": map[string]any{"id": reasoningItemID, "type": "reasoning", "status": "in_progress", "summary": []any{}},
|
||||
})
|
||||
emit("response.reasoning_summary_part.added", map[string]any{
|
||||
"item_id": reasoningItemID, "output_index": reasoningIndex, "summary_index": 0,
|
||||
"part": map[string]any{"type": "summary_text", "text": ""},
|
||||
})
|
||||
}
|
||||
|
||||
openMessage := func() {
|
||||
if msgOpen {
|
||||
return
|
||||
}
|
||||
msgOpen = true
|
||||
msgIndex = nextIndex
|
||||
nextIndex++
|
||||
msgItemID = "msg_" + compactUUID()
|
||||
emit("response.output_item.added", map[string]any{
|
||||
"output_index": msgIndex,
|
||||
"item": map[string]any{"id": msgItemID, "type": "message", "status": "in_progress", "role": "assistant", "content": []any{}},
|
||||
})
|
||||
emit("response.content_part.added", map[string]any{
|
||||
"item_id": msgItemID, "output_index": msgIndex, "content_index": 0,
|
||||
"part": map[string]any{"type": "output_text", "annotations": []any{}, "text": ""},
|
||||
})
|
||||
}
|
||||
|
||||
var usage map[string]any
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
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 u, ok := chunk["usage"].(map[string]any); ok {
|
||||
usage = u
|
||||
}
|
||||
choices, _ := chunk["choices"].([]any)
|
||||
for _, c := range choices {
|
||||
choice, _ := c.(map[string]any)
|
||||
delta, _ := choice["delta"].(map[string]any)
|
||||
if delta == nil {
|
||||
continue
|
||||
}
|
||||
if rc, ok := delta["reasoning_content"].(string); ok && rc != "" {
|
||||
openReasoning()
|
||||
reasoningSB.WriteString(rc)
|
||||
emit("response.reasoning_summary_text.delta", map[string]any{
|
||||
"item_id": reasoningItemID, "output_index": reasoningIndex, "summary_index": 0, "delta": rc,
|
||||
})
|
||||
}
|
||||
if ct, ok := delta["content"].(string); ok && ct != "" {
|
||||
openMessage()
|
||||
msgSB.WriteString(ct)
|
||||
emit("response.output_text.delta", map[string]any{
|
||||
"item_id": msgItemID, "output_index": msgIndex, "content_index": 0, "delta": ct,
|
||||
})
|
||||
}
|
||||
if tcs, ok := delta["tool_calls"].([]any); ok && len(tcs) > 0 {
|
||||
// 先提取本次参数增量,供合并后补发 delta 事件
|
||||
type argDelta struct {
|
||||
idx int
|
||||
arg string
|
||||
}
|
||||
var argDeltas []argDelta
|
||||
for _, tcAny := range tcs {
|
||||
tc, ok := tcAny.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
idx := 0
|
||||
if v, ok := tc["index"].(float64); ok {
|
||||
idx = int(v)
|
||||
}
|
||||
if fn, ok := tc["function"].(map[string]any); ok {
|
||||
if a, ok := fn["arguments"].(string); ok && a != "" {
|
||||
argDeltas = append(argDeltas, argDelta{idx: idx, arg: a})
|
||||
}
|
||||
}
|
||||
}
|
||||
before := len(toolOrder)
|
||||
applyToolCallDelta(toolCalls, &toolOrder, tcs)
|
||||
for i := before; i < len(toolOrder); i++ {
|
||||
idx := toolOrder[i]
|
||||
st := toolCalls[idx]
|
||||
if st.ID == "" {
|
||||
st.ID = "call_" + compactUUID()
|
||||
}
|
||||
tcItemID[idx] = "fc_" + compactUUID()
|
||||
tcIndex[idx] = nextIndex
|
||||
nextIndex++
|
||||
emit("response.output_item.added", map[string]any{
|
||||
"output_index": tcIndex[idx],
|
||||
"item": map[string]any{
|
||||
"id": tcItemID[idx], "type": "function_call", "status": "in_progress",
|
||||
"call_id": st.ID, "name": st.Name, "arguments": "",
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, ad := range argDeltas {
|
||||
emit("response.function_call_arguments.delta", map[string]any{
|
||||
"item_id": tcItemID[ad.idx], "output_index": tcIndex[ad.idx], "delta": ad.arg,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 若无任何输出,补一个空 message,保证 output 非空且事件序列完整
|
||||
if !reasoningOpen && !msgOpen && len(toolOrder) == 0 {
|
||||
openMessage()
|
||||
}
|
||||
|
||||
// 收尾:reasoning
|
||||
if reasoningOpen {
|
||||
text := reasoningSB.String()
|
||||
emit("response.reasoning_summary_text.done", map[string]any{"item_id": reasoningItemID, "output_index": reasoningIndex, "summary_index": 0, "text": text})
|
||||
emit("response.reasoning_summary_part.done", map[string]any{"item_id": reasoningItemID, "output_index": reasoningIndex, "summary_index": 0, "part": map[string]any{"type": "summary_text", "text": text}})
|
||||
item := map[string]any{"id": reasoningItemID, "type": "reasoning", "status": "completed", "summary": []any{map[string]any{"type": "summary_text", "text": text}}}
|
||||
emit("response.output_item.done", map[string]any{"output_index": reasoningIndex, "item": item})
|
||||
idxToItem[reasoningIndex] = item
|
||||
}
|
||||
|
||||
// 收尾:message
|
||||
if msgOpen {
|
||||
text := msgSB.String()
|
||||
emit("response.output_text.done", map[string]any{"item_id": msgItemID, "output_index": msgIndex, "content_index": 0, "text": text})
|
||||
emit("response.content_part.done", map[string]any{"item_id": msgItemID, "output_index": msgIndex, "content_index": 0, "part": map[string]any{"type": "output_text", "annotations": []any{}, "text": text}})
|
||||
item := map[string]any{"id": msgItemID, "type": "message", "status": "completed", "role": "assistant", "content": []any{map[string]any{"type": "output_text", "annotations": []any{}, "text": text}}}
|
||||
emit("response.output_item.done", map[string]any{"output_index": msgIndex, "item": item})
|
||||
idxToItem[msgIndex] = item
|
||||
}
|
||||
|
||||
// 收尾:function calls
|
||||
for _, idx := range toolOrder {
|
||||
st := toolCalls[idx]
|
||||
args := st.Args.String()
|
||||
emit("response.function_call_arguments.done", map[string]any{"item_id": tcItemID[idx], "output_index": tcIndex[idx], "name": st.Name, "arguments": args})
|
||||
item := map[string]any{"id": tcItemID[idx], "type": "function_call", "status": "completed", "call_id": st.ID, "name": st.Name, "arguments": args}
|
||||
emit("response.output_item.done", map[string]any{"output_index": tcIndex[idx], "item": item})
|
||||
idxToItem[tcIndex[idx]] = item
|
||||
}
|
||||
|
||||
output := make([]any, 0, len(idxToItem))
|
||||
for i := 0; i < nextIndex; i++ {
|
||||
if item, ok := idxToItem[i]; ok {
|
||||
output = append(output, item)
|
||||
}
|
||||
}
|
||||
|
||||
final := buildResponsesEnvelope(respID, modelName, created)
|
||||
final["status"] = "completed"
|
||||
final["output"] = output
|
||||
final["completed_at"] = time.Now().Unix()
|
||||
if usage != nil {
|
||||
final["usage"] = toResponsesUsage(usage)
|
||||
}
|
||||
emit("response.completed", map[string]any{"response": final})
|
||||
|
||||
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
flusher.Flush()
|
||||
log.Printf("[#%d] Responses 流式输出完成 (账号 %s [%s], 耗时 %v)", reqID, acc.Path, prof.Label, time.Since(startTime))
|
||||
}
|
||||
|
||||
// compactUUID 返回去掉连字符的随机 UUID。
|
||||
func compactUUID() string {
|
||||
return strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
Reference in New Issue
Block a user