diff --git a/backend/internal/pkg/apicompat/responses_namespace.go b/backend/internal/pkg/apicompat/responses_namespace.go new file mode 100644 index 0000000000..a5549760c5 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_namespace.go @@ -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 +} diff --git a/backend/internal/pkg/apicompat/responses_namespace_test.go b/backend/internal/pkg/apicompat/responses_namespace_test.go new file mode 100644 index 0000000000..ae686d38bd --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_namespace_test.go @@ -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":"&value"}]}}`) + 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":"&value"}]}}`, string(got)) + require.Contains(t, string(got), "&value") + 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)) + }) + } +} diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 1c13738020..f8dfdd2247 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -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) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 238d359b9e..9f62362ee8 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -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" { diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 5841ce6556..9efe649653 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -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" { diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 60790b9e4c..0aa536f94f 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -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) diff --git a/backend/internal/service/openai_responses_namespace.go b/backend/internal/service/openai_responses_namespace.go new file mode 100644 index 0000000000..279eb2afeb --- /dev/null +++ b/backend/internal/service/openai_responses_namespace.go @@ -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 +} diff --git a/docs/superpowers/plans/2026-07-13-native-responses-namespace.md b/docs/superpowers/plans/2026-07-13-native-responses-namespace.md deleted file mode 100644 index 9b9459d5ee..0000000000 --- a/docs/superpowers/plans/2026-07-13-native-responses-namespace.md +++ /dev/null @@ -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"`. diff --git a/docs/superpowers/specs/2026-07-13-native-responses-namespace-design.md b/docs/superpowers/specs/2026-07-13-native-responses-namespace-design.md deleted file mode 100644 index 38e0a7f81d..0000000000 --- a/docs/superpowers/specs/2026-07-13-native-responses-namespace-design.md +++ /dev/null @@ -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.