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