Add runtime restore and temp asset cleanup
This commit is contained in:
@@ -916,13 +916,26 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
|
||||
return
|
||||
}
|
||||
responsePlan := planTaskResponse(kind, compatible, body, r)
|
||||
prepared, err := s.prepareTaskRequest(r.Context(), r, user, body)
|
||||
if err != nil {
|
||||
s.logger.Warn("prepare task request failed", "kind", kind, "error", err)
|
||||
status := http.StatusBadRequest
|
||||
if code := clients.ErrorCode(err); strings.HasPrefix(code, "upload_") || code == "request_asset_upload_failed" {
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
writeError(w, status, err.Error(), clients.ErrorCode(err))
|
||||
return
|
||||
}
|
||||
|
||||
task, err := s.store.CreateTask(r.Context(), store.CreateTaskInput{
|
||||
Kind: kind,
|
||||
Model: model,
|
||||
RunMode: runModeFromRequest(body),
|
||||
Async: responsePlan.asyncMode,
|
||||
Request: body,
|
||||
Kind: kind,
|
||||
Model: model,
|
||||
RunMode: runModeFromRequest(prepared.Body),
|
||||
Async: responsePlan.asyncMode,
|
||||
Request: prepared.Body,
|
||||
ConversationID: prepared.ConversationID,
|
||||
NewMessageCount: prepared.NewMessageCount,
|
||||
MessageRefs: prepared.MessageRefs,
|
||||
}, user)
|
||||
if err != nil {
|
||||
s.logger.Error("create task failed", "kind", kind, "error", err)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) startLocalTempAssetCleanup(ctx context.Context) {
|
||||
go func() {
|
||||
s.cleanupExpiredLocalTempAssets(ctx, time.Now())
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
s.cleanupExpiredLocalTempAssets(ctx, now)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) cleanupExpiredLocalTempAssets(ctx context.Context, now time.Time) int {
|
||||
storageDir := strings.TrimSpace(s.cfg.LocalUploadedStorageDir)
|
||||
if storageDir == "" {
|
||||
storageDir = config.DefaultLocalUploadedStorageDir
|
||||
}
|
||||
entries, err := os.ReadDir(storageDir)
|
||||
if err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
s.logger.Warn("read local temp asset dir failed", "dir", storageDir, "error", err)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
ttl := time.Duration(s.localTempAssetTTLHours()) * time.Hour
|
||||
expiredBefore := now.Add(-ttl)
|
||||
deleted := 0
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasPrefix(entry.Name(), requestAssetFilePrefix) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().After(expiredBefore) {
|
||||
continue
|
||||
}
|
||||
localPath := filepath.Join(storageDir, entry.Name())
|
||||
if err := os.Remove(localPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logger.Warn("remove local temp asset failed", "path", localPath, "error", err)
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
if s.store != nil {
|
||||
if err := s.store.MarkRequestAssetExpiredByLocalPath(ctx, localPath, now); err != nil && !store.IsUndefinedDatabaseObject(err) {
|
||||
s.logger.Warn("mark local temp asset expired failed", "path", localPath, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/config"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const requestAssetFilePrefix = "gateway-request-asset-"
|
||||
|
||||
type preparedTaskRequest struct {
|
||||
Body map[string]any
|
||||
ConversationID string
|
||||
MessageRefs []store.TaskMessageRefInput
|
||||
NewMessageCount int
|
||||
}
|
||||
|
||||
type decodedRequestAsset struct {
|
||||
Bytes []byte
|
||||
ContentType string
|
||||
}
|
||||
|
||||
func (s *Server) prepareTaskRequest(ctx context.Context, r *http.Request, user *auth.User, body map[string]any) (preparedTaskRequest, error) {
|
||||
preparedBody, err := s.prepareRequestAssetRefs(ctx, body)
|
||||
if err != nil {
|
||||
return preparedTaskRequest{}, err
|
||||
}
|
||||
result := preparedTaskRequest{Body: preparedBody}
|
||||
conversationKey := requestConversationKey(r, preparedBody)
|
||||
if conversationKey == "" {
|
||||
return result, nil
|
||||
}
|
||||
messages, ok := preparedBody["messages"].([]any)
|
||||
if !ok || len(messages) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
conversationID, err := s.store.EnsureConversation(ctx, user, conversationKey, map[string]any{
|
||||
"source": "ai-gateway",
|
||||
"conversationKey": conversationKey,
|
||||
})
|
||||
if err != nil {
|
||||
return preparedTaskRequest{}, err
|
||||
}
|
||||
inputs := make([]store.ConversationMessageInput, 0, len(messages))
|
||||
for _, rawMessage := range messages {
|
||||
message, _ := rawMessage.(map[string]any)
|
||||
if message == nil {
|
||||
message = map[string]any{"content": rawMessage}
|
||||
}
|
||||
hash, assetHashes := canonicalConversationMessageHash(message)
|
||||
inputs = append(inputs, store.ConversationMessageInput{
|
||||
Hash: hash,
|
||||
Role: stringFromRequestAny(message["role"]),
|
||||
Snapshot: message,
|
||||
AssetSHA256s: assetHashes,
|
||||
})
|
||||
}
|
||||
refs, newCount, err := s.store.UpsertConversationMessages(ctx, conversationID, inputs)
|
||||
if err != nil {
|
||||
return preparedTaskRequest{}, err
|
||||
}
|
||||
preparedBody["conversationId"] = conversationKey
|
||||
preparedBody["conversationRecordId"] = conversationID
|
||||
preparedBody["messageRefs"] = messageRefsForRequest(refs)
|
||||
preparedBody["newMessageCount"] = newCount
|
||||
delete(preparedBody, "messages")
|
||||
result.ConversationID = conversationID
|
||||
result.MessageRefs = refs
|
||||
result.NewMessageCount = newCount
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Server) prepareRequestAssetRefs(ctx context.Context, body map[string]any) (map[string]any, error) {
|
||||
value, err := s.prepareRequestAssetValue(ctx, body, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next, _ := value.(map[string]any)
|
||||
if next == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (s *Server) prepareRequestAssetValue(ctx context.Context, value any, path []string, siblings map[string]any) (any, error) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if typed["assetRef"] != nil {
|
||||
return typed, nil
|
||||
}
|
||||
next := make(map[string]any, len(typed))
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
item := typed[key]
|
||||
if decoded, ok, err := requestAssetFromValue(key, path, item, typed); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
ref, err := s.ensureRequestAsset(ctx, decoded)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next[key] = requestAssetWrapper(ref)
|
||||
continue
|
||||
}
|
||||
prepared, err := s.prepareRequestAssetValue(ctx, item, append(path, key), typed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next[key] = prepared
|
||||
}
|
||||
return next, nil
|
||||
case []any:
|
||||
next := make([]any, 0, len(typed))
|
||||
for index, item := range typed {
|
||||
prepared, err := s.prepareRequestAssetValue(ctx, item, append(path, fmt.Sprintf("[%d]", index)), siblings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next = append(next, prepared)
|
||||
}
|
||||
return next, nil
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func requestAssetFromValue(key string, path []string, value any, siblings map[string]any) (decodedRequestAsset, bool, error) {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return decodedRequestAsset{}, false, nil
|
||||
}
|
||||
raw := strings.TrimSpace(text)
|
||||
if raw == "" || mediaURLString(raw) {
|
||||
return decodedRequestAsset{}, false, nil
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(raw), "data:") {
|
||||
contentType, encoded, ok, err := parseRequestDataURL(raw)
|
||||
if err != nil || !ok {
|
||||
return decodedRequestAsset{}, false, err
|
||||
}
|
||||
payload, err := decodeRequestBase64(encoded)
|
||||
if err != nil {
|
||||
return decodedRequestAsset{}, false, requestAssetDecodeError(err)
|
||||
}
|
||||
return decodedRequestAsset{
|
||||
Bytes: payload,
|
||||
ContentType: requestAssetContentType(contentType, payload, key, path, siblings),
|
||||
}, true, nil
|
||||
}
|
||||
strict := strictRequestBase64Field(key, path)
|
||||
if !strict && !likelyRequestBase64MediaField(key, path, raw) {
|
||||
return decodedRequestAsset{}, false, nil
|
||||
}
|
||||
payload, err := decodeRequestBase64(raw)
|
||||
if err != nil {
|
||||
if strict {
|
||||
return decodedRequestAsset{}, false, requestAssetDecodeError(err)
|
||||
}
|
||||
return decodedRequestAsset{}, false, nil
|
||||
}
|
||||
contentType := requestAssetContentType("", payload, key, path, siblings)
|
||||
if !strict && !requestContentTypeIsMedia(contentType) {
|
||||
return decodedRequestAsset{}, false, nil
|
||||
}
|
||||
return decodedRequestAsset{Bytes: payload, ContentType: contentType}, true, nil
|
||||
}
|
||||
|
||||
func (s *Server) ensureRequestAsset(ctx context.Context, decoded decodedRequestAsset) (map[string]any, error) {
|
||||
sum := sha256.Sum256(decoded.Bytes)
|
||||
sha := hex.EncodeToString(sum[:])
|
||||
contentType := strings.TrimSpace(decoded.ContentType)
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
now := time.Now()
|
||||
if existing, ok, err := s.store.FindRequestAsset(ctx, sha, contentType); err != nil && !store.IsUndefinedDatabaseObject(err) {
|
||||
return nil, err
|
||||
} else if ok && requestAssetStillUsable(existing, now) {
|
||||
if err := s.store.IncrementRequestAssetRefCount(ctx, sha, contentType); err != nil && !store.IsUndefinedDatabaseObject(err) {
|
||||
return nil, err
|
||||
}
|
||||
return requestAssetRef(existing), nil
|
||||
}
|
||||
|
||||
upload, err := s.runner.UploadFile(ctx, runner.FileUploadPayload{
|
||||
Bytes: decoded.Bytes,
|
||||
ContentType: contentType,
|
||||
FileName: requestAssetFileName(sha, contentType),
|
||||
Scene: store.FileStorageSceneRequestAsset,
|
||||
Source: "ai-gateway-request",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
storageProvider := requestAssetStorageProvider(upload)
|
||||
url := stringFromRequestAny(upload["url"])
|
||||
if url == "" {
|
||||
return nil, &clients.ClientError{Code: "request_asset_upload_failed", Message: "file storage response did not include url", Retryable: false}
|
||||
}
|
||||
var expiresAt *time.Time
|
||||
localPath := ""
|
||||
if storageProvider == "local_static" {
|
||||
expiry := now.Add(time.Duration(s.localTempAssetTTLHours()) * time.Hour)
|
||||
expiresAt = &expiry
|
||||
localPath = requestAssetLocalPath(s.cfg.LocalUploadedStorageDir, stringFromRequestAny(upload["fileName"]))
|
||||
}
|
||||
asset, err := s.store.UpsertRequestAsset(ctx, store.RequestAssetInput{
|
||||
SHA256: sha,
|
||||
ContentType: contentType,
|
||||
ByteSize: int64(len(decoded.Bytes)),
|
||||
URL: url,
|
||||
StorageProvider: storageProvider,
|
||||
LocalPath: localPath,
|
||||
ExpiresAt: expiresAt,
|
||||
})
|
||||
if err != nil {
|
||||
if store.IsUndefinedDatabaseObject(err) {
|
||||
return map[string]any{
|
||||
"sha256": sha,
|
||||
"url": url,
|
||||
"contentType": contentType,
|
||||
"size": len(decoded.Bytes),
|
||||
"storageProvider": storageProvider,
|
||||
"expiresAt": timePtrToRFC3339(expiresAt),
|
||||
}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return requestAssetRef(asset), nil
|
||||
}
|
||||
|
||||
func requestConversationKey(r *http.Request, body map[string]any) string {
|
||||
if r != nil {
|
||||
if value := strings.TrimSpace(r.Header.Get("X-EasyAI-Conversation-ID")); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
if value := firstNonEmptyRequestString(body, "conversation_id", "conversationId"); value != "" {
|
||||
return value
|
||||
}
|
||||
if metadata, ok := body["metadata"].(map[string]any); ok {
|
||||
return firstNonEmptyRequestString(metadata, "conversation_id", "conversationId")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func messageRefsForRequest(refs []store.TaskMessageRefInput) []any {
|
||||
out := make([]any, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
out = append(out, map[string]any{"messageId": ref.MessageID, "position": ref.Position})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func canonicalConversationMessageHash(message map[string]any) (string, []string) {
|
||||
parts := map[string]any{
|
||||
"role": stringFromRequestAny(message["role"]),
|
||||
}
|
||||
texts := []string{}
|
||||
assetHashes := []string{}
|
||||
collectConversationMessageParts(message, &texts, &assetHashes)
|
||||
parts["text"] = strings.Join(texts, "\n")
|
||||
parts["assets"] = assetHashes
|
||||
if parts["text"] == "" && len(assetHashes) == 0 {
|
||||
encoded, _ := json.Marshal(message)
|
||||
parts["fallback"] = string(encoded)
|
||||
}
|
||||
encoded, _ := json.Marshal(parts)
|
||||
sum := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(sum[:]), uniqueRequestStrings(assetHashes)
|
||||
}
|
||||
|
||||
func collectConversationMessageParts(value any, texts *[]string, assetHashes *[]string) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if ref, ok := typed["assetRef"].(map[string]any); ok {
|
||||
if sha := stringFromRequestAny(ref["sha256"]); sha != "" {
|
||||
*assetHashes = append(*assetHashes, sha)
|
||||
}
|
||||
}
|
||||
for key, item := range typed {
|
||||
switch key {
|
||||
case "content", "text":
|
||||
if text := stringFromRequestAny(item); text != "" && !strings.HasPrefix(strings.ToLower(text), "data:") {
|
||||
*texts = append(*texts, text)
|
||||
continue
|
||||
}
|
||||
}
|
||||
switch item.(type) {
|
||||
case map[string]any, []any:
|
||||
collectConversationMessageParts(item, texts, assetHashes)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
collectConversationMessageParts(item, texts, assetHashes)
|
||||
}
|
||||
case string:
|
||||
text := strings.TrimSpace(typed)
|
||||
if text != "" && !mediaURLString(text) && !strings.HasPrefix(strings.ToLower(text), "data:") {
|
||||
*texts = append(*texts, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestAssetWrapper(ref map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"assetRef": ref,
|
||||
"url": ref["url"],
|
||||
}
|
||||
}
|
||||
|
||||
func requestAssetRef(asset store.RequestAsset) map[string]any {
|
||||
return map[string]any{
|
||||
"sha256": asset.SHA256,
|
||||
"url": asset.URL,
|
||||
"contentType": asset.ContentType,
|
||||
"size": asset.ByteSize,
|
||||
"storageProvider": asset.StorageProvider,
|
||||
"expiresAt": timePtrToRFC3339(asset.ExpiresAt),
|
||||
}
|
||||
}
|
||||
|
||||
func requestAssetStillUsable(asset store.RequestAsset, now time.Time) bool {
|
||||
if asset.ExpiredAt != nil {
|
||||
return false
|
||||
}
|
||||
if asset.ExpiresAt != nil && !asset.ExpiresAt.After(now) {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(asset.URL) != ""
|
||||
}
|
||||
|
||||
func requestAssetStorageProvider(upload map[string]any) string {
|
||||
if channel, ok := upload["storageChannel"].(map[string]any); ok {
|
||||
if provider := stringFromRequestAny(channel["provider"]); provider != "" {
|
||||
return provider
|
||||
}
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func requestAssetLocalPath(storageDir string, fileName string) string {
|
||||
if strings.TrimSpace(storageDir) == "" {
|
||||
storageDir = config.DefaultLocalUploadedStorageDir
|
||||
}
|
||||
if strings.TrimSpace(fileName) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(storageDir, filepath.Base(fileName))
|
||||
}
|
||||
|
||||
func requestAssetFileName(sha string, contentType string) string {
|
||||
prefix := sha
|
||||
if len(prefix) > 16 {
|
||||
prefix = prefix[:16]
|
||||
}
|
||||
return requestAssetFilePrefix + prefix + requestAssetExtension(contentType)
|
||||
}
|
||||
|
||||
func requestAssetExtension(contentType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(contentType)) {
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/jpeg", "image/jpg":
|
||||
return ".jpg"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "audio/mpeg":
|
||||
return ".mp3"
|
||||
case "audio/wav", "audio/x-wav":
|
||||
return ".wav"
|
||||
case "video/mp4":
|
||||
return ".mp4"
|
||||
}
|
||||
if extensions, err := mime.ExtensionsByType(contentType); err == nil && len(extensions) > 0 {
|
||||
return extensions[0]
|
||||
}
|
||||
return ".bin"
|
||||
}
|
||||
|
||||
func requestAssetContentType(explicit string, payload []byte, key string, path []string, siblings map[string]any) string {
|
||||
if contentType := firstNonEmptyRequestString(siblings, "content_type", "contentType", "mime_type", "mimeType"); contentType != "" {
|
||||
return contentType
|
||||
}
|
||||
if explicit = strings.TrimSpace(explicit); explicit != "" {
|
||||
return explicit
|
||||
}
|
||||
detected := ""
|
||||
if len(payload) > 0 {
|
||||
detected = http.DetectContentType(payload)
|
||||
}
|
||||
if requestContentTypeIsMedia(detected) {
|
||||
return detected
|
||||
}
|
||||
switch requestMediaKind(key, path, siblings) {
|
||||
case "audio":
|
||||
return "audio/mpeg"
|
||||
case "video":
|
||||
return "video/mp4"
|
||||
default:
|
||||
return "image/png"
|
||||
}
|
||||
}
|
||||
|
||||
func parseRequestDataURL(value string) (string, string, bool, error) {
|
||||
prefix, payload, ok := strings.Cut(value, ",")
|
||||
if !ok {
|
||||
return "", "", false, &clients.ClientError{Code: "request_asset_decode_failed", Message: "invalid data URL media payload", Retryable: false}
|
||||
}
|
||||
meta := strings.TrimPrefix(strings.TrimPrefix(prefix, "data:"), "DATA:")
|
||||
parts := strings.Split(meta, ";")
|
||||
contentType := strings.TrimSpace(parts[0])
|
||||
for _, part := range parts[1:] {
|
||||
if strings.EqualFold(strings.TrimSpace(part), "base64") {
|
||||
return contentType, payload, true, nil
|
||||
}
|
||||
}
|
||||
return "", "", false, &clients.ClientError{Code: "request_asset_decode_failed", Message: "data URL media payload is not base64 encoded", Retryable: false}
|
||||
}
|
||||
|
||||
func decodeRequestBase64(value string) ([]byte, error) {
|
||||
normalized := strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '\n', '\r', '\t', ' ':
|
||||
return -1
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, strings.TrimSpace(value))
|
||||
encodings := []*base64.Encoding{
|
||||
base64.StdEncoding,
|
||||
base64.RawStdEncoding,
|
||||
base64.URLEncoding,
|
||||
base64.RawURLEncoding,
|
||||
}
|
||||
var lastErr error
|
||||
for _, encoding := range encodings {
|
||||
payload, err := encoding.DecodeString(normalized)
|
||||
if err == nil && len(payload) > 0 {
|
||||
return payload, nil
|
||||
}
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("empty base64 payload")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func requestAssetDecodeError(err error) error {
|
||||
return &clients.ClientError{Code: "request_asset_decode_failed", Message: err.Error(), Retryable: false}
|
||||
}
|
||||
|
||||
func strictRequestBase64Field(key string, path []string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(key))
|
||||
parent := ""
|
||||
if len(path) > 0 {
|
||||
parent = strings.ToLower(strings.Trim(path[len(path)-1], "[]"))
|
||||
}
|
||||
return lower == "b64_json" ||
|
||||
lower == "base64" ||
|
||||
lower == "b64" ||
|
||||
strings.Contains(lower, "base64") ||
|
||||
strings.Contains(lower, "_b64") ||
|
||||
(parent == "input_audio" && lower == "data")
|
||||
}
|
||||
|
||||
func likelyRequestBase64MediaField(key string, path []string, value string) bool {
|
||||
if len(value) < 64 {
|
||||
return false
|
||||
}
|
||||
return requestMediaKind(key, path, nil) != ""
|
||||
}
|
||||
|
||||
func requestMediaKind(key string, path []string, siblings map[string]any) string {
|
||||
candidates := append([]string{key}, path...)
|
||||
if siblings != nil {
|
||||
candidates = append(candidates, stringFromRequestAny(siblings["type"]), stringFromRequestAny(siblings["role"]))
|
||||
}
|
||||
for _, item := range candidates {
|
||||
lower := strings.ToLower(strings.TrimSpace(item))
|
||||
switch {
|
||||
case strings.Contains(lower, "audio"):
|
||||
return "audio"
|
||||
case strings.Contains(lower, "video"):
|
||||
return "video"
|
||||
case strings.Contains(lower, "image") || lower == "mask" || lower == "b64_json":
|
||||
return "image"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func requestContentTypeIsMedia(contentType string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(contentType))
|
||||
return strings.HasPrefix(lower, "image/") ||
|
||||
strings.HasPrefix(lower, "video/") ||
|
||||
strings.HasPrefix(lower, "audio/")
|
||||
}
|
||||
|
||||
func mediaURLString(value string) bool {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
if strings.HasPrefix(lower, "data:") {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(lower, "http://") ||
|
||||
strings.HasPrefix(lower, "https://") ||
|
||||
strings.HasPrefix(lower, "/") ||
|
||||
strings.Contains(lower, "://")
|
||||
}
|
||||
|
||||
func firstNonEmptyRequestString(values map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if values == nil {
|
||||
continue
|
||||
}
|
||||
if value := stringFromRequestAny(values[key]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringFromRequestAny(value any) string {
|
||||
text, _ := value.(string)
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func uniqueRequestStrings(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func timePtrToRFC3339(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func (s *Server) localTempAssetTTLHours() int {
|
||||
if s.cfg.LocalTempAssetTTLHours <= 0 {
|
||||
return 24
|
||||
}
|
||||
return s.cfg.LocalTempAssetTTLHours
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
||||
)
|
||||
|
||||
func TestRequestAssetFromValueDetectsDataURLAndRawBase64(t *testing.T) {
|
||||
payload := base64.StdEncoding.EncodeToString([]byte("inline image"))
|
||||
decoded, ok, err := requestAssetFromValue("url", []string{"messages", "[0]", "content", "[1]", "image_url"}, "data:image/png;base64,"+payload, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("decode data URL: %v", err)
|
||||
}
|
||||
if !ok || decoded.ContentType != "image/png" || string(decoded.Bytes) != "inline image" {
|
||||
t.Fatalf("unexpected data URL asset: ok=%v decoded=%+v", ok, decoded)
|
||||
}
|
||||
|
||||
audio := base64.StdEncoding.EncodeToString([]byte("inline audio"))
|
||||
decoded, ok, err = requestAssetFromValue("data", []string{"input_audio"}, audio, map[string]any{"format": "mp3"})
|
||||
if err != nil {
|
||||
t.Fatalf("decode raw audio: %v", err)
|
||||
}
|
||||
if !ok || decoded.ContentType != "audio/mpeg" || string(decoded.Bytes) != "inline audio" {
|
||||
t.Fatalf("unexpected raw audio asset: ok=%v decoded=%+v", ok, decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalConversationMessageHashUsesTextAndAssetRefs(t *testing.T) {
|
||||
message := map[string]any{
|
||||
"role": "user",
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": "describe it"},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{
|
||||
"url": "https://cdn.example/a.png",
|
||||
"assetRef": map[string]any{"sha256": "sha-a", "url": "https://cdn.example/a.png"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
sameMessage := map[string]any{
|
||||
"role": "user",
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": "describe it"},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{
|
||||
"url": "https://different.example/a.png",
|
||||
"assetRef": map[string]any{"sha256": "sha-a", "url": "https://different.example/a.png"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
changedMessage := map[string]any{
|
||||
"role": "user",
|
||||
"content": "describe something else",
|
||||
}
|
||||
|
||||
firstHash, assetHashes := canonicalConversationMessageHash(message)
|
||||
secondHash, _ := canonicalConversationMessageHash(sameMessage)
|
||||
changedHash, _ := canonicalConversationMessageHash(changedMessage)
|
||||
if firstHash != secondHash {
|
||||
t.Fatalf("message hash should ignore resource URL drift when asset sha is stable")
|
||||
}
|
||||
if firstHash == changedHash {
|
||||
t.Fatalf("message hash should change when text changes")
|
||||
}
|
||||
if len(assetHashes) != 1 || assetHashes[0] != "sha-a" {
|
||||
t.Fatalf("unexpected asset hashes: %+v", assetHashes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupExpiredLocalTempAssetsOnlyDeletesExpiredPrefixedFiles(t *testing.T) {
|
||||
storageDir := t.TempDir()
|
||||
oldTemp := filepath.Join(storageDir, requestAssetFilePrefix+"old.png")
|
||||
freshTemp := filepath.Join(storageDir, requestAssetFilePrefix+"fresh.png")
|
||||
oldGenerated := filepath.Join(storageDir, "gateway-result-old.png")
|
||||
for _, path := range []string{oldTemp, freshTemp, oldGenerated} {
|
||||
if err := os.WriteFile(path, []byte("asset"), 0o644); err != nil {
|
||||
t.Fatalf("write fixture %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
if err := os.Chtimes(oldTemp, now.Add(-25*time.Hour), now.Add(-25*time.Hour)); err != nil {
|
||||
t.Fatalf("touch old temp: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(freshTemp, now.Add(-23*time.Hour), now.Add(-23*time.Hour)); err != nil {
|
||||
t.Fatalf("touch fresh temp: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(oldGenerated, now.Add(-25*time.Hour), now.Add(-25*time.Hour)); err != nil {
|
||||
t.Fatalf("touch old generated: %v", err)
|
||||
}
|
||||
server := &Server{
|
||||
cfg: config.Config{LocalUploadedStorageDir: storageDir, LocalTempAssetTTLHours: 24},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
|
||||
deleted := server.cleanupExpiredLocalTempAssets(context.Background(), now)
|
||||
|
||||
if deleted != 1 {
|
||||
t.Fatalf("expected one expired temp asset delete, got %d", deleted)
|
||||
}
|
||||
if _, err := os.Stat(oldTemp); !os.IsNotExist(err) {
|
||||
t.Fatalf("old prefixed temp asset should be deleted, stat err=%v", err)
|
||||
}
|
||||
for _, path := range []string{freshTemp, oldGenerated} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("non-expired or non-prefixed file should remain %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestConversationKeyPriority(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/chat/completions", nil)
|
||||
request.Header.Set("X-EasyAI-Conversation-ID", "from-header")
|
||||
body := map[string]any{
|
||||
"conversation_id": "from-body",
|
||||
"metadata": map[string]any{"conversation_id": "from-metadata"},
|
||||
}
|
||||
if got := requestConversationKey(request, body); got != "from-header" {
|
||||
t.Fatalf("expected header conversation id, got %q", got)
|
||||
}
|
||||
request.Header.Del("X-EasyAI-Conversation-ID")
|
||||
if got := requestConversationKey(request, body); got != "from-body" {
|
||||
t.Fatalf("expected body conversation id, got %q", got)
|
||||
}
|
||||
delete(body, "conversation_id")
|
||||
if got := requestConversationKey(request, body); got != "from-metadata" {
|
||||
t.Fatalf("expected metadata conversation id, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,33 @@ func (s *Server) updatePlatformDynamicPriority(w http.ResponseWriter, r *http.Re
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
// restorePlatformModelRuntimeStatus godoc
|
||||
// @Summary 恢复平台模型运行状态
|
||||
// @Description 管理端手动解除平台模型停用、模型冷却、平台冷却或平台禁用状态,使其重新参与路由。
|
||||
// @Tags runtime
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param platformModelID path string true "平台模型 ID"
|
||||
// @Success 200 {object} store.ModelRateLimitStatus
|
||||
// @Failure 401 {object} ErrorEnvelope
|
||||
// @Failure 403 {object} ErrorEnvelope
|
||||
// @Failure 404 {object} ErrorEnvelope
|
||||
// @Failure 500 {object} ErrorEnvelope
|
||||
// @Router /api/admin/runtime/model-rate-limits/{platformModelID}/restore [post]
|
||||
func (s *Server) restorePlatformModelRuntimeStatus(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := s.store.RestorePlatformModelRuntimeStatus(r.Context(), r.PathValue("platformModelID"))
|
||||
if err != nil {
|
||||
if store.IsNotFound(err) {
|
||||
writeError(w, http.StatusNotFound, "platform model not found")
|
||||
return
|
||||
}
|
||||
s.logger.Error("restore platform model runtime status failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "restore platform model runtime status failed")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
// createRuntimePolicySet godoc
|
||||
// @Summary 创建运行策略集
|
||||
// @Description 管理端创建运行策略集,policyKey 和 name 必填。
|
||||
|
||||
@@ -36,6 +36,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
}
|
||||
server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey
|
||||
server.runner.StartAsyncQueueWorker(ctx)
|
||||
server.startLocalTempAssetCleanup(ctx)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", server.health)
|
||||
@@ -103,6 +104,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
|
||||
mux.Handle("DELETE /api/admin/runtime/policy-sets/{policySetID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteRuntimePolicySet)))
|
||||
mux.Handle("GET /api/admin/runtime/runner-policy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getRunnerPolicy)))
|
||||
mux.Handle("PATCH /api/admin/runtime/runner-policy", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateRunnerPolicy)))
|
||||
mux.Handle("POST /api/admin/runtime/model-rate-limits/{platformModelID}/restore", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.restorePlatformModelRuntimeStatus)))
|
||||
mux.Handle("GET /api/admin/config/network-proxy", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getNetworkProxyConfig)))
|
||||
mux.Handle("GET /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getFileStorageSettings)))
|
||||
mux.Handle("PATCH /api/admin/system/file-storage/settings", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateFileStorageSettings)))
|
||||
@@ -173,7 +175,7 @@ func (s *Server) cors(next http.Handler) http.Handler {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Comfy-Api-Key, X-Async")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Comfy-Api-Key, X-Async, X-EasyAI-Conversation-ID")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
|
||||
Reference in New Issue
Block a user