fix: support namespace tools in native responses

This commit is contained in:
xingzhi
2026-07-13 14:38:05 +08:00
parent 1d86c1bf81
commit 317de9c04b
9 changed files with 666 additions and 260 deletions
@@ -0,0 +1,212 @@
package apicompat
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
// ResponsesNamespaceName identifies a function child in a Responses namespace.
// It aliases the chat bridge mapping so both native and bridged paths share one
// namespace identity contract.
type ResponsesNamespaceName = NamespacedToolName
// FlattenResponsesNamespaces converts Codex private namespace declarations into
// public Responses function tools and rewrites namespace-qualified request calls.
func FlattenResponsesNamespaces(req map[string]any) (map[string]ResponsesNamespaceName, bool, error) {
return FlattenResponsesNamespacesExcept(req, nil)
}
// FlattenResponsesNamespacesExcept is FlattenResponsesNamespaces with a set of
// service-owned namespace names that must remain native in the request.
func FlattenResponsesNamespacesExcept(req map[string]any, preserved map[string]bool) (map[string]ResponsesNamespaceName, bool, error) {
if req == nil {
return nil, false, nil
}
tools, ok := req["tools"].([]any)
if !ok || len(tools) == 0 {
return nil, false, nil
}
topLevel := make(map[string]bool)
for _, raw := range tools {
tool, ok := raw.(map[string]any)
if !ok {
continue
}
typ := strings.TrimSpace(stringValue(tool["type"]))
name := strings.TrimSpace(stringValue(tool["name"]))
if (typ == "function" || typ == "custom") && name != "" {
topLevel[name] = true
}
}
names := make(map[string]ResponsesNamespaceName)
for _, raw := range tools {
tool, ok := raw.(map[string]any)
if !ok || strings.TrimSpace(stringValue(tool["type"])) != "namespace" {
continue
}
namespace := strings.TrimSpace(stringValue(tool["name"]))
if namespace == "" || preserved[namespace] {
continue
}
for _, rawChild := range namespaceChildren(tool) {
child, ok := rawChild.(map[string]any)
if !ok || strings.TrimSpace(stringValue(child["type"])) != "function" {
continue
}
name := strings.TrimSpace(stringValue(child["name"]))
if name == "" {
continue
}
flat := flattenNamespaceToolName(namespace, name)
entry := ResponsesNamespaceName{Namespace: namespace, Name: name}
if topLevel[flat] {
return nil, false, fmt.Errorf("namespace tool %q/%q flattens to %q which conflicts with a top-level tool of the same name; this upstream cannot disambiguate them, rename one of the tools", namespace, name, flat)
}
if prev, exists := names[flat]; exists && prev != entry {
return nil, false, fmt.Errorf("namespace tools %q/%q and %q/%q both flatten to %q; this upstream cannot disambiguate them, rename one of the tools", prev.Namespace, prev.Name, namespace, name, flat)
}
names[flat] = entry
}
}
if len(names) == 0 {
return nil, false, nil
}
flattened := make([]any, 0, len(tools)+len(names))
seen := make(map[string]bool)
for _, raw := range tools {
tool, ok := raw.(map[string]any)
if !ok || strings.TrimSpace(stringValue(tool["type"])) != "namespace" {
flattened = append(flattened, raw)
continue
}
namespace := strings.TrimSpace(stringValue(tool["name"]))
if preserved[namespace] {
flattened = append(flattened, raw)
continue
}
for _, rawChild := range namespaceChildren(tool) {
child, ok := rawChild.(map[string]any)
if !ok || strings.TrimSpace(stringValue(child["type"])) != "function" {
continue
}
name := strings.TrimSpace(stringValue(child["name"]))
flat := flattenNamespaceToolName(namespace, name)
if name == "" || seen[flat] {
continue
}
seen[flat] = true
flatChild := make(map[string]any, len(child))
for key, value := range child {
flatChild[key] = value
}
flatChild["name"] = flat
flattened = append(flattened, flatChild)
}
}
req["tools"] = flattened
rewriteNamespaceQualifiedCalls(req["input"], names)
if choice, ok := req["tool_choice"].(map[string]any); ok {
choiceNamespace := strings.TrimSpace(stringValue(choice["name"]))
if strings.TrimSpace(stringValue(choice["type"])) == "namespace" && !preserved[choiceNamespace] {
req["tool_choice"] = "auto"
} else {
rewriteNamespaceQualifiedCall(choice, names)
}
}
return names, true, nil
}
// RestoreResponsesNamespaceCalls restores flattened function calls in a JSON
// Responses payload to the namespace/name identity expected by Codex.
func RestoreResponsesNamespaceCalls(payload []byte, names map[string]ResponsesNamespaceName) ([]byte, bool, error) {
if len(payload) == 0 || len(names) == 0 {
return payload, false, nil
}
var value any
if err := json.Unmarshal(payload, &value); err != nil {
return payload, false, err
}
changed := restoreResponsesNamespaceValue(value, names)
if !changed {
return payload, false, nil
}
var rebuilt bytes.Buffer
encoder := json.NewEncoder(&rebuilt)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return payload, false, err
}
return bytes.TrimSuffix(rebuilt.Bytes(), []byte("\n")), true, nil
}
func namespaceChildren(tool map[string]any) []any {
if children, ok := tool["tools"].([]any); ok && len(children) > 0 {
return children
}
children, _ := tool["children"].([]any)
return children
}
func rewriteNamespaceQualifiedCalls(value any, names map[string]ResponsesNamespaceName) {
switch typed := value.(type) {
case []any:
for _, item := range typed {
rewriteNamespaceQualifiedCalls(item, names)
}
case map[string]any:
if strings.TrimSpace(stringValue(typed["type"])) == "function_call" {
rewriteNamespaceQualifiedCall(typed, names)
}
for _, child := range typed {
rewriteNamespaceQualifiedCalls(child, names)
}
}
}
func rewriteNamespaceQualifiedCall(item map[string]any, names map[string]ResponsesNamespaceName) bool {
namespace := strings.TrimSpace(stringValue(item["namespace"]))
name := strings.TrimSpace(stringValue(item["name"]))
if namespace == "" || name == "" {
return false
}
flat := flattenNamespaceToolName(namespace, name)
entry, ok := names[flat]
if !ok || entry.Namespace != namespace || entry.Name != name {
return false
}
item["name"] = flat
delete(item, "namespace")
return true
}
func restoreResponsesNamespaceValue(value any, names map[string]ResponsesNamespaceName) bool {
changed := false
switch typed := value.(type) {
case []any:
for _, item := range typed {
changed = restoreResponsesNamespaceValue(item, names) || changed
}
case map[string]any:
if strings.TrimSpace(stringValue(typed["type"])) == "function_call" {
if entry, ok := names[strings.TrimSpace(stringValue(typed["name"]))]; ok {
typed["name"] = entry.Name
typed["namespace"] = entry.Namespace
changed = true
}
}
for _, child := range typed {
changed = restoreResponsesNamespaceValue(child, names) || changed
}
}
return changed
}
func stringValue(value any) string {
text, _ := value.(string)
return text
}
@@ -0,0 +1,147 @@
package apicompat
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestFlattenResponsesNamespaces_RewritesDeclarationHistoryAndChoice(t *testing.T) {
req := map[string]any{
"model": "gpt-5.5",
"tools": []any{
map[string]any{"type": "function", "name": "plain", "description": "keep"},
map[string]any{
"type": "namespace",
"name": "collaboration",
"tools": []any{
map[string]any{"type": "function", "name": "spawn_agent", "description": "spawn", "parameters": map[string]any{"type": "object"}},
},
},
},
"tool_choice": map[string]any{"type": "function", "name": "spawn_agent", "namespace": "collaboration"},
"input": []any{
map[string]any{"type": "function_call", "call_id": "call_1", "name": "spawn_agent", "namespace": "collaboration", "arguments": "{}"},
map[string]any{"type": "message", "role": "user", "content": "hi", "name": "spawn_agent", "namespace": "collaboration"},
},
}
names, changed, err := FlattenResponsesNamespaces(req)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, ResponsesNamespaceName{Namespace: "collaboration", Name: "spawn_agent"}, names["collaboration__spawn_agent"])
tools := req["tools"].([]any)
require.Len(t, tools, 2)
require.Equal(t, "plain", tools[0].(map[string]any)["name"])
require.Equal(t, "collaboration__spawn_agent", tools[1].(map[string]any)["name"])
require.Equal(t, "spawn", tools[1].(map[string]any)["description"])
choice := req["tool_choice"].(map[string]any)
require.Equal(t, "collaboration__spawn_agent", choice["name"])
require.NotContains(t, choice, "namespace")
call := req["input"].([]any)[0].(map[string]any)
require.Equal(t, "collaboration__spawn_agent", call["name"])
require.NotContains(t, call, "namespace")
message := req["input"].([]any)[1].(map[string]any)
require.Equal(t, "spawn_agent", message["name"])
require.Equal(t, "collaboration", message["namespace"])
require.Equal(t, "gpt-5.5", req["model"])
}
func TestFlattenResponsesNamespaces_RejectsFlatNameCollision(t *testing.T) {
req := map[string]any{"tools": []any{
map[string]any{"type": "function", "name": "collaboration__spawn_agent"},
map[string]any{"type": "namespace", "name": "collaboration", "tools": []any{
map[string]any{"type": "function", "name": "spawn_agent"},
}},
}}
_, _, err := FlattenResponsesNamespaces(req)
require.ErrorContains(t, err, "conflicts with a top-level tool")
}
func TestFlattenResponsesNamespaces_NamespaceGroupChoiceFallsBackToAuto(t *testing.T) {
req := map[string]any{
"tools": []any{map[string]any{
"type": "namespace", "name": "collaboration", "tools": []any{
map[string]any{"type": "function", "name": "spawn_agent"},
map[string]any{"type": "function", "name": "send_message"},
},
}},
"tool_choice": map[string]any{"type": "namespace", "name": "collaboration"},
}
_, changed, err := FlattenResponsesNamespaces(req)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "auto", req["tool_choice"])
}
func TestFlattenResponsesNamespacesExcept_PreservesBuiltInNamespaceAndChoice(t *testing.T) {
req := map[string]any{
"tools": []any{
map[string]any{"type": "namespace", "name": "image_gen", "tools": []any{
map[string]any{"type": "function", "name": "imagegen"},
}},
map[string]any{"type": "namespace", "name": "collaboration", "tools": []any{
map[string]any{"type": "function", "name": "spawn_agent"},
}},
},
"tool_choice": map[string]any{"type": "namespace", "name": "image_gen"},
}
names, changed, err := FlattenResponsesNamespacesExcept(req, map[string]bool{"image_gen": true})
require.NoError(t, err)
require.True(t, changed)
require.Contains(t, names, "collaboration__spawn_agent")
tools := req["tools"].([]any)
require.Equal(t, "namespace", tools[0].(map[string]any)["type"])
require.Equal(t, "image_gen", tools[0].(map[string]any)["name"])
require.Equal(t, "function", tools[1].(map[string]any)["type"])
require.Equal(t, "collaboration__spawn_agent", tools[1].(map[string]any)["name"])
require.Equal(t, map[string]any{"type": "namespace", "name": "image_gen"}, req["tool_choice"])
}
func TestFlattenResponsesNamespaces_RejectsNamespaceCollision(t *testing.T) {
req := map[string]any{"tools": []any{
map[string]any{"type": "namespace", "name": "a", "tools": []any{
map[string]any{"type": "function", "name": "b__c"},
}},
map[string]any{"type": "namespace", "name": "a__b", "tools": []any{
map[string]any{"type": "function", "name": "c"},
}},
}}
_, _, err := FlattenResponsesNamespaces(req)
require.ErrorContains(t, err, "both flatten")
}
func TestRestoreResponsesNamespaceCalls_RewritesOnlyFunctionCalls(t *testing.T) {
payload := []byte(`{"type":"response.completed","response":{"output":[{"type":"function_call","name":"collaboration__spawn_agent","call_id":"call_1","arguments":"{}","extra":"keep"},{"type":"function_call","name":"plain","arguments":"{}"},{"type":"message","name":"collaboration__spawn_agent","content":"<tag>&value</tag>"}]}}`)
names := map[string]ResponsesNamespaceName{
"collaboration__spawn_agent": {Namespace: "collaboration", Name: "spawn_agent"},
}
got, changed, err := RestoreResponsesNamespaceCalls(payload, names)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"type":"response.completed","response":{"output":[{"type":"function_call","name":"spawn_agent","namespace":"collaboration","call_id":"call_1","arguments":"{}","extra":"keep"},{"type":"function_call","name":"plain","arguments":"{}"},{"type":"message","name":"collaboration__spawn_agent","content":"<tag>&value</tag>"}]}}`, string(got))
require.Contains(t, string(got), "<tag>&value</tag>")
require.NotContains(t, string(got), `\u003c`)
}
func TestRestoreResponsesNamespaceCalls_RewritesLifecycleItems(t *testing.T) {
for _, eventType := range []string{"response.output_item.added", "response.output_item.done"} {
t.Run(eventType, func(t *testing.T) {
payload := []byte(`{"type":"` + eventType + `","item":{"type":"function_call","name":"collaboration__spawn_agent","arguments":"{}"}}`)
got, changed, err := RestoreResponsesNamespaceCalls(payload, map[string]ResponsesNamespaceName{
"collaboration__spawn_agent": {Namespace: "collaboration", Name: "spawn_agent"},
})
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"type":"`+eventType+`","item":{"type":"function_call","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}}`, string(got))
})
}
}
@@ -42,6 +42,16 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if normalized {
body = normalizedBody
}
if account.Type == AccountTypeOAuth {
body, err = flattenOpenAIResponsesNamespaces(c, body)
if err != nil {
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error", "message": err.Error(), "param": "tools",
}})
return nil, err
}
}
originalBody := body
requestView := newOpenAIRequestView(body)
@@ -914,6 +914,17 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
trimmedData = strings.TrimSpace(string(normalizedData))
line = "data: " + string(normalizedData)
}
if trimmedData != "[DONE]" {
restoredData, restoreErr := restoreOpenAIResponsesNamespacePayload(c, dataBytes)
if restoreErr != nil {
return resultWithUsage(), fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr)
}
if !bytes.Equal(restoredData, dataBytes) {
dataBytes = restoredData
trimmedData = strings.TrimSpace(string(restoredData))
line = "data: " + string(restoredData)
}
}
eventType := strings.TrimSpace(gjson.Get(trimmedData, "type").String())
if eventType == "response.failed" {
failedMessage = extractOpenAISSEErrorMessage(dataBytes)
@@ -1094,6 +1105,10 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough(
if originalModel != "" && mappedModel != "" && originalModel != mappedModel {
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
}
body, err = restoreOpenAIResponsesNamespacePayload(c, body)
if err != nil {
return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", err)
}
if !writeOpenAICompactSSEBridge(c, resp.StatusCode, body) {
c.Data(resp.StatusCode, contentType, body)
}
@@ -1135,6 +1150,11 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c
}
// Correct tool calls in final response
body = s.correctToolCallsInResponseBody(body)
restoredBody, restoreErr := restoreOpenAIResponsesNamespacePayload(c, body)
if restoreErr != nil {
return nil, fmt.Errorf("restore OpenAI passthrough namespace response: %w", restoreErr)
}
body = restoredBody
} else {
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
if terminalOK && terminalType == "response.failed" {
@@ -305,6 +305,17 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp
line = "data: " + data
eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String())
}
restoredData, restoreErr := restoreOpenAIResponsesNamespacePayload(c, dataBytes)
if restoreErr != nil {
streamEarlyErr = fmt.Errorf("restore OpenAI namespace response: %w", restoreErr)
return
}
if !bytes.Equal(restoredData, dataBytes) {
dataBytes = restoredData
data = string(restoredData)
line = "data: " + data
eventType = strings.TrimSpace(gjson.GetBytes(dataBytes, "type").String())
}
if sanitizedData, sanitized := sanitizeOpenAIResponseFailedEventForClient(
dataBytes,
eventType,
@@ -850,7 +861,10 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r
if originalModel != mappedModel {
body = s.replaceModelInResponseBody(body, mappedModel, originalModel)
}
body, err = restoreOpenAIResponsesNamespacePayload(c, body)
if err != nil {
return nil, fmt.Errorf("restore OpenAI namespace response: %w", err)
}
responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
contentType := "application/json"
@@ -920,6 +934,11 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte
}
// Correct tool calls in final response
body = s.correctToolCallsInResponseBody(body)
restoredBody, restoreErr := restoreOpenAIResponsesNamespacePayload(c, body)
if restoreErr != nil {
return nil, fmt.Errorf("restore OpenAI namespace response: %w", restoreErr)
}
body = restoredBody
} else {
terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText)
if terminalOK && terminalType == "response.failed" {
@@ -14,6 +14,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
@@ -422,6 +423,194 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
require.NotContains(t, body, "\"name\":\"edit\"")
}
func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse(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":"{}"}]
}`)
upstreamSSE := strings.Join([]string{
`data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"collaboration__spawn_agent","arguments":""}}`,
"",
`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"collaboration__spawn_agent","arguments":"{}"}}`,
"",
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[{"type":"function_call","id":"fc_1","call_id":"call_1","name":"collaboration__spawn_agent","arguments":"{}"}],"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_namespace"}},
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
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),
}
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, "plain", gjson.GetBytes(upstream.lastBody, "tools.0.name").String())
require.Equal(t, "function", gjson.GetBytes(upstream.lastBody, "tools.1.type").String())
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(upstream.lastBody, "tools.1.name").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "tools.1.tools").Exists())
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(upstream.lastBody, "tool_choice.name").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice.namespace").Exists())
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(upstream.lastBody, "input.0.name").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.namespace").Exists())
downstream := rec.Body.String()
require.NotContains(t, downstream, "collaboration__spawn_agent")
require.Contains(t, downstream, `"name":"spawn_agent"`)
require.Contains(t, downstream, `"namespace":"collaboration"`)
}
func TestOpenAIGatewayService_NativeOAuth_NamespaceRequestAndStreamResponse(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")
body := []byte(`{
"model":"gpt-5.5","stream":true,"instructions":"test",
"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]}],
"input":[{"type":"function_call","call_id":"call_old","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}]
}`)
upstreamSSE := strings.Join([]string{
`data: {"type":"response.output_item.added","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"collaboration__spawn_agent","arguments":""}}`,
"",
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[{"type":"function_call","id":"fc_1","call_id":"call_1","name":"collaboration__spawn_agent","arguments":"{}"}],"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_native_namespace"}},
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
}}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
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"},
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
}
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "function", gjson.GetBytes(upstream.lastBody, "tools.0.type").String())
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(upstream.lastBody, "tools.0.name").String())
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(upstream.lastBody, "input.0.name").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.namespace").Exists())
require.NotContains(t, rec.Body.String(), "collaboration__spawn_agent")
require.Contains(t, rec.Body.String(), `"name":"spawn_agent"`)
require.Contains(t, rec.Body.String(), `"namespace":"collaboration"`)
}
func TestOpenAIGatewayService_NativeOAuth_NamespaceNonStreamingResponse(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))
setOpenAIResponsesNamespaceNames(c, map[string]apicompat.ResponsesNamespaceName{
"collaboration__spawn_agent": {Namespace: "collaboration", Name: "spawn_agent"},
})
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{
"id":"resp_1","output":[{"type":"function_call","name":"collaboration__spawn_agent","call_id":"call_1","arguments":"{}"}],
"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
}`)),
}
result, err := (&OpenAIGatewayService{cfg: &config.Config{}}).handleNonStreamingResponse(
context.Background(), resp, c, &Account{Type: AccountTypeOAuth}, "gpt-5.5", "gpt-5.5",
)
require.NoError(t, err)
require.NotNil(t, result)
require.NotContains(t, rec.Body.String(), "collaboration__spawn_agent")
require.Contains(t, rec.Body.String(), `"name":"spawn_agent"`)
require.Contains(t, rec.Body.String(), `"namespace":"collaboration"`)
}
func TestOpenAIGatewayService_OAuthPassthrough_NamespaceNonStreamingResponse(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))
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":"resp_1","output":[{"type":"function_call","name":"collaboration__spawn_agent","call_id":"call_1","arguments":"{}"}],"usage":{"input_tokens":1,"output_tokens":1}}`)),
}
names := map[string]apicompat.ResponsesNamespaceName{
"collaboration__spawn_agent": {Namespace: "collaboration", Name: "spawn_agent"},
}
setOpenAIResponsesNamespaceNames(c, names)
result, err := (&OpenAIGatewayService{cfg: &config.Config{}}).handleNonStreamingResponsePassthrough(
context.Background(), resp, c, "gpt-5.5", "",
)
require.NoError(t, err)
require.NotNil(t, result)
require.NotContains(t, rec.Body.String(), "collaboration__spawn_agent")
require.Contains(t, rec.Body.String(), `"name":"spawn_agent"`)
require.Contains(t, rec.Body.String(), `"namespace":"collaboration"`)
}
func TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollisionReturnsBadRequest(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")
body := []byte(`{
"model":"gpt-5.5","stream":true,"instructions":"test",
"tools":[
{"type":"function","name":"collaboration__spawn_agent","parameters":{"type":"object"}},
{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]}
],"input":"hi"
}`)
upstream := &httpUpstreamRecorder{}
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
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),
}
result, err := svc.Forward(context.Background(), c, account, body)
require.Error(t, err)
require.Nil(t, result)
require.Nil(t, upstream.lastReq)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Equal(t, "invalid_request_error", gjson.Get(rec.Body.String(), "error.type").String())
require.Equal(t, "tools", gjson.Get(rec.Body.String(), "error.param").String())
require.Contains(t, gjson.Get(rec.Body.String(), "error.message").String(), "conflicts with a top-level tool")
}
func TestOpenAIGatewayService_OAuthPassthrough_CompactUsesJSONAndKeepsNonStreaming(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -0,0 +1,68 @@
package service
import (
"bytes"
"encoding/json"
"fmt"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
)
const openAIResponsesNamespaceNamesContextKey = "openai_responses_namespace_names"
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
}
func setOpenAIResponsesNamespaceNames(c *gin.Context, names map[string]apicompat.ResponsesNamespaceName) {
if c != nil && len(names) > 0 {
c.Set(openAIResponsesNamespaceNamesContextKey, names)
}
}
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
}
@@ -1,179 +0,0 @@
# Native Responses Namespace Compatibility Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make native OpenAI Responses OAuth passthrough accept Codex namespace tools by flattening requests and restoring namespace identity in JSON and SSE responses.
**Architecture:** Add a focused, request-local namespace adapter in `internal/pkg/apicompat`. The passthrough service transforms the request once, carries the returned flat-name mapping through the response handlers, and restores mapped function calls immediately before writing downstream bytes.
**Tech Stack:** Go, Gin, `encoding/json`, `tidwall/gjson`, `testify`.
## Global Constraints
- Target only native HTTP Responses OAuth passthrough demonstrated by issue #4135.
- Reuse the chat bridge's collision-safe flattened naming convention.
- Preserve unrelated request fields, ordinary tools, and unmapped response calls.
- Support JSON and SSE response restoration, including terminal completed output.
- Return a client-visible 400-class error for ambiguous flattened names.
- Do not change WebSocket forwarding, connector security policy, or account scheduling.
---
## File Structure
- Create `backend/internal/pkg/apicompat/responses_namespace.go`: transport-independent request flattening, mapping, and response restoration.
- Create `backend/internal/pkg/apicompat/responses_namespace_test.go`: unit tests for transformations and collisions.
- Modify `backend/internal/service/openai_gateway_passthrough.go`: apply the adapter and thread its mapping through HTTP response handling.
- Modify `backend/internal/service/openai_oauth_passthrough_test.go`: end-to-end regression tests for OAuth passthrough request and SSE response behavior.
### Task 1: Request Namespace Adapter
**Files:**
- Create: `backend/internal/pkg/apicompat/responses_namespace.go`
- Create: `backend/internal/pkg/apicompat/responses_namespace_test.go`
**Interfaces:**
- Produces: `type ResponsesNamespaceName struct { Namespace string; Name string }`
- Produces: `func FlattenResponsesNamespaces(req map[string]any) (map[string]ResponsesNamespaceName, bool, error)`
- [ ] **Step 1: Write failing request transformation tests**
Add table-driven tests that construct a request with a namespace declaration, a historical `function_call`, and a namespace `tool_choice`. Assert that the namespace child becomes an ordinary top-level function named `collaboration__spawn_agent`, historical input uses the same name without `namespace`, the choice is rewritten, and unrelated tools/fields are unchanged. Add collision cases for a top-level flat-name conflict and two namespace pairs that flatten identically.
- [ ] **Step 2: Run the tests and verify RED**
Run: `cd backend && go test ./internal/pkg/apicompat -run 'TestFlattenResponsesNamespaces' -count=1`
Expected: build failure because `FlattenResponsesNamespaces` does not exist.
- [ ] **Step 3: Implement the minimal request adapter**
Decode `tools` and optional `children`/nested `tools` values from `map[string]any`, call the package's existing `flattenNamespaceToolName`, and build a flat-name ownership map. Replace each namespace declaration with its function children, rewrite matching `input` function calls and namespace `tool_choice`, and return a descriptive error when ownership is ambiguous.
- [ ] **Step 4: Run the tests and verify GREEN**
Run: `cd backend && go test ./internal/pkg/apicompat -run 'TestFlattenResponsesNamespaces' -count=1`
Expected: PASS.
### Task 2: Response Namespace Restoration
**Files:**
- Modify: `backend/internal/pkg/apicompat/responses_namespace.go`
- Modify: `backend/internal/pkg/apicompat/responses_namespace_test.go`
**Interfaces:**
- Consumes: `map[string]ResponsesNamespaceName`
- Produces: `func RestoreResponsesNamespaceCalls(payload []byte, names map[string]ResponsesNamespaceName) ([]byte, bool, error)`
- [ ] **Step 1: Write failing response restoration tests**
Cover a direct `function_call`, `response.output_item.added`, `response.output_item.done`, and `response.completed.response.output`. Assert that a mapped name is restored to the child name and gains `namespace`, while an ordinary call and unknown JSON fields remain unchanged.
- [ ] **Step 2: Run the tests and verify RED**
Run: `cd backend && go test ./internal/pkg/apicompat -run 'TestRestoreResponsesNamespaceCalls' -count=1`
Expected: build failure because `RestoreResponsesNamespaceCalls` does not exist.
- [ ] **Step 3: Implement recursive, type-constrained restoration**
Unmarshal the payload, visit maps and arrays, and rewrite only maps whose `type` is `function_call` and whose string `name` exists in the request mapping. Set the original child `name` and `namespace`, then marshal only when a change occurred.
- [ ] **Step 4: Run the tests and verify GREEN**
Run: `cd backend && go test ./internal/pkg/apicompat -run 'TestRestoreResponsesNamespaceCalls' -count=1`
Expected: PASS.
### Task 3: OAuth Passthrough Integration
**Files:**
- Modify: `backend/internal/service/openai_gateway_passthrough.go`
- Modify: `backend/internal/service/openai_oauth_passthrough_test.go`
**Interfaces:**
- Consumes: `apicompat.FlattenResponsesNamespaces` and `apicompat.RestoreResponsesNamespaceCalls`.
- Changes the private passthrough response handlers to accept the request-local namespace mapping.
- [ ] **Step 1: Write a failing end-to-end streaming regression test**
Send an OAuth passthrough body containing `tools:[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent",...}]}]` and a historical input call tagged with `namespace:"collaboration"`. Return SSE added/done/completed events using `collaboration__spawn_agent`. Assert the recorded upstream request has no namespace declarations or input namespace fields and the downstream SSE restores `name:"spawn_agent"` plus `namespace:"collaboration"` in every function-call item.
- [ ] **Step 2: Run the streaming regression and verify RED**
Run: `cd backend && go test ./internal/service -run 'TestOpenAIGatewayService_OAuthPassthrough_Namespace' -count=1`
Expected: FAIL because the upstream body still contains namespace fields or the downstream body retains the flat name.
- [ ] **Step 3: Integrate request flattening and SSE restoration**
After OAuth passthrough normalization, unmarshal the body, call `FlattenResponsesNamespaces`, marshal with the repository's non-HTML-escaping JSON helper, and retain the mapping. Pass it into `handleStreamingResponsePassthrough`; after existing argument/model normalization, call `RestoreResponsesNamespaceCalls` on each SSE data payload and rebuild the `data:` line.
- [ ] **Step 4: Run the streaming regression and verify GREEN**
Run: `cd backend && go test ./internal/service -run 'TestOpenAIGatewayService_OAuthPassthrough_Namespace' -count=1`
Expected: PASS.
- [ ] **Step 5: Write a failing non-stream JSON regression test**
Exercise the non-stream handler with a completed Responses JSON body whose output contains `collaboration__spawn_agent`, then assert the client receives `spawn_agent` and `namespace:"collaboration"`.
- [ ] **Step 6: Run the JSON regression and verify RED**
Run: `cd backend && go test ./internal/service -run 'TestOpenAIGatewayService_OAuthPassthrough_NamespaceNonStreaming' -count=1`
Expected: FAIL because JSON response restoration is not wired.
- [ ] **Step 7: Integrate JSON and SSE-to-JSON restoration**
Pass the mapping into `handleNonStreamingResponsePassthrough` and `handlePassthroughSSEToJSON`. Restore namespace calls after model correction and before writing the body. Return transformation errors instead of writing partially transformed output.
- [ ] **Step 8: Run all namespace regressions and verify GREEN**
Run: `cd backend && go test ./internal/service -run 'TestOpenAIGatewayService_OAuthPassthrough_Namespace' -count=1`
Expected: PASS.
### Task 4: Client Error and Regression Verification
**Files:**
- Modify: `backend/internal/service/openai_gateway_passthrough.go`
- Modify: `backend/internal/service/openai_oauth_passthrough_test.go`
- [ ] **Step 1: Write a failing service collision test**
Send an OAuth passthrough request whose namespace child conflicts with an existing top-level function. Assert no upstream request occurs and the client receives status 400 with `invalid_request_error` and a collision explanation.
- [ ] **Step 2: Run the collision test and verify RED**
Run: `cd backend && go test ./internal/service -run 'TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollision' -count=1`
Expected: FAIL because the adapter error is not yet mapped to a 400 response.
- [ ] **Step 3: Map adapter failures to the existing invalid-request response shape**
When request flattening returns an error, set the ops upstream error and write `{"error":{"type":"invalid_request_error","message":err.Error(),"param":"tools"}}` with HTTP 400, then return the same error without contacting upstream.
- [ ] **Step 4: Run targeted suites**
Run: `cd backend && go test ./internal/pkg/apicompat ./internal/service -run 'Namespace|OAuthPassthrough_StreamKeepsToolNameAndBodyNormalized' -count=1`
Expected: PASS.
- [ ] **Step 5: Format and run full relevant package tests**
Run: `cd backend && gofmt -w internal/pkg/apicompat/responses_namespace.go internal/pkg/apicompat/responses_namespace_test.go internal/service/openai_gateway_passthrough.go internal/service/openai_oauth_passthrough_test.go`
Run: `cd backend && go test ./internal/pkg/apicompat ./internal/service -count=1`
Expected: PASS with zero failures.
- [ ] **Step 6: Review the final diff and commit**
Run: `git diff --check && git status -sb && git diff --stat origin/main...HEAD`
Expected: no whitespace errors and only the design, plan, namespace adapter, passthrough integration, and regression tests in scope.
Commit with: `git commit -m "fix: support namespace tools in native responses"`.
@@ -1,80 +0,0 @@
# Native Responses Namespace Compatibility
## Context
Codex clients can send private `namespace` tool declarations and tag historical
`function_call` input items with a `namespace`. The chat-completions fallback
bridge already flattens these declarations into ordinary function tools and
restores the namespace on responses. The native Responses OAuth path currently
forwards the private fields unchanged. Upstreams that implement only the public
Responses schema reject them, and sub2api surfaces the failure as a 502.
## Scope
Extend namespace compatibility to the native Responses OAuth path without
changing the existing chat-completions bridge or ordinary function tools.
The request transformation must:
- flatten every function child of a `type: "namespace"` tool into a top-level
function tool using the existing collision-safe naming convention;
- rewrite namespace-qualified historical `function_call` items to the same
flattened name and remove their private `namespace` field;
- rewrite namespace-qualified `tool_choice` values when present;
- reject ambiguous flattened names with a clear client error instead of
forwarding a request that cannot be routed safely;
- preserve all unrelated request fields and tools.
The response transformation must:
- restore a flattened tool call to its original child name plus `namespace`;
- cover both JSON responses and every relevant SSE lifecycle event, including
the final completed response;
- leave tool calls that are not in the request mapping unchanged.
## Design
Extract the namespace flattening and mapping behavior that is currently tied to
the chat-completions bridge into reusable helpers in `internal/pkg/apicompat`.
The helpers will operate on Responses request structures and expose the mapping
from flattened names to original namespace/name pairs.
The native OAuth forwarding path will apply the request helper during its
existing Codex OAuth normalization pass. The resulting mapping is request-local
and will be passed to the native response handling code. JSON and SSE response
rewriters will use it to restore function-call identity before bytes are sent to
the client. No mapping is persisted across requests because the request contains
the declarations needed to reconstruct it.
This change targets the native HTTP Responses forwarding path demonstrated by
the issue. WebSocket forwarding is outside the scope unless a failing regression
test proves that it shares the same HTTP transformation boundary.
## Error Handling
Malformed namespace declarations that cannot be transformed safely, including
flattened-name collisions, return a 400-class invalid-request response. Upstream
transport failures continue through the existing error and failover handling.
Response rewriting is conservative: unrecognized event shapes pass through
unchanged, while valid mapped function-call objects are restored.
## Testing
Tests will be written before implementation and will demonstrate the regression
against the current code. Coverage includes:
- OAuth native request forwarding with namespace tools and historical calls;
- namespace `tool_choice` rewriting;
- collision rejection;
- non-stream JSON response restoration;
- streaming SSE restoration across added, done, and completed events;
- ordinary function tools and payload fields remaining unchanged;
- targeted service and `apicompat` test suites, followed by formatting and the
repository's relevant backend verification commands.
## Non-goals
- Changing the Codex client or requiring a client downgrade.
- Forcing OAuth accounts through the chat-completions fallback bridge.
- Changing connector security policy from issue #3408.
- Adding persistent namespace state or changing account scheduling behavior.