Add runtime restore and temp asset cleanup
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user