From 54a8606e2afec3c8c5e67a2ebc68c56a79c9aca3 Mon Sep 17 00:00:00 2001 From: xingzhi Date: Mon, 13 Jul 2026 12:03:01 +0800 Subject: [PATCH 1/4] docs: design native responses namespace compatibility --- ...07-13-native-responses-namespace-design.md | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-13-native-responses-namespace-design.md 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 new file mode 100644 index 0000000000..38e0a7f81d --- /dev/null +++ b/docs/superpowers/specs/2026-07-13-native-responses-namespace-design.md @@ -0,0 +1,80 @@ +# 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. From 1d86c1bf81592920353850091720940de0f36842 Mon Sep 17 00:00:00 2001 From: xingzhi Date: Mon, 13 Jul 2026 12:13:52 +0800 Subject: [PATCH 2/4] docs: plan native responses namespace compatibility --- .../2026-07-13-native-responses-namespace.md | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-native-responses-namespace.md diff --git a/docs/superpowers/plans/2026-07-13-native-responses-namespace.md b/docs/superpowers/plans/2026-07-13-native-responses-namespace.md new file mode 100644 index 0000000000..9b9459d5ee --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-native-responses-namespace.md @@ -0,0 +1,179 @@ +# 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"`. From 317de9c04b610eb853080991c2bd8f4574db8d2f Mon Sep 17 00:00:00 2001 From: xingzhi Date: Mon, 13 Jul 2026 14:38:05 +0800 Subject: [PATCH 3/4] fix: support namespace tools in native responses --- .../pkg/apicompat/responses_namespace.go | 212 ++++++++++++++++++ .../pkg/apicompat/responses_namespace_test.go | 147 ++++++++++++ .../service/openai_gateway_forward.go | 10 + .../service/openai_gateway_passthrough.go | 20 ++ .../openai_gateway_response_handling.go | 21 +- .../service/openai_oauth_passthrough_test.go | 189 ++++++++++++++++ .../service/openai_responses_namespace.go | 68 ++++++ .../2026-07-13-native-responses-namespace.md | 179 --------------- ...07-13-native-responses-namespace-design.md | 80 ------- 9 files changed, 666 insertions(+), 260 deletions(-) create mode 100644 backend/internal/pkg/apicompat/responses_namespace.go create mode 100644 backend/internal/pkg/apicompat/responses_namespace_test.go create mode 100644 backend/internal/service/openai_responses_namespace.go delete mode 100644 docs/superpowers/plans/2026-07-13-native-responses-namespace.md delete mode 100644 docs/superpowers/specs/2026-07-13-native-responses-namespace-design.md 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. From fa1641f05f1607276b867e20194e12ad5499f4ef Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 14 Jul 2026 14:48:22 +0800 Subject: [PATCH 4/4] fix: keep namespace verbatim on WSv2 forwards and check test type assertions WSv2 egress relays upstream events verbatim without the HTTP-path namespace restore, so flattening requests that take the WSv2 branch would surface flattened tool names the client cannot match. Resolve the WS transport decision before flattening and skip flattening only when the request will actually go WSv2 (passthrough accounts return via HTTP before the WSv2 branch and still flatten). Also check all type assertions in responses_namespace_test.go to satisfy golangci-lint errcheck. --- .../pkg/apicompat/responses_namespace_test.go | 41 +++++++++++++------ .../service/openai_gateway_forward.go | 11 +++-- .../service/openai_responses_namespace.go | 15 +++++++ .../openai_responses_namespace_test.go | 34 +++++++++++++++ 4 files changed, 83 insertions(+), 18 deletions(-) create mode 100644 backend/internal/service/openai_responses_namespace_test.go diff --git a/backend/internal/pkg/apicompat/responses_namespace_test.go b/backend/internal/pkg/apicompat/responses_namespace_test.go index ae686d38bd..4c8e313c5d 100644 --- a/backend/internal/pkg/apicompat/responses_namespace_test.go +++ b/backend/internal/pkg/apicompat/responses_namespace_test.go @@ -31,20 +31,31 @@ func TestFlattenResponsesNamespaces_RewritesDeclarationHistoryAndChoice(t *testi require.True(t, changed) require.Equal(t, ResponsesNamespaceName{Namespace: "collaboration", Name: "spawn_agent"}, names["collaboration__spawn_agent"]) - tools := req["tools"].([]any) + tools, ok := req["tools"].([]any) + require.True(t, ok) 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"]) + plainTool, ok := tools[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "plain", plainTool["name"]) + flatTool, ok := tools[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "collaboration__spawn_agent", flatTool["name"]) + require.Equal(t, "spawn", flatTool["description"]) - choice := req["tool_choice"].(map[string]any) + choice, ok := req["tool_choice"].(map[string]any) + require.True(t, ok) require.Equal(t, "collaboration__spawn_agent", choice["name"]) require.NotContains(t, choice, "namespace") - call := req["input"].([]any)[0].(map[string]any) + input, ok := req["input"].([]any) + require.True(t, ok) + require.Len(t, input, 2) + call, ok := input[0].(map[string]any) + require.True(t, ok) require.Equal(t, "collaboration__spawn_agent", call["name"]) require.NotContains(t, call, "namespace") - message := req["input"].([]any)[1].(map[string]any) + message, ok := input[1].(map[string]any) + require.True(t, ok) require.Equal(t, "spawn_agent", message["name"]) require.Equal(t, "collaboration", message["namespace"]) require.Equal(t, "gpt-5.5", req["model"]) @@ -96,11 +107,17 @@ func TestFlattenResponsesNamespacesExcept_PreservesBuiltInNamespaceAndChoice(t * 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"]) + tools, ok := req["tools"].([]any) + require.True(t, ok) + require.Len(t, tools, 2) + preservedTool, ok := tools[0].(map[string]any) + require.True(t, ok) + require.Equal(t, "namespace", preservedTool["type"]) + require.Equal(t, "image_gen", preservedTool["name"]) + flatTool, ok := tools[1].(map[string]any) + require.True(t, ok) + require.Equal(t, "function", flatTool["type"]) + require.Equal(t, "collaboration__spawn_agent", flatTool["name"]) require.Equal(t, map[string]any{"type": "namespace", "name": "image_gen"}, req["tool_choice"]) } diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 980d2ffe33..6c7d45946a 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -42,7 +42,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if normalized { body = normalizedBody } - if account.Type == AccountTypeOAuth { + wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) + // 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。 + wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c)) + passthroughEnabled := account.IsOpenAIPassthroughEnabled() + if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled) { body, err = flattenOpenAIResponsesNamespaces(c, body) if err != nil { setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") @@ -74,10 +78,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if isCodexCLI { codexImageGenerationExplicitToolPolicy = account.CodexImageGenerationExplicitToolPolicy() } - wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) - clientTransport := GetOpenAIClientTransport(c) - // 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。 - wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, clientTransport) if c != nil { c.Set("openai_ws_transport_decision", string(wsDecision.Transport)) c.Set("openai_ws_transport_reason", wsDecision.Reason) @@ -106,7 +106,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } return nil, errors.New("openai ws v1 is temporarily unsupported; use ws v2") } - passthroughEnabled := account.IsOpenAIPassthroughEnabled() if passthroughEnabled { if isCodexCLI && codexImageGenerationExplicitToolPolicy == codexImageGenerationExplicitToolPolicyStrip { strippedBody, changed, stripErr := stripOpenAIImageGenerationToolsFromRawPayload(body) diff --git a/backend/internal/service/openai_responses_namespace.go b/backend/internal/service/openai_responses_namespace.go index 279eb2afeb..7d71b64814 100644 --- a/backend/internal/service/openai_responses_namespace.go +++ b/backend/internal/service/openai_responses_namespace.go @@ -11,6 +11,21 @@ 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 { + if account == nil || account.Type != AccountTypeOAuth { + return false + } + if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled { + return false + } + return true +} + func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, error) { if !bytes.Contains(body, []byte(`"namespace"`)) { return body, nil diff --git a/backend/internal/service/openai_responses_namespace_test.go b/backend/internal/service/openai_responses_namespace_test.go new file mode 100644 index 0000000000..7aa1392260 --- /dev/null +++ b/backend/internal/service/openai_responses_namespace_test.go @@ -0,0 +1,34 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) { + oauth := &Account{Type: AccountTypeOAuth} + apiKey := &Account{Type: AccountTypeAPIKey} + + tests := []struct { + name string + account *Account + transport OpenAIUpstreamTransport + passthroughEnabled bool + want bool + }{ + {name: "oauth_http", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true}, + {name: "oauth_http_passthrough", account: oauth, 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: "apikey_http", account: apiKey, 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)) + }) + } +}