feat: OpenAI Responses API + session normalization (v1.8.1)

- add /v1/responses (and /responses): OpenAI Responses API support
  - streaming semantic events, non-streaming aggregation, function tools
  - input item / content / tool / tool_choice conversion to chat format
- ensure first message is system: fix upstream 11128
  "first message is not system prompt" for non-harness clients
  (assistant/tool-leading or missing system); normalize developer role
- fix tool call delta merging (applyToolCallDelta) across streamed chunks
- refactor handleChatCompletions: extract upstreamChat for 429 cooldown
  fallback across the account pool
- bump version to 1.8.1
- tests: 37 passing (session normalization, responses conversion,
  tool call merge, responses<->chat mapping)
This commit is contained in:
CangShui
2026-09-13 17:43:01 +08:00
parent 5ac6ee7975
commit 1ee094ffa1
4 changed files with 1299 additions and 145 deletions
+289 -144
View File
@@ -31,10 +31,14 @@ import (
)
const (
version = "1.7.0"
version = "1.8.1"
// 状态快照文件名:serve 后台周期写入,monitor 前台命令实时读取展示
statusSnapshotFile = "workbuddy-status.json"
// defaultSystemPrompt 是当客户端首条消息不是 system 时注入的保底系统提示,
// 用于满足腾讯上游「首条消息必须是 system prompt」的硬性要求 (code 11128)。
defaultSystemPrompt = "You are a helpful assistant."
)
// -----------------------------------------------------------------------------
@@ -1540,6 +1544,8 @@ func runServe() {
mux := http.NewServeMux()
mux.HandleFunc("/v1/chat/completions", handleChatCompletions)
mux.HandleFunc("/chat/completions", handleChatCompletions)
mux.HandleFunc("/v1/responses", handleResponses)
mux.HandleFunc("/responses", handleResponses)
mux.HandleFunc("/v1/models", handleModels)
mux.HandleFunc("/models", handleModels)
mux.HandleFunc("/health", handleHealth)
@@ -1678,6 +1684,104 @@ func authMiddleware(next http.Handler) http.Handler {
// 路由处理: /v1/chat/completions (支持任意 model 透传)
// -----------------------------------------------------------------------------
// upstreamChat 完成「多账号轮询 + 429 冷却代偿 + 授权失效禁用 + 单账号串行」的上游调度。
// 成功时返回 200 响应(调用方负责关闭 Body)与命中的账号/站点;失败时函数内部已写回
// 错误响应并返回 ok=false。Chat Completions 与 Responses 两个入口共用此逻辑。
func upstreamChat(w http.ResponseWriter, r *http.Request, reqID uint64, upstreamBytes []byte, startTime time.Time) (*http.Response, *Account, *upstreamProfile, bool) {
accountMu.Lock()
poolSize := len(accounts)
accountMu.Unlock()
if poolSize == 0 {
writeOpenAIError(w, http.StatusUnauthorized, "no_auth", "未找到有效登录凭据,请先执行 login 命令扫码登录")
return nil, nil, nil, false
}
var lastRateErr string
var lastAuthErr string
for attempt := 0; attempt < poolSize; attempt++ {
acc, err := nextAccount()
if err != nil {
// 所有账号均不可用(冷却或失效)
msg := fmt.Sprintf("无可用账号: %v", err)
if lastAuthErr != "" {
msg += " | 最近一次授权失效: " + truncate(lastAuthErr, 200)
}
if lastRateErr != "" {
msg += " | 最近一次频率限制: " + truncate(lastRateErr, 200)
}
log.Printf("[#%d] %s", reqID, msg)
writeOpenAIError(w, http.StatusServiceUnavailable, "no_available_account", msg)
return nil, nil, nil, false
}
_ = ensureValidTokenFor(acc)
// 令牌刷新时若发现授权失效会禁用账号;若被禁用则跳过换下一个
accountMu.Lock()
disabled := acc.Disabled
accountMu.Unlock()
if disabled {
lastAuthErr = acc.DisabledReason
continue
}
// 按账号所属站点(国内站/国际站)路由上游与指纹 Header
prof := acc.Profile()
upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, prof.chatURL(), bytes.NewReader(upstreamBytes))
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, "req_create_error", err.Error())
return nil, nil, nil, false
}
// 注入 CodeBuddy 凭据与指纹 Header
backendHeaders(upstreamReq, acc.Auth, prof)
// 限制同一账号向腾讯上游的请求严格单并发串行排队,防止并发双发触发腾讯风控
acc.lock.Lock()
resp, err := cfg.HttpClient.Do(upstreamReq)
acc.lock.Unlock()
if err != nil {
log.Printf("[#%d] 账号 %s [%s] 上游请求失败: %v", reqID, acc.Path, prof.Label, err)
writeOpenAIError(w, http.StatusBadGateway, "upstream_network_error", fmt.Sprintf("网络转发失败: %v", err))
return nil, nil, nil, false
}
// 上游非 200 响应处理
if resp.StatusCode >= 400 {
errBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
errStr := string(errBody)
log.Printf("[#%d] 账号 %s [%s] 上游返回 HTTP %d: %s (耗时 %v)", reqID, acc.Path, prof.Label, resp.StatusCode, errStr, time.Since(startTime))
if isRateLimited(resp.StatusCode, errStr) {
// 429 频率限制:解析重置时间并屏蔽该账号,交由其他账号代偿
until, ok := parseResetTime(errStr)
if !ok {
until = time.Now().Add(60 * time.Second) // 无法解析时默认冷却 60 秒
}
markCooldown(acc, until, errStr)
lastRateErr = errStr
continue // 尝试下一个账号
}
if isAuthFailure(resp.StatusCode, errStr) {
// 授权失效(401/403 / token 无效 / 登录过期):禁用该账号并删除凭据文件,
// 自动改用下一个可用账号,控制台提示用户重新登录
disableAccount(acc, fmt.Sprintf("上游鉴权失败 (HTTP %d): %s", resp.StatusCode, truncate(errStr, 200)))
lastAuthErr = errStr
continue // 尝试下一个账号
}
writeOpenAIError(w, resp.StatusCode, "upstream_error", fmt.Sprintf("upstream %d: %s", resp.StatusCode, errStr))
return nil, nil, nil, false
}
return resp, acc, prof, true
}
// 理论上不可达(poolSize 次尝试后未成功即已在循环内返回)
writeOpenAIError(w, http.StatusTooManyRequests, "all_accounts_cooldown", "所有账号均处于冷却状态")
return nil, nil, nil, false
}
func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeOpenAIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "仅支持 POST 请求")
@@ -1719,6 +1823,10 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
// 模板净化:改写 Claude Code 等框架被腾讯官方逐字拉黑的固定 prompt 语句
sanitizeMessages(reqObj)
// 会话结构归一化:保证首条消息为 system,修复部分非 harness 客户端
//(以 assistant / tool 续写或回传工具结果)触发的上游 11128 错误
ensureLeadingSystemMessage(reqObj)
upstreamBytes, err := json.Marshal(reqObj)
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, "encode_error", "序列化请求失败")
@@ -1731,150 +1839,64 @@ func handleChatCompletions(w http.ResponseWriter, r *http.Request) {
log.Printf("[#%d] POST /v1/chat/completions -> Upstream [Model: %s, Stream: %v]", reqID, modelName, isStream)
}
// 多账号轮询 + 429 自动冷却代偿:
// 按轮询顺序尝试账号;命中 429 频率限制时立即将当前账号屏蔽到重置时间,
// 并自动改用下一个可用账号重试(代偿),直至成功或所有账号均不可用。
accountMu.Lock()
poolSize := len(accounts)
accountMu.Unlock()
if poolSize == 0 {
writeOpenAIError(w, http.StatusUnauthorized, "no_auth", "未找到有效登录凭据,请先执行 login 命令扫码登录")
resp, acc, prof, ok := upstreamChat(w, r, reqID, upstreamBytes, startTime)
if !ok {
return
}
if isStream {
streamChatResponse(w, resp, reqID, acc, prof, startTime)
} else {
writeChatAggregate(w, resp, modelName, reqID, acc, prof, startTime)
}
}
// streamChatResponse 将上游 SSE 逐行透传为 OpenAI Chat Completions 流式响应。
func streamChatResponse(w http.ResponseWriter, resp *http.Response, 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
}
var lastRateErr string
var lastAuthErr string
for attempt := 0; attempt < poolSize; attempt++ {
acc, err := nextAccount()
if err != nil {
// 所有账号均不可用(冷却或失效)
msg := fmt.Sprintf("无可用账号: %v", err)
if lastAuthErr != "" {
msg += " | 最近一次授权失效: " + truncate(lastAuthErr, 200)
}
if lastRateErr != "" {
msg += " | 最近一次频率限制: " + truncate(lastRateErr, 200)
}
log.Printf("[#%d] %s", reqID, msg)
writeOpenAIError(w, http.StatusServiceUnavailable, "no_available_account", msg)
return
}
_ = ensureValidTokenFor(acc)
// 令牌刷新时若发现授权失效会禁用账号;若被禁用则跳过换下一个
accountMu.Lock()
disabled := acc.Disabled
accountMu.Unlock()
if disabled {
lastAuthErr = acc.DisabledReason
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
for scanner.Scan() {
cleanData := stripDataPrefix(scanner.Text())
if cleanData == "" {
continue
}
// 按账号所属站点(国内站/国际站)路由上游与指纹 Header
prof := acc.Profile()
upstreamReq, err := http.NewRequestWithContext(r.Context(), http.MethodPost, prof.chatURL(), bytes.NewReader(upstreamBytes))
if err != nil {
writeOpenAIError(w, http.StatusInternalServerError, "req_create_error", err.Error())
return
if cleanData == "[DONE]" {
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
break
}
// 注入 CodeBuddy 凭据与指纹 Header
backendHeaders(upstreamReq, acc.Auth, prof)
// 限制同一账号向腾讯上游的请求严格单并发串行排队,防止 DSH 同时发起标题生成+主对话触发腾讯 11128 风控
acc.lock.Lock()
resp, err := cfg.HttpClient.Do(upstreamReq)
acc.lock.Unlock()
if err != nil {
log.Printf("[#%d] 账号 %s [%s] 上游请求失败: %v", reqID, acc.Path, prof.Label, err)
writeOpenAIError(w, http.StatusBadGateway, "upstream_network_error", fmt.Sprintf("网络转发失败: %v", err))
return
if cleanedChunk := cleanChunkJSON(cleanData); cleanedChunk != "" {
_, _ = fmt.Fprintf(w, "data: %s\n\n", cleanedChunk)
flusher.Flush()
}
}
log.Printf("[#%d] 流式输出完成 (账号 %s [%s], 耗时 %v)", reqID, acc.Path, prof.Label, time.Since(startTime))
}
// 上游非 200 响应处理
if resp.StatusCode >= 400 {
errBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
errStr := string(errBody)
log.Printf("[#%d] 账号 %s [%s] 上游返回 HTTP %d: %s (耗时 %v)", reqID, acc.Path, prof.Label, resp.StatusCode, errStr, time.Since(startTime))
if isRateLimited(resp.StatusCode, errStr) {
// 429 频率限制:解析重置时间并屏蔽该账号,交由其他账号代偿
until, ok := parseResetTime(errStr)
if !ok {
until = time.Now().Add(60 * time.Second) // 无法解析时默认冷却 60 秒
}
markCooldown(acc, until, errStr)
lastRateErr = errStr
continue // 尝试下一个账号
}
if isAuthFailure(resp.StatusCode, errStr) {
// 授权失效(401/403 / token 无效 / 登录过期):禁用该账号并删除凭据文件,
// 自动改用下一个可用账号,控制台提示用户重新登录
disableAccount(acc, fmt.Sprintf("上游鉴权失败 (HTTP %d): %s", resp.StatusCode, truncate(errStr, 200)))
lastAuthErr = errStr
continue // 尝试下一个账号
}
writeOpenAIError(w, resp.StatusCode, "upstream_error", fmt.Sprintf("upstream %d: %s", resp.StatusCode, errStr))
return
}
if isStream {
// 客户端需要流式响应
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
flusher, ok := w.(http.Flusher)
if !ok {
resp.Body.Close()
writeOpenAIError(w, http.StatusInternalServerError, "streaming_unsupported", "服务器不支持流式响应 Flush")
return
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
for scanner.Scan() {
line := scanner.Text()
cleanData := stripDataPrefix(line)
if cleanData == "" {
continue
}
if cleanData == "[DONE]" {
_, _ = fmt.Fprintf(w, "data: [DONE]\n\n")
flusher.Flush()
break
}
cleanedChunk := cleanChunkJSON(cleanData)
if cleanedChunk != "" {
_, _ = fmt.Fprintf(w, "data: %s\n\n", cleanedChunk)
flusher.Flush()
}
}
resp.Body.Close()
log.Printf("[#%d] 流式输出完成 (账号 %s [%s], 耗时 %v)", reqID, acc.Path, prof.Label, time.Since(startTime))
} else {
// 客户端需要完整 JSON 响应,聚合 SSE 数据流
completionJSON, err := aggregateCompletion(resp.Body, modelName)
resp.Body.Close()
if err != nil {
log.Printf("[#%d] 聚合响应失败: %v", reqID, err)
writeOpenAIError(w, http.StatusInternalServerError, "aggregate_error", "聚合上游流式响应失败: "+err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(completionJSON)
log.Printf("[#%d] 非流式响应完成 (账号 %s [%s], 耗时 %v)", reqID, acc.Path, prof.Label, time.Since(startTime))
}
// writeChatAggregate 聚合上游 SSE 为完整 Chat Completions JSON 响应。
func writeChatAggregate(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
}
// 理论上不可达(poolSize 次尝试后未成功即已在循环内返回)
writeOpenAIError(w, http.StatusTooManyRequests, "all_accounts_cooldown", "所有账号均处于冷却状态")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(completionJSON)
log.Printf("[#%d] 非流式响应完成 (账号 %s [%s], 耗时 %v)", reqID, acc.Path, prof.Label, time.Since(startTime))
}
// -----------------------------------------------------------------------------
@@ -1918,7 +1940,7 @@ func handleIndex(w http.ResponseWriter, r *http.Request) {
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)
_, _ = fmt.Fprintf(w, "WorkBuddy Local Gateway v%s is running.\n\nEndpoints:\n- POST /v1/chat/completions\n- POST /v1/responses\n- GET /v1/models\n- GET /health\n", version)
}
// -----------------------------------------------------------------------------
@@ -1948,6 +1970,12 @@ func sanitizeMessages(obj map[string]any) {
if !ok {
continue
}
// OpenAI 新版 developer 角色(GPT-5 系客户端/Codex)不被腾讯上游接受,
// 会返回 11128 "Illegal API invocation from an unapproved channel",
// 统一归一化为 system(语义等价)。
if roleOfMessage(msg) == "developer" {
msg["role"] = "system"
}
switch c := msg["content"].(type) {
case string:
msg["content"] = sanitizeBlockedTemplates(c)
@@ -1965,6 +1993,66 @@ func sanitizeMessages(obj map[string]any) {
}
}
// ensureLeadingSystemMessage 保证 messages 的首条消息符合腾讯上游的会话结构校验。
//
// 上游要求首条消息为 system prompt,否则返回 HTTP 400
// {"code":11128,"msg":"first message is not system prompt"}。实测国内站对 user 开头较宽容,
// 但国际站(workbuddy.ai)严格校验;账号池混挂时表现为约 50% 请求随机失败。部分非 harness
// 客户端在续写或仅回传工具结果时还会以 assistant / tool 作为首条消息。此处统一归一化为:
// 1. 首条已是 system:原样透传;
// 2. 首条是 developer(OpenAI 新版 system 别名):重命名为 system;
// 3. 后续存在 system/developer:提升到首位(developer 归一化为 system),其余保持原序;
// 4. 其余情况(user / assistant / tool 开头且无 system):在最前注入一条保底 system。
func ensureLeadingSystemMessage(obj map[string]any) {
messages, ok := obj["messages"].([]any)
if !ok || len(messages) == 0 {
obj["messages"] = []any{map[string]any{"role": "system", "content": defaultSystemPrompt}}
return
}
switch roleOfMessage(messages[0]) {
case "system":
return
case "developer":
if msg, ok := messages[0].(map[string]any); ok {
msg["role"] = "system"
}
return
}
// 后续存在 system/developer:提升到首位,其余保持原序
for i := 1; i < len(messages); i++ {
switch roleOfMessage(messages[i]) {
case "system", "developer":
if msg, ok := messages[i].(map[string]any); ok {
msg["role"] = "system"
}
reordered := make([]any, 0, len(messages))
reordered = append(reordered, messages[i])
reordered = append(reordered, messages[:i]...)
reordered = append(reordered, messages[i+1:]...)
obj["messages"] = reordered
return
}
}
// 无任何 system:在最前注入保底 system(兼容国内站/国际站)
injected := make([]any, 0, len(messages)+1)
injected = append(injected, map[string]any{"role": "system", "content": defaultSystemPrompt})
injected = append(injected, messages...)
obj["messages"] = injected
}
// roleOfMessage 读取消息的 role 字段并归一化为小写去空格;非法结构返回空串。
func roleOfMessage(m any) string {
msg, ok := m.(map[string]any)
if !ok {
return ""
}
role, _ := msg["role"].(string)
return strings.ToLower(strings.TrimSpace(role))
}
func sanitizeBlockedTemplates(s string) string {
s = strings.ReplaceAll(s,
"You are Claude Code, Anthropic's official CLI for Claude.",
@@ -2053,11 +2141,55 @@ func doJSON(client *http.Client, method, fullURL string, headers func(*http.Requ
// 流式聚合与格式清理
// -----------------------------------------------------------------------------
// mergedToolCall 累积合并上游按 index 分片下发的工具调用增量。
type mergedToolCall struct {
ID string
Type string
Name string
Args strings.Builder
}
// applyToolCallDelta 将上游 tool_calls 增量按 index 归并进 map;order 记录首次出现的
// index 顺序,保证最终输出顺序稳定。Chat Completions 聚合与 Responses 流式共用。
func applyToolCallDelta(toolCalls map[int]*mergedToolCall, order *[]int, tcs []any) {
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)
}
st, exists := toolCalls[idx]
if !exists {
st = &mergedToolCall{Type: "function"}
toolCalls[idx] = st
*order = append(*order, idx)
}
if id, ok := tc["id"].(string); ok && id != "" {
st.ID = id
}
if t, ok := tc["type"].(string); ok && t != "" {
st.Type = t
}
if fn, ok := tc["function"].(map[string]any); ok {
if n, ok := fn["name"].(string); ok && n != "" {
st.Name = n
}
if a, ok := fn["arguments"].(string); ok && a != "" {
st.Args.WriteString(a)
}
}
}
}
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
toolCalls := map[int]*mergedToolCall{}
var toolOrder []int
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
@@ -2096,11 +2228,7 @@ func aggregateCompletion(r io.Reader, model string) ([]byte, error) {
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)
}
}
applyToolCallDelta(toolCalls, &toolOrder, tcs)
}
}
if v, ok := choice["finish_reason"].(string); ok && v != "" {
@@ -2113,8 +2241,25 @@ func aggregateCompletion(r io.Reader, model string) ([]byte, error) {
if reasoning != "" {
message["reasoning_content"] = reasoning
}
if len(toolCalls) > 0 {
message["tool_calls"] = toolCalls
if len(toolOrder) > 0 {
calls := make([]map[string]any, 0, len(toolOrder))
for _, idx := range toolOrder {
st := toolCalls[idx]
id := st.ID
if id == "" {
id = fmt.Sprintf("call_%d_%d", time.Now().UnixNano(), idx)
}
calls = append(calls, map[string]any{
"id": id,
"type": ifEmpty(st.Type, "function"),
"index": idx,
"function": map[string]any{
"name": st.Name,
"arguments": st.Args.String(),
},
})
}
message["tool_calls"] = calls
}
if created == 0 {
created = time.Now().Unix()