mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
fix(openai): pin Responses Lite parallel tool calls
This commit is contained in:
@@ -91,7 +91,7 @@ func TestApplyCodexOAuthTransform_ReservedPythonNameIsOAuthOnly(t *testing.T) {
|
||||
require.Equal(t, codexPythonToolAlias, tool["name"])
|
||||
|
||||
apiKeyBody := []byte(`{"type":"response.create","tools":[{"type":"function","name":"python"}]}`)
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(apiKeyBody, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(apiKeyBody, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.JSONEq(t, string(apiKeyBody), string(normalized))
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestWebSocketCompatibilityNormalizesTriggerAfterPairedOutputCleanup(t *test
|
||||
body := []byte(`{"type":"response.create","model":"gpt-5.4","input":[{"type":"compaction_trigger"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"call_1","output":"ok"},{"type":"message","role":"user","content":"visible"}]}`)
|
||||
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account)
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
items := gjson.GetBytes(normalized, "input").Array()
|
||||
|
||||
@@ -271,7 +271,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyPreservesOpaqueRefere
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: accountType,
|
||||
})
|
||||
}, false)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
@@ -285,7 +285,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyPreservesOpaqueRefere
|
||||
second, changedAgain, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: accountType,
|
||||
})
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changedAgain)
|
||||
require.JSONEq(t, string(normalized), string(second))
|
||||
|
||||
@@ -152,7 +152,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel)
|
||||
}
|
||||
if account.IsOpenAIApiKey() {
|
||||
if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body); normalizeErr != nil {
|
||||
if normalized, changed, normalizeErr := normalizeOpenAIParallelToolCallsWithoutTools(body, responsesLite); normalizeErr != nil {
|
||||
return nil, normalizeErr
|
||||
} else if changed {
|
||||
body = normalized
|
||||
|
||||
@@ -212,7 +212,8 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
}
|
||||
}
|
||||
if account != nil && account.IsOpenAI() {
|
||||
normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account)
|
||||
responsesLite := isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) || isOpenAIResponsesLiteWebSocketPayload(body)
|
||||
normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, account, responsesLite)
|
||||
if normalizeErr != nil {
|
||||
return nil, fmt.Errorf("normalize passthrough Responses compatibility: %w", normalizeErr)
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) {
|
||||
}
|
||||
normalized = next
|
||||
}
|
||||
if next, removed, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized); err != nil {
|
||||
if next, removed, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, false); err != nil {
|
||||
return body, false, err
|
||||
} else if removed {
|
||||
normalized = next
|
||||
@@ -361,7 +361,10 @@ func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) {
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, error) {
|
||||
func normalizeOpenAIParallelToolCallsWithoutTools(body []byte, responsesLite bool) ([]byte, bool, error) {
|
||||
if responsesLite {
|
||||
return body, false, nil
|
||||
}
|
||||
parallel := gjson.GetBytes(body, "parallel_tool_calls")
|
||||
if !parallel.Exists() {
|
||||
return body, false, nil
|
||||
@@ -376,16 +379,7 @@ func normalizeOpenAIParallelToolCallsWithoutTools(body []byte) ([]byte, bool, er
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
// openAIRequestBodyHasTools is the []byte counterpart of openAIResponsesLiteHasTools:
|
||||
// besides the top-level "tools" array it also recognizes the Responses Lite carrier.
|
||||
// normalizeOpenAIResponsesLiteTools moves namespace tools into an input item of type
|
||||
// "additional_tools" and drops the top-level "tools" key; the request still carries
|
||||
// tools at that point. Looking only at the top level therefore misreads such a body as
|
||||
// "no tools" and deletes the parallel_tool_calls:false that
|
||||
// ensureOpenAIResponsesLiteParallelToolCalls had just pinned, and OpenAI falls back to
|
||||
// its default of true and rejects the request with
|
||||
// 400 unsupported_value: "X-OpenAI-Internal-Codex-Responses-Lite requires
|
||||
// `parallel_tool_calls` to be false."
|
||||
// openAIRequestBodyHasTools 同时识别顶层 tools 和 input[].additional_tools。
|
||||
func openAIRequestBodyHasTools(body []byte) bool {
|
||||
if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 {
|
||||
return true
|
||||
@@ -948,7 +942,7 @@ func normalizeOpenAIResponseFormatSchemasBody(body []byte) ([]byte, bool, error)
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account) ([]byte, bool, error) {
|
||||
func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account, responsesLite bool) ([]byte, bool, error) {
|
||||
if account == nil || !account.IsOpenAI() {
|
||||
return body, false, nil
|
||||
}
|
||||
@@ -962,7 +956,7 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac
|
||||
}
|
||||
}
|
||||
if account.IsOpenAIApiKey() {
|
||||
if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized); err != nil {
|
||||
if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, responsesLite); err != nil {
|
||||
return body, false, err
|
||||
} else if normalizedParallel {
|
||||
normalized = next
|
||||
|
||||
@@ -259,34 +259,37 @@ func TestNormalizeOpenAIAPIKeyStoreFalseReasoningReplayRejectsEmptyEncryptedCont
|
||||
|
||||
func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) {
|
||||
withTools := []byte(`{"tools":[{"type":"function","name":"lookup"}],"parallel_tool_calls":false}`)
|
||||
normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(withTools)
|
||||
normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(withTools, false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(withTools), string(normalized))
|
||||
|
||||
withoutTools := []byte(`{"input":"hi","parallel_tool_calls":true}`)
|
||||
normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(withoutTools)
|
||||
normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(withoutTools, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
|
||||
}
|
||||
|
||||
// A Responses Lite body that has already been through normalizeOpenAIResponsesLiteTools
|
||||
// carries its tools in an input item of type "additional_tools" and no longer has a
|
||||
// top-level "tools" key. It still has tools, so the parallel_tool_calls:false that
|
||||
// ensureOpenAIResponsesLiteParallelToolCalls pinned must survive this normalization —
|
||||
// otherwise OpenAI applies its default of true and rejects the request.
|
||||
// Lite 工具迁移到 input[].additional_tools 后,仍应按有工具请求处理。
|
||||
func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) {
|
||||
liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`)
|
||||
normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(liteBody)
|
||||
normalized, changed, err := normalizeOpenAIParallelToolCallsWithoutTools(liteBody, false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type)
|
||||
|
||||
// An empty additional_tools item carries no tools, so the field is still dropped.
|
||||
// 非 Lite 请求的空 additional_tools 不构成有效工具声明,字段仍需删除。
|
||||
emptyLiteBody := []byte(`{"input":[{"type":"additional_tools","tools":[]}],"parallel_tool_calls":true}`)
|
||||
normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(emptyLiteBody)
|
||||
normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(emptyLiteBody, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
|
||||
|
||||
// Lite 请求即使没有工具,也必须保留已经固定的 false。
|
||||
toolLessLiteBody := []byte(`{"input":"hi","parallel_tool_calls":false}`)
|
||||
normalized, changed, err = normalizeOpenAIParallelToolCallsWithoutTools(toolLessLiteBody, true)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, gjson.False, gjson.GetBytes(normalized, "parallel_tool_calls").Type)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestNormalizeOpenAIOAuthResponsesCompatibilityBody_PreservesExplicitInput(t
|
||||
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_OnlyStripsOAuthFields(t *testing.T) {
|
||||
body := []byte(`{"type":"response.create","prompt":"hello","commands":{},"truncation":"auto","stop_sequences":["END"],"chat_template_kwargs":{"enable_thinking":true}}`)
|
||||
|
||||
oauthBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth})
|
||||
oauthBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "hello", gjson.GetBytes(oauthBody, "input").String())
|
||||
@@ -63,7 +63,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_OnlyStripsOAuthField
|
||||
require.False(t, gjson.GetBytes(oauthBody, field).Exists(), field)
|
||||
}
|
||||
|
||||
apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
|
||||
apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.JSONEq(t, string(body), string(apiKeyBody))
|
||||
@@ -81,7 +81,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesNativeItemI
|
||||
if oauth {
|
||||
accountType = AccountTypeOAuth
|
||||
}
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType})
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "response.create", gjson.GetBytes(normalized, "type").String())
|
||||
@@ -105,7 +105,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_APIKeyStoreFalseRepl
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
})
|
||||
}, false)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
@@ -141,13 +141,13 @@ func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) {
|
||||
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_ReasoningModeAccountScope(t *testing.T) {
|
||||
body := []byte(`{"type":"response.create","reasoning":{"mode":"pro"}}`)
|
||||
for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType})
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String())
|
||||
require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists())
|
||||
}
|
||||
apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey})
|
||||
apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.JSONEq(t, string(body), string(apiKeyBody))
|
||||
@@ -156,7 +156,7 @@ func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_ReasoningModeAccount
|
||||
func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesToolSchemas(t *testing.T) {
|
||||
body := []byte(`{"type":"response.create","tools":[{"type":"function","name":"search","parameters":{"type":null,"properties":{"q":{"type":"string","pattern":"^(?=.*foo).+$"}}}}]}`)
|
||||
for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType})
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String())
|
||||
|
||||
@@ -106,13 +106,6 @@ func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed
|
||||
return false, newOpenAIResponsesLiteValidationError("parallel_tool_calls", "responses Lite requires parallel_tool_calls to be a boolean")
|
||||
}
|
||||
}
|
||||
if !openAIResponsesLiteHasTools(reqBody) {
|
||||
if exists {
|
||||
delete(reqBody, "parallel_tool_calls")
|
||||
return true, nil
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
if parallel == false {
|
||||
return changed, nil
|
||||
}
|
||||
@@ -120,23 +113,6 @@ func ensureOpenAIResponsesLiteParallelToolCalls(reqBody map[string]any, changed
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func openAIResponsesLiteHasTools(reqBody map[string]any) bool {
|
||||
if tools, ok := reqBody["tools"].([]any); ok && len(tools) > 0 {
|
||||
return true
|
||||
}
|
||||
input, _ := reqBody["input"].([]any)
|
||||
for _, rawItem := range input {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" {
|
||||
continue
|
||||
}
|
||||
if tools, ok := item["tools"].([]any); ok && len(tools) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ensureOpenAIResponsesLiteReasoningContext(reqBody map[string]any) (bool, error) {
|
||||
rawReasoning, exists := reqBody["reasoning"]
|
||||
if !exists || rawReasoning == nil {
|
||||
|
||||
@@ -201,25 +201,33 @@ func TestNormalizeOpenAIResponsesLiteTools_ForcesParallelToolCallsFalse(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteTools_RemovesParallelToolCallsWithoutTools(t *testing.T) {
|
||||
for _, value := range []bool{true, false} {
|
||||
reqBody := map[string]any{
|
||||
"reasoning": map[string]any{"context": "all_turns"},
|
||||
"parallel_tool_calls": value,
|
||||
}
|
||||
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.NotContains(t, reqBody, "parallel_tool_calls")
|
||||
func TestNormalizeOpenAIResponsesLiteTools_PinsParallelToolCallsWithoutTools(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
parallel any
|
||||
include bool
|
||||
wantChanged bool
|
||||
}{
|
||||
{name: "字段缺失", wantChanged: true},
|
||||
{name: "值为 true", parallel: true, include: true, wantChanged: true},
|
||||
{name: "值为 false", parallel: false, include: true, wantChanged: false},
|
||||
}
|
||||
|
||||
reqBody := map[string]any{"reasoning": map[string]any{"context": "all_turns"}}
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.NotContains(t, reqBody, "parallel_tool_calls")
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reqBody := map[string]any{"reasoning": map[string]any{"context": "all_turns"}}
|
||||
if tt.include {
|
||||
reqBody["parallel_tool_calls"] = tt.parallel
|
||||
}
|
||||
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantChanged, changed)
|
||||
require.Contains(t, reqBody, "parallel_tool_calls")
|
||||
require.Equal(t, false, reqBody["parallel_tool_calls"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteTools_RejectsNonBooleanParallelToolCalls(t *testing.T) {
|
||||
@@ -490,49 +498,70 @@ func TestOpenAIGatewayServiceForward_NormalizesResponsesLiteToolsForOAuth(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayServiceForward_RemovesParallelToolCallsForToollessResponsesLite(t *testing.T) {
|
||||
func TestOpenAIGatewayServiceForward_PinsParallelToolCallsForToollessResponsesLite(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: 502, Name: "responses-lite-no-tools", 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},
|
||||
accountCases := []struct {
|
||||
name string
|
||||
accountType string
|
||||
credentials map[string]any
|
||||
}{
|
||||
{name: "oauth", accountType: AccountTypeOAuth, credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-account"}},
|
||||
{name: "apikey", accountType: AccountTypeAPIKey, credentials: map[string]any{"api_key": "sk-test"}},
|
||||
}
|
||||
parallelCases := []struct {
|
||||
name string
|
||||
field string
|
||||
}{
|
||||
{name: "字段缺失"},
|
||||
{name: "值为 true", field: `,"parallel_tool_calls":true`},
|
||||
{name: "值为 false", field: `,"parallel_tool_calls":false`},
|
||||
}
|
||||
|
||||
for _, accountCase := range accountCases {
|
||||
for _, passthrough := range []bool{false, true} {
|
||||
mode := "managed"
|
||||
if passthrough {
|
||||
mode = "passthrough"
|
||||
}
|
||||
body := []byte(`{
|
||||
"model":"gpt-5.6-terra","stream":true,"instructions":"test",
|
||||
"reasoning":{"effort":"high","context":"current_turn"},
|
||||
"parallel_tool_calls":true,
|
||||
"input":[{"type":"message","role":"user","content":"hello"}]
|
||||
}`)
|
||||
for _, parallelCase := range parallelCases {
|
||||
name := accountCase.name + "/" + mode + "/" + parallelCase.name
|
||||
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: 502, Name: "responses-lite-no-tools", Platform: PlatformOpenAI, Type: accountCase.accountType,
|
||||
Concurrency: 1, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
Credentials: accountCase.credentials,
|
||||
Extra: map[string]any{"openai_passthrough": passthrough},
|
||||
}
|
||||
body := []byte(`{
|
||||
"model":"gpt-5.6-terra","stream":true,"instructions":"test",
|
||||
"reasoning":{"effort":"high","context":"current_turn"},
|
||||
"input":[{"type":"message","role":"user","content":"hello"}]` + parallelCase.field + `
|
||||
}`)
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
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, "parallel_tool_calls").Exists(), string(upstream.lastBody))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "true", upstream.lastReq.Header.Get(responsesLiteHeader))
|
||||
require.Equal(t, gjson.False, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Type, string(upstream.lastBody))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -497,7 +497,7 @@ func TestOpenAIResponsesToolSchemaPlatformGate_APIKeyAndOAuth(t *testing.T) {
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: accountType,
|
||||
})
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String())
|
||||
@@ -508,7 +508,7 @@ func TestOpenAIResponsesToolSchemaPlatformGate_APIKeyAndOAuth(t *testing.T) {
|
||||
normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeAPIKey,
|
||||
})
|
||||
}, false)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, string(body), string(normalized))
|
||||
|
||||
@@ -262,7 +262,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
normalized = capped
|
||||
}
|
||||
}
|
||||
if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account); compatibilityErr != nil {
|
||||
responsesLite := isOpenAIResponsesLiteWebSocketPayload(normalized)
|
||||
if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, responsesLite); compatibilityErr != nil {
|
||||
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", compatibilityErr)
|
||||
} else if compatibilityChanged {
|
||||
normalized = compatibilityBody
|
||||
@@ -320,7 +321,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
|
||||
if accountScoped {
|
||||
normalized = accountScopedPayload
|
||||
}
|
||||
if isOpenAIResponsesLiteWebSocketPayload(normalized) {
|
||||
if responsesLite {
|
||||
litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(normalized, account)
|
||||
if liteErr != nil {
|
||||
return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(
|
||||
|
||||
@@ -1364,8 +1364,7 @@ func TestOpenAIGatewayService_ProxyResponsesWebSocketFromClient_HTTPBridgeModeRe
|
||||
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{
|
||||
"type":"response.create","model":"gpt-5.1","stream":false,
|
||||
"parallel_tool_calls":true,
|
||||
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},
|
||||
"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]
|
||||
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}
|
||||
}`))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1239,7 +1239,7 @@ func TestOpenAIWSHTTPBridgeRelaysSSEFramesAsWebSocketMessages(t *testing.T) {
|
||||
Concurrency: 1,
|
||||
Status: StatusActive,
|
||||
}
|
||||
payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}],"parallel_tool_calls":true}`)
|
||||
payload := []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":true,"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"},"input":"hi","parallel_tool_calls":true}`)
|
||||
|
||||
type bridgeResult struct {
|
||||
result *OpenAIForwardResult
|
||||
|
||||
@@ -735,7 +735,8 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
||||
if capturedSessionModel != "" && capturedSessionModel != strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) {
|
||||
firstClientMessage = s.ReplaceModelInBody(firstClientMessage, capturedSessionModel)
|
||||
}
|
||||
if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account); normalizeErr != nil {
|
||||
firstMessageResponsesLite := isOpenAIResponsesLiteWebSocketPayload(firstClientMessage)
|
||||
if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account, firstMessageResponsesLite); normalizeErr != nil {
|
||||
return fmt.Errorf("normalize first websocket response.create: %w", normalizeErr)
|
||||
} else if compatibilityChanged {
|
||||
firstClientMessage = normalized
|
||||
@@ -984,8 +985,9 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
||||
}
|
||||
}()
|
||||
}
|
||||
responsesLite := isResponseCreate && isOpenAIResponsesLiteWebSocketPayload(payload)
|
||||
if isResponseCreate {
|
||||
if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account); normalizeErr != nil {
|
||||
if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account, responsesLite); normalizeErr != nil {
|
||||
return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", normalizeErr)
|
||||
} else if compatibilityChanged {
|
||||
payload = normalized
|
||||
@@ -1011,7 +1013,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough(
|
||||
}
|
||||
}
|
||||
if isResponseCreate {
|
||||
if isOpenAIResponsesLiteWebSocketPayload(payload) {
|
||||
if responsesLite {
|
||||
litePayload, _, liteErr := normalizeOpenAIResponsesLitePayloadForAccount(payload, account)
|
||||
if liteErr != nil {
|
||||
return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, liteErr.Error(), liteErr)
|
||||
|
||||
@@ -190,13 +190,18 @@ func startPassthroughLifecycleServer(
|
||||
}
|
||||
|
||||
func dialPassthroughLifecycleClient(t *testing.T, server *httptest.Server) *coderws.Conn {
|
||||
t.Helper()
|
||||
return dialPassthroughLifecycleClientWithPayload(t, server, `{"type":"response.create","model":"gpt-5.1","stream":false}`)
|
||||
}
|
||||
|
||||
func dialPassthroughLifecycleClientWithPayload(t *testing.T, server *httptest.Server, payload string) *coderws.Conn {
|
||||
t.Helper()
|
||||
dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(server.URL, "http"), nil)
|
||||
cancelDial()
|
||||
require.NoError(t, err)
|
||||
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}`))
|
||||
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(payload))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
return clientConn
|
||||
@@ -221,6 +226,35 @@ func requirePassthroughUpstreamWrite(t *testing.T, upstream *stagedPassthroughCo
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassthroughLifecycle_ResponsesLiteFirstFramePinsParallelToolCalls(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
controlCtx, cancelControl := context.WithCancelCause(context.Background())
|
||||
defer cancelControl(context.Canceled)
|
||||
upstream := newStagedPassthroughConn()
|
||||
upstream.Send(`{"type":"response.completed","response":{"id":"resp_lite","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
|
||||
server, serverErr := startPassthroughLifecycleServer(t, controlCtx, newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream), passthroughLifecycleAccount())
|
||||
defer server.Close()
|
||||
clientConn := dialPassthroughLifecycleClientWithPayload(t, server, `{
|
||||
"type":"response.create","model":"gpt-5.1","stream":false,
|
||||
"parallel_tool_calls":true,
|
||||
"client_metadata":{"ws_request_header_x_openai_internal_codex_responses_lite":"true"}
|
||||
}`)
|
||||
defer func() { _ = clientConn.CloseNow() }()
|
||||
|
||||
upstreamBody := requirePassthroughUpstreamWrite(t, upstream, 3*time.Second)
|
||||
require.Equal(t, gjson.False, gjson.GetBytes(upstreamBody, "parallel_tool_calls").Type, string(upstreamBody))
|
||||
|
||||
event, err := readPassthroughLifecycleFrame(t, clientConn, 3*time.Second)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String())
|
||||
require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done"))
|
||||
select {
|
||||
case <-serverErr:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Lite 首帧测试等待 passthrough 退出超时")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIWSPassthroughTurnLifecycle_SerializesTerminalCommitAndNextTurn(t *testing.T) {
|
||||
clientFrameConn := &openAIWSClientFrameConn{interTurnStarted: make(chan struct{}, 1)}
|
||||
clientFrameConn.markTurnCompleted()
|
||||
|
||||
Reference in New Issue
Block a user