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,244 @@
|
||||
package service
|
||||
|
||||
// 国产供应商 Anthropic 协议转换路径的上游读间隔超时回归测试(B3):
|
||||
// 上游挂住 SSE(不发数据也不断连)时,CC×anthropic / Responses×anthropic
|
||||
// 的读循环必须按 gateway.stream_data_interval_timeout 结束,而不是永久阻塞。
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func newNativeAnthropicHangTestService(intervalSec int) *OpenAIGatewayService {
|
||||
return &OpenAIGatewayService{
|
||||
cfg: &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
StreamDataIntervalTimeout: intervalSec,
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newHangingUpstreamResponse() (*http.Response, *io.PipeReader, *io.PipeWriter) {
|
||||
pr, pw := io.Pipe()
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}, pr, pw
|
||||
}
|
||||
|
||||
// miniAnthropicSSEStream 是一段最小可转换的 Anthropic 事件流。
|
||||
func miniAnthropicSSEStream() string {
|
||||
return strings.Join([]string{
|
||||
"event: message_start",
|
||||
`data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":10,"output_tokens":1}}}`,
|
||||
"",
|
||||
"event: content_block_start",
|
||||
`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
|
||||
"",
|
||||
"event: content_block_delta",
|
||||
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}`,
|
||||
"",
|
||||
"event: content_block_stop",
|
||||
`data: {"type":"content_block_stop","index":0}`,
|
||||
"",
|
||||
"event: message_delta",
|
||||
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`,
|
||||
"",
|
||||
"event: message_stop",
|
||||
`data: {"type":"message_stop"}`,
|
||||
"",
|
||||
"",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func TestAnthropicNativeLinePump_TimesOutWithoutData(t *testing.T) {
|
||||
pr, _ := io.Pipe()
|
||||
scanner := bufio.NewScanner(pr)
|
||||
defer func() { _ = pr.Close() }()
|
||||
|
||||
pump := newAnthropicNativeLinePump(scanner, 50*time.Millisecond)
|
||||
defer pump.stop()
|
||||
|
||||
start := time.Now()
|
||||
_, err := pump.next()
|
||||
if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
|
||||
t.Fatalf("expected interval timeout, got %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Fatalf("timeout not respected: %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnthropicNativeLinePump_DataResetsTimer(t *testing.T) {
|
||||
pr, pw := io.Pipe()
|
||||
scanner := bufio.NewScanner(pr)
|
||||
pump := newAnthropicNativeLinePump(scanner, 1*time.Second)
|
||||
defer pump.stop()
|
||||
|
||||
go func() {
|
||||
_, _ = pw.Write([]byte("event: ping\n"))
|
||||
// 保持流打开且不再发数据:第二次 next 必须超时。
|
||||
time.Sleep(3 * time.Second)
|
||||
_ = pw.Close()
|
||||
}()
|
||||
defer func() { _ = pr.Close() }()
|
||||
|
||||
line, err := pump.next()
|
||||
if err != nil || line != "event: ping" {
|
||||
t.Fatalf("expected first line, got %q err=%v", line, err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
_, err = pump.next()
|
||||
if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
|
||||
t.Fatalf("expected interval timeout after data stops, got %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Fatalf("timeout not respected: %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCCStreamingFromNativeAnthropic_HangTimesOut(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newNativeAnthropicHangTestService(1)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
resp, pr, pw := newHangingUpstreamResponse()
|
||||
start := time.Now()
|
||||
res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start, true)
|
||||
_ = pw.Close()
|
||||
_ = pr.Close()
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
|
||||
t.Fatalf("expected stream timeout error, got %v", err)
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("expected result carrying accumulated usage")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Fatalf("handler did not respect interval bound: %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCCBufferedFromNativeAnthropic_HangTimesOut(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newNativeAnthropicHangTestService(1)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
resp, pr, pw := newHangingUpstreamResponse()
|
||||
start := time.Now()
|
||||
_, err := svc.handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start)
|
||||
_ = pw.Close()
|
||||
_ = pr.Close()
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
|
||||
t.Fatalf("expected stream timeout error, got %v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Fatalf("handler did not respect interval bound: %v", elapsed)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Upstream stream data interval timeout") {
|
||||
t.Fatalf("expected 502 error body, got %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesStreamingFromNativeAnthropic_HangTimesOut(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newNativeAnthropicHangTestService(1)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
resp, pr, pw := newHangingUpstreamResponse()
|
||||
start := time.Now()
|
||||
res, err := svc.handleResponsesStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start, apicompat.ResponsesClientToolMapping{})
|
||||
_ = pw.Close()
|
||||
_ = pr.Close()
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
|
||||
t.Fatalf("expected stream timeout error, got %v", err)
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("expected result carrying accumulated usage")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||
t.Fatalf("handler did not respect interval bound: %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCCStreamingFromNativeAnthropic_HappyPathStillConverts(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newNativeAnthropicHangTestService(5)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
resp, pr, pw := newHangingUpstreamResponse()
|
||||
go func() {
|
||||
_, _ = pw.Write([]byte(miniAnthropicSSEStream()))
|
||||
_ = pw.Close()
|
||||
}()
|
||||
defer func() { _ = pr.Close() }()
|
||||
|
||||
res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), true)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("expected result")
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Hello") {
|
||||
t.Fatalf("expected converted text chunk, got %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "data: [DONE]") {
|
||||
t.Fatalf("expected [DONE] terminator, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCCBufferedFromNativeAnthropic_HappyPathStillConverts(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newNativeAnthropicHangTestService(5)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
resp, pr, pw := newHangingUpstreamResponse()
|
||||
go func() {
|
||||
_, _ = pw.Write([]byte(miniAnthropicSSEStream()))
|
||||
_ = pw.Close()
|
||||
}()
|
||||
defer func() { _ = pr.Close() }()
|
||||
|
||||
res, err := svc.handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("expected result")
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Hello") {
|
||||
t.Fatalf("expected converted text in buffered response, got %q", body)
|
||||
}
|
||||
if res.Usage.InputTokens != 10 || res.Usage.OutputTokens != 5 {
|
||||
t.Fatalf("expected usage 10/5, got %+v", res.Usage)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user