Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
//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{
|
||||
"reasoning": map[string]any{"context": "all_turns"},
|
||||
"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_EnsuresReasoningContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reasoning any
|
||||
}{
|
||||
{name: "missing"},
|
||||
{name: "missing context", reasoning: map[string]any{"effort": "high"}},
|
||||
{name: "wrong context", reasoning: map[string]any{"effort": "medium", "context": "current_turn"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reqBody := map[string]any{"input": "hello"}
|
||||
if tt.reasoning != nil {
|
||||
reqBody["reasoning"] = tt.reasoning
|
||||
}
|
||||
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
reasoning := reqBody["reasoning"].(map[string]any)
|
||||
require.Equal(t, "all_turns", reasoning["context"])
|
||||
if tt.name != "missing" {
|
||||
require.Equal(t, tt.reasoning.(map[string]any)["effort"], reasoning["effort"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOpenAIResponsesLiteTools_RejectsNonObjectReasoning(t *testing.T) {
|
||||
reqBody := map[string]any{"reasoning": "high"}
|
||||
|
||||
changed, err := normalizeOpenAIResponsesLiteTools(reqBody)
|
||||
|
||||
require.ErrorContains(t, err, "reasoning to be an object")
|
||||
require.False(t, changed)
|
||||
require.Equal(t, "high", reqBody["reasoning"])
|
||||
}
|
||||
|
||||
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",
|
||||
"reasoning":{"effort":"high","context":"current_turn"},
|
||||
"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.Equal(t, "high", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
|
||||
require.Equal(t, "all_turns", gjson.GetBytes(upstream.lastBody, "reasoning.context").String())
|
||||
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())
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user