feat(api): 统一官方兼容接口响应协议
兼容接口现在以入口协议作为最终响应协议,同协议保留官方 Wire 响应,跨协议统一转换成功、任务状态与错误结构。 同时修正异步提交状态边界,持久化兼容公开任务标识和官方提交响应,并新增迁移、流式响应及协议契约测试。 验证:go vet ./...;go test ./...;govulncheck ./...;pnpm lint;pnpm test;pnpm build;pnpm audit --audit-level high;pnpm openapi;全部 CI 脚本。
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func TestDecodeHTTPResponseForProtocolCapturesOfficialErrorWire(t *testing.T) {
|
||||
response := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Status: "429 Too Many Requests",
|
||||
Header: http.Header{
|
||||
"Content-Type": {"application/json"},
|
||||
"X-Request-Id": {"req_1"},
|
||||
"X-Ratelimit-Reset-Requests": {"1s"},
|
||||
"Set-Cookie": {"secret=must-not-leak"},
|
||||
"Connection": {"keep-alive"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"slow down","future_field":true}}`)),
|
||||
}
|
||||
_, wire, err := decodeHTTPResponseForProtocol(response, ProtocolOpenAIResponses)
|
||||
if err == nil {
|
||||
t.Fatal("expected upstream error")
|
||||
}
|
||||
if wire == nil || wire.StatusCode != http.StatusTooManyRequests || wire.Protocol != ProtocolOpenAIResponses {
|
||||
t.Fatalf("unexpected wire response: %+v", wire)
|
||||
}
|
||||
if wire.Headers["X-Request-Id"][0] != "req_1" || wire.Headers["X-Ratelimit-Reset-Requests"][0] != "1s" {
|
||||
t.Fatalf("official response headers were lost: %+v", wire.Headers)
|
||||
}
|
||||
if _, ok := wire.Headers["Set-Cookie"]; ok {
|
||||
t.Fatalf("sensitive header leaked: %+v", wire.Headers)
|
||||
}
|
||||
if _, ok := wire.Headers["Connection"]; ok {
|
||||
t.Fatalf("hop-by-hop header leaked: %+v", wire.Headers)
|
||||
}
|
||||
if ErrorWireResponse(err) != wire {
|
||||
t.Fatal("client error did not retain its wire response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiNativeStreamPreservesOfficialEvents(t *testing.T) {
|
||||
var requestedPath string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestedPath = r.URL.RequestURI()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("X-Goog-Request-Id", "goog-stream-1")
|
||||
_, _ = io.WriteString(w, "data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hello\"}]}}],\"futureOfficialField\":true}\n\n")
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
var events []StreamDeltaEvent
|
||||
response, err := (GeminiClient{HTTPClient: upstream.Client()}).Run(context.Background(), Request{
|
||||
Kind: "chat.completions", ModelType: "text", Model: "gemini-test", Stream: true,
|
||||
Body: map[string]any{"prompt": "hello"},
|
||||
Candidate: store.RuntimeModelCandidate{
|
||||
Provider: "gemini", BaseURL: upstream.URL, ProviderModelName: "gemini-test",
|
||||
Credentials: map[string]any{"apiKey": "test-key"},
|
||||
},
|
||||
StreamDelta: func(event StreamDeltaEvent) error {
|
||||
events = append(events, event)
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Gemini stream failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(requestedPath, ":streamGenerateContent") || !strings.Contains(requestedPath, "alt=sse") {
|
||||
t.Fatalf("unexpected Gemini stream endpoint: %s", requestedPath)
|
||||
}
|
||||
if len(events) != 1 || events[0].WireProtocol != ProtocolGeminiGenerateContent || events[0].Event["futureOfficialField"] != true {
|
||||
raw, _ := json.Marshal(events)
|
||||
t.Fatalf("official Gemini event was not preserved: %s", raw)
|
||||
}
|
||||
if response.Wire == nil || response.Wire.Headers["X-Goog-Request-Id"][0] != "goog-stream-1" {
|
||||
t.Fatalf("Gemini stream wire metadata was lost: %+v", response.Wire)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user