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
372 lines
16 KiB
Go
372 lines
16 KiB
Go
//go:build unit
|
|
|
|
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
func TestForwardResponses_ForceChatCompletionsRoutesNonStreamingToChatCompletions(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
body := []byte(`{"model":"gpt-5.4","input":"hello","stream":false}`)
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_resp_chat_json"}},
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"id":"chatcmpl_json","object":"chat.completion","model":"gpt-5.4","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5,"prompt_tokens_details":{"cached_tokens":1}}}`,
|
|
)),
|
|
}}
|
|
svc := &OpenAIGatewayService{
|
|
cfg: rawChatCompletionsTestConfig(),
|
|
httpUpstream: upstream,
|
|
}
|
|
|
|
result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
|
|
require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
|
|
require.Equal(t, "hello", gjson.GetBytes(upstream.lastBody, "messages.0.content").String())
|
|
require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
|
require.Equal(t, "response", gjson.Get(rec.Body.String(), "object").String())
|
|
require.Equal(t, "ok", gjson.Get(rec.Body.String(), "output.0.content.0.text").String())
|
|
require.Equal(t, 3, result.Usage.InputTokens)
|
|
require.Equal(t, 2, result.Usage.OutputTokens)
|
|
require.Equal(t, 1, result.Usage.CacheReadInputTokens)
|
|
require.False(t, result.Stream)
|
|
}
|
|
|
|
func TestForwardResponses_PassthroughFlagWithUnsupportedResponsesUsesAccountMapping(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
for _, path := range []string{"/v1/responses", "/v1/responses/compact"} {
|
|
path := path
|
|
t.Run(path, func(t *testing.T) {
|
|
body := []byte(`{"model":"gpt-5.4-channel","input":"hello","stream":false}`)
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"id":"chatcmpl_mapping","object":"chat.completion","model":"gpt-5.4-account","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
|
|
)),
|
|
}}
|
|
svc := &OpenAIGatewayService{
|
|
cfg: rawChatCompletionsTestConfig(),
|
|
httpUpstream: upstream,
|
|
}
|
|
account := rawChatCompletionsTestAccount()
|
|
account.Credentials["model_mapping"] = map[string]any{
|
|
"gpt-5.4-channel": "gpt-5.4-account",
|
|
}
|
|
account.Credentials["compact_model_mapping"] = map[string]any{
|
|
"gpt-5.4-account": "gpt-5.4-compact",
|
|
}
|
|
account.Extra = map[string]any{
|
|
"openai_passthrough": true,
|
|
openai_compat.ExtraKeyResponsesSupported: false,
|
|
}
|
|
|
|
result, err := svc.Forward(context.Background(), c, account, body)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
|
|
require.Equal(t, "gpt-5.4-account", gjson.GetBytes(upstream.lastBody, "model").String())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestForwardResponses_ForceChatCompletionsRoutesStreamingToChatCompletions(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
body := []byte(`{"model":"gpt-5.4","input":"hello","stream":true}`)
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
upstreamBody := strings.Join([]string{
|
|
`data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`,
|
|
"",
|
|
`data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"he"},"finish_reason":null}]}`,
|
|
"",
|
|
`data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"llo"},"finish_reason":null}]}`,
|
|
"",
|
|
`data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
|
|
"",
|
|
`data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`,
|
|
"",
|
|
"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_resp_chat_stream"}},
|
|
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
|
}}
|
|
svc := &OpenAIGatewayService{
|
|
cfg: rawChatCompletionsTestConfig(),
|
|
httpUpstream: upstream,
|
|
}
|
|
|
|
result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
|
|
require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool())
|
|
require.Contains(t, rec.Body.String(), "event: response.output_text.delta")
|
|
require.Contains(t, rec.Body.String(), `"delta":"he"`)
|
|
require.Contains(t, rec.Body.String(), "event: response.completed")
|
|
require.Contains(t, rec.Body.String(), `"input_tokens":4`)
|
|
require.Contains(t, rec.Body.String(), "data: [DONE]")
|
|
require.Equal(t, 4, result.Usage.InputTokens)
|
|
require.Equal(t, 3, result.Usage.OutputTokens)
|
|
require.True(t, result.Stream)
|
|
require.NotNil(t, result.FirstTokenMs)
|
|
}
|
|
|
|
func TestForwardResponses_DeepSeekReasoningOnlyStreamProducesVisibleText(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
body := []byte(`{"model":"deepseek-reasoner","input":"hello","stream":true}`)
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
upstreamBody := strings.Join([]string{
|
|
`data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""},"finish_reason":null}]}`,
|
|
"",
|
|
`data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"visible fallback"},"finish_reason":null}]}`,
|
|
"",
|
|
`data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":""},"finish_reason":"length"}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`,
|
|
"",
|
|
"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_deepseek_reasoning_responses_stream"}},
|
|
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
|
}}
|
|
svc := &OpenAIGatewayService{
|
|
cfg: rawChatCompletionsTestConfig(),
|
|
httpUpstream: upstream,
|
|
}
|
|
|
|
result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.True(t, result.Stream)
|
|
require.Contains(t, rec.Body.String(), "event: response.output_text.delta")
|
|
require.Contains(t, rec.Body.String(), `"delta":"visible fallback"`)
|
|
require.Contains(t, rec.Body.String(), `"status":"incomplete"`)
|
|
require.Contains(t, rec.Body.String(), "data: [DONE]")
|
|
}
|
|
|
|
func TestForwardResponses_AutoSupportedAccountStillUsesResponsesEndpoint(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
body := []byte(`{"model":"gpt-5.4","input":"hello","stream":false}`)
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_resp_native"}},
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"id":"resp_native","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}],"status":"completed"}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}`,
|
|
)),
|
|
}}
|
|
svc := &OpenAIGatewayService{
|
|
cfg: rawChatCompletionsTestConfig(),
|
|
httpUpstream: upstream,
|
|
}
|
|
account := rawChatCompletionsTestAccount()
|
|
account.Extra = map[string]any{
|
|
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto),
|
|
openai_compat.ExtraKeyResponsesSupported: true,
|
|
}
|
|
|
|
result, err := svc.Forward(context.Background(), c, account, body)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
require.Equal(t, "http://upstream.example/v1/responses", upstream.lastReq.URL.String())
|
|
require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
|
|
require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
|
|
require.Equal(t, "ok", gjson.Get(rec.Body.String(), "output.0.content.0.text").String())
|
|
}
|
|
|
|
func forceChatResponsesFallbackAccount() *Account {
|
|
account := rawChatCompletionsTestAccount()
|
|
account.Extra = map[string]any{
|
|
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
|
|
}
|
|
return account
|
|
}
|
|
|
|
// reasoningRecordingCache 记录 reasoning 缓存写入、并按需响应回查。
|
|
type reasoningRecordingCache struct {
|
|
stubGatewayCache
|
|
mu sync.Mutex
|
|
sets map[string]string
|
|
getResp map[string]string
|
|
}
|
|
|
|
func (c *reasoningRecordingCache) SetReasoningContent(_ context.Context, itemID string, content string, _ time.Duration) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.sets == nil {
|
|
c.sets = make(map[string]string)
|
|
}
|
|
c.sets[itemID] = content
|
|
return nil
|
|
}
|
|
|
|
func (c *reasoningRecordingCache) GetReasoningContent(_ context.Context, itemID string) (string, error) {
|
|
if v, ok := c.getResp[itemID]; ok {
|
|
return v, nil
|
|
}
|
|
return "", ErrReasoningContentNotFound
|
|
}
|
|
|
|
func (c *reasoningRecordingCache) snapshotSets() map[string]string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
out := make(map[string]string, len(c.sets))
|
|
for k, v := range c.sets {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 流式响应里的 reasoning_content 应按 reasoning item id 写入缓存,供后续轮次
|
|
// 客户端不回传明文 summary 时回注(DeepSeek thinking mode 400 修复的写入侧)。
|
|
func TestForwardResponses_ChatFallbackCachesStreamedReasoning(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
body := []byte(`{"model":"deepseek-reasoner","input":"hello","stream":true}`)
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
upstreamBody := strings.Join([]string{
|
|
`data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`,
|
|
"",
|
|
`data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"think "},"finish_reason":null}]}`,
|
|
"",
|
|
`data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"first"},"finish_reason":null}]}`,
|
|
"",
|
|
`data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":"stop"}]}`,
|
|
"",
|
|
`data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`,
|
|
"",
|
|
"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_reasoning_cache_stream"}},
|
|
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
|
}}
|
|
cache := &reasoningRecordingCache{}
|
|
svc := &OpenAIGatewayService{
|
|
cfg: rawChatCompletionsTestConfig(),
|
|
httpUpstream: upstream,
|
|
cache: cache,
|
|
}
|
|
|
|
result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
sets := cache.snapshotSets()
|
|
require.Len(t, sets, 1, "应恰好缓存一个 reasoning item")
|
|
for itemID, content := range sets {
|
|
require.NotEmpty(t, itemID)
|
|
require.Equal(t, "think first", content)
|
|
}
|
|
}
|
|
|
|
// 请求侧:encrypted-only reasoning item(无明文 summary)经缓存回查补回
|
|
// reasoning_content;带明文 summary 的 item 顺手回写缓存(自愈)。
|
|
func TestForwardResponses_ChatFallbackRestoresReasoningFromCache(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
body := []byte(`{
|
|
"model":"deepseek-reasoner",
|
|
"stream":false,
|
|
"input":[
|
|
{"type":"reasoning","id":"item_plain","summary":[{"type":"summary_text","text":"plain thinking"}]},
|
|
{"type":"function_call","call_id":"call_0","name":"get_value","arguments":"{}"},
|
|
{"type":"function_call_output","call_id":"call_0","output":"ok"},
|
|
{"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"},
|
|
{"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
|
|
{"type":"function_call_output","call_id":"call_1","output":"ok"},
|
|
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
|
|
]
|
|
}`)
|
|
rec := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(rec)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
|
c.Request.Header.Set("Content-Type", "application/json")
|
|
|
|
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_reasoning_cache_restore"}},
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"id":"chatcmpl_restore","object":"chat.completion","model":"deepseek-reasoner","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`,
|
|
)),
|
|
}}
|
|
cache := &reasoningRecordingCache{
|
|
getResp: map[string]string{"item_enc1": "cached thinking"},
|
|
}
|
|
svc := &OpenAIGatewayService{
|
|
cfg: rawChatCompletionsTestConfig(),
|
|
httpUpstream: upstream,
|
|
cache: cache,
|
|
}
|
|
|
|
result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
// 明文 summary 的 assistant 工具调用消息:reasoning_content 来自 summary 本身。
|
|
require.Equal(t, "plain thinking", gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String())
|
|
require.Equal(t, "call_0", gjson.GetBytes(upstream.lastBody, "messages.0.tool_calls.0.id").String())
|
|
require.Equal(t, "tool", gjson.GetBytes(upstream.lastBody, "messages.1.role").String())
|
|
// encrypted-only 的 assistant 工具调用消息:reasoning_content 来自缓存回查。
|
|
require.Equal(t, "cached thinking", gjson.GetBytes(upstream.lastBody, "messages.2.reasoning_content").String())
|
|
require.Equal(t, "call_1", gjson.GetBytes(upstream.lastBody, "messages.2.tool_calls.0.id").String())
|
|
require.Equal(t, "tool", gjson.GetBytes(upstream.lastBody, "messages.3.role").String())
|
|
|
|
// 明文 summary 的 item 被回写进缓存(自愈)。
|
|
require.Equal(t, "plain thinking", cache.snapshotSets()["item_plain"])
|
|
}
|