Files
sub2api/backend/internal/service/antigravity_gateway_compat_stream.go
T
李建琦 6d655c9903
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
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

475 lines
14 KiB
Go

package service
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/gin-gonic/gin"
)
type antigravityCompatStreamAdapter interface {
Emit(*apicompat.AnthropicStreamEvent, *antigravityClientWriter)
Finalize(*antigravityClientWriter)
WriteError(*antigravityClientWriter, string)
}
type antigravityChatStreamAdapter struct {
anthropicState *apicompat.AnthropicEventToResponsesState
chatState *apicompat.ResponsesEventToChatState
}
func newAntigravityChatStreamAdapter(model string, includeUsage bool) *antigravityChatStreamAdapter {
anthropicState := apicompat.NewAnthropicEventToResponsesState()
anthropicState.Model = model
chatState := apicompat.NewResponsesEventToChatState()
chatState.Model = model
chatState.IncludeUsage = includeUsage
return &antigravityChatStreamAdapter{
anthropicState: anthropicState,
chatState: chatState,
}
}
func (a *antigravityChatStreamAdapter) Emit(event *apicompat.AnthropicStreamEvent, writer *antigravityClientWriter) {
for _, responseEvent := range apicompat.AnthropicEventToResponsesEvents(event, a.anthropicState) {
a.emitResponseEvent(&responseEvent, writer)
}
}
func (a *antigravityChatStreamAdapter) Finalize(writer *antigravityClientWriter) {
for _, responseEvent := range apicompat.FinalizeAnthropicResponsesStream(a.anthropicState) {
a.emitResponseEvent(&responseEvent, writer)
}
for _, chunk := range apicompat.FinalizeResponsesChatStream(a.chatState) {
if data, err := apicompat.ChatChunkToSSE(chunk); err == nil {
writer.Write([]byte(data))
}
}
writer.Write([]byte("data: [DONE]\n\n"))
}
func (a *antigravityChatStreamAdapter) WriteError(writer *antigravityClientWriter, reason string) {
writer.Fprintf("data: {\"error\":{\"message\":%q,\"type\":\"upstream_error\"}}\n\n", reason)
}
func (a *antigravityChatStreamAdapter) emitResponseEvent(event *apicompat.ResponsesStreamEvent, writer *antigravityClientWriter) {
for _, chunk := range apicompat.ResponsesEventToChatChunks(event, a.chatState) {
if data, err := apicompat.ChatChunkToSSE(chunk); err == nil {
writer.Write([]byte(data))
}
}
}
type antigravityResponsesStreamAdapter struct {
anthropicState *apicompat.AnthropicEventToResponsesState
}
func newAntigravityResponsesStreamAdapter(model string) *antigravityResponsesStreamAdapter {
state := apicompat.NewAnthropicEventToResponsesState()
state.Model = model
return &antigravityResponsesStreamAdapter{anthropicState: state}
}
func (a *antigravityResponsesStreamAdapter) Emit(event *apicompat.AnthropicStreamEvent, writer *antigravityClientWriter) {
for _, responseEvent := range apicompat.AnthropicEventToResponsesEvents(event, a.anthropicState) {
a.emitResponseEvent(responseEvent, writer)
}
}
func (a *antigravityResponsesStreamAdapter) Finalize(writer *antigravityClientWriter) {
for _, responseEvent := range apicompat.FinalizeAnthropicResponsesStream(a.anthropicState) {
a.emitResponseEvent(responseEvent, writer)
}
}
func (a *antigravityResponsesStreamAdapter) WriteError(writer *antigravityClientWriter, reason string) {
writer.Fprintf("event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"upstream_error\",\"message\":%q}}\n\n", reason)
}
func (a *antigravityResponsesStreamAdapter) emitResponseEvent(event apicompat.ResponsesStreamEvent, writer *antigravityClientWriter) {
if data, err := apicompat.ResponsesEventToSSE(event); err == nil {
writer.Write([]byte(data))
}
}
type antigravityCompatScanEvent struct {
line string
err error
}
type antigravityCompatStreamSession struct {
processor *antigravity.StreamingProcessor
adapter antigravityCompatStreamAdapter
writer *antigravityClientWriter
usage *ClaudeUsage
pendingEvents []apicompat.AnthropicStreamEvent
firstTokenMs *int
startTime time.Time
meaningfulData bool
}
func newAntigravityCompatStreamSession(
model string,
startTime time.Time,
adapter antigravityCompatStreamAdapter,
writer *antigravityClientWriter,
) *antigravityCompatStreamSession {
return &antigravityCompatStreamSession{
processor: antigravity.NewStreamingProcessor(model),
adapter: adapter,
writer: writer,
usage: &ClaudeUsage{},
startTime: startTime,
}
}
func (s *antigravityCompatStreamSession) consume(line string) {
claudeEvents := s.processor.ProcessLine(strings.TrimRight(line, "\r\n"))
if len(claudeEvents) == 0 {
return
}
s.consumeClaudeEvents(claudeEvents)
}
func (s *antigravityCompatStreamSession) hasMeaningfulData() bool {
return s.meaningfulData
}
func (s *antigravityCompatStreamSession) finish() *antigravityStreamResult {
finalEvents, usage := s.processor.Finish()
mergeAntigravityCompatUsage(s.usage, usage)
s.consumeClaudeEvents(finalEvents)
s.adapter.Finalize(s.writer)
return s.result(s.writer.Disconnected())
}
func (s *antigravityCompatStreamSession) collectResult(clientDisconnect bool) *antigravityStreamResult {
_, usage := s.processor.Finish()
mergeAntigravityCompatUsage(s.usage, usage)
return s.result(clientDisconnect)
}
func (s *antigravityCompatStreamSession) result(clientDisconnect bool) *antigravityStreamResult {
return &antigravityStreamResult{
usage: s.usage,
firstTokenMs: s.firstTokenMs,
clientDisconnect: clientDisconnect,
}
}
func (s *antigravityCompatStreamSession) consumeClaudeEvents(data []byte) {
var eventType string
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "event:"):
eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
s.consumeClaudeData(eventType, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
}
func (s *antigravityCompatStreamSession) consumeClaudeData(eventType, payload string) {
var event apicompat.AnthropicStreamEvent
if json.Unmarshal([]byte(payload), &event) != nil {
return
}
if event.Type == "" {
event.Type = eventType
}
if event.Usage != nil {
mergeAnthropicUsage(s.usage, *event.Usage)
}
if event.Message != nil {
mergeAnthropicUsage(s.usage, event.Message.Usage)
}
s.emitOrBuffer(event)
}
func (s *antigravityCompatStreamSession) emitOrBuffer(event apicompat.AnthropicStreamEvent) {
if s.meaningfulData {
s.adapter.Emit(&event, s.writer)
return
}
s.pendingEvents = append(s.pendingEvents, event)
if !isMeaningfulAntigravityCompatEvent(&event) {
return
}
s.meaningfulData = true
ms := int(time.Since(s.startTime).Milliseconds())
s.firstTokenMs = &ms
for i := range s.pendingEvents {
s.adapter.Emit(&s.pendingEvents[i], s.writer)
}
s.pendingEvents = nil
}
func isMeaningfulAntigravityCompatEvent(event *apicompat.AnthropicStreamEvent) bool {
if event == nil {
return false
}
if event.Type == "message_stop" {
return true
}
if event.ContentBlock != nil {
block := event.ContentBlock
return block.Type == "tool_use" ||
block.Text != "" ||
block.Thinking != "" ||
block.Signature != "" ||
block.Source != nil
}
if event.Delta != nil {
delta := event.Delta
return delta.Text != "" ||
delta.PartialJSON != "" ||
delta.Thinking != "" ||
delta.Signature != "" ||
delta.StopReason != ""
}
return false
}
func mergeAntigravityCompatUsage(dst *ClaudeUsage, src *antigravity.ClaudeUsage) {
if dst == nil || src == nil {
return
}
dst.InputTokens = src.InputTokens
dst.OutputTokens = src.OutputTokens
dst.CacheCreationInputTokens = src.CacheCreationInputTokens
dst.CacheReadInputTokens = src.CacheReadInputTokens
dst.ImageOutputTokens = src.ImageOutputTokens
}
func (s *AntigravityGatewayService) handleAntigravityCompatStream(
c *gin.Context,
resp *http.Response,
startTime time.Time,
originalModel string,
adapter antigravityCompatStreamAdapter,
prefix string,
) (*antigravityStreamResult, error) {
flusher, ok := c.Writer.(http.Flusher)
if !ok {
return nil, errors.New("streaming not supported")
}
writer := newAntigravityClientWriter(c.Writer, flusher, prefix)
writer.beforeFirstWrite = func() {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
c.Status(http.StatusOK)
}
session := newAntigravityCompatStreamSession(originalModel, startTime, adapter, writer)
events, stopScanner, maxLineSize := s.startAntigravityCompatScanner(resp.Body)
defer stopScanner()
timeout := s.antigravityCompatStreamTimeout()
timeoutTimer, timeoutCh := newAntigravityCompatTimer(timeout)
if timeoutTimer != nil {
defer timeoutTimer.Stop()
}
keepaliveTicker, keepaliveCh := s.newAntigravityCompatKeepaliveTicker()
if keepaliveTicker != nil {
defer keepaliveTicker.Stop()
}
for {
select {
case event, open := <-events:
if !open {
if !session.hasMeaningfulData() && !writer.Disconnected() {
return nil, antigravityCompatEmptyStreamError()
}
return session.finish(), nil
}
if event.err != nil {
return s.handleAntigravityCompatReadError(c, session, event.err, maxLineSize, prefix)
}
resetAntigravityCompatTimer(timeoutTimer, timeout)
s.observeAntigravityGeminiSSELine(c, event.line)
session.consume(event.line)
case <-timeoutCh:
if writer.Disconnected() {
return session.collectResult(true), nil
}
if !session.hasMeaningfulData() {
return nil, antigravityCompatEmptyStreamError()
}
logger.LegacyPrintf("service.antigravity_gateway", "Stream data interval timeout (%s)", prefix)
writeAntigravityCompatStreamError(c, adapter, writer, "stream_timeout")
return session.collectResult(false), fmt.Errorf("stream data interval timeout")
case <-keepaliveCh:
if session.hasMeaningfulData() && !writer.Disconnected() {
writer.Write([]byte(": ping\n\n"))
}
}
}
}
func (s *AntigravityGatewayService) startAntigravityCompatScanner(
body io.Reader,
) (<-chan antigravityCompatScanEvent, func(), int) {
maxLineSize := defaultMaxLineSize
if s.settingService != nil && s.settingService.cfg != nil && s.settingService.cfg.Gateway.MaxLineSize > 0 {
maxLineSize = s.settingService.cfg.Gateway.MaxLineSize
}
scanner := bufio.NewScanner(body)
scanBuf := getSSEScannerBuf64K()
scanner.Buffer(scanBuf[:0], maxLineSize)
events := make(chan antigravityCompatScanEvent, 16)
done := make(chan struct{})
go func() {
defer putSSEScannerBuf64K(scanBuf)
defer close(events)
send := func(event antigravityCompatScanEvent) bool {
select {
case events <- event:
return true
case <-done:
return false
}
}
for scanner.Scan() {
if !send(antigravityCompatScanEvent{line: scanner.Text()}) {
return
}
}
if err := scanner.Err(); err != nil {
send(antigravityCompatScanEvent{err: err})
}
}()
return events, func() { close(done) }, maxLineSize
}
func (s *AntigravityGatewayService) antigravityCompatStreamTimeout() time.Duration {
if s.settingService == nil || s.settingService.cfg == nil {
return 0
}
return time.Duration(s.settingService.cfg.Gateway.StreamDataIntervalTimeout) * time.Second
}
func (s *AntigravityGatewayService) newAntigravityCompatKeepaliveTicker() (*time.Ticker, <-chan time.Time) {
if s.settingService == nil || s.settingService.cfg == nil {
return nil, nil
}
interval := time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second
if interval <= 0 {
return nil, nil
}
ticker := time.NewTicker(interval)
return ticker, ticker.C
}
func newAntigravityCompatTimer(timeout time.Duration) (*time.Timer, <-chan time.Time) {
if timeout <= 0 {
return nil, nil
}
timer := time.NewTimer(timeout)
return timer, timer.C
}
func resetAntigravityCompatTimer(timer *time.Timer, timeout time.Duration) {
if timer == nil {
return
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(timeout)
}
func (s *AntigravityGatewayService) handleAntigravityCompatReadError(
c *gin.Context,
session *antigravityCompatStreamSession,
err error,
maxLineSize int,
prefix string,
) (*antigravityStreamResult, error) {
if !session.hasMeaningfulData() && !session.writer.Disconnected() {
return nil, antigravityCompatEmptyStreamError()
}
if disconnect, handled := handleStreamReadError(err, session.writer.Disconnected(), prefix); handled {
return session.collectResult(disconnect), nil
}
if errors.Is(err, bufio.ErrTooLong) {
logger.LegacyPrintf("service.antigravity_gateway", "SSE line too long (%s): max_size=%d error=%v", prefix, maxLineSize, err)
writeAntigravityCompatStreamError(c, session.adapter, session.writer, "response_too_large")
return session.result(false), err
}
writeAntigravityCompatStreamError(c, session.adapter, session.writer, "stream_read_error")
return nil, fmt.Errorf("stream read error: %w", err)
}
func writeAntigravityCompatStreamError(
c *gin.Context,
adapter antigravityCompatStreamAdapter,
writer *antigravityClientWriter,
reason string,
) {
adapter.WriteError(writer, reason)
MarkResponseCommitted(c)
}
func antigravityCompatEmptyStreamError() error {
logger.LegacyPrintf("service.antigravity_gateway", "Empty Antigravity compatibility stream, triggering failover")
return &UpstreamFailoverError{
StatusCode: http.StatusBadGateway,
ResponseBody: []byte(`{"error":"empty stream response from upstream"}`),
RetryableOnSameAccount: true,
}
}
func (s *AntigravityGatewayService) handleChatCompletionsStreamingFromAntigravity(
c *gin.Context,
resp *http.Response,
startTime time.Time,
originalModel string,
includeUsage bool,
) (*antigravityStreamResult, error) {
return s.handleAntigravityCompatStream(
c,
resp,
startTime,
originalModel,
newAntigravityChatStreamAdapter(originalModel, includeUsage),
"antigravity chat completions stream",
)
}
func (s *AntigravityGatewayService) handleResponsesStreamingFromAntigravity(
c *gin.Context,
resp *http.Response,
startTime time.Time,
originalModel string,
) (*antigravityStreamResult, error) {
return s.handleAntigravityCompatStream(
c,
resp,
startTime,
originalModel,
newAntigravityResponsesStreamAdapter(originalModel),
"antigravity responses stream",
)
}