fix(messages): /v1/messages 非 cyber response.failed 补全 failover 和错误回写

This commit is contained in:
li
2026-07-09 16:49:13 +08:00
parent 0438057c0b
commit 104fd2b6ec
2 changed files with 145 additions and 4 deletions
@@ -348,7 +348,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
result, handleErr = s.handleAnthropicStreamingResponse(resp, c, account, originalModel, billingModel, upstreamModel, startTime)
} else {
// Client wants JSON: buffer the streaming response and assemble a JSON reply.
result, handleErr = s.handleAnthropicBufferedStreamingResponse(resp, c, originalModel, billingModel, upstreamModel, startTime)
result, handleErr = s.handleAnthropicBufferedStreamingResponse(resp, c, account, originalModel, billingModel, upstreamModel, startTime)
}
// cyber_policy:标记已设、error 已按 Anthropic 格式发给客户端。丢弃 result、返回哨兵,
@@ -424,6 +424,7 @@ func (s *OpenAIGatewayService) handleAnthropicErrorResponse(
func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
resp *http.Response,
c *gin.Context,
account *Account,
originalModel string,
billingModel string,
upstreamModel string,
@@ -441,8 +442,6 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
return nil, fmt.Errorf("upstream stream ended without terminal event")
}
// cyber_policy:上游硬阻断(response.failed)。anthropic buffered 原对 failed 无特殊分支,
// 此处仅为 cyber 增加:以 Anthropic 错误格式回写,标记供 handler 事后写风控/邮件/tokens=0 用量行。
if strings.TrimSpace(finalResponse.Status) == "failed" {
payload, _ := json.Marshal(gin.H{"type": "response.failed", "response": finalResponse})
if hit, code, msg := detectOpenAICyberPolicy(payload); hit {
@@ -461,6 +460,13 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", clientMsg)
return nil, fmt.Errorf("openai cyber_policy: %s", msg)
}
message := openAICompatFailedResponseMessage(finalResponse)
if openAIStreamFailedEventShouldFailover(payload, message) {
return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message)
}
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payload, message)
writeAnthropicError(c, http.StatusBadGateway, "api_error", message)
return nil, fmt.Errorf("upstream response failed: %s", message)
}
// When the terminal event has an empty output array, reconstruct from
@@ -701,6 +707,8 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
firstChunk := true
clientDisconnected := false
clientOutputStarted := false
var streamFailoverErr error
var streamNonFailoverErr error
scanner := s.newUpstreamSSEScanner(resp.Body)
@@ -767,7 +775,8 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
// cyber_policy 致命不可重试:标记供 handler 事后记录;以 Anthropic SSE error 事件
// 回写让客户端感知并停止重试(F4),丢弃后续转换输出。
if strings.TrimSpace(event.Type) == "response.failed" {
if hit, code, msg := detectOpenAICyberPolicy([]byte(payload)); hit {
payloadBytes := []byte(payload)
if hit, code, msg := detectOpenAICyberPolicy(payloadBytes); hit {
MarkOpsCyberPolicy(c, CyberPolicyMark{
Code: code,
Message: msg,
@@ -789,6 +798,25 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
}
return true
}
message := extractOpenAISSEErrorMessage(payloadBytes)
if openAIStreamFailedEventShouldFailover(payloadBytes, message) {
streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message)
return true
}
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payloadBytes, message)
if !clientDisconnected {
if !clientOutputStarted {
writeAnthropicError(c, http.StatusBadGateway, "api_error", message)
clientOutputStarted = true
} else {
writeStreamHeaders()
if _, err := fmt.Fprint(c.Writer, buildAnthropicStreamErrorSSE("api_error", message)); err == nil {
c.Writer.Flush()
}
}
}
streamNonFailoverErr = fmt.Errorf("upstream response failed: %s", message)
return true
}
}
@@ -823,6 +851,12 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
// finalizeStream sends any remaining Anthropic events and returns the result.
finalizeStream := func() (*OpenAIForwardResult, error) {
if streamFailoverErr != nil {
return resultWithUsage(), streamFailoverErr
}
if streamNonFailoverErr != nil {
return resultWithUsage(), streamNonFailoverErr
}
if finalEvents := apicompat.FinalizeResponsesAnthropicStream(state); len(finalEvents) > 0 && !clientDisconnected {
for _, evt := range finalEvents {
sse, err := apicompat.ResponsesAnthropicEventToSSE(evt)
@@ -0,0 +1,107 @@
//go:build unit
package service
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func buildResponsesFailedSSEStream(errType, errorMessage string) string {
failed := fmt.Sprintf(`{"type":"response.failed","response":{"id":"resp_err","object":"response","status":"failed","error":{"type":"%s","message":"%s"},"output":[],"usage":{"input_tokens":10,"output_tokens":0,"total_tokens":10}}}`, errType, errorMessage)
return fmt.Sprintf("data: %s\n\n", failed)
}
func TestForwardAsAnthropic_BufferedResponseFailed_ReturnsError(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
ssePayload := buildResponsesFailedSSEStream("invalid_request_error", "Content policy violation")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(ssePayload)),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.Error(t, err, "non-cyber response.failed must return an error, not swallow as 200")
require.Contains(t, err.Error(), "upstream response failed")
require.Equal(t, http.StatusBadGateway, rec.Code, "should write 502 for non-failover failed response")
}
func TestForwardAsAnthropic_StreamingResponseFailed_ReturnsError(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
ssePayload := buildResponsesFailedSSEStream("invalid_request_error", "Content policy violation")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(ssePayload)),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.Error(t, err, "streaming response.failed must return an error")
require.Contains(t, err.Error(), "upstream response failed")
}
func TestForwardAsAnthropic_BufferedResponseFailed_Failover(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
ssePayload := buildResponsesFailedSSEStream("rate_limit_error", "Rate limit reached")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(ssePayload)),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.Error(t, err)
var failoverErr *UpstreamFailoverError
require.True(t, errors.As(err, &failoverErr), "rate_limit_error should trigger UpstreamFailoverError for failover, got: %T: %v", err, err)
}