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:
2026-07-22 15:34:59 +08:00
parent 42e8b517fd
commit e07a997aa9
32 changed files with 2436 additions and 591 deletions
+75 -3
View File
@@ -1,6 +1,7 @@
package clients
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -24,18 +25,35 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
}
body := geminiBody(request)
raw, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, geminiURL(request.Candidate.BaseURL, upstreamModelName(request.Candidate), apiKey), bytes.NewReader(raw))
endpoint := geminiURL(request.Candidate.BaseURL, upstreamModelName(request.Candidate), apiKey)
if request.Stream {
endpoint = geminiStreamURL(request.Candidate.BaseURL, upstreamModelName(request.Candidate), apiKey)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
if err != nil {
return Response{}, err
}
req.Header.Set("Content-Type", "application/json")
responseStartedAt := time.Now()
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
if err := notifyResponseReceived(request); err != nil {
return Response{}, err
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
var result map[string]any
var wire *WireResponse
if request.Stream && resp.StatusCode >= 200 && resp.StatusCode < 300 {
wire = &WireResponse{Protocol: ProtocolGeminiGenerateContent, StatusCode: resp.StatusCode, Headers: compatibleResponseHeaders(resp.Header)}
result, err = decodeGeminiStreamResponse(resp, request.StreamDelta, wire)
} else {
result, wire, err = decodeHTTPResponseForProtocol(resp, ProtocolGeminiGenerateContent)
}
responseFinishedAt := time.Now()
if err != nil {
return Response{}, annotateResponseError(err, requestID, responseStartedAt, responseFinishedAt)
@@ -52,10 +70,20 @@ func (c GeminiClient) Run(ctx context.Context, request Request) (Response, error
ResponseStartedAt: responseStartedAt,
ResponseFinishedAt: responseFinishedAt,
ResponseDurationMS: responseDurationMS(responseStartedAt, responseFinishedAt),
UpstreamProtocol: ProtocolGeminiGenerateContent,
Wire: wire,
}, nil
}
func geminiURL(baseURL string, model string, apiKey string) string {
return geminiActionURL(baseURL, model, "generateContent", apiKey, false)
}
func geminiStreamURL(baseURL string, model string, apiKey string) string {
return geminiActionURL(baseURL, model, "streamGenerateContent", apiKey, true)
}
func geminiActionURL(baseURL string, model string, action string, apiKey string, stream bool) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if base == "" {
base = "https://generativelanguage.googleapis.com"
@@ -67,7 +95,51 @@ func geminiURL(baseURL string, model string, apiKey string) string {
base += "/v1beta"
}
escapedModel := url.PathEscape(model)
return fmt.Sprintf("%s/models/%s:generateContent?key=%s", base, escapedModel, url.QueryEscape(apiKey))
endpoint := fmt.Sprintf("%s/models/%s:%s?key=%s", base, escapedModel, action, url.QueryEscape(apiKey))
if stream {
endpoint += "&alt=sse"
}
return endpoint
}
func decodeGeminiStreamResponse(resp *http.Response, onDelta StreamDelta, wire *WireResponse) (map[string]any, error) {
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 16*1024*1024)
result := map[string]any{}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" || data == "[DONE]" {
continue
}
var event map[string]any
if err := json.Unmarshal([]byte(data), &event); err != nil {
return nil, &ClientError{Code: "invalid_response", Message: err.Error(), StatusCode: resp.StatusCode, Retryable: false, Wire: wire}
}
result = event
if onDelta != nil {
if err := onDelta(StreamDeltaEvent{
Text: geminiText(event),
Event: event,
WireProtocol: ProtocolGeminiGenerateContent,
WireStatusCode: resp.StatusCode,
WireHeaders: compatibleResponseHeaders(resp.Header),
}); err != nil {
return nil, err
}
}
}
if err := scanner.Err(); err != nil {
return nil, &ClientError{Code: "network", Message: err.Error(), Retryable: true, Wire: wire}
}
if len(result) == 0 {
return nil, &ClientError{Code: "invalid_response", Message: "gemini stream returned no events", StatusCode: resp.StatusCode, Retryable: false, Wire: wire}
}
return result, nil
}
func geminiBody(request Request) map[string]any {
+68 -4
View File
@@ -44,25 +44,89 @@ func intValue(body map[string]any, key string, fallback int) int {
}
func decodeHTTPResponse(resp *http.Response) (map[string]any, error) {
result, _, err := decodeHTTPResponseForProtocol(resp, "")
return result, err
}
func decodeHTTPResponseForProtocol(resp *http.Response, protocol string) (map[string]any, *WireResponse, error) {
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
wire := &WireResponse{
Protocol: strings.TrimSpace(protocol),
StatusCode: resp.StatusCode,
Headers: compatibleResponseHeaders(resp.Header),
RawJSON: append([]byte(nil), raw...),
}
if len(raw) > 0 {
_ = json.Unmarshal(raw, &wire.Body)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, &ClientError{
return nil, wire, &ClientError{
Code: statusCodeName(resp.StatusCode),
Message: errorMessage(raw, resp.Status),
StatusCode: resp.StatusCode,
RequestID: requestIDFromHTTPResponse(resp),
Retryable: HTTPRetryable(resp.StatusCode),
Wire: wire,
}
}
var out map[string]any
if len(raw) == 0 {
return map[string]any{}, nil
wire.Body = map[string]any{}
return map[string]any{}, wire, nil
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, &ClientError{Code: "invalid_response", Message: err.Error(), Retryable: false}
return nil, wire, &ClientError{Code: "invalid_response", Message: err.Error(), StatusCode: resp.StatusCode, Retryable: false, Wire: wire}
}
return out, nil
wire.Body = cloneBody(out)
return out, wire, nil
}
func compatibleResponseHeaders(source http.Header) map[string][]string {
if len(source) == 0 {
return nil
}
allowed := map[string]bool{
"content-type": true, "retry-after": true,
"x-request-id": true, "request-id": true,
"x-ratelimit-limit-requests": true, "x-ratelimit-remaining-requests": true,
"x-ratelimit-reset-requests": true, "x-ratelimit-limit-tokens": true,
"x-ratelimit-remaining-tokens": true, "x-ratelimit-reset-tokens": true,
"x-goog-request-id": true, "x-goog-upload-status": true,
"x-goog-upload-url": true, "x-goog-upload-size-received": true,
}
out := map[string][]string{}
for key, values := range source {
if !allowed[strings.ToLower(strings.TrimSpace(key))] {
continue
}
out[http.CanonicalHeaderKey(key)] = append([]string(nil), values...)
}
if len(out) == 0 {
return nil
}
return out
}
func notifySubmissionStarted(request Request) error {
if request.OnUpstreamSubmissionStarted == nil {
return nil
}
return request.OnUpstreamSubmissionStarted()
}
func notifyResponseReceived(request Request) error {
if request.OnUpstreamResponseReceived == nil {
return nil
}
return request.OnUpstreamResponseReceived()
}
func notifyWireResponse(request Request, wire *WireResponse) error {
if wire == nil || request.OnUpstreamWireResponse == nil {
return nil
}
return request.OnUpstreamWireResponse(wire)
}
func decodeOpenAIStreamResponse(resp *http.Response, onDelta StreamDelta) (map[string]any, error) {
+44 -12
View File
@@ -80,7 +80,18 @@ func (c KelingClient) runVideo(ctx context.Context, request Request, token strin
}()
if upstreamTaskID == "" {
submitResult, requestID, err := c.postJSON(ctx, request, prepared.Endpoint, token, prepared.Payload)
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
submitResult, requestID, submitWire, err := c.postJSON(ctx, request, prepared.Endpoint, token, prepared.Payload)
if notifyErr := notifyWireResponse(request, submitWire); notifyErr != nil {
return Response{}, notifyErr
}
if err == nil || ErrorResponseMetadata(err).StatusCode > 0 {
if notifyErr := notifyResponseReceived(request); notifyErr != nil {
return Response{}, notifyErr
}
}
submitRequestID = requestID
if err != nil {
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now())
@@ -122,6 +133,11 @@ func (c KelingClient) runVideo(ctx context.Context, request Request, token strin
return Response{}, annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
}
lastResult = pollResult
if request.OnRemoteTaskPolled != nil {
if err := request.OnRemoteTaskPolled(upstreamTaskID, pollResult); err != nil {
return Response{}, err
}
}
switch kelingTaskStatus(pollResult) {
case "succeed":
@@ -176,7 +192,18 @@ func (c KelingClient) runTaskAPIVideo(ctx context.Context, request Request, toke
if err != nil {
return Response{}, err
}
submitResult, requestID, err := c.postJSONAt(ctx, request, taskAPIBaseURL, endpoint, token, payload)
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
submitResult, requestID, submitWire, err := c.postJSONAt(ctx, request, taskAPIBaseURL, endpoint, token, payload, ProtocolKlingV2Omni)
if notifyErr := notifyWireResponse(request, submitWire); notifyErr != nil {
return Response{}, notifyErr
}
if err == nil || ErrorResponseMetadata(err).StatusCode > 0 {
if notifyErr := notifyResponseReceived(request); notifyErr != nil {
return Response{}, notifyErr
}
}
submitRequestID = requestID
if err != nil {
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now())
@@ -224,6 +251,11 @@ func (c KelingClient) runTaskAPIVideo(ctx context.Context, request Request, toke
if err != nil {
return Response{}, annotateResponseError(err, requestID, pollStartedAt, pollFinishedAt)
}
if request.OnRemoteTaskPolled != nil {
if err := request.OnRemoteTaskPolled(upstreamTaskID, pollResult); err != nil {
return Response{}, err
}
}
task := kelingTaskAPITask(pollResult, upstreamTaskID)
lastStatus = strings.ToLower(strings.TrimSpace(stringFromAny(task["status"])))
@@ -567,31 +599,31 @@ func (c KelingClient) kelingOmniElementList(ctx context.Context, request Request
return elements, createdIDs, nil
}
func (c KelingClient) postJSON(ctx context.Context, request Request, path string, token string, body map[string]any) (map[string]any, string, error) {
return c.postJSONAt(ctx, request, request.Candidate.BaseURL, path, token, body)
func (c KelingClient) postJSON(ctx context.Context, request Request, path string, token string, body map[string]any) (map[string]any, string, *WireResponse, error) {
return c.postJSONAt(ctx, request, request.Candidate.BaseURL, path, token, body, ProtocolKlingV1Omni)
}
func (c KelingClient) postJSONAt(ctx context.Context, request Request, baseURL string, path string, token string, body map[string]any) (map[string]any, string, error) {
func (c KelingClient) postJSONAt(ctx context.Context, request Request, baseURL string, path string, token string, body map[string]any, protocol string) (map[string]any, string, *WireResponse, error) {
raw, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
if err != nil {
return nil, "", err
return nil, "", nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
return nil, "", nil, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
result, wire, err := decodeHTTPResponseForProtocol(resp, protocol)
if err != nil {
return result, requestID, err
return result, requestID, wire, err
}
if code := intFromAny(result["code"]); code != 0 {
return result, requestID, &ClientError{Code: kelingEnvelopeErrorCode(result), Message: kelingEnvelopeErrorMessage(result), RequestID: firstNonEmpty(requestID, stringFromAny(result["request_id"])), Retryable: false}
return result, requestID, wire, &ClientError{Code: kelingEnvelopeErrorCode(result), Message: kelingEnvelopeErrorMessage(result), RequestID: firstNonEmpty(requestID, stringFromAny(result["request_id"])), Retryable: false, Wire: wire}
}
return result, firstNonEmpty(requestID, stringFromAny(result["request_id"])), nil
return result, firstNonEmpty(requestID, stringFromAny(result["request_id"])), wire, nil
}
func (c KelingClient) getJSON(ctx context.Context, request Request, path string, token string) (map[string]any, string, error) {
@@ -663,7 +695,7 @@ func (c KelingClient) cleanupKelingElements(ctx context.Context, request Request
if id == "" {
continue
}
_, _, _ = c.postJSON(ctx, request, "/general/delete-elements", token, map[string]any{"element_id": id})
_, _, _, _ = c.postJSON(ctx, request, "/general/delete-elements", token, map[string]any{"element_id": id})
}
return nil
}
+53 -3
View File
@@ -70,23 +70,37 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
responseStartedAt := time.Now()
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
if err := notifyResponseReceived(request); err != nil {
return Response{}, err
}
requestID := requestIDFromHTTPResponse(resp)
var result map[string]any
var wire *WireResponse
upstreamResponseID := ""
nativeStreamDelta := openAIWireStreamDelta(request.StreamDelta, openAIWireProtocol(endpointKind), resp)
if request.Kind == "responses" && protocol == ProtocolOpenAIResponses && stream {
result, upstreamResponseID, err = decodeNativeResponsesStream(resp, request.StreamDelta)
result, upstreamResponseID, err = decodeNativeResponsesStream(resp, nativeStreamDelta)
wire = &WireResponse{Protocol: openAIWireProtocol(endpointKind), StatusCode: resp.StatusCode, Headers: compatibleResponseHeaders(resp.Header)}
} else {
var streamDelta StreamDelta = request.StreamDelta
var streamDelta StreamDelta = nativeStreamDelta
var adapter *chatResponsesStreamAdapter
if request.Kind == "responses" && protocol == ProtocolOpenAIChatCompletions && stream {
adapter = newChatResponsesStreamAdapter(request.PublicResponseID, request.Model)
streamDelta = func(event StreamDeltaEvent) error { return adapter.delta(event, request.StreamDelta) }
}
result, err = decodeOpenAIResponse(resp, stream, streamDelta)
if stream {
result, err = decodeOpenAIResponse(resp, true, streamDelta)
wire = &WireResponse{Protocol: openAIWireProtocol(endpointKind), StatusCode: resp.StatusCode, Headers: compatibleResponseHeaders(resp.Header)}
} else {
result, wire, err = decodeHTTPResponseForProtocol(resp, openAIWireProtocol(endpointKind))
}
if err == nil && endpointKind == "chat.completions" {
result = NormalizeChatCompletionResult(result)
}
@@ -103,6 +117,12 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
Progress: providerProgress(request), ResponseStartedAt: responseStartedAt, ResponseFinishedAt: time.Now(),
UpstreamProtocol: protocol, UpstreamEndpoint: endpoint, UpstreamResponseID: upstreamResponseID,
PublicResponseID: request.PublicResponseID, ResponseConverted: true,
Wire: func() *WireResponse {
if wire != nil {
wire.Converted = true
}
return wire
}(),
}, nil
}
}
@@ -138,9 +158,39 @@ func (c OpenAIClient) Run(ctx context.Context, request Request) (Response, error
UpstreamEndpoint: endpoint,
UpstreamResponseID: upstreamResponseID,
PublicResponseID: publicResponseID,
Wire: wire,
}, nil
}
func openAIWireStreamDelta(next StreamDelta, protocol string, response *http.Response) StreamDelta {
if next == nil {
return nil
}
return func(event StreamDeltaEvent) error {
event.WireProtocol = protocol
if response != nil {
event.WireStatusCode = response.StatusCode
event.WireHeaders = compatibleResponseHeaders(response.Header)
}
return next(event)
}
}
func openAIWireProtocol(kind string) string {
switch kind {
case "chat.completions":
return ProtocolOpenAIChatCompletions
case "responses":
return ProtocolOpenAIResponses
case "embeddings":
return ProtocolOpenAIEmbeddings
case "images.generations", "images.edits":
return ProtocolOpenAIImages
default:
return "openai_" + strings.ReplaceAll(kind, ".", "_")
}
}
func decodeOpenAIResponse(resp *http.Response, stream bool, onDelta StreamDelta) (map[string]any, error) {
if stream {
result, err := decodeOpenAIStreamResponse(resp, onDelta)
@@ -45,7 +45,15 @@ func (c providerTaskClient) Run(ctx context.Context, request Request) (Response,
requestID := upstreamTaskID
var submitResult map[string]any
if upstreamTaskID == "" {
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
result, id, err := c.submit(ctx, request, payload)
if err == nil || ErrorResponseMetadata(err).StatusCode > 0 {
if notifyErr := notifyResponseReceived(request); notifyErr != nil {
return Response{}, notifyErr
}
}
if err != nil {
return Response{}, annotateResponseError(err, id, startedAt, time.Now())
}
@@ -15,6 +15,12 @@ const (
ProtocolOpenAIChatCompletions = "openai_chat_completions"
ProtocolOpenAIResponses = "openai_responses"
ProtocolAnthropicMessages = "anthropic_messages"
ProtocolOpenAIEmbeddings = "openai_embeddings"
ProtocolOpenAIImages = "openai_images"
ProtocolGeminiGenerateContent = "gemini_generate_content"
ProtocolVolcesContents = "volces_contents_generations_v3"
ProtocolKlingV1Omni = "kling_v1_omni_video"
ProtocolKlingV2Omni = "kling_v2_omni_video"
)
var supportedResponseFallbackParameters = map[string]struct{}{
@@ -0,0 +1,51 @@
package clients
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestLocalUnsupportedKindDoesNotMarkUpstreamSubmissionStarted(t *testing.T) {
started, responseReceived := false, false
_, err := (VolcesClient{}).Run(context.Background(), Request{
Kind: "speech.generations",
Candidate: store.RuntimeModelCandidate{
Provider: "volces", Credentials: map[string]any{"apiKey": "test"},
},
OnUpstreamSubmissionStarted: func() error { started = true; return nil },
OnUpstreamResponseReceived: func() error { responseReceived = true; return nil },
})
if err == nil || ErrorCode(err) != "unsupported_kind" {
t.Fatalf("expected local unsupported_kind, got %v", err)
}
if started || responseReceived {
t.Fatalf("local validation must remain not_submitted: started=%v response=%v", started, responseReceived)
}
}
func TestNetworkDisconnectMarksSubmittingWithoutResponseReceived(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
baseURL := upstream.URL
upstream.Close()
started, responseReceived := false, false
_, err := (VolcesClient{HTTPClient: upstream.Client()}).Run(context.Background(), Request{
Kind: "images.generations",
Body: map[string]any{"prompt": "test"},
Candidate: store.RuntimeModelCandidate{
Provider: "volces", BaseURL: baseURL, ModelName: "seedream-test",
Credentials: map[string]any{"apiKey": "test"},
},
OnUpstreamSubmissionStarted: func() error { started = true; return nil },
OnUpstreamResponseReceived: func() error { responseReceived = true; return nil },
})
if err == nil || ErrorCode(err) != "network" {
t.Fatalf("expected network error, got %v", err)
}
if !started || responseReceived {
t.Fatalf("network disconnect must remain submitting: started=%v response=%v", started, responseReceived)
}
}
+50 -17
View File
@@ -11,23 +11,38 @@ import (
)
type Request struct {
Kind string
ModelType string
Model string
Body map[string]any
Candidate store.RuntimeModelCandidate
HTTPClient *http.Client
RemoteTaskID string
RemoteTaskPayload map[string]any
OnRemoteTaskSubmitted func(remoteTaskID string, payload map[string]any) error
OnRemoteTaskPolled func(remoteTaskID string, payload map[string]any) error
Stream bool
StreamDelta StreamDelta
UpstreamProtocol string
PublicResponseID string
PublicPreviousResponseID string
UpstreamPreviousResponseID string
PreviousResponseTurns []ResponseTurn
Kind string
ModelType string
Model string
Body map[string]any
Candidate store.RuntimeModelCandidate
HTTPClient *http.Client
RemoteTaskID string
RemoteTaskPayload map[string]any
OnRemoteTaskSubmitted func(remoteTaskID string, payload map[string]any) error
OnRemoteTaskPolled func(remoteTaskID string, payload map[string]any) error
OnUpstreamSubmissionStarted func() error
OnUpstreamResponseReceived func() error
OnUpstreamWireResponse func(*WireResponse) error
Stream bool
StreamDelta StreamDelta
UpstreamProtocol string
PublicResponseID string
PublicPreviousResponseID string
UpstreamPreviousResponseID string
PreviousResponseTurns []ResponseTurn
}
// WireResponse preserves the provider-facing response independently from the
// canonical result used by billing, retries, and cross-protocol conversion.
// Headers are filtered before this value leaves the clients package.
type WireResponse struct {
Protocol string `json:"protocol,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
Headers map[string][]string `json:"headers,omitempty"`
Body map[string]any `json:"body,omitempty"`
RawJSON []byte `json:"-"`
Converted bool `json:"converted,omitempty"`
}
type ResponseTurn struct {
@@ -54,6 +69,7 @@ type Response struct {
ResponseChainDepth int
ResponseConverted bool
InternalResult map[string]any
Wire *WireResponse
}
type Usage struct {
@@ -75,6 +91,10 @@ type StreamDeltaEvent struct {
Text string
ReasoningContent string
Event map[string]any
WireProtocol string
WireConverted bool
WireStatusCode int
WireHeaders map[string][]string
}
type StreamDelta func(event StreamDeltaEvent) error
@@ -112,6 +132,15 @@ type ClientError struct {
ResponseFinishedAt time.Time
ResponseDurationMS int64
Retryable bool
Wire *WireResponse
}
func ErrorWireResponse(err error) *WireResponse {
var clientErr *ClientError
if errors.As(err, &clientErr) {
return clientErr.Wire
}
return nil
}
func ErrorParam(err error) string {
@@ -142,6 +171,10 @@ func upstreamModelName(candidate store.RuntimeModelCandidate) string {
}
func ErrorCode(err error) string {
var coded interface{ ErrorCode() string }
if errors.As(err, &coded) && strings.TrimSpace(coded.ErrorCode()) != "" {
return strings.TrimSpace(coded.ErrorCode())
}
var clientErr *ClientError
if errors.As(err, &clientErr) && clientErr.Code != "" {
return clientErr.Code
+8
View File
@@ -40,7 +40,15 @@ func (c UniversalClient) Run(ctx context.Context, request Request) (Response, er
if err != nil {
return Response{}, err
}
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
submitResult, submitRequestID, err = c.universalSubmit(ctx, executor, request, modelType, payload)
if err == nil || ErrorResponseMetadata(err).StatusCode > 0 {
if notifyErr := notifyResponseReceived(request); notifyErr != nil {
return Response{}, notifyErr
}
}
if err != nil {
return Response{}, annotateResponseError(err, submitRequestID, startedAt, time.Now())
}
+58 -15
View File
@@ -46,12 +46,18 @@ func (c VolcesClient) runImage(ctx context.Context, request Request, apiKey stri
req.Header.Set("Authorization", "Bearer "+apiKey)
responseStartedAt := time.Now()
if err := notifySubmissionStarted(request); err != nil {
return Response{}, err
}
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return Response{}, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
if err := notifyResponseReceived(request); err != nil {
return Response{}, err
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
result, wire, err := decodeHTTPResponseForProtocol(resp, ProtocolOpenAIImages)
responseFinishedAt := time.Now()
if err != nil {
return Response{}, annotateResponseError(err, requestID, responseStartedAt, responseFinishedAt)
@@ -67,6 +73,8 @@ func (c VolcesClient) runImage(ctx context.Context, request Request, apiKey stri
ResponseStartedAt: responseStartedAt,
ResponseFinishedAt: responseFinishedAt,
ResponseDurationMS: responseDurationMS(responseStartedAt, responseFinishedAt),
UpstreamProtocol: ProtocolOpenAIImages,
Wire: wire,
}, nil
}
@@ -80,7 +88,7 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
if err := validateVolcesVideoTaskBody(body); err != nil {
return Response{}, err
}
submitResult, requestID, err := c.postJSON(ctx, request, request.Candidate.BaseURL, taskPath, apiKey, body)
submitResult, requestID, _, err := c.postJSON(ctx, request, request.Candidate.BaseURL, taskPath, apiKey, body)
submitRequestID = requestID
if err != nil {
return Response{}, annotateResponseError(err, submitRequestID, submitStartedAt, time.Now())
@@ -119,7 +127,7 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
}
case <-nextPoll.C:
pollStartedAt := time.Now()
pollResult, pollRequestID, err := c.getJSON(ctx, request, request.Candidate.BaseURL, taskPath+"/"+upstreamTaskID, apiKey)
pollResult, pollRequestID, pollWire, err := c.getJSON(ctx, request, request.Candidate.BaseURL, taskPath+"/"+upstreamTaskID, apiKey)
pollFinishedAt := time.Now()
requestID := firstNonEmpty(pollRequestID, submitRequestID, upstreamTaskID)
lastRequestID = requestID
@@ -151,6 +159,8 @@ func (c VolcesClient) runVideo(ctx context.Context, request Request, apiKey stri
ResponseStartedAt: submitStartedAt,
ResponseFinishedAt: pollFinishedAt,
ResponseDurationMS: responseDurationMS(submitStartedAt, pollFinishedAt),
UpstreamProtocol: ProtocolVolcesContents,
Wire: pollWire,
}, nil
case "failed", "cancelled":
return Response{}, &ClientError{
@@ -214,44 +224,73 @@ func volcesVideoTaskPath(request Request) string {
return strings.TrimRight(path, "/")
}
func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string, body map[string]any) (map[string]any, string, error) {
func (c VolcesClient) postJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string, body map[string]any) (map[string]any, string, *WireResponse, error) {
raw, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(baseURL, path), bytes.NewReader(raw))
if err != nil {
return nil, "", err
return nil, "", nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
if err := notifySubmissionStarted(request); err != nil {
return nil, "", nil, err
}
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
return nil, "", nil, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
if err := notifyResponseReceived(request); err != nil {
return nil, "", nil, err
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
result, wire, err := decodeHTTPResponseForProtocol(resp, ProtocolVolcesContents)
if err != nil {
return result, requestID, err
if notifyErr := notifyWireResponse(request, wire); notifyErr != nil {
return result, requestID, wire, notifyErr
}
return result, requestID, wire, err
}
original := result
result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result)
return result, firstNonEmpty(requestID, envelopeRequestID), err
if wire != nil {
wire.Converted = volcesCompatibleResultConverted(original)
}
if notifyErr := notifyWireResponse(request, wire); notifyErr != nil {
return result, firstNonEmpty(requestID, envelopeRequestID), wire, notifyErr
}
return result, firstNonEmpty(requestID, envelopeRequestID), wire, err
}
func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string) (map[string]any, string, error) {
func (c VolcesClient) getJSON(ctx context.Context, request Request, baseURL string, path string, apiKey string) (map[string]any, string, *WireResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(baseURL, path), nil)
if err != nil {
return nil, "", err
return nil, "", nil, err
}
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := httpClient(request.HTTPClient, c.HTTPClient).Do(req)
if err != nil {
return nil, "", &ClientError{Code: "network", Message: err.Error(), Retryable: true}
return nil, "", nil, &ClientError{Code: "network", Message: err.Error(), Retryable: true}
}
requestID := requestIDFromHTTPResponse(resp)
result, err := decodeHTTPResponse(resp)
result, wire, err := decodeHTTPResponseForProtocol(resp, ProtocolVolcesContents)
if err != nil {
return result, requestID, err
return result, requestID, wire, err
}
original := result
result, envelopeRequestID, err := normalizeVolcesCompatibleResult(result)
return result, firstNonEmpty(requestID, envelopeRequestID), err
if wire != nil {
wire.Converted = volcesCompatibleResultConverted(original)
}
return result, firstNonEmpty(requestID, envelopeRequestID), wire, err
}
func volcesCompatibleResultConverted(result map[string]any) bool {
if _, ok := result["error"].(map[string]any); ok {
return true
}
_, hasCode := result["code"]
_, hasData := result["data"].(map[string]any)
return hasCode && hasData
}
func normalizeVolcesCompatibleResult(result map[string]any) (map[string]any, string, error) {
@@ -354,6 +393,10 @@ func cleanProviderBody(body map[string]any) map[string]any {
"poll_interval_ms",
"pollTimeoutSeconds",
"poll_timeout_seconds",
"_gateway_compatibility",
"_gateway_target_protocol",
"_compat_provider",
"_kling_compat_version",
} {
delete(out, key)
}
@@ -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)
}
}