新增 Aliyun OSS 与 S3 协议、通道内重试和按优先级跨通道切换,保留 server-main 兼容与环境 OSS 内存通道。 将请求及结果中的 Base64、Data URI、Buffer、multipart 和内联二进制统一对象化,生产路径不再写入本机静态目录,历史本地资源仅保留只读兼容。 引入 PublicErrorV1 并统一 API、异步查询、兼容协议和失败回调的安全错误输出,同时补充迁移、管理端、指标、OpenAPI 与本地模拟验收。 验证:go test ./... -count=1;go vet ./...;pnpm lint;pnpm test;pnpm build;pnpm openapi;tests/ci/migrations-test.sh。
202 lines
6.6 KiB
Go
202 lines
6.6 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/publicerror"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
)
|
|
|
|
func (s *Server) requireProtocolUser(protocol string, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
user, err := s.auth.Authenticate(r)
|
|
if err != nil {
|
|
status, code, message := http.StatusUnauthorized, "unauthorized", "unauthorized"
|
|
var requestErr *auth.RequestAuthError
|
|
if errors.As(err, &requestErr) {
|
|
status, code, message = requestErr.Status, requestErr.Code, requestErr.Message
|
|
if requestErr.RetryAfterSeconds > 0 {
|
|
w.Header().Set("Retry-After", strconv.Itoa(requestErr.RetryAfterSeconds))
|
|
}
|
|
}
|
|
writeProtocolError(w, protocol, status, message, nil, code)
|
|
return
|
|
}
|
|
if auth.PermissionLevel(user.Roles) < 1 {
|
|
writeProtocolError(w, protocol, http.StatusForbidden, "forbidden", nil, "permission_denied")
|
|
return
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(user.Source), "oidc") {
|
|
result, resolveErr := s.resolveOIDCUserProjection(r.Context(), r, user)
|
|
if resolveErr != nil {
|
|
status, code, message := http.StatusServiceUnavailable, errorCodeGatewayProvisioningFailed, "Gateway 账号初始化失败,请稍后重试"
|
|
switch {
|
|
case errors.Is(resolveErr, store.ErrOIDCUserNotProvisioned):
|
|
status, code, message = http.StatusForbidden, errorCodeGatewayUserNotProvisioned, "该账号尚未开通 EasyAI Gateway"
|
|
case errors.Is(resolveErr, store.ErrOIDCUserDisabled):
|
|
status, code, message = http.StatusForbidden, errorCodeGatewayUserDisabled, "该 Gateway 账号已停用,请联系管理员"
|
|
case errors.Is(resolveErr, store.ErrOIDCTenantUnavailable):
|
|
status, code, message = http.StatusServiceUnavailable, errorCodeGatewayTenantUnavailable, "Gateway 租户尚未就绪,请联系管理员"
|
|
}
|
|
writeProtocolError(w, protocol, status, message, nil, code)
|
|
return
|
|
}
|
|
user = result.User
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(auth.WithUser(r.Context(), user)))
|
|
})
|
|
}
|
|
|
|
func targetProtocolForTaskRequest(kind string, r *http.Request) string {
|
|
switch kind {
|
|
case "chat.completions":
|
|
return clients.ProtocolOpenAIChatCompletions
|
|
case "responses":
|
|
return clients.ProtocolOpenAIResponses
|
|
case "embeddings":
|
|
return clients.ProtocolOpenAIEmbeddings
|
|
case "images.generations", "images.edits":
|
|
return clients.ProtocolOpenAIImages
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func wireResponseMatches(wire *clients.WireResponse, targetProtocol string) bool {
|
|
return wire != nil && !wire.Converted && strings.TrimSpace(wire.Protocol) == strings.TrimSpace(targetProtocol)
|
|
}
|
|
|
|
func writeWireResponse(w http.ResponseWriter, wire *clients.WireResponse) {
|
|
if wire == nil {
|
|
return
|
|
}
|
|
for name, values := range wire.Headers {
|
|
for _, value := range values {
|
|
if strings.EqualFold(name, "Content-Type") {
|
|
w.Header().Set(name, value)
|
|
} else {
|
|
w.Header().Add(name, value)
|
|
}
|
|
}
|
|
}
|
|
status := wire.StatusCode
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
w.WriteHeader(status)
|
|
if len(wire.RawJSON) > 0 {
|
|
_, _ = w.Write(wire.RawJSON)
|
|
return
|
|
}
|
|
_ = json.NewEncoder(w).Encode(wire.Body)
|
|
}
|
|
|
|
func writeProtocolError(w http.ResponseWriter, protocol string, status int, message string, details map[string]any, code string) {
|
|
standard := publicerror.FromFields(code, message, status, status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= 500)
|
|
standard = publicErrorWithRetryAfter(standard, details)
|
|
publicerror.Observe(standard)
|
|
status, message, code = standard.HTTPStatus, standard.Message, standard.Code
|
|
details = safePublicErrorDetails(details, standard, true)
|
|
switch protocol {
|
|
case clients.ProtocolGeminiGenerateContent:
|
|
writeGeminiError(w, status, message, details, code)
|
|
case clients.ProtocolVolcesContents:
|
|
writeVolcesPublicError(w, standard)
|
|
case clients.ProtocolKlingV1Omni, clients.ProtocolKlingV2Omni:
|
|
writeKelingCompatError(w, "", newKelingCompatError(status, kelingCompatBusinessCode(code, message), message))
|
|
default:
|
|
writeOpenAIError(w, status, message, details, code)
|
|
}
|
|
}
|
|
|
|
func writeOpenAIError(w http.ResponseWriter, status int, message string, details map[string]any, code string) {
|
|
errorType := "server_error"
|
|
if status >= 400 && status < 500 {
|
|
errorType = "invalid_request_error"
|
|
}
|
|
var param any
|
|
if details != nil {
|
|
param = details["param"]
|
|
}
|
|
payload := map[string]any{
|
|
"message": message,
|
|
"type": errorType,
|
|
"param": param,
|
|
"code": nil,
|
|
}
|
|
if strings.TrimSpace(code) != "" {
|
|
payload["code"] = code
|
|
}
|
|
if len(details) > 0 {
|
|
publicDetails := map[string]any{}
|
|
for key, value := range details {
|
|
if key != "param" {
|
|
publicDetails[key] = value
|
|
}
|
|
}
|
|
if len(publicDetails) > 0 {
|
|
payload["details"] = publicDetails
|
|
}
|
|
}
|
|
writeJSON(w, status, map[string]any{"error": payload})
|
|
}
|
|
|
|
func writeGeminiError(w http.ResponseWriter, status int, message string, details map[string]any, code string) {
|
|
writeJSON(w, status, geminiErrorEnvelope(status, message, details, code))
|
|
}
|
|
|
|
func geminiErrorEnvelope(status int, message string, details map[string]any, code string) map[string]any {
|
|
detailList := []any{}
|
|
if len(details) > 0 {
|
|
detailList = append(detailList, details)
|
|
}
|
|
return map[string]any{"error": map[string]any{
|
|
"code": status,
|
|
"message": message,
|
|
"status": googleRPCStatus(status, code),
|
|
"details": detailList,
|
|
}}
|
|
}
|
|
|
|
func googleRPCStatus(status int, code string) string {
|
|
switch status {
|
|
case http.StatusBadRequest:
|
|
return "INVALID_ARGUMENT"
|
|
case http.StatusUnauthorized:
|
|
return "UNAUTHENTICATED"
|
|
case http.StatusForbidden:
|
|
return "PERMISSION_DENIED"
|
|
case http.StatusNotFound:
|
|
return "NOT_FOUND"
|
|
case http.StatusConflict:
|
|
return "ALREADY_EXISTS"
|
|
case http.StatusTooManyRequests:
|
|
return "RESOURCE_EXHAUSTED"
|
|
case http.StatusServiceUnavailable:
|
|
return "UNAVAILABLE"
|
|
case http.StatusGatewayTimeout:
|
|
return "DEADLINE_EXCEEDED"
|
|
default:
|
|
if strings.EqualFold(code, "cancelled") || strings.EqualFold(code, "canceled") {
|
|
return "CANCELLED"
|
|
}
|
|
return "INTERNAL"
|
|
}
|
|
}
|
|
|
|
func writeVolcesError(w http.ResponseWriter, status int, message string, code string) {
|
|
standard := publicerror.FromFields(code, message, status, status == http.StatusRequestTimeout || status == http.StatusTooManyRequests || status >= 500)
|
|
publicerror.Observe(standard)
|
|
writeVolcesPublicError(w, standard)
|
|
}
|
|
|
|
func writeVolcesPublicError(w http.ResponseWriter, standard publicerror.Error) {
|
|
writeJSON(w, standard.HTTPStatus, map[string]any{"error": standard})
|
|
}
|