Files
sub2api/backend/internal/service/openai_responses_namespace.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

237 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const openAIResponsesNamespaceNamesContextKey = "openai_responses_namespace_names"
// shouldFlattenOpenAIResponsesNamespaces 判定原生 Responses 转发前是否摊平
// Codex namespace 工具。
//
// 默认不摊平:OAuth 账号的 HTTP 出口恒为 chatgpt.com/backend-api/codex/responses
// buildUpstreamRequest 只在 API Key 分支读 base_url),也就是 namespace 扩展的
// 定义方本身;Codex 客户端对 WS 与 HTTP 两条传输发送同一份 toolscodex-rs
// client.rs build_responses_request 无传输分支,WS 失败后会 session 级回落 HTTP
// 继续发同样的声明)。摊平只改写工具名,改不掉客户端在 tools 描述与 developer
// 消息里写死的 `to=functions.<namespace>.<tool>` 寻址约定,模型据此寻址必然落空
// issue #4978);命名空间名还可由 features.multi_agent_v2.tool_namespace 自定义、
// 或由 MCP/connector 动态生成(mcp__codex_apps__gmail),保留名单枚举不完。
//
// compact 端点例外:已知它的 schema 比 /responses 窄(连 input[].namespace 都会报
// Unknown parameter,见 issue #4761),而是否接受 namespace 工具声明没有任何实测
// 证据;compact 只做历史摘要、不需要模型寻址工具,回程也没有工具调用可还原,因此
// 保持 0.1.166 起就在跑的摊平行为,不随本次默认值翻转扩大风险面。
//
// 账号开关 openai_responses_flatten_namespaces 为不认识 namespace 的兼容上游保留
// 退路,打开后恢复旧行为:WSv2 上游原生支持 namespace,且 WS 出口
// openai_ws_forwarder_v2)原样转发上游事件、不经 HTTP 回程还原,摊平后的平名
// 无法还原会破坏客户端工具匹配,因此实际走 WSv2 分支的请求仍保持 namespace 原样;
// 透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。
func shouldFlattenOpenAIResponsesNamespaces(
account *Account,
transport OpenAIUpstreamTransport,
passthroughEnabled bool,
compactPath bool,
) bool {
if account == nil || !account.IsOpenAIOAuth() {
return false
}
if !compactPath && !account.IsOpenAIResponsesFlattenNamespacesEnabled() {
return false
}
if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled {
return false
}
return true
}
// shouldStripOpenAIResponsesInputNamespaces removes residual input item
// namespaces for OpenAI OAuth and API Key HTTP forwarding. Native WSv2 keeps
// namespaces because that protocol supports them and does not restore payloads.
func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool {
if account == nil || (!account.IsOpenAIOAuth() && !account.IsOpenAIApiKey()) {
return false
}
if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled {
return false
}
return true
}
// shouldKeepOpenAIResponsesToolCallNamespaces 判定清理 input 残留 namespace 时是否
// 保留工具调用项上的 namespace。
//
// 上游对这个字段有两套互斥要求,判定按「出口 + 端点」而非工具声明内容:
// - /backend-api/codex/responses 会按 namespace 解析历史调用,缺字段直接 400
// `Missing namespace for function_call '...'. Round-trip the model's
// function_call item with its namespace field included.`issue #4761 回帖),
// 故 OAuth 非 compact 请求必须保留。
// - compact 端点的 schema 不含该字段,携带即 400 `Unknown parameter:
// input[N].namespace`issue #4761 正文),故 compact 一律清理。
// - API Key 出口是标准 Responses APIapi.openai.com 或自定义 base_url),同样
// 不认识该字段,维持全量清理;否则只能退化成
// openai_responses_rejected_field_retry 的逐项删除,6 次上限根本盖不住长历史。
// - 摊平模式下调用项已被改写成平名,残留 namespace 指向的声明已不存在,一律清理。
func shouldKeepOpenAIResponsesToolCallNamespaces(
account *Account,
transport OpenAIUpstreamTransport,
passthroughEnabled bool,
compactPath bool,
) bool {
if account == nil || !account.IsOpenAIOAuth() {
return false
}
if compactPath {
return false
}
return !shouldFlattenOpenAIResponsesNamespaces(account, transport, passthroughEnabled, compactPath)
}
// openAIResponsesToolCallItemTypes 是携带 namespace 的调用项类型集合。与
// removeOpenAIResponsesRejectedNamespaceAtIndex 的反应式白名单保持一致;codex-rs
// protocol/src/models.rs 中只有 FunctionCall 与 CustomToolCall 序列化 namespace
// 其余类型带该字段一定是非 Codex 客户端或历史残留,清掉才安全。
var openAIResponsesToolCallItemTypes = map[string]bool{
"function_call": true,
"tool_call": true,
"custom_tool_call": true,
"mcp_tool_call": true,
}
func isOpenAIResponsesToolCallItemType(itemType string) bool {
return openAIResponsesToolCallItemTypes[strings.ToLower(strings.TrimSpace(itemType))]
}
func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, error) {
if !bytes.Contains(body, []byte(`"namespace"`)) {
return body, nil
}
var requestBody map[string]any
if err := json.Unmarshal(body, &requestBody); err != nil {
return body, fmt.Errorf("decode OpenAI namespace body: %w", err)
}
names, changed, err := apicompat.FlattenResponsesNamespacesExcept(requestBody, map[string]bool{"image_gen": true})
if err != nil {
return body, err
}
if !changed {
return body, nil
}
rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
if err != nil {
return body, fmt.Errorf("encode OpenAI namespace body: %w", err)
}
setOpenAIResponsesNamespaceNames(c, names)
return rebuilt, nil
}
// stripOpenAIResponsesInputNamespaces removes namespace only from direct input
// array items. Namespace declarations and nested namespace fields are left
// untouched. Rebuilding the input array once keeps this linear for long
// histories and avoids decoding JSON numbers through float64.
//
// keepToolCallNamespaces 保留工具调用项(function_call / custom_tool_call 等)上的
// namespace,让 Codex 调用能按上游要求原样回传;判定见
// shouldKeepOpenAIResponsesToolCallNamespaces。
func stripOpenAIResponsesInputNamespaces(body []byte, keepToolCallNamespaces bool) ([]byte, error) {
if !bytes.Contains(body, []byte(`"namespace"`)) {
return body, nil
}
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body, nil
}
var rebuilt bytes.Buffer
rebuilt.Grow(len(input.Raw))
_ = rebuilt.WriteByte('[')
changed := false
first := true
var stripErr error
input.ForEach(func(_, item gjson.Result) bool {
if !first {
_ = rebuilt.WriteByte(',')
}
first = false
itemBody := []byte(item.Raw)
// 先判存在再判类型:长历史里绝大多数是 message/reasoning 等不带 namespace
// 的项,这样它们无需再扫一次 type。
if item.IsObject() && item.Get("namespace").Exists() &&
(!keepToolCallNamespaces || !isOpenAIResponsesToolCallItemType(item.Get("type").String())) {
itemBody, stripErr = sjson.DeleteBytes(itemBody, "namespace")
if stripErr != nil {
return false
}
changed = true
}
_, _ = rebuilt.Write(itemBody)
return true
})
_ = rebuilt.WriteByte(']')
if stripErr != nil {
return body, fmt.Errorf("delete OpenAI input namespace: %w", stripErr)
}
if !changed {
return body, nil
}
stripped, err := sjson.SetRawBytes(body, "input", rebuilt.Bytes())
if err != nil {
return body, fmt.Errorf("replace OpenAI input after namespace deletion: %w", err)
}
return stripped, nil
}
func setOpenAIResponsesNamespaceNames(c *gin.Context, names map[string]apicompat.ResponsesNamespaceName) {
if c != nil && len(names) > 0 {
c.Set(openAIResponsesNamespaceNamesContextKey, names)
}
}
// clearOpenAIResponsesNamespaceNames 清除上一次尝试登记的摊平名映射。handler 的
// failover 会在同一个 *gin.Context 上重试下一个账号,映射不清会让保留 namespace 的
// 账号拿着上一个账号的摊平名做回程还原。
func clearOpenAIResponsesNamespaceNames(c *gin.Context) {
if c == nil {
return
}
if _, exists := c.Get(openAIResponsesNamespaceNamesContextKey); exists {
c.Set(openAIResponsesNamespaceNamesContextKey, map[string]apicompat.ResponsesNamespaceName(nil))
}
}
func openAIResponsesNamespaceNames(c *gin.Context) map[string]apicompat.ResponsesNamespaceName {
if c == nil {
return nil
}
value, ok := c.Get(openAIResponsesNamespaceNamesContextKey)
if !ok {
return nil
}
names, _ := value.(map[string]apicompat.ResponsesNamespaceName)
return names
}
func restoreOpenAIResponsesNamespacePayload(c *gin.Context, payload []byte) ([]byte, error) {
names := openAIResponsesNamespaceNames(c)
if len(names) == 0 || !json.Valid(payload) {
return payload, nil
}
restored, changed, err := apicompat.RestoreResponsesNamespaceCalls(payload, names)
if err != nil {
return payload, err
}
if changed {
return restored, nil
}
return payload, nil
}