mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
Merge pull request #4324 from superman2003/fix/responses-lite-tool-compat
fix(openai): normalize Responses Lite tool declarations
This commit is contained in:
@@ -312,13 +312,30 @@ func normalizeCodexToolChoice(reqBody map[string]any) bool {
|
||||
}
|
||||
return modified
|
||||
}
|
||||
if codexToolsContainType(reqBody["tools"], choiceType) {
|
||||
if codexToolsContainType(reqBody["tools"], choiceType) || codexInputAdditionalToolsContainType(reqBody["input"], choiceType) {
|
||||
return modified
|
||||
}
|
||||
reqBody["tool_choice"] = "auto"
|
||||
return true
|
||||
}
|
||||
|
||||
func codexInputAdditionalToolsContainType(rawInput any, toolType string) bool {
|
||||
input, ok := rawInput.([]any)
|
||||
if !ok || strings.TrimSpace(toolType) == "" {
|
||||
return false
|
||||
}
|
||||
for _, rawItem := range input {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" {
|
||||
continue
|
||||
}
|
||||
if codexToolsContainType(item["tools"], toolType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func codexToolsContainType(rawTools any, toolType string) bool {
|
||||
tools, ok := rawTools.([]any)
|
||||
if !ok || strings.TrimSpace(toolType) == "" {
|
||||
|
||||
@@ -44,6 +44,19 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
if normalized {
|
||||
body = normalizedBody
|
||||
}
|
||||
if account.IsOpenAIOAuth() && isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) {
|
||||
liteBody, changed, liteErr := normalizeOpenAIResponsesLiteToolsPayload(body)
|
||||
if liteErr != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadRequest, liteErr.Error(), "")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
||||
"type": "invalid_request_error", "message": liteErr.Error(), "param": "tools",
|
||||
}})
|
||||
return nil, liteErr
|
||||
}
|
||||
if changed {
|
||||
body = liteBody
|
||||
}
|
||||
}
|
||||
wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account)
|
||||
// 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。
|
||||
wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c))
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// normalizeOpenAIResponsesLiteTools converts private namespace declarations
|
||||
// into the input.additional_tools carrier required by Responses Lite. Other
|
||||
// top-level tools must belong to the small set accepted by the Lite endpoint;
|
||||
// rejecting unsupported hosted tools is intentional because silently dropping
|
||||
// them would change the client's requested behavior.
|
||||
func normalizeOpenAIResponsesLiteTools(reqBody map[string]any) (bool, error) {
|
||||
if reqBody == nil {
|
||||
return false, nil
|
||||
}
|
||||
rawTools, exists := reqBody["tools"]
|
||||
if !exists || rawTools == nil {
|
||||
return false, nil
|
||||
}
|
||||
tools, ok := rawTools.([]any)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("responses Lite requires tools to be an array")
|
||||
}
|
||||
|
||||
topLevelTools := make([]any, 0, len(tools))
|
||||
namespaceTools := make([]any, 0, len(tools))
|
||||
for index, rawTool := range tools {
|
||||
if customTool, ok := rawTool.(string); ok {
|
||||
if strings.TrimSpace(customTool) == "" {
|
||||
return false, fmt.Errorf("responses Lite custom tool at index %d must not be empty", index)
|
||||
}
|
||||
topLevelTools = append(topLevelTools, rawTool)
|
||||
continue
|
||||
}
|
||||
tool, ok := rawTool.(map[string]any)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("responses Lite tool at index %d must be an object", index)
|
||||
}
|
||||
toolType := strings.TrimSpace(firstNonEmptyString(tool["type"]))
|
||||
switch toolType {
|
||||
case "function", "custom", "tool_search":
|
||||
topLevelTools = append(topLevelTools, rawTool)
|
||||
case "namespace":
|
||||
namespaceTools = append(namespaceTools, rawTool)
|
||||
case "":
|
||||
return false, fmt.Errorf("responses Lite tool at index %d is missing type", index)
|
||||
default:
|
||||
return false, fmt.Errorf("responses Lite does not support top-level tool type %q at index %d", toolType, index)
|
||||
}
|
||||
}
|
||||
if len(namespaceTools) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
input, err := appendOpenAIResponsesLiteAdditionalTools(reqBody["input"], namespaceTools)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
reqBody["input"] = input
|
||||
if len(topLevelTools) == 0 {
|
||||
delete(reqBody, "tools")
|
||||
} else {
|
||||
reqBody["tools"] = topLevelTools
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func appendOpenAIResponsesLiteAdditionalTools(input any, namespaceTools []any) ([]any, error) {
|
||||
var items []any
|
||||
switch typed := input.(type) {
|
||||
case nil:
|
||||
items = make([]any, 0, 1)
|
||||
case string:
|
||||
items = []any{map[string]any{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": typed,
|
||||
}}
|
||||
case []any:
|
||||
items = typed
|
||||
default:
|
||||
return nil, fmt.Errorf("responses Lite namespace tools require input to be a string or array")
|
||||
}
|
||||
|
||||
var target map[string]any
|
||||
var targetTools []any
|
||||
var allAdditionalTools []any
|
||||
for _, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" {
|
||||
continue
|
||||
}
|
||||
rawAdditionalTools, exists := item["tools"]
|
||||
additionalTools := []any(nil)
|
||||
toolsOK := true
|
||||
if exists && rawAdditionalTools != nil {
|
||||
additionalTools, toolsOK = rawAdditionalTools.([]any)
|
||||
}
|
||||
if !toolsOK {
|
||||
return nil, fmt.Errorf("responses Lite input.additional_tools tools must be an array")
|
||||
}
|
||||
if target == nil {
|
||||
target = item
|
||||
targetTools = additionalTools
|
||||
}
|
||||
allAdditionalTools = append(allAdditionalTools, additionalTools...)
|
||||
}
|
||||
|
||||
merged, err := mergeOpenAIResponsesLiteAdditionalTools(allAdditionalTools, namespaceTools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newTools := merged[len(allAdditionalTools):]
|
||||
if target != nil {
|
||||
if len(newTools) > 0 {
|
||||
target["tools"] = append(append([]any(nil), targetTools...), newTools...)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
items = append(items, map[string]any{
|
||||
"type": "additional_tools",
|
||||
"role": "developer",
|
||||
"tools": newTools,
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func mergeOpenAIResponsesLiteAdditionalTools(existing []any, moved []any) ([]any, error) {
|
||||
merged := append([]any(nil), existing...)
|
||||
seen := make(map[string]any, len(existing)+len(moved))
|
||||
for _, rawTool := range existing {
|
||||
if identity := openAIResponsesLiteToolIdentity(rawTool); identity != "" {
|
||||
if previous, exists := seen[identity]; exists && !reflect.DeepEqual(previous, rawTool) {
|
||||
return nil, fmt.Errorf("responses Lite additional_tools contains conflicting definitions for %s", openAIResponsesLiteToolIdentityForError(rawTool))
|
||||
}
|
||||
seen[identity] = rawTool
|
||||
}
|
||||
}
|
||||
for _, rawTool := range moved {
|
||||
identity := openAIResponsesLiteToolIdentity(rawTool)
|
||||
if identity != "" {
|
||||
if previous, exists := seen[identity]; exists {
|
||||
if reflect.DeepEqual(previous, rawTool) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("responses Lite additional_tools conflicts with migrated %s", openAIResponsesLiteToolIdentityForError(rawTool))
|
||||
}
|
||||
seen[identity] = rawTool
|
||||
}
|
||||
merged = append(merged, rawTool)
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func openAIResponsesLiteToolIdentity(rawTool any) string {
|
||||
tool, ok := rawTool.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
toolType := strings.TrimSpace(firstNonEmptyString(tool["type"]))
|
||||
name := strings.TrimSpace(firstNonEmptyString(tool["name"]))
|
||||
if toolType == "" || name == "" {
|
||||
return ""
|
||||
}
|
||||
return toolType + "\x00" + name
|
||||
}
|
||||
|
||||
func openAIResponsesLiteToolIdentityForError(rawTool any) string {
|
||||
tool, _ := rawTool.(map[string]any)
|
||||
return fmt.Sprintf("tool type %q name %q", strings.TrimSpace(firstNonEmptyString(tool["type"])), strings.TrimSpace(firstNonEmptyString(tool["name"])))
|
||||
}
|
||||
|
||||
func normalizeOpenAIResponsesLiteToolsPayload(body []byte) ([]byte, bool, error) {
|
||||
var requestBody map[string]any
|
||||
if err := json.Unmarshal(body, &requestBody); err != nil {
|
||||
return body, false, fmt.Errorf("decode responses Lite request body: %w", err)
|
||||
}
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(requestBody)
|
||||
if err != nil || !changed {
|
||||
return body, false, err
|
||||
}
|
||||
rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
|
||||
if err != nil {
|
||||
return body, false, fmt.Errorf("encode responses Lite request body: %w", err)
|
||||
}
|
||||
return rebuilt, true, nil
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteTools_MovesNamespacesAndKeepsSupportedTools(t *testing.T) {
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.6-terra",
|
||||
"tools": []any{
|
||||
map[string]any{"type": "function", "name": "shell"},
|
||||
map[string]any{"type": "custom", "name": "exec"},
|
||||
map[string]any{"type": "tool_search"},
|
||||
map[string]any{"type": "namespace", "name": "collaboration", "tools": []any{
|
||||
map[string]any{"type": "function", "name": "spawn_agent"},
|
||||
}},
|
||||
},
|
||||
"input": []any{
|
||||
map[string]any{"type": "message", "role": "user", "content": "hello"},
|
||||
map[string]any{"type": "additional_tools", "role": "developer", "tools": []any{
|
||||
map[string]any{"type": "namespace", "name": "image_gen"},
|
||||
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": "collaboration"},
|
||||
}
|
||||
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
tools := reqBody["tools"].([]any)
|
||||
require.Len(t, tools, 3)
|
||||
require.Equal(t, "function", tools[0].(map[string]any)["type"])
|
||||
require.Equal(t, "custom", tools[1].(map[string]any)["type"])
|
||||
require.Equal(t, "tool_search", tools[2].(map[string]any)["type"])
|
||||
input := reqBody["input"].([]any)
|
||||
require.Len(t, input, 2)
|
||||
additional := input[1].(map[string]any)["tools"].([]any)
|
||||
require.Len(t, additional, 2)
|
||||
require.Equal(t, "image_gen", additional[0].(map[string]any)["name"])
|
||||
require.Equal(t, "collaboration", additional[1].(map[string]any)["name"], "existing namespace must not be duplicated")
|
||||
require.Equal(t, map[string]any{"type": "namespace", "name": "collaboration"}, reqBody["tool_choice"])
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteTools_RejectsConflictingAdditionalTool(t *testing.T) {
|
||||
reqBody := map[string]any{
|
||||
"tools": []any{map[string]any{
|
||||
"type": "namespace",
|
||||
"name": "collaboration",
|
||||
"tools": []any{map[string]any{"type": "function", "name": "spawn_agent"}},
|
||||
}},
|
||||
"input": []any{map[string]any{
|
||||
"type": "additional_tools",
|
||||
"tools": []any{map[string]any{
|
||||
"type": "namespace",
|
||||
"name": "collaboration",
|
||||
"tools": []any{map[string]any{"type": "function", "name": "send_message"}},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
|
||||
|
||||
require.ErrorContains(t, err, `conflicts with migrated tool type "namespace" name "collaboration"`)
|
||||
require.False(t, changed)
|
||||
require.Len(t, reqBody["tools"], 1, "conflicts must not partially remove top-level tools")
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteTools_DeduplicatesAcrossAdditionalToolItems(t *testing.T) {
|
||||
namespace := map[string]any{
|
||||
"type": "namespace",
|
||||
"name": "collaboration",
|
||||
"tools": []any{map[string]any{"type": "function", "name": "spawn_agent"}},
|
||||
}
|
||||
reqBody := map[string]any{
|
||||
"tools": []any{namespace},
|
||||
"input": []any{
|
||||
map[string]any{
|
||||
"type": "additional_tools",
|
||||
"tools": []any{map[string]any{"type": "custom", "name": "exec"}},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "additional_tools",
|
||||
"tools": []any{namespace},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.NotContains(t, reqBody, "tools")
|
||||
input := reqBody["input"].([]any)
|
||||
require.Len(t, input[0].(map[string]any)["tools"], 1)
|
||||
require.Len(t, input[1].(map[string]any)["tools"], 1)
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteTools_ConvertsStringInput(t *testing.T) {
|
||||
reqBody := map[string]any{
|
||||
"input": "hello",
|
||||
"tools": []any{map[string]any{
|
||||
"type": "namespace",
|
||||
"name": "collaboration",
|
||||
}},
|
||||
}
|
||||
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.NotContains(t, reqBody, "tools")
|
||||
input := reqBody["input"].([]any)
|
||||
require.Len(t, input, 2)
|
||||
require.Equal(t, "message", input[0].(map[string]any)["type"])
|
||||
require.Equal(t, "hello", input[0].(map[string]any)["content"])
|
||||
require.Equal(t, "additional_tools", input[1].(map[string]any)["type"])
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteTools_KeepsSupportedTopLevelTools(t *testing.T) {
|
||||
reqBody := map[string]any{
|
||||
"tools": []any{
|
||||
map[string]any{"type": "function", "name": "shell"},
|
||||
map[string]any{"type": "custom", "name": "exec"},
|
||||
map[string]any{"type": "tool_search"},
|
||||
"custom shorthand",
|
||||
},
|
||||
}
|
||||
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Len(t, reqBody["tools"], 4)
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteTools_RejectsUnsupportedTools(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tool map[string]any
|
||||
want string
|
||||
}{
|
||||
{name: "hosted web search", tool: map[string]any{"type": "web_search"}, want: `top-level tool type "web_search"`},
|
||||
{name: "hosted image generation", tool: map[string]any{"type": "image_generation"}, want: `top-level tool type "image_generation"`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reqBody := map[string]any{"tools": []any{tt.tool}}
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
|
||||
require.ErrorContains(t, err, tt.want)
|
||||
require.False(t, changed)
|
||||
require.Len(t, reqBody["tools"], 1, "validation errors must not partially mutate tools")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteToolsPayload_PreservesResponseCreateShape(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"type":"response.create",
|
||||
"model":"gpt-5.6-terra",
|
||||
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},
|
||||
"input":[{"type":"message","role":"user","content":"hello"}],
|
||||
"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}],
|
||||
"tool_choice":{"type":"namespace","name":"collaboration"}
|
||||
}`)
|
||||
|
||||
updated, changed, err := normalizeOpenAIResponsesLiteToolsPayload(body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "response.create", gjson.GetBytes(updated, "type").String())
|
||||
require.False(t, gjson.GetBytes(updated, "tools").Exists())
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(updated, `input.#(type=="additional_tools").tools.0.name`).String())
|
||||
require.Equal(t, "namespace", gjson.GetBytes(updated, "tool_choice.type").String())
|
||||
}
|
||||
|
||||
func TestApplyCodexOAuthTransform_PreservesLiteNamespaceToolChoice(t *testing.T) {
|
||||
reqBody := map[string]any{
|
||||
"model": "gpt-5.6-terra",
|
||||
"input": []any{map[string]any{
|
||||
"type": "additional_tools",
|
||||
"tools": []any{map[string]any{
|
||||
"type": "namespace",
|
||||
"name": "collaboration",
|
||||
}},
|
||||
}},
|
||||
"tool_choice": map[string]any{"type": "namespace", "name": "collaboration"},
|
||||
}
|
||||
|
||||
applyCodexOAuthTransform(reqBody, true, false)
|
||||
|
||||
require.Equal(t, map[string]any{"type": "namespace", "name": "collaboration"}, reqBody["tool_choice"])
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
for _, passthrough := range []bool{false, true} {
|
||||
name := "managed"
|
||||
if passthrough {
|
||||
name = "passthrough"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
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")
|
||||
c.Request.Header.Set(responsesLiteHeader, "true")
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_lite\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}}\n\n" +
|
||||
"data: [DONE]\n\n",
|
||||
)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 501, Name: "responses-lite", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
|
||||
Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"},
|
||||
Extra: map[string]any{"openai_passthrough": passthrough},
|
||||
}
|
||||
body := []byte(`{
|
||||
"model":"gpt-5.6-terra","stream":true,"instructions":"test",
|
||||
"tools":[
|
||||
{"type":"function","name":"shell","parameters":{"type":"object"}},
|
||||
{"type":"custom","name":"exec"},
|
||||
{"type":"tool_search"},
|
||||
{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]}
|
||||
],
|
||||
"input":[{"type":"message","role":"user","content":"hello"}],
|
||||
"tool_choice":{"type":"namespace","name":"collaboration"}
|
||||
}`)
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="namespace")`).Exists())
|
||||
require.Equal(t, "shell", gjson.GetBytes(upstream.lastBody, `tools.#(type=="function").name`).String())
|
||||
require.Equal(t, "exec", gjson.GetBytes(upstream.lastBody, `tools.#(type=="custom").name`).String())
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="tool_search")`).Exists())
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, `input.#(type=="additional_tools").tools.0.name`).String())
|
||||
require.Equal(t, "namespace", gjson.GetBytes(upstream.lastBody, "tool_choice.type").String())
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tool_choice.name").String())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,17 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
}
|
||||
normalized = next
|
||||
}
|
||||
if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(normalized) {
|
||||
litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(normalized)
|
||||
if liteErr != nil {
|
||||
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(
|
||||
coderws.StatusPolicyViolation,
|
||||
liteErr.Error(),
|
||||
liteErr,
|
||||
)
|
||||
}
|
||||
normalized = litePayload
|
||||
}
|
||||
apiKey := getAPIKeyFromContext(c)
|
||||
imageGenerationAllowed := GroupAllowsImageGeneration(apiKeyGroup(apiKey))
|
||||
codexImageGenerationExplicitToolPolicy := codexImageGenerationExplicitToolPolicyAllow
|
||||
|
||||
@@ -531,10 +531,12 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge
|
||||
"stream":false,
|
||||
"previous_response_id":"resp_codex_image_bridge",
|
||||
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},
|
||||
"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}],
|
||||
"input":[
|
||||
{"type":"additional_tools","role":"developer","tools":[{"type":"custom","name":"exec","description":"Execute code-mode tools, including image_gen.imagegen."}]},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"draw a cat"}]}
|
||||
]
|
||||
],
|
||||
"tool_choice":{"type":"namespace","name":"collaboration"}
|
||||
}`))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
@@ -564,10 +566,13 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_CodexImageBridge
|
||||
|
||||
litePayload := requestToJSONString(captureConn.writes[1])
|
||||
require.False(t, gjson.Get(litePayload, `tools.#(type=="image_generation")`).Exists())
|
||||
require.False(t, gjson.Get(litePayload, "tool_choice").Exists())
|
||||
require.NotContains(t, gjson.Get(litePayload, "instructions").String(), "image_generation")
|
||||
require.Equal(t, "exec", gjson.Get(litePayload, `input.#(type=="additional_tools").tools.0.name`).String())
|
||||
require.Contains(t, gjson.Get(litePayload, `input.#(type=="additional_tools").tools.0.description`).String(), "image_gen.imagegen")
|
||||
require.False(t, gjson.Get(litePayload, `tools.#(type=="namespace")`).Exists())
|
||||
require.Equal(t, "collaboration", gjson.Get(litePayload, `input.#(type=="additional_tools").tools.1.name`).String())
|
||||
require.Equal(t, "namespace", gjson.Get(litePayload, "tool_choice.type").String())
|
||||
require.Equal(t, "collaboration", gjson.Get(litePayload, "tool_choice.name").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_DedicatedModeDoesNotReuseConnAcrossSessions(t *testing.T) {
|
||||
@@ -940,7 +945,16 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade
|
||||
}()
|
||||
|
||||
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false,"prompt_cache_key":"pcache_passthrough"}`))
|
||||
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{
|
||||
"type":"response.create",
|
||||
"model":"gpt-5.1",
|
||||
"stream":false,
|
||||
"prompt_cache_key":"pcache_passthrough",
|
||||
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},
|
||||
"tools":[{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent"}]}],
|
||||
"input":[{"type":"message","role":"user","content":"hello"}],
|
||||
"tool_choice":{"type":"namespace","name":"collaboration"}
|
||||
}`))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -963,6 +977,12 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_PassthroughHeade
|
||||
require.Equal(t, isolateOpenAISessionID(0, "pcache_passthrough"), captureDialer.lastHeaders.Get("session_id"))
|
||||
require.Equal(t, "turn-state-1", captureDialer.lastHeaders.Get(openAIWSTurnStateHeader))
|
||||
require.Equal(t, "turn-meta-1", captureDialer.lastHeaders.Get(openAIWSTurnMetadataHeader))
|
||||
require.Len(t, upstreamConn.writes, 1)
|
||||
forwarded := requestToJSONString(upstreamConn.writes[0])
|
||||
require.False(t, gjson.Get(forwarded, `tools.#(type=="namespace")`).Exists())
|
||||
require.Equal(t, "collaboration", gjson.Get(forwarded, `input.#(type=="additional_tools").tools.0.name`).String())
|
||||
require.Equal(t, "namespace", gjson.Get(forwarded, "tool_choice.type").String())
|
||||
require.Equal(t, "collaboration", gjson.Get(forwarded, "tool_choice.name").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRelaysHTTPStream(t *testing.T) {
|
||||
|
||||
@@ -246,6 +246,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
||||
if err := validateOpenAIWSBearerToken(account, token); err != nil {
|
||||
return err
|
||||
}
|
||||
if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) {
|
||||
liteFirstMessage, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(firstClientMessage)
|
||||
if liteErr != nil {
|
||||
return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr)
|
||||
}
|
||||
firstClientMessage = liteFirstMessage
|
||||
}
|
||||
requestModel := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String())
|
||||
requestPreviousResponseID := strings.TrimSpace(gjson.GetBytes(firstClientMessage, "previous_response_id").String())
|
||||
logOpenAIWSV2Passthrough(
|
||||
@@ -428,6 +435,15 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
||||
if msgType != coderws.MessageText {
|
||||
return payload, nil, nil
|
||||
}
|
||||
if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" {
|
||||
if account.IsOpenAIOAuth() && isOpenAIResponsesLiteWebSocketPayload(payload) {
|
||||
litePayload, _, liteErr := normalizeOpenAIResponsesLiteToolsPayload(payload)
|
||||
if liteErr != nil {
|
||||
return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr)
|
||||
}
|
||||
payload = litePayload
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" && hooks != nil && hooks.BeforeRequest != nil {
|
||||
turnNo := int(completedTurns.Load()) + 1
|
||||
if turnNo < 2 {
|
||||
|
||||
Reference in New Issue
Block a user