Add runtime restore and temp asset cleanup

This commit is contained in:
2026-06-05 20:28:26 +08:00
parent 644a6f9d17
commit d41d9482c7
22 changed files with 1837 additions and 50 deletions
+18 -5
View File
@@ -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 必填。
+3 -1
View File
@@ -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 {