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:
Sakurasan
2026-08-15 21:05:02 +08:00
co-authored by Claude Sonnet 5
parent b0c7439c01
commit d0e31b198f
45 changed files with 6222 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
package proxy
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"openteam/server/internal/pkg/crypto"
"openteam/server/internal/store"
)
// tokenUsage is the normalized usage extracted from any protocol.
type tokenUsage struct {
input int64
output int64
cacheRead int64
cacheCreation int64
}
// respStreamEvent is the streaming shape of the OpenAI Responses API used for
// usage sniffing on passthrough responses streams.
type respStreamEvent struct {
Type string `json:"type"`
Response *struct {
Usage *struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage"`
} `json:"response"`
}
// authenticate resolves the Bearer API key to a key + user.
func (g *Gateway) authenticate(c *gin.Context) (*authContext, error) {
auth := c.GetHeader("Authorization")
token := ""
if strings.HasPrefix(auth, "Bearer ") {
token = strings.TrimPrefix(auth, "Bearer ")
} else if strings.HasPrefix(auth, "sk-") {
// Some clients send the raw key without the Bearer scheme.
token = auth
} else {
return nil, errUnauthorized
}
var key store.ApiKey
if err := g.db.Where("key_hash = ?", crypto.HashSHA256(token)).First(&key).Error; err != nil {
return nil, errUnauthorized
}
if key.Status != "active" {
return nil, errUnauthorized
}
if key.ExpiresAt != nil && key.ExpiresAt.Before(now()) {
return nil, errUnauthorized
}
var user store.User
if err := g.db.First(&user, key.UserID).Error; err != nil {
return nil, errUnauthorized
}
if user.Status != "active" {
return nil, errUnauthorized
}
return &authContext{key: &key, user: &user}, nil
}
var errUnauthorized = &unauthorizedError{}
type unauthorizedError struct{}
func (*unauthorizedError) Error() string { return "invalid API key" }
// writeProxyError writes a gateway-generated error in the client's protocol.
func (g *Gateway) writeProxyError(c *gin.Context, proto Protocol, status int, message string) {
c.Header("Content-Type", "application/json")
switch proto {
case ProtocolAnthropic:
c.AbortWithStatusJSON(status, map[string]any{
"type": "error",
"error": map[string]any{"type": statusType(status), "message": message},
})
default:
c.AbortWithStatusJSON(status, map[string]any{
"error": map[string]any{"message": message, "type": "gateway_error", "code": "gateway_error"},
})
}
}
// writeUpstreamError maps an upstream error body to the client protocol.
func (g *Gateway) writeUpstreamError(c *gin.Context, proto Protocol, status int, body []byte) {
switch proto {
case ProtocolAnthropic:
// Extract the upstream Claude error if present.
var up struct {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(body, &up) == nil && up.Error.Message != "" {
c.AbortWithStatusJSON(status, map[string]any{
"type": "error",
"error": map[string]any{"type": up.Error.Type, "message": up.Error.Message},
})
return
}
c.AbortWithStatusJSON(status, map[string]any{
"type": "error",
"error": map[string]any{"type": statusType(status), "message": upstreamMessage(status, body)},
})
default:
var up struct {
Error struct {
Message string `json:"message"`
Type string `json:"type"`
Code string `json:"code"`
Param string `json:"param"`
} `json:"error"`
}
if json.Unmarshal(body, &up) == nil && up.Error.Message != "" {
c.AbortWithStatusJSON(status, map[string]any{
"error": map[string]any{
"message": up.Error.Message, "type": up.Error.Type, "code": up.Error.Code, "param": up.Error.Param,
},
})
return
}
c.AbortWithStatusJSON(status, map[string]any{
"error": map[string]any{
"message": upstreamMessage(status, body), "type": statusType(status), "code": statusType(status),
},
})
}
}
func statusType(status int) string {
switch {
case status == 429:
return "rate_limit_error"
case status >= 500:
return "api_error"
case status >= 400:
return "invalid_request_error"
default:
return "api_error"
}
}
func upstreamMessage(status int, body []byte) string {
msg := strings.TrimSpace(string(body))
if msg == "" {
msg = http.StatusText(status)
}
return truncateText(msg, 512)
}
func truncateText(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// copyProxyHeaders forwards selected request headers upstream.
func copyProxyHeaders(c *gin.Context, req *http.Request) {
for _, h := range []string{"OpenAI-Organization", "OpenAI-Beta", "anthropic-version", "anthropic-beta", "X-Stainless-Lang", "X-Stainless-Package-Version"} {
if v := c.GetHeader(h); v != "" {
req.Header.Set(h, v)
}
}
}
// bodyStreamFlag determines streaming intent from the raw body + route.
func bodyStreamFlag(body []byte, route Route) bool {
var m struct {
Stream bool `json:"stream"`
}
_ = json.Unmarshal(body, &m)
return m.Stream
}
// setModelField rewrites the "model" key in a JSON object.
func setModelField(body []byte, model string) ([]byte, error) {
var obj map[string]json.RawMessage
if err := json.Unmarshal(body, &obj); err != nil {
return nil, err
}
m, _ := json.Marshal(model)
obj["model"] = m
return json.Marshal(obj)
}
// sameModel reports whether the body's model already equals upstreamModel.
func sameModel(body []byte, upstreamModel string) bool {
var m struct {
Model string `json:"model"`
}
_ = json.Unmarshal(body, &m)
return m.Model == upstreamModel
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// upstreamPath returns the endpoint to POST to. When the channel serves the
// client protocol natively the route's own path is used; otherwise the request
// is converted and must hit the channel's conversion-target path.
func upstreamPath(route Route, ch *store.Channel) string {
if ch.SupportsFormat(string(route.Protocol)) {
return route.UpstreamPath
}
if ch.Provider == "anthropic" {
return "/v1/messages"
}
return "/v1/chat/completions"
}
// upstreamProvider returns the format family ("openai" | "anthropic") the
// channel will actually speak for this request: the client's own family on
// passthrough, otherwise the channel's conversion target.
func upstreamProvider(route Route, ch *store.Channel) string {
if ch.SupportsFormat(string(route.Protocol)) {
return route.NativeProvider
}
return ch.Provider
}
// channelServesFormat reports whether the channel can handle the route's
// protocol: natively, or via a conversion path that exists. Every protocol can
// convert to either Claude or chat completions except one case: Responses-API
// requests have no conversion into an openai-family upstream (the
// responses->chat response side is unimplemented), so an openai-family channel
// that does not declare responses support cannot serve them at all.
func channelServesFormat(ch *store.Channel, route Route) bool {
if ch.SupportsFormat(string(route.Protocol)) {
return true
}
return !(route.Protocol == ProtocolOpenAIResponses && ch.Provider != "anthropic")
}
func now() time.Time { return time.Now() }