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