feat(server): API relay gateway backend M0-M4
Gin + GORM + pure-Go SQLite. Users/auth (JWT), API key management with quotas, proxy gateway with weighted channel failover and health checks, usage/billing ledger, cross-protocol conversion (Anthropic Messages / OpenAI Chat Completions / OpenAI Responses), and channel/model admin API. Channels declare native API formats and auto-convert the rest. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b0c7439c01
commit
d0e31b198f
@@ -0,0 +1,201 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"openteam/server/internal/proxy/claude"
|
||||
"openteam/server/internal/proxy/convert"
|
||||
"openteam/server/internal/proxy/openai"
|
||||
"openteam/server/internal/proxy/stream"
|
||||
"openteam/server/internal/store"
|
||||
)
|
||||
|
||||
// streamOpenAI forwards an SSE stream to an OpenAI-protocol client.
|
||||
func (g *Gateway) streamOpenAI(c *gin.Context, route Route, resp *http.Response,
|
||||
start time.Time, reqID string, auth *authContext, model *store.Model, ch *store.Channel) {
|
||||
|
||||
w := c.Writer
|
||||
tr := convert.NewTranslator(clientProto(route), upstreamProvider(route, ch))
|
||||
done := make(chan struct{})
|
||||
|
||||
if tr == nil {
|
||||
// Passthrough: copy raw frames, sniffing usage from OpenAI chunks
|
||||
// and Responses-API stream events.
|
||||
var usage *tokenUsage
|
||||
onEvent := func(ev stream.SSEEvent) {
|
||||
if ev.Done || ev.Data == "" {
|
||||
return
|
||||
}
|
||||
var chunk openai.ChatChunk
|
||||
if json.Unmarshal([]byte(ev.Data), &chunk) == nil && chunk.Usage != nil {
|
||||
usage = &tokenUsage{
|
||||
input: chunk.Usage.PromptTokens,
|
||||
output: chunk.Usage.CompletionTokens,
|
||||
}
|
||||
return
|
||||
}
|
||||
var resp respStreamEvent
|
||||
if json.Unmarshal([]byte(ev.Data), &resp) == nil && resp.Response != nil && resp.Response.Usage != nil {
|
||||
usage = &tokenUsage{
|
||||
input: resp.Response.Usage.InputTokens,
|
||||
output: resp.Response.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
defer close(done)
|
||||
copyRawSSE(w, resp.Body, onEvent)
|
||||
}()
|
||||
<-done
|
||||
g.afterComplete(start, reqID, auth, model, ch, usage, "success", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Converted: read upstream events, emit translated frames.
|
||||
var finalUsage *tokenUsage
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := stream.ReadSSE(resp.Body, func(ev stream.SSEEvent) error {
|
||||
frames, ferr := tr.Feed(ev)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
for _, f := range frames {
|
||||
if err := stream.Write(w, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
g.log.Debug("upstream stream read ended", zap.Error(err))
|
||||
}
|
||||
fin, _ := tr.Finish()
|
||||
for _, f := range fin {
|
||||
_ = stream.Write(w, f)
|
||||
}
|
||||
if u := tr.Usage(); u != nil {
|
||||
finalUsage = &tokenUsage{input: u.Input, output: u.Output, cacheRead: u.CacheRead, cacheCreation: u.CacheCreation}
|
||||
}
|
||||
}()
|
||||
<-done
|
||||
g.afterComplete(start, reqID, auth, model, ch, finalUsage, "success", "")
|
||||
}
|
||||
|
||||
// streamAnthropic forwards an SSE stream to a Claude-protocol client.
|
||||
func (g *Gateway) streamAnthropic(c *gin.Context, route Route, resp *http.Response,
|
||||
start time.Time, reqID string, auth *authContext, model *store.Model, ch *store.Channel) {
|
||||
|
||||
w := c.Writer
|
||||
tr := convert.NewTranslator(clientProto(route), upstreamProvider(route, ch))
|
||||
done := make(chan struct{})
|
||||
|
||||
if tr == nil {
|
||||
// Passthrough: copy raw frames, sniffing usage from message_delta.
|
||||
var usage *tokenUsage
|
||||
onEvent := func(ev stream.SSEEvent) {
|
||||
if ev.Done || ev.Data == "" {
|
||||
return
|
||||
}
|
||||
var e claude.StreamEvent
|
||||
if json.Unmarshal([]byte(ev.Data), &e) != nil || e.Type != "message_delta" {
|
||||
return
|
||||
}
|
||||
var u claude.Usage
|
||||
if json.Unmarshal(e.Usage, &u) == nil {
|
||||
usage = &tokenUsage{
|
||||
input: u.InputTokens, output: u.OutputTokens,
|
||||
cacheRead: u.CacheReadInputTokens, cacheCreation: u.CacheCreationInputTokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
defer close(done)
|
||||
copyRawSSE(w, resp.Body, onEvent)
|
||||
}()
|
||||
<-done
|
||||
g.afterComplete(start, reqID, auth, model, ch, usage, "success", "")
|
||||
return
|
||||
}
|
||||
|
||||
// Converted: read upstream events, emit translated frames.
|
||||
var finalUsage *tokenUsage
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := stream.ReadSSE(resp.Body, func(ev stream.SSEEvent) error {
|
||||
frames, ferr := tr.Feed(ev)
|
||||
if ferr != nil {
|
||||
return ferr
|
||||
}
|
||||
for _, f := range frames {
|
||||
if err := stream.Write(w, f); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
g.log.Debug("upstream stream read ended", zap.Error(err))
|
||||
}
|
||||
fin, _ := tr.Finish()
|
||||
for _, f := range fin {
|
||||
_ = stream.Write(w, f)
|
||||
}
|
||||
if u := tr.Usage(); u != nil {
|
||||
finalUsage = &tokenUsage{input: u.Input, output: u.Output, cacheRead: u.CacheRead, cacheCreation: u.CacheCreation}
|
||||
}
|
||||
}()
|
||||
<-done
|
||||
g.afterComplete(start, reqID, auth, model, ch, finalUsage, "success", "")
|
||||
}
|
||||
|
||||
// copyRawSSE copies an upstream SSE stream verbatim, invoking onEvent for
|
||||
// each data frame (used for passthrough + usage sniffing).
|
||||
func copyRawSSE(w http.ResponseWriter, r io.Reader, onEvent func(stream.SSEEvent)) {
|
||||
br := bufio.NewReader(r)
|
||||
for {
|
||||
line, err := br.ReadBytes('\n')
|
||||
if len(line) > 0 {
|
||||
if _, werr := w.Write(line); werr != nil {
|
||||
return
|
||||
}
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if bytes.HasPrefix(trimmed, []byte("data:")) {
|
||||
data := bytes.TrimSpace(trimmed[len("data:"):])
|
||||
ev := stream.SSEEvent{
|
||||
Data: string(data),
|
||||
Done: bytes.Equal(data, []byte("[DONE]")),
|
||||
}
|
||||
if ev.Data != "" {
|
||||
onEvent(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// clientProto maps a proxy Protocol to the convert package's protocol type.
|
||||
func clientProto(route Route) convert.ClientProtocol {
|
||||
switch route.Protocol {
|
||||
case ProtocolOpenAIResponses:
|
||||
return convert.ClientOpenAIResponses
|
||||
case ProtocolAnthropic:
|
||||
return convert.ClientAnthropic
|
||||
default:
|
||||
return convert.ClientOpenAIChat
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user