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:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user