mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-28 16:09:34 +00:00
fix(openai): preserve Codex namespace tools on OAuth Responses forwarding
Flattening Codex `type:"namespace"` tool declarations into `namespace__name` renames the tool but cannot rewrite the contract the client already handed the model: Codex tells it to call `to=functions.collaboration.spawn_agent`. Under `tool_mode: code_mode_only` (gpt-5.6-*) collaboration tools are the model's only direct channel, so the rename left it unable to address them — it fell back to `functions.exec` and looped on meaningless shell commands until the session collapsed, with no upstream error anywhere. Fixes #4978. Preserving is what the upstream actually wants. OAuth egress is unconditionally chatgpt.com/backend-api/codex/responses (base_url is read only for API-key accounts), i.e. the party that defines the extension; codex-rs builds one request for both WS and HTTP and falls back WS->HTTP mid-session, so these declarations already reach that host over HTTP — as they do today via the responses-lite carrier and the WS/HTTP bridge, neither of which ever flattened. Namespace names cannot be allowlisted: `features.multi_agent_v2.tool_namespace` is user-configurable (openai/codex#31864 recommends renaming it to `agents`) and MCP/connector namespaces are generated at runtime (mcp__codex_apps__gmail). So the default is inverted rather than extended with more names. - Preserve namespace declarations by default for OpenAI OAuth; the new per-account `extra.openai_responses_flatten_namespaces` restores the previous behavior for deployments routing OAuth traffic to a relay that rejects them. - Keep `input[].namespace` on tool-call items for OAuth non-compact requests. The upstream requires the round-trip ("Missing namespace for function_call '...'. Round-trip the model's function_call item with its namespace field included."), so preserving declarations while stripping the calls would 400 on the second turn. The item-type allowlist mirrors the existing reactive strip. - Keep compact on its current behavior: that endpoint rejects the field outright ("Unknown parameter: 'input[894].namespace'", #4761) and there is no evidence either way about namespace declarations there, so this change does not widen its surface. API-key accounts are untouched — their upstream is a standard Responses API and the reactive retry only clears one index per round trip. - Clear the flatten mapping at the start of each Forward attempt so an account reached through failover cannot restore responses with the previous account's mapping. Admin UI gets an OAuth-only toggle (create/edit/bulk) plus zh/en copy noting the compact exception.
This commit is contained in:
@@ -1825,6 +1825,22 @@ func (a *Account) IsOpenAIWSForceHTTPEnabled() bool {
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
// IsOpenAIResponsesFlattenNamespacesEnabled 返回账号级"摊平 Codex namespace 工具"开关。
|
||||
// 字段:accounts.extra.openai_responses_flatten_namespaces,缺省 false(原样保留)。
|
||||
//
|
||||
// namespace 是 Codex 后端定义的私有扩展,OAuth 出口恒为 chatgpt.com/backend-api/codex
|
||||
// (buildUpstreamRequest 只对 API Key 账号取 base_url),即定义方本身,因此默认保留。
|
||||
// 该开关只为把流量转发到不认识 namespace 的兼容上游的部署保留退路:打开后恢复
|
||||
// 0.1.166 及更早版本的摊平行为。仅对 OpenAI OAuth 账号有效——API Key 走 chat
|
||||
// completions 回退桥时由桥自行摊平,Grok/Anthropic 出口有各自的适配链路。
|
||||
func (a *Account) IsOpenAIResponsesFlattenNamespacesEnabled() bool {
|
||||
if a == nil || !a.IsOpenAI() || a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
enabled, ok := a.Extra["openai_responses_flatten_namespaces"].(bool)
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
// IsOpenAIWSAllowStoreRecoveryEnabled 返回账号级 store 恢复开关。
|
||||
// 字段:accounts.extra.openai_ws_allow_store_recovery。
|
||||
func (a *Account) IsOpenAIWSAllowStoreRecoveryEnabled() bool {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
// Forward forwards request to OpenAI API
|
||||
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
|
||||
clearGrokResponsesClientToolMapping(c)
|
||||
clearOpenAIResponsesNamespaceNames(c)
|
||||
startTime := time.Now()
|
||||
// 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。
|
||||
canonicalImageIntentBody := body
|
||||
@@ -62,7 +63,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
// 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。
|
||||
wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c))
|
||||
passthroughEnabled := account.IsOpenAIPassthroughEnabled()
|
||||
if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled) {
|
||||
compactPath := isOpenAIResponsesCompactPath(c)
|
||||
if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled, compactPath) {
|
||||
body, err = flattenOpenAIResponsesNamespaces(c, body)
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
|
||||
@@ -73,7 +75,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
}
|
||||
if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) {
|
||||
body, err = stripOpenAIResponsesInputNamespaces(body)
|
||||
keepToolCallNamespaces := shouldKeepOpenAIResponsesToolCallNamespaces(
|
||||
account, wsDecision.Transport, passthroughEnabled, compactPath,
|
||||
)
|
||||
body, err = stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
||||
@@ -273,7 +278,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
markPatchSet("model", billingModel)
|
||||
}
|
||||
upstreamModel := billingModel
|
||||
isCompactRequest := isOpenAIResponsesCompactPath(c)
|
||||
isCompactRequest := compactPath
|
||||
compactMapped := false
|
||||
if isCompactRequest {
|
||||
compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel)
|
||||
|
||||
@@ -433,7 +433,77 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
|
||||
require.NotContains(t, body, "\"name\":\"edit\"")
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse(t *testing.T) {
|
||||
// 「自动透传(仅替换认证)」的默认行为必须真的只替换认证:namespace 声明、
|
||||
// namespace 形态的 tool_choice、历史调用项上的 namespace 都原样转发,只清掉
|
||||
// 非调用项上的残留 namespace(Codex 协议里只有调用项会带该字段)。
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_PreservesNamespaceRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
|
||||
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
|
||||
|
||||
originalBody := []byte(`{
|
||||
"model":"gpt-5.5",
|
||||
"stream":true,
|
||||
"instructions":"local-test-instructions",
|
||||
"tools":[
|
||||
{"type":"function","name":"plain","description":"keep","parameters":{"type":"object"}},
|
||||
{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","description":"spawn","parameters":{"type":"object"}}]}
|
||||
],
|
||||
"tool_choice":{"type":"function","name":"spawn_agent","namespace":"collaboration"},
|
||||
"input":[
|
||||
{"type":"function_call","call_id":"call_old","name":"spawn_agent","namespace":"collaboration","arguments":"{}"},
|
||||
{"type":"message","role":"user","namespace":"residual","content":[{"type":"input_text","text":"keep","namespace":"nested"}]}
|
||||
]
|
||||
}`)
|
||||
|
||||
upstreamSSE := strings.Join([]string{
|
||||
`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}}`,
|
||||
"",
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
}, "\n")
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_preserve"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 125, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, originalBody)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
require.Len(t, gjson.GetBytes(upstream.lastBody, "tools").Array(), 2)
|
||||
require.Equal(t, "namespace", gjson.GetBytes(upstream.lastBody, "tools.1.type").String())
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tools.1.name").String())
|
||||
require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "tools.1.tools.0.name").String())
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tool_choice.namespace").String())
|
||||
require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "tool_choice.name").String())
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "input.0.namespace").String())
|
||||
require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "input.0.name").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.namespace").Exists())
|
||||
require.Equal(t, "nested", gjson.GetBytes(upstream.lastBody, "input.1.content.0.namespace").String())
|
||||
require.NotContains(t, string(upstream.lastBody), "collaboration__spawn_agent")
|
||||
|
||||
// 未摊平即无需回程还原,上游事件原样下发。
|
||||
downstream := rec.Body.String()
|
||||
require.Contains(t, downstream, `"name":"spawn_agent"`)
|
||||
require.Contains(t, downstream, `"namespace":"collaboration"`)
|
||||
}
|
||||
|
||||
// 兼容开关打开时的旧行为:摊平请求、回程还原。默认路径见
|
||||
// TestOpenAIGatewayService_OAuthPassthrough_PreservesNamespaceRequest。
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_FlattenEnabledNamespaceRequestAndStreamResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -475,7 +545,11 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse
|
||||
account := &Account{
|
||||
ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
Extra: map[string]any{
|
||||
"openai_passthrough": true,
|
||||
"openai_responses_flatten_namespaces": true,
|
||||
},
|
||||
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, originalBody)
|
||||
@@ -501,7 +575,9 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse
|
||||
require.Contains(t, downstream, `"namespace":"collaboration"`)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_NativeOAuth_NamespaceRequestAndStreamResponse(t *testing.T) {
|
||||
// 兼容开关打开时的旧行为;默认保留路径见
|
||||
// TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools。
|
||||
func TestOpenAIGatewayService_NativeOAuth_FlattenEnabledNamespaceRequestAndStreamResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
@@ -529,6 +605,7 @@ func TestOpenAIGatewayService_NativeOAuth_NamespaceRequestAndStreamResponse(t *t
|
||||
account := &Account{
|
||||
ID: 124, Name: "native", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
|
||||
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
@@ -596,7 +673,8 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceNonStreamingResponse(t *
|
||||
require.Contains(t, rec.Body.String(), `"namespace":"collaboration"`)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollisionReturnsBadRequest(t *testing.T) {
|
||||
// 摊平名冲突只在兼容开关打开时才可能发生:默认保留 namespace,不存在平名冲突。
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_FlattenEnabledNamespaceCollisionReturnsBadRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
@@ -614,7 +692,11 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollisionReturnsBadReque
|
||||
account := &Account{
|
||||
ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
Extra: map[string]any{
|
||||
"openai_passthrough": true,
|
||||
"openai_responses_flatten_namespaces": true,
|
||||
},
|
||||
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -14,14 +15,39 @@ import (
|
||||
const openAIResponsesNamespaceNamesContextKey = "openai_responses_namespace_names"
|
||||
|
||||
// shouldFlattenOpenAIResponsesNamespaces 判定原生 Responses 转发前是否摊平
|
||||
// Codex namespace 工具。WSv2 上游原生支持 namespace,且 WS 出口
|
||||
// (openai_ws_forwarder_v2)原样转发上游事件、不经 HTTP 回程还原,摊平后的
|
||||
// 平名无法还原会破坏客户端工具匹配,因此实际走 WSv2 分支的请求保持 namespace
|
||||
// 原样。透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。
|
||||
func shouldFlattenOpenAIResponsesNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool {
|
||||
// Codex namespace 工具。
|
||||
//
|
||||
// 默认不摊平:OAuth 账号的 HTTP 出口恒为 chatgpt.com/backend-api/codex/responses
|
||||
// (buildUpstreamRequest 只在 API Key 分支读 base_url),也就是 namespace 扩展的
|
||||
// 定义方本身;Codex 客户端对 WS 与 HTTP 两条传输发送同一份 tools(codex-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
|
||||
}
|
||||
@@ -41,6 +67,50 @@ func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenA
|
||||
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 API(api.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
|
||||
@@ -68,7 +138,11 @@ func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, erro
|
||||
// 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.
|
||||
func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
|
||||
//
|
||||
// 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
|
||||
}
|
||||
@@ -89,7 +163,10 @@ func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
|
||||
}
|
||||
first = false
|
||||
itemBody := []byte(item.Raw)
|
||||
if item.IsObject() && item.Get("namespace").Exists() {
|
||||
// 先判存在再判类型:长历史里绝大多数是 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
|
||||
@@ -99,13 +176,13 @@ func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
|
||||
_, _ = 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
|
||||
}
|
||||
_ = rebuilt.WriteByte(']')
|
||||
stripped, err := sjson.SetRawBytes(body, "input", rebuilt.Bytes())
|
||||
if err != nil {
|
||||
return body, fmt.Errorf("replace OpenAI input after namespace deletion: %w", err)
|
||||
@@ -119,6 +196,18 @@ func setOpenAIResponsesNamespaceNames(c *gin.Context, names map[string]apicompat
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// codexNamespaceRequestBody 模拟 Codex 多智能体请求:collaboration 命名空间声明 +
|
||||
// 历史里的命名空间调用项 + 带残留 namespace 的普通消息项。
|
||||
const codexNamespaceRequestBody = `{
|
||||
"model":"gpt-5.6-terra",
|
||||
"stream":false,
|
||||
"instructions":"test",
|
||||
"tools":[
|
||||
{"type":"namespace","name":"collaboration","description":"Tools for spawning and managing sub-agents.","tools":[
|
||||
{"type":"function","name":"spawn_agent","description":"Call as to=functions.collaboration.spawn_agent","parameters":{"type":"object"}},
|
||||
{"type":"function","name":"wait_agent","parameters":{"type":"object"}}
|
||||
]},
|
||||
{"type":"function","name":"exec","parameters":{"type":"object"}}
|
||||
],
|
||||
"input":[
|
||||
{"type":"function_call","namespace":"collaboration","name":"spawn_agent","call_id":"call_1","arguments":"{}"},
|
||||
{"type":"message","role":"user","namespace":"leftover","content":[{"type":"input_text","text":"hello"}]}
|
||||
]
|
||||
}`
|
||||
|
||||
const namespaceForwardOKResponse = `{"id":"resp_ns","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`
|
||||
|
||||
// OAuth 出口即 namespace 扩展的定义方:声明必须原样送达,历史调用项必须保留
|
||||
// namespace(缺字段上游会 400 "Missing namespace for function_call"),而非调用项上的
|
||||
// 残留 namespace 仍要清掉。回归 issue #4978。
|
||||
func TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools(t *testing.T) {
|
||||
body := []byte(codexNamespaceRequestBody)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||||
}}
|
||||
c := newOpenAIRejectedFieldTestContext(body)
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1)
|
||||
forwarded := upstream.bodies[0]
|
||||
|
||||
namespaceTool := gjson.GetBytes(forwarded, `tools.#(type=="namespace")`)
|
||||
require.True(t, namespaceTool.Exists(), "namespace 声明必须原样转发")
|
||||
require.Equal(t, "collaboration", namespaceTool.Get("name").String())
|
||||
require.Equal(t, "spawn_agent", namespaceTool.Get("tools.0.name").String())
|
||||
require.Equal(t, "wait_agent", namespaceTool.Get("tools.1.name").String())
|
||||
// 摊平名一旦出现,模型就无法按工具描述里的 to=functions.collaboration.spawn_agent 寻址。
|
||||
require.NotContains(t, string(forwarded), "collaboration__spawn_agent")
|
||||
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(forwarded, "input.0.namespace").String())
|
||||
require.Equal(t, "spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
|
||||
|
||||
// 未摊平即无需回程还原,不得登记映射。
|
||||
require.Empty(t, openAIResponsesNamespaceNames(c))
|
||||
}
|
||||
|
||||
// compact 端点 schema 更窄:input[].namespace 会 400 Unknown parameter(issue #4761),
|
||||
// 且没有证据表明它接受 namespace 工具声明。compact 只做历史摘要、不需要模型寻址工具,
|
||||
// 因此保持既有的摊平 + 全量清理行为,不随默认值翻转扩大风险面。
|
||||
func TestOpenAIGatewayService_OAuthCompactKeepsFlattening(t *testing.T) {
|
||||
body := []byte(codexNamespaceRequestBody)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||||
}}
|
||||
c := newOpenAIRejectedFieldTestContext(body)
|
||||
c.Request.URL.Path = "/v1/responses/compact"
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1)
|
||||
forwarded := upstream.bodies[0]
|
||||
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
|
||||
require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists())
|
||||
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
|
||||
}
|
||||
|
||||
// 账号开关为不认识 namespace 的兼容上游保留退路:打开后恢复 0.1.166 的摊平行为。
|
||||
func TestOpenAIGatewayService_OAuthFlattenFlagRestoresLegacyBehavior(t *testing.T) {
|
||||
body := []byte(codexNamespaceRequestBody)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||||
}}
|
||||
c := newOpenAIRejectedFieldTestContext(body)
|
||||
account := newOpenAIOAuthNamespaceTestAccount()
|
||||
account.Extra = map[string]any{"openai_responses_flatten_namespaces": true}
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), c, account, body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1)
|
||||
forwarded := upstream.bodies[0]
|
||||
|
||||
require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists())
|
||||
require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__spawn_agent")`).Exists())
|
||||
require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__wait_agent")`).Exists())
|
||||
// 摊平后调用项已改写成平名,不得再带 namespace。
|
||||
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
|
||||
|
||||
names := openAIResponsesNamespaceNames(c)
|
||||
require.Equal(t,
|
||||
apicompat.ResponsesNamespaceName{Namespace: "collaboration", Name: "spawn_agent"},
|
||||
names["collaboration__spawn_agent"],
|
||||
)
|
||||
}
|
||||
|
||||
// handler 的 failover 在同一个 *gin.Context 上重试下一个账号;保留 namespace 的账号
|
||||
// 不得沿用上一个账号登记的摊平名映射做回程还原。
|
||||
func TestOpenAIGatewayService_ForwardClearsStaleNamespaceNames(t *testing.T) {
|
||||
body := []byte(codexNamespaceRequestBody)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||||
}}
|
||||
c := newOpenAIRejectedFieldTestContext(body)
|
||||
setOpenAIResponsesNamespaceNames(c, map[string]apicompat.ResponsesNamespaceName{
|
||||
"stale__tool": {Namespace: "stale", Name: "tool"},
|
||||
})
|
||||
|
||||
_, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, openAIResponsesNamespaceNames(c))
|
||||
}
|
||||
@@ -12,27 +12,96 @@ func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) {
|
||||
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
grokOAuth := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}
|
||||
// 账号级兼容开关:为不认识 namespace 的兼容上游恢复旧的摊平行为。
|
||||
flattenOAuth := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
|
||||
}
|
||||
flattenAPIKey := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
transport OpenAIUpstreamTransport
|
||||
passthroughEnabled bool
|
||||
compactPath bool
|
||||
want bool
|
||||
}{
|
||||
{name: "oauth_http", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
|
||||
{name: "oauth_http_passthrough", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true},
|
||||
// 默认保留:OAuth 出口是 namespace 扩展的定义方,摊平会让模型无法按
|
||||
// `to=functions.<namespace>.<tool>` 寻址(issue #4978)。
|
||||
{name: "oauth_http_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "oauth_http_passthrough_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: false},
|
||||
{name: "oauth_wsv2_default_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
|
||||
// compact 端点 schema 更窄且无实测证据,保持既有摊平行为。
|
||||
{name: "oauth_compact_flattens", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: true},
|
||||
{name: "oauth_compact_wsv2_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false},
|
||||
{name: "apikey_compact", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false},
|
||||
{name: "oauth_flatten_enabled_http", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
|
||||
{name: "oauth_flatten_enabled_http_passthrough", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true},
|
||||
// WSv2 出口原样转发上游事件、不做回程还原,摊平会让客户端收到无法匹配的平名。
|
||||
{name: "oauth_wsv2", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
|
||||
// 透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。
|
||||
{name: "oauth_wsv2_passthrough", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
|
||||
{name: "oauth_flatten_enabled_wsv2", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
|
||||
// 透传账号先于 WSv2 分支经 HTTP 转发返回,开关打开时仍需摊平。
|
||||
{name: "oauth_flatten_enabled_wsv2_passthrough", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
|
||||
// 开关仅对 OAuth 生效:API Key 走 chat completions 回退桥时由桥自行摊平。
|
||||
{name: "apikey_flatten_enabled_http", account: flattenAPIKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "apikey_http", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "grok_oauth_http", account: grokOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldFlattenOpenAIResponsesNamespaces(tt.account, tt.transport, tt.passthroughEnabled))
|
||||
require.Equal(t, tt.want, shouldFlattenOpenAIResponsesNamespaces(
|
||||
tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) {
|
||||
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
setupToken := &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}
|
||||
flattenOAuth := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
transport OpenAIUpstreamTransport
|
||||
passthroughEnabled bool
|
||||
compactPath bool
|
||||
want bool
|
||||
}{
|
||||
// 上游按 namespace 解析历史调用,缺字段会 400 "Missing namespace for function_call"。
|
||||
{name: "oauth_http_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
|
||||
{name: "oauth_http_passthrough_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true},
|
||||
// compact 端点 schema 不含该字段,携带即 400 "Unknown parameter: input[N].namespace"。
|
||||
{name: "oauth_compact_strips", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false},
|
||||
// 摊平后调用项已是平名,残留 namespace 指向的声明不存在。
|
||||
{name: "oauth_flatten_enabled_strips", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
// WSv2 实际由 shouldStrip 提前短路,此处只钉住策略本身的取值。
|
||||
{name: "oauth_wsv2_keeps", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: true},
|
||||
// WSv2 + compact 是唯一「不摊平但仍必须清理」的组合,钉住 compact 判定本身,
|
||||
// 使其不会被误当成可由 shouldFlatten 推导出的冗余分支。
|
||||
{name: "oauth_compact_wsv2_strips", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false},
|
||||
// API Key 出口是标准 Responses API,不认识该字段。
|
||||
{name: "apikey_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "setup_token_strips", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldKeepOpenAIResponsesToolCallNamespaces(
|
||||
tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -85,7 +154,7 @@ func TestStripOpenAIResponsesInputNamespaces(t *testing.T) {
|
||||
]
|
||||
}`)
|
||||
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body)
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body, false)
|
||||
require.NoError(t, err)
|
||||
for index := 0; index < 8; index++ {
|
||||
require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists())
|
||||
@@ -106,8 +175,61 @@ func TestStripOpenAIResponsesInputNamespacesLeavesOtherShapesByteExact(t *testin
|
||||
[]byte(`{"input":[{"content":{"namespace":"nested-only"}}],"tools":[{"namespace":"keep"}]}`),
|
||||
}
|
||||
for _, body := range tests {
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, body, stripped)
|
||||
for _, keepToolCallNamespaces := range []bool{false, true} {
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, body, stripped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保留模式下只有工具调用项留住 namespace:上游按 namespace 解析历史调用,
|
||||
// 而 message / reasoning / 输出项带该字段会被 schema 拒绝。
|
||||
func TestStripOpenAIResponsesInputNamespacesKeepsToolCallNamespaces(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"meta":9007199254740993,
|
||||
"input":[
|
||||
{"type":"function_call","namespace":"collaboration","name":"spawn_agent","arguments":"{}","large":9007199254740993},
|
||||
{"type":"custom_tool_call","namespace":"codex_app","name":"exec","input":"{}"},
|
||||
{"type":"tool_call","namespace":"mcp__codex_apps__gmail","name":"send"},
|
||||
{"type":"mcp_tool_call","namespace":"mcp__codex_apps__gmail","name":"list"},
|
||||
{"type":"message","namespace":"leftover","role":"assistant","content":[{"type":"output_text","text":"hi"}]},
|
||||
{"type":"function_call_output","namespace":"leftover","output":"ok"},
|
||||
{"type":"reasoning","namespace":"leftover"},
|
||||
{"type":"item","namespace":"leftover"}
|
||||
]
|
||||
}`)
|
||||
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(stripped, "input.0.namespace").String())
|
||||
require.Equal(t, "codex_app", gjson.GetBytes(stripped, "input.1.namespace").String())
|
||||
require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.2.namespace").String())
|
||||
require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.3.namespace").String())
|
||||
for index := 4; index < 8; index++ {
|
||||
require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists())
|
||||
}
|
||||
// 大整数不得经 float64 往返。
|
||||
require.Equal(t, gjson.GetBytes(body, "meta").Raw, gjson.GetBytes(stripped, "meta").Raw)
|
||||
require.Equal(t, gjson.GetBytes(body, "input.0.large").Raw, gjson.GetBytes(stripped, "input.0.large").Raw)
|
||||
|
||||
// 类型比对不区分大小写与首尾空白。
|
||||
mixedCase := []byte(`{"input":[{"type":" Function_Call ","namespace":"collaboration","name":"spawn_agent"}]}`)
|
||||
keptMixedCase, err := stripOpenAIResponsesInputNamespaces(mixedCase, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, mixedCase, keptMixedCase)
|
||||
|
||||
// 全部为调用项时无改动,应原样返回。
|
||||
callsOnly := []byte(`{"input":[{"type":"function_call","namespace":"collaboration","name":"spawn_agent"}]}`)
|
||||
unchanged, err := stripOpenAIResponsesInputNamespaces(callsOnly, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, callsOnly, unchanged)
|
||||
|
||||
// 关闭保留时回到全量清理。
|
||||
strippedAll, err := stripOpenAIResponsesInputNamespaces(body, false)
|
||||
require.NoError(t, err)
|
||||
for index := 0; index < 8; index++ {
|
||||
require.False(t, gjson.GetBytes(strippedAll, "input."+strconv.Itoa(index)+".namespace").Exists())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user