chore(git): 同步主分支发布与业务变更

# Conflicts:
#	apps/api/go.mod
#	apps/api/go.sum
This commit is contained in:
2026-07-28 10:47:41 +08:00
238 changed files with 25370 additions and 2079 deletions
@@ -0,0 +1,213 @@
package httpapi
import (
"net/http"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/google/uuid"
)
// listAdminTasks godoc
// @Summary 管理员列出任务
// @Description 跨租户分页查询任务;请求、结果和执行快照中的常见敏感字段默认脱敏。
// @Tags admin-tasks
// @Produce json
// @Security BearerAuth
// @Param q query string false "关键词,匹配任务、用户、租户、模型、平台和 API Key"
// @Param tenantId query string false "网关租户 ID"
// @Param userId query string false "网关用户 ID"
// @Param userGroupId query string false "用户组 ID"
// @Param status query string false "任务状态"
// @Param platformId query string false "执行平台 ID"
// @Param model query string false "调用或实际模型名称"
// @Param modelType query string false "模型类型"
// @Param runMode query string false "运行模式"
// @Param billingStatus query string false "计费状态"
// @Param apiKey query string false "API Key ID、名称或前缀"
// @Param createdFrom query string false "创建时间起点,支持 RFC3339 或日期格式"
// @Param createdTo query string false "创建时间终点,支持 RFC3339 或日期格式"
// @Param page query int false "页码" default(1)
// @Param pageSize query int false "每页数量" default(50)
// @Success 200 {object} AdminTaskListResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/tasks [get]
func (s *Server) listAdminTasks(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
page, err := positiveQueryInt(query.Get("page"), 1)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid page")
return
}
pageSize, err := positiveQueryInt(query.Get("pageSize"), 50)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid pageSize")
return
}
createdFrom, err := parseTaskListTime(query.Get("createdFrom"), query.Get("from"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid createdFrom")
return
}
createdTo, err := parseTaskListTime(query.Get("createdTo"), query.Get("to"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid createdTo")
return
}
for name, value := range map[string]string{
"tenantId": query.Get("tenantId"),
"userId": query.Get("userId"),
"userGroupId": query.Get("userGroupId"),
"platformId": query.Get("platformId"),
} {
if value != "" && !validUUID(value) {
writeError(w, http.StatusBadRequest, "invalid "+name)
return
}
}
result, err := s.store.ListAdminTasks(r.Context(), store.AdminTaskListFilter{
Query: firstNonEmpty(query.Get("q"), query.Get("query")),
GatewayTenant: query.Get("tenantId"),
GatewayUser: query.Get("userId"),
UserGroup: query.Get("userGroupId"),
Status: query.Get("status"),
Platform: query.Get("platformId"),
Model: query.Get("model"),
ModelType: query.Get("modelType"),
RunMode: query.Get("runMode"),
BillingStatus: query.Get("billingStatus"),
APIKey: query.Get("apiKey"),
CreatedFrom: createdFrom,
CreatedTo: createdTo,
Page: page,
PageSize: pageSize,
})
if err != nil {
s.logger.Error("list admin tasks failed", "error", err)
writeError(w, http.StatusInternalServerError, "list admin tasks failed")
return
}
for index := range result.Items {
result.Items[index] = store.MaskAdminGatewayTask(result.Items[index])
}
writeJSON(w, http.StatusOK, map[string]any{
"items": result.Items,
"total": result.Total,
"page": result.Page,
"pageSize": result.PageSize,
})
}
// getAdminTask godoc
// @Summary 管理员获取任务详情
// @Description 返回任意租户的任务详情;默认脱敏,sensitive=full 时返回完整存储内容。
// @Tags admin-tasks
// @Produce json
// @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Param sensitive query string false "敏感字段模式" Enums(masked,full) default(masked)
// @Success 200 {object} store.AdminGatewayTask
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/tasks/{taskID} [get]
func (s *Server) getAdminTask(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("taskID")
if !validUUID(taskID) {
writeError(w, http.StatusBadRequest, "invalid taskID")
return
}
full, ok := parseAdminTaskSensitiveMode(r.URL.Query().Get("sensitive"))
if !ok {
writeError(w, http.StatusBadRequest, "invalid sensitive mode")
return
}
task, err := s.store.GetAdminTask(r.Context(), taskID)
if err != nil {
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "task not found")
return
}
s.logger.Error("get admin task failed", "taskID", taskID, "error", err)
writeError(w, http.StatusInternalServerError, "get admin task failed")
return
}
task.GatewayTask, err = s.hydrateTaskResult(r.Context(), task.GatewayTask)
if err != nil {
writeStoredBinaryResultError(w, err)
return
}
if !full {
task = store.MaskAdminGatewayTask(task)
}
writeJSON(w, http.StatusOK, task)
}
// adminTaskParamPreprocessing godoc
// @Summary 管理员获取任务参数预处理日志
// @Description 返回任意租户任务的参数改写、校验或模板处理日志;默认脱敏。
// @Tags admin-tasks
// @Produce json
// @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Param sensitive query string false "敏感字段模式" Enums(masked,full) default(masked)
// @Success 200 {object} TaskParamPreprocessingLogListResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/tasks/{taskID}/param-preprocessing [get]
func (s *Server) adminTaskParamPreprocessing(w http.ResponseWriter, r *http.Request) {
taskID := r.PathValue("taskID")
if !validUUID(taskID) {
writeError(w, http.StatusBadRequest, "invalid taskID")
return
}
full, ok := parseAdminTaskSensitiveMode(r.URL.Query().Get("sensitive"))
if !ok {
writeError(w, http.StatusBadRequest, "invalid sensitive mode")
return
}
if _, err := s.store.GetAdminTask(r.Context(), taskID); err != nil {
if store.IsNotFound(err) {
writeError(w, http.StatusNotFound, "task not found")
return
}
s.logger.Error("get admin task failed", "taskID", taskID, "error", err)
writeError(w, http.StatusInternalServerError, "get admin task failed")
return
}
items, err := s.store.ListTaskParamPreprocessingLogs(r.Context(), taskID)
if err != nil {
s.logger.Error("list admin task parameter preprocessing logs failed", "taskID", taskID, "error", err)
writeError(w, http.StatusInternalServerError, "list admin task parameter preprocessing logs failed")
return
}
if !full {
items = store.MaskTaskParamPreprocessingLogs(items)
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func parseAdminTaskSensitiveMode(value string) (bool, bool) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "masked":
return false, true
case "full":
return true, true
default:
return false, false
}
}
func validUUID(value string) bool {
_, err := uuid.Parse(strings.TrimSpace(value))
return err == nil
}
@@ -0,0 +1,22 @@
package httpapi
import "testing"
func TestParseAdminTaskSensitiveMode(t *testing.T) {
tests := []struct {
value string
full bool
accepted bool
}{
{value: "", full: false, accepted: true},
{value: "masked", full: false, accepted: true},
{value: "FULL", full: true, accepted: true},
{value: "invalid", full: false, accepted: false},
}
for _, test := range tests {
full, accepted := parseAdminTaskSensitiveMode(test.value)
if full != test.full || accepted != test.accepted {
t.Fatalf("parseAdminTaskSensitiveMode(%q)=(%v,%v), want (%v,%v)", test.value, full, accepted, test.full, test.accepted)
}
}
}
@@ -0,0 +1,321 @@
package httpapi
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestAdminTasksCrossTenantQueryAndRedaction(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the PostgreSQL integration flow")
}
ctx := context.Background()
applyMigration(t, ctx, databaseURL)
db, err := store.Connect(ctx, databaseURL)
if err != nil {
t.Fatalf("connect store: %v", err)
}
defer db.Close()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
createTenant := func(key, name string) store.GatewayTenant {
t.Helper()
tenant, err := db.CreateTenant(ctx, store.GatewayTenantInput{
TenantKey: key + "-" + suffix,
Name: name,
Source: "gateway",
Status: "active",
})
if err != nil {
t.Fatalf("create tenant %s: %v", name, err)
}
return tenant
}
createUser := func(tenant store.GatewayTenant, prefix string, roles []string) store.GatewayUser {
t.Helper()
username := prefix + "_" + suffix
user, err := db.CreateGatewayUser(ctx, store.GatewayUserInput{
UserKey: "gateway:" + username,
Username: username,
DisplayName: prefix + " display",
Email: username + "@example.com",
Source: "gateway",
GatewayTenantID: tenant.ID,
TenantKey: tenant.TenantKey,
Roles: roles,
Status: "active",
})
if err != nil {
t.Fatalf("create gateway user %s: %v", prefix, err)
}
return user
}
authUser := func(user store.GatewayUser, apiKeyName string) *auth.User {
return &auth.User{
ID: user.ID,
Username: user.Username,
Roles: user.Roles,
Source: user.Source,
GatewayUserID: user.ID,
GatewayTenantID: user.GatewayTenantID,
TenantKey: user.TenantKey,
APIKeyID: "key-" + apiKeyName + "-" + suffix,
APIKeyName: apiKeyName,
APIKeyPrefix: "sk-" + apiKeyName,
}
}
createTask := func(user *auth.User, model, secret string) store.GatewayTask {
t.Helper()
task, err := db.CreateTask(ctx, store.CreateTaskInput{
Kind: "chat.completions",
Model: model,
RunMode: "simulation",
Request: map[string]any{
"model": model,
"diagnostic": map[string]any{
"apiKey": secret,
},
},
}, user)
if err != nil {
t.Fatalf("create task for %s: %v", user.Username, err)
}
return task
}
tenantA := createTenant("admin-task-a", "Admin Task Tenant A")
tenantB := createTenant("admin-task-b", "Admin Task Tenant B")
operator := createUser(tenantA, "admin_task_operator", []string{"operator"})
userA := createUser(tenantA, "admin_task_user_a", []string{"user"})
userB := createUser(tenantB, "admin_task_user_b", []string{"user"})
userAAuth := authUser(userA, "alpha-key")
userBAuth := authUser(userB, "bravo-key")
modelA := "admin-task-model-a-" + suffix
modelB := "admin-task-model-b-" + suffix
taskA := createTask(userAAuth, modelA, "alpha-secret-"+suffix)
taskBSecret := "bravo-secret-" + suffix
taskB := createTask(userBAuth, modelB, taskBSecret)
platform, err := db.CreatePlatform(ctx, store.CreatePlatformInput{
Provider: "openai",
PlatformKey: "admin-task-platform-" + suffix,
Name: "Admin Task Platform " + suffix,
BaseURL: "https://example.invalid/v1",
AuthType: "bearer",
Credentials: map[string]any{"apiKey": "platform-secret-" + suffix},
Status: "enabled",
})
if err != nil {
t.Fatalf("create platform: %v", err)
}
attemptID, err := db.CreateTaskAttempt(ctx, store.CreateTaskAttemptInput{
TaskID: taskB.ID,
AttemptNo: 1,
PlatformID: platform.ID,
QueueKey: "admin-task-integration-" + suffix,
Status: "succeeded",
Simulated: true,
})
if err != nil {
t.Fatalf("create task attempt: %v", err)
}
if _, err := db.Pool().Exec(ctx, `
UPDATE gateway_tasks
SET status = 'succeeded',
model_type = 'text_generate',
requested_model = $2,
resolved_model = $2,
request_id = $3,
billing_status = 'settled',
result = $4::jsonb,
finished_at = now(),
updated_at = now()
WHERE id = $1::uuid`,
taskB.ID,
modelB,
"request-admin-task-"+suffix,
`{"authorization":"Bearer result-secret","output":"ok"}`,
); err != nil {
t.Fatalf("enrich task: %v", err)
}
if _, err := db.Pool().Exec(ctx, `
UPDATE gateway_task_attempts
SET request_snapshot = $2::jsonb,
response_snapshot = $3::jsonb,
finished_at = now()
WHERE id = $1::uuid`,
attemptID,
`{"headers":{"authorization":"Bearer attempt-secret"}}`,
`{"cookie":"attempt-cookie","status":"ok"}`,
); err != nil {
t.Fatalf("enrich task attempt: %v", err)
}
if _, err := db.Pool().Exec(ctx, `
INSERT INTO gateway_task_param_preprocessing_logs (
task_id, attempt_id, attempt_no, model_type, platform_id, client_id,
changed, change_count, actual_input, converted_output, changes, model_snapshot
)
VALUES (
$1::uuid, $2::uuid, 1, 'text_generate', $3::uuid, $4,
true, 1, $5::jsonb, $6::jsonb, $7::jsonb, '{}'::jsonb
)`,
taskB.ID,
attemptID,
platform.ID,
"admin-task-client-"+suffix,
`{"password":"input-secret","prompt":"hello"}`,
`{"token":"converted-secret","prompt":"hello"}`,
`[{"path":"credentials.secret","before":"old-secret","after":"new-secret"}]`,
); err != nil {
t.Fatalf("create parameter preprocessing log: %v", err)
}
const jwtSecret = "admin-task-integration-jwt-secret"
handlerCtx, cancelHandler := context.WithCancel(ctx)
defer cancelHandler()
server := httptest.NewServer(NewServerWithContext(handlerCtx, config.Config{
AppEnv: "test",
HTTPAddr: ":0",
DatabaseURL: databaseURL,
IdentityMode: "hybrid",
JWTSecret: jwtSecret,
CORSAllowedOrigin: "*",
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer server.Close()
authenticator := auth.New(jwtSecret, "", "")
signToken := func(user *auth.User) string {
t.Helper()
token, err := authenticator.SignJWT(user, time.Hour)
if err != nil {
t.Fatalf("sign JWT for %s: %v", user.Username, err)
}
return token
}
operatorToken := signToken(&auth.User{
ID: operator.ID,
Username: operator.Username,
Roles: operator.Roles,
Source: operator.Source,
GatewayUserID: operator.ID,
GatewayTenantID: operator.GatewayTenantID,
TenantKey: operator.TenantKey,
})
userAToken := signToken(userAAuth)
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks", userAToken, nil, http.StatusForbidden, nil)
query := url.Values{
"q": {taskB.ID},
"tenantId": {tenantB.ID},
"userId": {userB.ID},
"status": {"succeeded"},
"platformId": {platform.ID},
"model": {modelB},
"modelType": {"text_generate"},
"runMode": {"simulation"},
"billingStatus": {"settled"},
"apiKey": {"bravo-key"},
"page": {"1"},
"pageSize": {"10"},
}
var listResponse struct {
Items []store.AdminGatewayTask `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks?"+query.Encode(), operatorToken, nil, http.StatusOK, &listResponse)
if len(listResponse.Items) != 1 || listResponse.Total != 1 || listResponse.Page != 1 || listResponse.PageSize != 10 {
t.Fatalf("unexpected filtered admin task list: %+v", listResponse)
}
listed := listResponse.Items[0]
if listed.ID != taskB.ID || listed.AdminContext.User == nil || listed.AdminContext.User.ID != userB.ID ||
listed.AdminContext.Tenant == nil || listed.AdminContext.Tenant.ID != tenantB.ID ||
listed.AdminContext.LatestPlatform == nil || listed.AdminContext.LatestPlatform.ID != platform.ID {
t.Fatalf("admin task list should expose cross-tenant identity and platform summaries: %+v", listed)
}
if nestedString(listed.Request, "diagnostic", "apiKey") != "***" ||
nestedString(listed.Result, "authorization") != "***" ||
nestedString(listed.Attempts[0].RequestSnapshot, "headers", "authorization") != "***" {
t.Fatalf("admin task list should mask task and attempt secrets: %+v", listed)
}
var maskedDetail store.AdminGatewayTask
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID, operatorToken, nil, http.StatusOK, &maskedDetail)
if nestedString(maskedDetail.Request, "diagnostic", "apiKey") != "***" {
t.Fatalf("admin task detail should be masked by default: %+v", maskedDetail.Request)
}
var fullDetail store.AdminGatewayTask
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID+"?sensitive=full", operatorToken, nil, http.StatusOK, &fullDetail)
if nestedString(fullDetail.Request, "diagnostic", "apiKey") != taskBSecret {
t.Fatalf("explicit full task detail should expose stored content: %+v", fullDetail.Request)
}
var maskedLogs struct {
Items []store.TaskParamPreprocessingLog `json:"items"`
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID+"/param-preprocessing", operatorToken, nil, http.StatusOK, &maskedLogs)
if len(maskedLogs.Items) != 1 ||
nestedString(maskedLogs.Items[0].ActualInput, "password") != "***" ||
nestedString(maskedLogs.Items[0].ConvertedOutput, "token") != "***" {
t.Fatalf("admin task preprocessing logs should be masked by default: %+v", maskedLogs.Items)
}
var fullLogs struct {
Items []store.TaskParamPreprocessingLog `json:"items"`
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID+"/param-preprocessing?sensitive=full", operatorToken, nil, http.StatusOK, &fullLogs)
if len(fullLogs.Items) != 1 ||
nestedString(fullLogs.Items[0].ActualInput, "password") != "input-secret" ||
nestedString(fullLogs.Items[0].ConvertedOutput, "token") != "converted-secret" {
t.Fatalf("explicit full preprocessing logs should expose stored content: %+v", fullLogs.Items)
}
var ownTasks struct {
Items []store.GatewayTask `json:"items"`
}
doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks?pageSize=100", userAToken, nil, http.StatusOK, &ownTasks)
if !gatewayTaskListHasID(ownTasks.Items, taskA.ID) || gatewayTaskListHasID(ownTasks.Items, taskB.ID) {
t.Fatalf("workspace task list must remain scoped to the current user: %+v", ownTasks.Items)
}
doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks/"+taskB.ID, userAToken, nil, http.StatusNotFound, nil)
doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks/"+taskB.ID+"/param-preprocessing", userAToken, nil, http.StatusNotFound, nil)
}
func nestedString(value map[string]any, path ...string) string {
var current any = value
for _, key := range path {
object, ok := current.(map[string]any)
if !ok {
return ""
}
current = object[key]
}
result, _ := current.(string)
return result
}
func gatewayTaskListHasID(items []store.GatewayTask, taskID string) bool {
for _, item := range items {
if item.ID == taskID {
return true
}
}
return false
}
@@ -11,7 +11,7 @@ import "net/http"
// @Security BearerAuth
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
// @Param input body ImageVectorizeRequest true "图片矢量化请求"
// @Success 200 {object} CompatibleResponse
// @Success 200 {object} EasyAIGeneratedResponse
// @Success 202 {object} TaskAcceptedResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
@@ -32,7 +32,7 @@ func (s *Server) createImageVectorizeTask() http.Handler {
// @Security BearerAuth
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
// @Param input body VideoUpscaleRequest true "视频超分请求"
// @Success 200 {object} CompatibleResponse
// @Success 200 {object} EasyAIGeneratedResponse
// @Success 202 {object} TaskAcceptedResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
@@ -0,0 +1,479 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestAsyncWorkerDynamicConcurrencyAcceptance(t *testing.T) {
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run dynamic worker acceptance tests")
}
ctx, cancel := context.WithTimeout(context.Background(), 7*time.Minute)
defer cancel()
applyMigration(t, ctx, databaseURL)
db, err := store.Connect(ctx, databaseURL)
if err != nil {
t.Fatalf("connect store: %v", err)
}
defer db.Close()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatalf("connect acceptance pool: %v", err)
}
defer pool.Close()
if _, err := pool.Exec(ctx, `UPDATE integration_platforms SET status = 'disabled' WHERE deleted_at IS NULL`); err != nil {
t.Fatalf("isolate acceptance platforms: %v", err)
}
serverCtx, cancelServer := context.WithCancel(ctx)
defer cancelServer()
server := httptest.NewServer(NewServerWithContext(serverCtx, config.Config{
AppEnv: "test",
HTTPAddr: ":0",
DatabaseURL: databaseURL,
IdentityMode: "hybrid",
JWTSecret: "test-secret",
BillingEngineMode: "observe",
CORSAllowedOrigin: "*",
AsyncWorkerHardLimit: 256,
AsyncWorkerRefreshIntervalSeconds: 1,
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
defer server.Close()
adminToken := createAsyncAcceptanceAdmin(t, ctx, pool, server.URL)
if _, err := pool.Exec(ctx, `
UPDATE gateway_user_groups
SET rate_limit_policy = '{"rules":[{"metric":"concurrent","limit":256,"leaseTtlSeconds":120}]}'::jsonb
WHERE status = 'active'`); err != nil {
t.Fatalf("raise acceptance user-group concurrency: %v", err)
}
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
t.Run("超过旧64上限的高并发", func(t *testing.T) {
model := "worker-burst-" + suffix
platform := createAsyncAcceptancePlatform(t, server.URL, adminToken, "burst-"+suffix, "Burst Simulation", 128)
defer updateAsyncAcceptancePlatform(t, server.URL, adminToken, platform, 128, "disabled")
modelID := createAsyncAcceptanceModel(t, server.URL, adminToken, platform.ID, model, "video_generate", "override", 96, 120)
waitForAsyncWorkerMetric(t, server.URL, "easyai_gateway_async_worker_capacity", 96, 15*time.Second)
startedAt := time.Now()
taskIDs := submitAsyncSimulationTasks(t, server.URL, adminToken, "/api/v1/videos/generations", model, 128, 15*time.Second)
peakRunning, peakLeases := waitForAcceptanceTasks(t, ctx, pool, taskIDs, modelID, 60*time.Second)
elapsed := time.Since(startedAt)
if peakRunning < 80 || peakLeases < 80 {
t.Fatalf("running peak=%d lease peak=%d, want both >=80", peakRunning, peakLeases)
}
if peakLeases > 96 {
t.Fatalf("lease peak=%d exceeded model concurrent limit 96", peakLeases)
}
assertAcceptanceAttempts(t, ctx, pool, taskIDs, len(taskIDs))
if elapsed >= 60*time.Second {
t.Fatalf("128 tasks completed in %s, want <60s", elapsed)
}
t.Logf("高并发证据: tasks=128 duration=%s running_peak=%d lease_peak=%d capacity=96", elapsed.Round(time.Millisecond), peakRunning, peakLeases)
})
t.Run("三分钟视频任务在线扩缩容与续租", func(t *testing.T) {
model := "worker-video-long-" + suffix
platform := createAsyncAcceptancePlatform(t, server.URL, adminToken, "video-long-"+suffix, "Long Video Simulation", 1)
modelID := createAsyncAcceptanceModel(t, server.URL, adminToken, platform.ID, model, "video_generate", "inherit", 0, 120)
waitForAsyncWorkerMetric(t, server.URL, "easyai_gateway_async_worker_capacity", 1, 15*time.Second)
longTaskIDs := submitAsyncSimulationTasks(t, server.URL, adminToken, "/api/v1/videos/generations", model, 3, 180*time.Second)
waitForTaskCount(t, ctx, pool, longTaskIDs, "running", 1, 10*time.Second)
updateStartedAt := time.Now()
updateAsyncAcceptancePlatform(t, server.URL, adminToken, platform, 3, "enabled")
waitForAsyncWorkerMetric(t, server.URL, "easyai_gateway_async_worker_desired_capacity", 3, 15*time.Second)
waitForAsyncWorkerMetric(t, server.URL, "easyai_gateway_async_worker_capacity", 3, 15*time.Second)
waitForActiveLeaseCount(t, ctx, pool, modelID, 3, 15*time.Second)
waitForTaskCount(t, ctx, pool, longTaskIDs, "running", 3, 5*time.Second)
if time.Since(updateStartedAt) > 15*time.Second {
t.Fatalf("capacity expansion took %s, want <=15s", time.Since(updateStartedAt))
}
initialExpiry := activeLeaseExpiry(t, ctx, pool, modelID, 3)
updateAsyncAcceptancePlatform(t, server.URL, adminToken, platform, 1, "enabled")
waitForAsyncWorkerMetric(t, server.URL, "easyai_gateway_async_worker_capacity", 1, 15*time.Second)
shortTaskIDs := submitAsyncSimulationTasks(t, server.URL, adminToken, "/api/v1/videos/generations", model, 1, time.Second)
time.Sleep(3 * time.Second)
if attempts := taskAttemptCount(t, ctx, pool, shortTaskIDs); attempts != 0 {
t.Fatalf("short task created %d upstream attempts while three long leases exceeded reduced limit", attempts)
}
waitForTaskCount(t, ctx, pool, longTaskIDs, "running", 3, 5*time.Second)
time.Sleep(130 * time.Second)
renewedExpiry := activeLeaseExpiry(t, ctx, pool, modelID, 3)
if !renewedExpiry.After(initialExpiry.Add(30*time.Second)) || !renewedExpiry.After(time.Now()) {
t.Fatalf("lease was not renewed: initial=%s renewed=%s now=%s", initialExpiry, renewedExpiry, time.Now())
}
waitForTaskCount(t, ctx, pool, longTaskIDs, "succeeded", 3, 75*time.Second)
shortStartedAt := time.Now()
waitForTaskAttempts(t, ctx, pool, shortTaskIDs, 1, 5*time.Second)
waitForTaskCount(t, ctx, pool, shortTaskIDs, "succeeded", 1, 10*time.Second)
if time.Since(shortStartedAt) > 5*time.Second {
t.Fatalf("short task started after %s once long tasks completed, want <=5s", time.Since(shortStartedAt))
}
allTaskIDs := append(append([]string{}, longTaskIDs...), shortTaskIDs...)
assertAcceptanceAttempts(t, ctx, pool, allTaskIDs, 4)
assertSimulationOutputsAndEvents(t, ctx, pool, allTaskIDs)
t.Logf("长任务证据: initial_capacity=1 expanded_capacity=3 reduced_capacity=1 initial_expiry=%s renewed_expiry=%s tasks=4", initialExpiry.UTC().Format(time.RFC3339Nano), renewedExpiry.UTC().Format(time.RFC3339Nano))
})
}
type asyncAcceptancePlatform struct {
ID string `json:"id"`
Provider string `json:"provider"`
PlatformKey string `json:"platformKey"`
Name string `json:"name"`
BaseURL string `json:"baseUrl"`
AuthType string `json:"authType"`
Priority int `json:"priority"`
}
func createAsyncAcceptanceAdmin(t *testing.T, ctx context.Context, pool *pgxpool.Pool, baseURL string) string {
t.Helper()
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
username := "async_acceptance_" + suffix
password := "password123"
doJSON(t, baseURL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
"username": username,
"email": username + "@example.com",
"password": password,
}, http.StatusCreated, &map[string]any{})
if _, err := pool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
t.Fatalf("promote acceptance user: %v", err)
}
var login struct {
AccessToken string `json:"accessToken"`
}
doJSON(t, baseURL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"account": username, "password": password,
}, http.StatusOK, &login)
if login.AccessToken == "" {
t.Fatal("acceptance login returned no access token")
}
return login.AccessToken
}
func createAsyncAcceptancePlatform(t *testing.T, baseURL, token, key, name string, concurrent int) asyncAcceptancePlatform {
t.Helper()
var platform asyncAcceptancePlatform
doJSON(t, baseURL, http.MethodPost, "/api/admin/platforms", token, asyncAcceptancePlatformPayload(key, name, concurrent, "enabled"), http.StatusCreated, &platform)
if platform.ID == "" {
t.Fatal("acceptance platform returned no id")
}
return platform
}
func updateAsyncAcceptancePlatform(t *testing.T, baseURL, token string, platform asyncAcceptancePlatform, concurrent int, status string) {
t.Helper()
doJSON(t, baseURL, http.MethodPatch, "/api/admin/platforms/"+platform.ID, token,
asyncAcceptancePlatformPayload(platform.PlatformKey, platform.Name, concurrent, status), http.StatusOK, &asyncAcceptancePlatform{})
}
func asyncAcceptancePlatformPayload(key, name string, concurrent int, status string) map[string]any {
return map[string]any{
"provider": "openai",
"platformKey": key,
"name": name,
"baseUrl": "https://api.openai.com/v1",
"authType": "bearer",
"credentials": map[string]any{"mode": "simulation"},
"config": map[string]any{"testMode": true},
"priority": 1,
"status": status,
"rateLimitPolicy": map[string]any{"rules": []any{
map[string]any{"metric": "concurrent", "limit": concurrent, "leaseTtlSeconds": 120},
}},
}
}
func createAsyncAcceptanceModel(t *testing.T, baseURL, token, platformID, model, modelType, mode string, concurrent, ttl int) string {
t.Helper()
payload := map[string]any{
"modelName": model,
"providerModelName": model,
"modelAlias": model,
"modelType": []string{modelType},
"displayName": model,
"rateLimitPolicyMode": mode,
}
if mode == "override" {
payload["rateLimitPolicy"] = map[string]any{"rules": []any{
map[string]any{"metric": "concurrent", "limit": concurrent, "leaseTtlSeconds": ttl},
}}
}
var response struct {
ID string `json:"id"`
}
doJSON(t, baseURL, http.MethodPost, "/api/admin/platforms/"+platformID+"/models", token, payload, http.StatusCreated, &response)
if response.ID == "" {
t.Fatal("acceptance model returned no id")
}
return response.ID
}
func submitAsyncSimulationTasks(t *testing.T, baseURL, token, path, model string, count int, duration time.Duration) []string {
t.Helper()
ids := make([]string, count)
errs := make(chan error, count)
var wg sync.WaitGroup
for index := 0; index < count; index++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
payload := map[string]any{
"model": model,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": duration.Milliseconds(),
"prompt": fmt.Sprintf("async acceptance %d", index),
"input": fmt.Sprintf("async acceptance %d", index),
}
raw, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(raw))
if err != nil {
errs <- err
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Async", "true")
resp, err := http.DefaultClient.Do(req)
if err != nil {
errs <- err
return
}
defer resp.Body.Close()
responseBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusAccepted {
errs <- fmt.Errorf("submit task %d status=%d body=%s", index, resp.StatusCode, responseBody)
return
}
var response struct {
TaskID string `json:"taskId"`
}
if err := json.Unmarshal(responseBody, &response); err != nil || response.TaskID == "" {
errs <- fmt.Errorf("decode task %d: %w body=%s", index, err, responseBody)
return
}
ids[index] = response.TaskID
}(index)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
return ids
}
func waitForAcceptanceTasks(t *testing.T, ctx context.Context, pool *pgxpool.Pool, taskIDs []string, modelID string, timeout time.Duration) (int64, int64) {
t.Helper()
deadline := time.Now().Add(timeout)
var peakRunning, peakLeases int64
for time.Now().Before(deadline) {
var running, succeeded, failed, leases int64
if err := pool.QueryRow(ctx, `
SELECT COUNT(*) FILTER (WHERE status = 'running'),
COUNT(*) FILTER (WHERE status = 'succeeded'),
COUNT(*) FILTER (WHERE status IN ('failed', 'cancelled', 'manual_review'))
FROM gateway_tasks
WHERE id = ANY($1::uuid[])`, taskIDs).Scan(&running, &succeeded, &failed); err != nil {
t.Fatalf("read acceptance tasks: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM gateway_concurrency_leases
WHERE scope_type = 'platform_model'
AND scope_key = $1
AND released_at IS NULL
AND expires_at > now()`, modelID).Scan(&leases); err != nil {
t.Fatalf("read acceptance leases: %v", err)
}
peakRunning = maxInt64(peakRunning, running)
peakLeases = maxInt64(peakLeases, leases)
if failed > 0 {
t.Fatalf("acceptance tasks entered failed/manual status: %d", failed)
}
if succeeded == int64(len(taskIDs)) {
return peakRunning, peakLeases
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("tasks did not finish within %s", timeout)
return 0, 0
}
func waitForTaskCount(t *testing.T, ctx context.Context, pool *pgxpool.Pool, taskIDs []string, status string, count int, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
var got int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_tasks WHERE id = ANY($1::uuid[]) AND status = $2`, taskIDs, status).Scan(&got); err != nil {
t.Fatalf("read task count: %v", err)
}
if got == count {
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("task status %s count did not reach %d within %s", status, count, timeout)
}
func activeLeaseExpiry(t *testing.T, ctx context.Context, pool *pgxpool.Pool, modelID string, wantCount int) time.Time {
t.Helper()
var count int
var earliest time.Time
if err := pool.QueryRow(ctx, `
SELECT COUNT(*), MIN(expires_at)
FROM gateway_concurrency_leases
WHERE scope_type = 'platform_model'
AND scope_key = $1
AND released_at IS NULL
AND expires_at > now()`, modelID).Scan(&count, &earliest); err != nil {
t.Fatalf("read active lease expiry: %v", err)
}
if count != wantCount {
t.Fatalf("active lease count=%d, want=%d", count, wantCount)
}
return earliest
}
func waitForActiveLeaseCount(t *testing.T, ctx context.Context, pool *pgxpool.Pool, modelID string, count int, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
var got int
if err := pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM gateway_concurrency_leases
WHERE scope_type = 'platform_model'
AND scope_key = $1
AND released_at IS NULL
AND expires_at > now()`, modelID).Scan(&got); err != nil {
t.Fatalf("read active lease count: %v", err)
}
if got == count {
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("active lease count did not reach %d within %s", count, timeout)
}
func taskAttemptCount(t *testing.T, ctx context.Context, pool *pgxpool.Pool, taskIDs []string) int {
t.Helper()
var count int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM gateway_task_attempts WHERE task_id = ANY($1::uuid[])`, taskIDs).Scan(&count); err != nil {
t.Fatalf("count task attempts: %v", err)
}
return count
}
func waitForTaskAttempts(t *testing.T, ctx context.Context, pool *pgxpool.Pool, taskIDs []string, count int, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if taskAttemptCount(t, ctx, pool, taskIDs) == count {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("attempt count did not reach %d within %s", count, timeout)
}
func assertAcceptanceAttempts(t *testing.T, ctx context.Context, pool *pgxpool.Pool, taskIDs []string, want int) {
t.Helper()
var attempts, duplicateTasks, manualReview int
if err := pool.QueryRow(ctx, `
SELECT COUNT(*),
COUNT(DISTINCT task_id) FILTER (WHERE per_task.attempts > 1),
COUNT(*) FILTER (WHERE status = 'manual_review')
FROM (
SELECT task_id, COUNT(*) AS attempts, MAX(status) AS status
FROM gateway_task_attempts
WHERE task_id = ANY($1::uuid[])
GROUP BY task_id
) per_task`, taskIDs).Scan(&attempts, &duplicateTasks, &manualReview); err != nil {
t.Fatalf("read attempt acceptance evidence: %v", err)
}
if attempts != want || duplicateTasks != 0 || manualReview != 0 {
t.Fatalf("attempts=%d duplicate_tasks=%d manual_review=%d, want %d/0/0", attempts, duplicateTasks, manualReview, want)
}
}
func assertSimulationOutputsAndEvents(t *testing.T, ctx context.Context, pool *pgxpool.Pool, taskIDs []string) {
t.Helper()
var complete int
if err := pool.QueryRow(ctx, `
SELECT COUNT(*)
FROM gateway_tasks task
WHERE task.id = ANY($1::uuid[])
AND task.status = 'succeeded'
AND task.finished_at IS NOT NULL
AND task.result IS NOT NULL
AND task.result <> '{}'::jsonb
AND task.metrics IS NOT NULL
AND jsonb_typeof(task.billings) = 'array'
AND jsonb_array_length(task.billings) > 0
AND EXISTS (SELECT 1 FROM gateway_task_events event WHERE event.task_id = task.id AND event.event_type = 'task.completed')`,
taskIDs).Scan(&complete); err != nil {
t.Fatalf("read simulation output evidence: %v", err)
}
if complete != len(taskIDs) {
t.Fatalf("complete simulation outputs/events=%d, want=%d", complete, len(taskIDs))
}
}
func waitForAsyncWorkerMetric(t *testing.T, baseURL, metric string, want int64, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := http.Get(baseURL + "/metrics")
if err == nil {
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
for _, line := range strings.Split(string(raw), "\n") {
fields := strings.Fields(line)
if len(fields) == 2 && fields[0] == metric {
value, _ := strconv.ParseInt(fields[1], 10, 64)
if value == want {
return
}
}
}
}
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("metric %s did not reach %d within %s", metric, want, timeout)
}
func maxInt64(left, right int64) int64 {
if right > left {
return right
}
return left
}
@@ -0,0 +1,30 @@
package httpapi
import (
"context"
"net/http"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func (s *Server) hydrateTaskResult(ctx context.Context, task store.GatewayTask) (store.GatewayTask, error) {
if task.Status != "succeeded" || len(task.Result) == 0 {
return task, nil
}
result, err := s.runner.HydrateTaskResult(ctx, task.ID, task.Result)
if err != nil {
return store.GatewayTask{}, err
}
task.Result = result
return task, nil
}
func writeStoredBinaryResultError(w http.ResponseWriter, err error) {
status := statusFromRunError(err)
code := clients.ErrorCode(err)
if code == "" {
code = "binary_result_corrupted"
}
writeError(w, status, err.Error(), code)
}
@@ -182,6 +182,10 @@ func (s *Server) createBaseModel(w http.ResponseWriter, r *http.Request) {
}
item, err := s.store.CreateBaseModel(r.Context(), input)
if err != nil {
if errors.Is(err, store.ErrModelAliasConflict) {
writeError(w, http.StatusConflict, err.Error(), "model_alias_conflict")
return
}
if store.IsUniqueViolation(err) {
writeError(w, http.StatusConflict, "canonical model key already exists")
return
@@ -230,6 +234,10 @@ func (s *Server) updateBaseModel(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, "canonical model key already exists")
return
}
if errors.Is(err, store.ErrModelAliasConflict) {
writeError(w, http.StatusConflict, err.Error(), "model_alias_conflict")
return
}
s.logger.Error("update base model failed", "error", err)
writeError(w, http.StatusInternalServerError, "update base model failed")
return
@@ -301,6 +309,7 @@ func (s *Server) resetAllBaseModels(w http.ResponseWriter, r *http.Request) {
// @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 409 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/admin/catalog/base-models/{baseModelID} [delete]
func (s *Server) deleteBaseModel(w http.ResponseWriter, r *http.Request) {
@@ -309,6 +318,10 @@ func (s *Server) deleteBaseModel(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "base model not found")
return
}
if errors.Is(err, store.ErrBaseModelInUse) {
writeError(w, http.StatusConflict, "base model is still referenced by platform models", "base_model_in_use")
return
}
s.logger.Error("delete base model failed", "error", err)
writeError(w, http.StatusInternalServerError, "delete base model failed")
return
@@ -123,6 +123,17 @@ func writeOpenAIError(w http.ResponseWriter, status int, message string, details
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})
}
@@ -300,23 +300,41 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
Items []struct {
ID string `json:"id"`
CanonicalModelKey string `json:"canonicalModelKey"`
InvocationName string `json:"invocationName"`
ProviderModelName string `json:"providerModelName"`
ModelType []string `json:"modelType"`
ModelAlias string `json:"modelAlias"`
DisplayName string `json:"displayName"`
Status string `json:"status"`
} `json:"items"`
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/catalog/base-models", loginResponse.AccessToken, nil, http.StatusOK, &baseModels)
if len(baseModels.Items) < 300 {
t.Fatalf("server-main seed should include the migrated base model catalog: got %d", len(baseModels.Items))
if len(baseModels.Items) == 0 {
t.Fatal("migrated base model catalog must not be empty")
}
requiredLifecycleModels := map[string]string{
"gemini:gemini-3.6-flash": "active",
"aliyun:qwen3.7-max": "active",
}
for _, model := range baseModels.Items {
if expectedStatus, ok := requiredLifecycleModels[model.CanonicalModelKey]; ok {
if model.InvocationName == "" || model.ModelAlias != model.InvocationName || model.Status != expectedStatus {
t.Fatalf("base model identity/lifecycle mismatch: %+v", model)
}
delete(requiredLifecycleModels, model.CanonicalModelKey)
}
}
if len(requiredLifecycleModels) != 0 {
t.Fatalf("missing lifecycle-managed base models: %+v", requiredLifecycleModels)
}
requiredMiniMaxModels := map[string]struct {
providerModelName string
modelType string
alias string
}{
"minimax:speech-2.8-hd": {providerModelName: "speech-2.8-hd", modelType: "text_to_speech", alias: "MiniMax-Speech-2.8-HD"},
"minimax:speech-2.8-turbo": {providerModelName: "speech-2.8-turbo", modelType: "text_to_speech", alias: "MiniMax-Speech-2.8-Turbo"},
"minimax:voice-clone": {providerModelName: "voice_clone", modelType: "voice_clone", alias: "MiniMax-Voice-Clone"},
"minimax:speech-2.8-hd": {providerModelName: "speech-2.8-hd", modelType: "text_to_speech", alias: "speech-2.8-hd"},
"minimax:speech-2.8-turbo": {providerModelName: "speech-2.8-turbo", modelType: "text_to_speech", alias: "speech-2.8-turbo"},
"minimax:voice-clone": {providerModelName: "voice_clone", modelType: "voice_clone", alias: "voice_clone"},
}
seenMiniMaxModels := map[string]bool{}
for _, model := range baseModels.Items {
@@ -361,9 +379,11 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
baseModelInput := map[string]any{
"providerKey": "openai",
"canonicalModelKey": "openai:smoke-base-" + suffixText,
"invocationName": "smoke-base-" + suffixText,
"providerModelName": "smoke-base-" + suffixText,
"modelType": []string{"text_generate"},
"modelAlias": "Smoke Base Model",
"displayName": "Smoke Base Model",
"legacyAliases": []string{"Smoke Base Model Legacy"},
"capabilities": map[string]any{"originalTypes": []string{"text_generate"}},
"metadata": map[string]any{"source": "test"},
}
@@ -376,12 +396,13 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
if createdBaseModel.ID == "" || createdBaseModel.CanonicalModelKey != baseModelInput["canonicalModelKey"] {
t.Fatalf("unexpected created base model: %+v", createdBaseModel)
}
baseModelInput["modelAlias"] = "Smoke Base Model Updated"
baseModelInput["displayName"] = "Smoke Base Model Updated"
var updatedBaseModel struct {
ModelAlias string `json:"modelAlias"`
ModelAlias string `json:"modelAlias"`
DisplayName string `json:"displayName"`
}
doJSON(t, server.URL, http.MethodPatch, "/api/admin/catalog/base-models/"+createdBaseModel.ID, loginResponse.AccessToken, baseModelInput, http.StatusOK, &updatedBaseModel)
if updatedBaseModel.ModelAlias != "Smoke Base Model Updated" {
if updatedBaseModel.ModelAlias != baseModelInput["invocationName"] || updatedBaseModel.DisplayName != "Smoke Base Model Updated" {
t.Fatalf("unexpected updated base model: %+v", updatedBaseModel)
}
doJSON(t, server.URL, http.MethodDelete, "/api/admin/catalog/base-models/"+createdBaseModel.ID, loginResponse.AccessToken, nil, http.StatusNoContent, nil)
@@ -466,6 +487,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
cancelReq.Header.Set("Content-Type", "application/json")
cancelErrCh := make(chan error, 1)
cancelTaskIDCh := make(chan string, 1)
cancelRequestStartedAt := time.Now().UTC().Add(-time.Second)
go func() {
resp, err := http.DefaultClient.Do(cancelReq)
if resp != nil {
@@ -484,7 +506,7 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
t.Fatal("cancelled stream did not return response headers")
}
if cancelTaskID == "" {
t.Fatal("cancelled stream response did not expose X-Gateway-Task-Id")
cancelTaskID = waitForLatestTaskIDSince(t, ctx, testPool, "chat.completions", cancelRequestStartedAt, 2*time.Second)
}
cancelRequest()
select {
@@ -498,11 +520,20 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
}
var imageResponse struct {
Task struct {
Status string `json:"status"`
LegacyTaskID string `json:"task_id"`
TaskID string `json:"taskId"`
QueryURL string `json:"query_url"`
Task struct {
ID string `json:"id"`
Status string `json:"status"`
Result map[string]any `json:"result"`
} `json:"task"`
Next struct {
Detail string `json:"detail"`
Events string `json:"events"`
Result string `json:"result"`
} `json:"next"`
}
doJSONWithHeaders(t, server.URL, http.MethodPost, "/api/v1/images/generations", apiKeyResponse.Secret, map[string]any{
"model": defaultImageModel,
@@ -513,14 +544,59 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
"simulation": true,
"simulationDurationMs": 5,
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageResponse)
if imageResponse.Status != "submitted" ||
imageResponse.TaskID == "" ||
imageResponse.LegacyTaskID != imageResponse.TaskID ||
imageResponse.Task.ID != imageResponse.TaskID ||
imageResponse.QueryURL != "/api/v1/ai/result/"+imageResponse.TaskID ||
imageResponse.Next.Result != imageResponse.QueryURL {
t.Fatalf("unexpected EasyAI-compatible image submission: %+v", imageResponse)
}
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, imageResponse.Task.ID, []string{"succeeded"}, 10*time.Second)
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+imageResponse.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &imageResponse.Task)
if imageResponse.Task.Status != "succeeded" || imageResponse.Task.Result["id"] == "" {
t.Fatalf("unexpected image generation task: %+v", imageResponse.Task)
}
var imageEasyAIResult struct {
Status string `json:"status"`
TaskID string `json:"task_id"`
Data []map[string]any `json:"data"`
Output []string `json:"output"`
OutputContent []map[string]any `json:"output_content"`
}
doJSON(t, server.URL, http.MethodGet, imageResponse.QueryURL, apiKeyResponse.Secret, nil, http.StatusOK, &imageEasyAIResult)
if imageEasyAIResult.Status != "success" ||
imageEasyAIResult.TaskID != imageResponse.TaskID ||
len(imageEasyAIResult.Data) == 0 ||
len(imageEasyAIResult.Output) == 0 ||
len(imageEasyAIResult.OutputContent) != len(imageEasyAIResult.Data) ||
imageEasyAIResult.Data[0]["type"] != "image" {
t.Fatalf("unexpected EasyAI-compatible image result: %+v", imageEasyAIResult)
}
var ordinaryLoginResponse struct {
AccessToken string `json:"accessToken"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"account": ordinaryUsername,
"password": password,
}, http.StatusOK, &ordinaryLoginResponse)
var inaccessibleImageResult struct {
Status string `json:"status"`
Code string `json:"code"`
Data []any `json:"data"`
}
doJSON(t, server.URL, http.MethodGet, imageResponse.QueryURL, ordinaryLoginResponse.AccessToken, nil, http.StatusNotFound, &inaccessibleImageResult)
if inaccessibleImageResult.Status != "failed" ||
inaccessibleImageResult.Code != "not_found" ||
len(inaccessibleImageResult.Data) != 0 {
t.Fatalf("EasyAI task result must stay owner-isolated: %+v", inaccessibleImageResult)
}
var imageEditResponse struct {
Task struct {
Status string `json:"status"`
LegacyTaskID string `json:"task_id"`
TaskID string `json:"taskId"`
Task struct {
ID string `json:"id"`
Status string `json:"status"`
Result map[string]any `json:"result"`
@@ -535,6 +611,12 @@ VALUES ($1, 5, '{"purpose":"core-flow"}'::jsonb)`, inviteCode); err != nil {
"simulation": true,
"simulationDurationMs": 5,
}, map[string]string{"X-Async": "true"}, http.StatusAccepted, &imageEditResponse)
if imageEditResponse.Status != "submitted" ||
imageEditResponse.TaskID == "" ||
imageEditResponse.LegacyTaskID != imageEditResponse.TaskID ||
imageEditResponse.Task.ID != imageEditResponse.TaskID {
t.Fatalf("unexpected EasyAI-compatible image edit submission: %+v", imageEditResponse)
}
waitForTaskStatus(t, server.URL, apiKeyResponse.Secret, imageEditResponse.Task.ID, []string{"succeeded"}, 10*time.Second)
doJSON(t, server.URL, http.MethodGet, "/api/v1/tasks/"+imageEditResponse.Task.ID, apiKeyResponse.Secret, nil, http.StatusOK, &imageEditResponse.Task)
if imageEditResponse.Task.Status != "succeeded" || imageEditResponse.Task.Result["id"] == "" {
@@ -1613,6 +1695,64 @@ WHERE m.platform_id = $1::uuid
if !taskListContains(workspaceTaskList.Items, taskResponse.Task.ID) || !taskListContains(workspaceTaskList.Items, pricingTask.Task.ID) {
t.Fatalf("workspace task list should include persisted task records, got %+v", workspaceTaskList.Items)
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks", ordinaryLoginResponse.AccessToken, nil, http.StatusForbidden, nil)
if _, err := testPool.Exec(ctx, `
UPDATE gateway_tasks
SET request = request || '{"diagnostic":{"apiKey":"private-admin-task-test-key"}}'::jsonb
WHERE id = $1::uuid`, taskResponse.Task.ID); err != nil {
t.Fatalf("seed admin task redaction payload: %v", err)
}
var adminTaskList struct {
Items []struct {
ID string `json:"id"`
Request map[string]any `json:"request"`
Context struct {
User struct {
ID string `json:"id"`
Username string `json:"username"`
} `json:"user"`
Tenant struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"tenant"`
} `json:"adminContext"`
} `json:"items"`
Total int `json:"total"`
}
adminTaskQuery := "/api/admin/tasks?q=" + taskResponse.Task.ID +
"&userId=" + smokeGatewayUserID +
"&status=succeeded" +
"&platformId=" + platform.ID +
"&model=" + defaultTextModel +
"&modelType=text_generate" +
"&runMode=simulation" +
"&billingStatus=settled" +
"&apiKey=smoke" +
"&pageSize=10"
doJSON(t, server.URL, http.MethodGet, adminTaskQuery, loginResponse.AccessToken, nil, http.StatusOK, &adminTaskList)
if len(adminTaskList.Items) != 1 || adminTaskList.Total != 1 || adminTaskList.Items[0].Context.User.Username != username {
t.Fatalf("admin task list should expose the matching task and identity summary: %+v", adminTaskList)
}
maskedDiagnostic, _ := adminTaskList.Items[0].Request["diagnostic"].(map[string]any)
if maskedDiagnostic["apiKey"] != "***" {
t.Fatalf("admin task list must mask nested secrets: %+v", adminTaskList.Items[0].Request)
}
var maskedAdminTask struct {
Request map[string]any `json:"request"`
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskResponse.Task.ID, loginResponse.AccessToken, nil, http.StatusOK, &maskedAdminTask)
maskedDiagnostic, _ = maskedAdminTask.Request["diagnostic"].(map[string]any)
if maskedDiagnostic["apiKey"] != "***" {
t.Fatalf("admin task detail must be masked by default: %+v", maskedAdminTask.Request)
}
var fullAdminTask struct {
Request map[string]any `json:"request"`
}
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskResponse.Task.ID+"?sensitive=full", loginResponse.AccessToken, nil, http.StatusOK, &fullAdminTask)
fullDiagnostic, _ := fullAdminTask.Request["diagnostic"].(map[string]any)
if fullDiagnostic["apiKey"] != "private-admin-task-test-key" {
t.Fatalf("explicit full admin task detail should return stored content: %+v", fullAdminTask.Request)
}
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/tasks/"+pricingTask.Task.ID+"/events", nil)
if err != nil {
@@ -1664,6 +1804,7 @@ WHERE m.platform_id = $1::uuid
}
restartModel := "worker-restart-" + suffixText
createSimulationTextBaseModel(t, server.URL, loginResponse.AccessToken, restartModel)
createSimulationTextPlatformModel(
t,
server.URL,
@@ -1782,6 +1923,7 @@ func TestCacheAffinityRoutingFlow(t *testing.T) {
model := "cache-affinity-smoke-" + suffixText
cacheKey := "cache-affinity-key-" + suffixText
createSimulationTextBaseModel(t, server.URL, loginResponse.AccessToken, model)
lowPlatform, lowPlatformModelID := createSimulationTextPlatformModel(t, server.URL, loginResponse.AccessToken, "cache-low-"+suffixText, "Cache Affinity Low Priority", model, 20, map[string]any{
"rules": []map[string]any{
{"metric": "concurrent", "limit": 1, "leaseTtlSeconds": 120},
@@ -1798,8 +1940,31 @@ func TestCacheAffinityRoutingFlow(t *testing.T) {
}
}
highPlatform, _ := createSimulationTextPlatformModel(t, server.URL, loginResponse.AccessToken, "cache-high-"+suffixText, "Cache Affinity High Priority", model, 1, nil)
_ = highPlatform
tools := []any{map[string]any{
"type": "function",
"function": map[string]any{
"name": "lookup_weather",
"description": "Look up current weather",
"parameters": map[string]any{"type": "object", "properties": map[string]any{"city": map[string]any{"type": "string"}}},
},
}}
initialToolMessages := []any{
map[string]any{"role": "system", "content": "Use tools when helpful."},
map[string]any{"role": "user", "content": "Weather in Hangzhou?"},
}
for index := 0; index < 3; index++ {
detail := runCacheAffinityChatTaskWithBody(t, ctx, testPool, server.URL, apiKeyResponse.Secret, model, "tool-prime-"+strconv.Itoa(index)+"-"+suffixText, map[string]any{
"tools": tools,
"messages": initialToolMessages,
"simulationUsage": map[string]any{"inputTokens": 1000, "cachedInputTokens": 750, "outputTokens": 20},
})
if detail.Status != "succeeded" || len(detail.Attempts) != 1 || detail.Attempts[0].PlatformName != "Cache Affinity Low Priority" {
t.Fatalf("tool-history priming request should use the only platform, got %+v", detail)
}
}
peerPlatform, _ := createSimulationTextPlatformModel(t, server.URL, loginResponse.AccessToken, "cache-peer-"+suffixText, "Cache Affinity Peer", model, 20, nil)
_ = peerPlatform
sameKeyDetail := runCacheAffinityChatTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, model, cacheKey, "same-key-"+suffixText, map[string]any{
"inputTokens": 1000,
"cachedInputTokens": 700,
@@ -1817,12 +1982,36 @@ func TestCacheAffinityRoutingFlow(t *testing.T) {
t.Fatalf("same cache key task detail should expose simulated usage, got %+v", sameKeyDetail.Usage)
}
toolHistoryDetail := runCacheAffinityChatTaskWithBody(t, ctx, testPool, server.URL, apiKeyResponse.Secret, model, "tool-history-"+suffixText, map[string]any{
"tools": tools,
"messages": append(append([]any{}, initialToolMessages...),
map[string]any{"role": "assistant", "tool_calls": []any{map[string]any{
"id": "call_weather",
"type": "function",
"function": map[string]any{
"name": "lookup_weather",
"arguments": `{"city":"Hangzhou"}`,
},
}}},
map[string]any{"role": "tool", "tool_call_id": "call_weather", "content": `{"temperature":31}`},
),
"simulationUsage": map[string]any{"inputTokens": 1400, "cachedInputTokens": 900, "outputTokens": 30},
})
if toolHistoryDetail.Status != "succeeded" || len(toolHistoryDetail.Attempts) != 1 || toolHistoryDetail.Attempts[0].PlatformName != "Cache Affinity Low Priority" {
t.Fatalf("tool continuation should retain the cached platform, got %+v", toolHistoryDetail)
}
if !boolFromTestMap(toolHistoryDetail.Attempts[0].Metrics, "cacheAffinityMatched") ||
intFromTestAny(toolHistoryDetail.Attempts[0].Metrics["cacheAffinityMatchedPrefixDepth"]) < 2 ||
intFromTestAny(toolHistoryDetail.Attempts[0].Metrics["cacheAffinityCandidateCount"]) < 1 {
t.Fatalf("tool continuation should expose prefix and candidate metrics, got %+v", toolHistoryDetail.Attempts[0].Metrics)
}
newKeyDetail := runCacheAffinityChatTask(t, ctx, testPool, server.URL, apiKeyResponse.Secret, model, "cache-affinity-new-"+suffixText, "new-key-"+suffixText, map[string]any{
"inputTokens": 1000,
"cachedInputTokens": 0,
"outputTokens": 20,
})
if newKeyDetail.Status != "succeeded" || len(newKeyDetail.Attempts) != 1 || newKeyDetail.Attempts[0].PlatformName != "Cache Affinity High Priority" {
if newKeyDetail.Status != "succeeded" || len(newKeyDetail.Attempts) != 1 || newKeyDetail.Attempts[0].PlatformName != "Cache Affinity Peer" {
t.Fatalf("new cache key should fall back to base priority, got %+v", newKeyDetail)
}
@@ -1832,9 +2021,12 @@ func TestCacheAffinityRoutingFlow(t *testing.T) {
"cachedInputTokens": 0,
"outputTokens": 20,
})
if fullAvoidedDetail.Status != "succeeded" || len(fullAvoidedDetail.Attempts) != 1 || fullAvoidedDetail.Attempts[0].PlatformName != "Cache Affinity High Priority" {
if fullAvoidedDetail.Status != "succeeded" || len(fullAvoidedDetail.Attempts) != 1 || fullAvoidedDetail.Attempts[0].PlatformName != "Cache Affinity Peer" {
t.Fatalf("full cached platform should be avoided before cache affinity boost, got %+v", fullAvoidedDetail)
}
if fullAvoidedDetail.Attempts[0].Metrics["cacheAffinityOverrideReason"] != "capacity_tier_unavailable" {
t.Fatalf("full cached platform override reason should be visible, got %+v", fullAvoidedDetail.Attempts[0].Metrics)
}
var requestCount int
var inputTokens int
@@ -1844,7 +2036,7 @@ func TestCacheAffinityRoutingFlow(t *testing.T) {
SELECT request_count::int, input_tokens::int, cached_input_tokens::int, ema_hit_ratio::float8
FROM gateway_cache_affinity_stats
WHERE client_id = $1
ORDER BY updated_at DESC
ORDER BY request_count DESC, cached_input_tokens DESC, updated_at DESC
LIMIT 1`, lowPlatform.PlatformKey+":text_generate:"+model).Scan(&requestCount, &inputTokens, &cachedTokens, &emaHitRatio); err != nil {
t.Fatalf("read cache affinity stats: %v", err)
}
@@ -1927,6 +2119,7 @@ func applyMigration(t *testing.T, ctx context.Context, databaseURL string) {
t.Fatalf("connect migration db: %v", err)
}
defer pool.Close()
requireDedicatedIntegrationTestDatabase(t, ctx, pool)
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version text PRIMARY KEY,
@@ -1947,6 +2140,22 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
if err != nil {
t.Fatalf("read migration %s: %v", filepath.Base(migrationPath), err)
}
migrationSQL := string(migration)
const noTransactionMarker = "-- easyai:migration:no-transaction"
if strings.HasPrefix(strings.TrimSpace(migrationSQL), noTransactionMarker) {
migrationSQL = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(migrationSQL), noTransactionMarker))
for _, statement := range strings.Split(migrationSQL, "-- easyai:migration:statement") {
if statement = strings.TrimSpace(statement); statement != "" {
if _, err := pool.Exec(ctx, statement); err != nil {
t.Fatalf("apply non-transaction migration %s: %v", filepath.Base(migrationPath), err)
}
}
}
if _, err := pool.Exec(ctx, "INSERT INTO schema_migrations(version) VALUES($1)", version); err != nil {
t.Fatalf("record non-transaction migration %s: %v", filepath.Base(migrationPath), err)
}
continue
}
tx, err := pool.Begin(ctx)
if err != nil {
t.Fatalf("begin migration %s: %v", filepath.Base(migrationPath), err)
@@ -1974,9 +2183,9 @@ SELECT EXISTS (
FROM base_model_catalog model
WHERE model.provider_key = 'volces'
AND model.canonical_model_key = 'volces:doubao-seedream-5-0-pro-260628'
AND model.invocation_name = 'doubao-seedream-5-0-pro-260628'
AND model.provider_model_name = 'doubao-seedream-5-0-pro-260628'
AND model.display_name = 'Seedream-5.0-Pro'
AND model.display_name !~ '[[:space:]]'
AND model.display_name = 'Seedream 5.0 Pro'
AND model.model_type = '["image_edit","image_generate"]'::jsonb
AND model.capabilities->'image_edit'->>'input_multiple_images' = 'true'
AND model.capabilities->'image_edit'->>'input_max_images_count' = '10'
@@ -1991,7 +2200,7 @@ SELECT EXISTS (
AND model.capabilities->>'stream' = 'false'
AND model.capabilities->>'supportWebSearch' = 'false'
AND model.default_rate_limit_policy->'rules'->0->>'limit' = '500'
AND model.default_snapshot->>'modelAlias' = 'Seedream-5.0-Pro'
AND model.default_snapshot->>'modelAlias' = 'doubao-seedream-5-0-pro-260628'
AND model.default_snapshot->>'displayName' = 'Seedream 5.0 Pro'
)`).Scan(&valid); err != nil {
t.Fatalf("query Seedream 5.0 Pro catalog migration: %v", err)
@@ -2048,16 +2257,17 @@ func doJSONWithHeaders(t *testing.T, baseURL string, method string, path string,
func doAPIV1ChatCompletionAndLoadTask(t *testing.T, ctx context.Context, pool *pgxpool.Pool, baseURL string, token string, payload map[string]any, marker string, expectedStatus int, responseOut any, taskDetailOut any) string {
t.Helper()
_ = ctx
_ = pool
_ = marker
if responseOut == nil {
responseOut = &map[string]any{}
}
requestStartedAt := time.Now().UTC().Add(-time.Second)
responseHeaders := doJSONWithHeaders(t, baseURL, http.MethodPost, "/api/v1/chat/completions", token, payload, nil, expectedStatus, responseOut)
taskID := strings.TrimSpace(responseHeaders.Get("X-Gateway-Task-Id"))
if taskID == "" {
t.Fatal("chat completion response did not expose X-Gateway-Task-Id")
taskID = waitForLatestTaskIDSince(t, ctx, pool, "chat.completions", requestStartedAt, 2*time.Second)
}
if taskID == "" {
t.Fatalf("load chat completion task %s failed: headers=%v body=%+v", marker, responseHeaders, responseOut)
}
if taskDetailOut != nil {
doJSON(t, baseURL, http.MethodGet, "/api/v1/tasks/"+taskID, token, nil, http.StatusOK, taskDetailOut)
@@ -2065,6 +2275,27 @@ func doAPIV1ChatCompletionAndLoadTask(t *testing.T, ctx context.Context, pool *p
return taskID
}
func waitForLatestTaskIDSince(t *testing.T, ctx context.Context, pool *pgxpool.Pool, kind string, since time.Time, timeout time.Duration) string {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
var taskID string
err := pool.QueryRow(ctx, `
SELECT id::text
FROM gateway_tasks
WHERE kind = $1
AND created_at >= $2
ORDER BY created_at DESC
LIMIT 1`, kind, since).Scan(&taskID)
if err == nil && taskID != "" {
return taskID
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("latest %s task was not persisted within %s", kind, timeout)
return ""
}
type taskWaitDetail struct {
ID string `json:"id"`
Status string `json:"status"`
@@ -2093,6 +2324,20 @@ type cacheAffinityTaskDetail struct {
} `json:"attempts"`
}
func createSimulationTextBaseModel(t *testing.T, baseURL string, adminToken string, model string) {
t.Helper()
doJSON(t, baseURL, http.MethodPost, "/api/admin/catalog/base-models", adminToken, map[string]any{
"providerKey": "openai",
"canonicalModelKey": "openai:" + model,
"invocationName": model,
"providerModelName": model,
"modelType": []string{"text_generate"},
"displayName": model,
"capabilities": map[string]any{"originalTypes": []string{"text_generate"}},
"metadata": map[string]any{"source": "cache-affinity-integration-test"},
}, http.StatusCreated, nil)
}
func createSimulationTextPlatformModel(t *testing.T, baseURL string, adminToken string, platformKey string, platformName string, model string, priority int, rateLimitPolicy map[string]any) (cacheAffinityPlatformFixture, string) {
t.Helper()
var platform cacheAffinityPlatformFixture
@@ -2109,7 +2354,7 @@ func createSimulationTextPlatformModel(t *testing.T, baseURL string, adminToken
t.Fatalf("cache affinity platform was not created: %+v", platform)
}
payload := map[string]any{
"canonicalModelKey": "openai:gpt-4o-mini",
"canonicalModelKey": "openai:" + model,
"modelName": model,
"providerModelName": model,
"modelAlias": model,
@@ -2130,17 +2375,27 @@ func createSimulationTextPlatformModel(t *testing.T, baseURL string, adminToken
}
func runCacheAffinityChatTask(t *testing.T, ctx context.Context, pool *pgxpool.Pool, baseURL string, token string, model string, cacheAffinityKey string, marker string, simulationUsage map[string]any) cacheAffinityTaskDetail {
t.Helper()
return runCacheAffinityChatTaskWithBody(t, ctx, pool, baseURL, token, model, marker, map[string]any{
"cacheAffinityKey": cacheAffinityKey,
"simulationUsage": simulationUsage,
"messages": []any{map[string]any{"role": "user", "content": "cache affinity route"}},
})
}
func runCacheAffinityChatTaskWithBody(t *testing.T, ctx context.Context, pool *pgxpool.Pool, baseURL string, token string, model string, marker string, body map[string]any) cacheAffinityTaskDetail {
t.Helper()
var detail cacheAffinityTaskDetail
doAPIV1ChatCompletionAndLoadTask(t, ctx, pool, baseURL, token, map[string]any{
requestBody := map[string]any{
"model": model,
"runMode": "simulation",
"simulation": true,
"simulationDurationMs": 5,
"cacheAffinityKey": cacheAffinityKey,
"simulationUsage": simulationUsage,
"messages": []map[string]any{{"role": "user", "content": "cache affinity route"}},
}, marker, http.StatusOK, nil, &detail)
}
for key, value := range body {
requestBody[key] = value
}
doAPIV1ChatCompletionAndLoadTask(t, ctx, pool, baseURL, token, requestBody, marker, http.StatusOK, nil, &detail)
return detail
}
+489
View File
@@ -0,0 +1,489 @@
package httpapi
import (
"encoding/json"
"net/http"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/runner"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func easyAIAsyncMediaRequest(kind string, r *http.Request) bool {
if r == nil || !asyncRequest(r) {
return false
}
switch kind {
case "images.generations",
"images.edits",
"images.vectorize",
"videos.generations",
"videos.upscales",
"song.generations",
"music.generations",
"speech.generations",
"voice.clone":
return true
default:
return false
}
}
func easyAITaskAcceptedResponse(task store.GatewayTask) map[string]any {
resultPath := "/api/v1/ai/result/" + task.ID
return map[string]any{
"status": "submitted",
"task_id": task.ID,
"taskId": task.ID,
"query_url": resultPath,
"task": task,
"next": map[string]string{
"events": "/api/v1/tasks/" + task.ID + "/events",
"detail": "/api/v1/tasks/" + task.ID,
"result": resultPath,
},
}
}
func easyAITaskAcceptedResponseWithExtensions(task store.GatewayTask, extensions map[string]any) map[string]any {
response := cloneEasyAIMap(extensions)
for key, value := range easyAITaskAcceptedResponse(task) {
response[key] = value
}
return response
}
func writeEasyAIAsyncError(w http.ResponseWriter, status int, message string, details map[string]any, codes ...string) {
code := ""
if len(codes) > 0 {
code = strings.TrimSpace(codes[0])
}
errorPayload := map[string]any{
"message": message,
"status": status,
}
if code != "" {
errorPayload["code"] = code
}
for key, value := range details {
errorPayload[key] = value
}
response := map[string]any{
"status": "failed",
"message": message,
"data": []any{},
"error": errorPayload,
}
if code != "" {
response["code"] = code
}
writeJSON(w, status, response)
}
func easyAISynchronousTaskResponse(task store.GatewayTask, output map[string]any) map[string]any {
if task.Kind != "images.vectorize" {
return output
}
response := cloneEasyAIMap(output)
response["taskId"] = task.ID
response["task_id"] = task.ID
response["query_url"] = "/api/v1/ai/result/" + task.ID
return response
}
func easyAIFileUploadResponse(upload map[string]any) map[string]any {
response := cloneEasyAIMap(upload)
response["status"] = "success"
response["data"] = cloneEasyAIMap(upload)
return response
}
func easyAITaskResultResponse(task store.GatewayTask) map[string]any {
sourceResult := cloneEasyAIMap(task.Result)
cleanResult := cloneEasyAIMap(sourceResult)
delete(cleanResult, "raw")
delete(cleanResult, "raw_data")
normalizeEasyAIInlineMediaFields(cleanResult)
data := easyAITaskResultData(task, sourceResult)
status := easyAITaskResultStatus(task.Status)
if status == "failed" {
data = []any{}
}
cleanResult["data"] = data
cleanResult["output_content"] = data
response := cloneEasyAIMap(cleanResult)
response["status"] = status
response["task_id"] = task.ID
response["taskId"] = task.ID
response["query_url"] = "/api/v1/ai/result/" + task.ID
response["created"] = task.CreatedAt.UnixMilli()
response["data"] = data
response["output_content"] = data
response["output"] = easyAIOutputURLs(data)
response["result"] = cleanResult
upstreamTaskID := firstNonEmpty(
easyAIString(cleanResult["upstream_task_id"]),
task.RemoteTaskID,
)
if upstreamTaskID != "" {
response["upstream_task_id"] = upstreamTaskID
}
if len(task.Usage) > 0 && response["usage"] == nil {
response["usage"] = task.Usage
}
cancelState := runner.DescribeTaskCancellation(task)
response["cancellable"] = cancelState.Cancellable
response["submitted"] = cancelState.Submitted
message := firstNonEmpty(
easyAIString(cleanResult["message"]),
task.ErrorMessage,
task.Error,
task.Message,
)
if message == "" {
switch status {
case "submitted":
message = "任务已提交,请稍后查看结果"
case "process":
message = "任务处理中"
case "failed":
message = "任务执行失败"
}
}
if message != "" {
response["message"] = message
}
if code := firstNonEmpty(task.ErrorCode, easyAIString(cleanResult["code"])); code != "" {
response["code"] = code
}
return response
}
func easyAITaskResultStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "succeeded", "success", "completed":
return "success"
case "failed", "cancelled", "canceled", "rejected":
return "failed"
case "running", "processing", "in_progress":
return "process"
default:
return "submitted"
}
}
func easyAITaskResultData(task store.GatewayTask, result map[string]any) []any {
for _, source := range easyAIResultSources(result) {
if items := easyAIOutputArray(source); len(items) > 0 {
return normalizeEasyAIOutputItems(task, items)
}
}
if item := easyAIOutputMap(result); len(item) > 0 {
normalizeEasyAIInlineMediaFields(item)
if easyAIOutputURL(item) != "" ||
easyAIOutputHasInlineMedia(item) ||
(strings.EqualFold(easyAIString(item["type"]), "text") && (item["text"] != nil || item["content"] != nil)) {
return normalizeEasyAIOutputItems(task, []any{item})
}
}
return []any{}
}
func easyAIResultSources(result map[string]any) []any {
sources := []any{
result["data"],
result["output_content"],
easyAIStructuredOutput(result["content"]),
result["images"],
result["videos"],
result["audios"],
result["files"],
result["output"],
}
if raw, ok := result["raw"].(map[string]any); ok {
sources = append(sources,
raw["data"],
raw["output_content"],
easyAIStructuredOutput(raw["content"]),
raw["images"],
raw["videos"],
raw["audios"],
raw["files"],
raw["output"],
)
}
return sources
}
func easyAIStructuredOutput(value any) any {
switch value.(type) {
case []any, []string, map[string]any:
return value
default:
return nil
}
}
func easyAIOutputArray(value any) []any {
switch typed := value.(type) {
case []any:
return typed
case []string:
items := make([]any, 0, len(typed))
for _, item := range typed {
items = append(items, item)
}
return items
case map[string]any, string:
return []any{typed}
default:
return nil
}
}
func normalizeEasyAIOutputItems(task store.GatewayTask, items []any) []any {
normalized := make([]any, 0, len(items))
for _, item := range items {
output := easyAIOutputMap(item)
if len(output) == 0 {
continue
}
delete(output, "raw_data")
normalizeEasyAIInlineMediaFields(output)
mediaURL := easyAIOutputURL(output)
if mediaURL != "" {
output["url"] = mediaURL
}
if strings.TrimSpace(easyAIString(output["type"])) == "" {
if outputType := easyAIOutputType(task, output, mediaURL); outputType != "" {
output["type"] = outputType
}
}
normalized = append(normalized, output)
}
return normalized
}
func easyAIOutputMap(value any) map[string]any {
switch typed := value.(type) {
case map[string]any:
return cloneEasyAIMap(typed)
case string:
trimmed := strings.TrimSpace(typed)
if trimmed == "" {
return nil
}
if strings.HasPrefix(strings.ToLower(trimmed), "data:") {
return map[string]any{"url": trimmed}
}
if strings.HasPrefix(trimmed, "/") ||
strings.HasPrefix(strings.ToLower(trimmed), "http://") ||
strings.HasPrefix(strings.ToLower(trimmed), "https://") {
return map[string]any{"url": trimmed}
}
return map[string]any{"type": "text", "content": trimmed}
default:
return nil
}
}
func easyAIOutputURL(item map[string]any) string {
for _, key := range []string{
"url",
"generated_url",
"generatedUrl",
"output_url",
"outputUrl",
"download_url",
"downloadUrl",
"image_url",
"video_url",
"audio_url",
} {
if value := easyAINestedURL(item[key]); value != "" {
return value
}
}
return ""
}
func easyAINestedURL(value any) string {
var candidate string
switch typed := value.(type) {
case string:
candidate = strings.TrimSpace(typed)
case map[string]any:
candidate = strings.TrimSpace(easyAIString(typed["url"]))
default:
return ""
}
if strings.HasPrefix(strings.ToLower(candidate), "data:") {
return ""
}
return candidate
}
func normalizeEasyAIInlineMediaFields(item map[string]any) {
for _, key := range []string{
"url",
"generated_url",
"generatedUrl",
"output_url",
"outputUrl",
"download_url",
"downloadUrl",
"image_url",
"video_url",
"audio_url",
} {
value := easyAINestedURLAllowData(item[key])
contentType, payload, ok := easyAIBase64DataURL(value)
if !ok {
continue
}
if strings.HasPrefix(contentType, "image/") {
if easyAIString(item["b64_json"]) == "" {
item["b64_json"] = payload
}
} else if easyAIString(item["content"]) == "" {
item["content"] = value
}
if easyAIString(item["mime_type"]) == "" {
item["mime_type"] = contentType
}
delete(item, key)
}
}
func easyAIBase64DataURL(value string) (string, string, bool) {
value = strings.TrimSpace(value)
if !strings.HasPrefix(strings.ToLower(value), "data:") {
return "", "", false
}
meta, payload, ok := strings.Cut(value, ",")
if !ok || strings.TrimSpace(payload) == "" {
return "", "", false
}
parts := strings.Split(strings.TrimSpace(meta[5:]), ";")
if len(parts) < 2 || !strings.EqualFold(strings.TrimSpace(parts[len(parts)-1]), "base64") {
return "", "", false
}
contentType := strings.ToLower(strings.TrimSpace(parts[0]))
if !strings.HasPrefix(contentType, "image/") &&
!strings.HasPrefix(contentType, "audio/") &&
!strings.HasPrefix(contentType, "video/") &&
!strings.HasPrefix(contentType, "application/") {
return "", "", false
}
return contentType, strings.TrimSpace(payload), true
}
func easyAIOutputHasInlineMedia(item map[string]any) bool {
if easyAIString(item["b64_json"]) != "" {
return true
}
content := strings.ToLower(easyAIString(item["content"]))
return strings.HasPrefix(content, "data:") && strings.Contains(content, ";base64,")
}
func easyAINestedURLAllowData(value any) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case map[string]any:
return strings.TrimSpace(easyAIString(typed["url"]))
default:
return ""
}
}
func easyAIOutputType(task store.GatewayTask, item map[string]any, mediaURL string) string {
kind := strings.ToLower(strings.TrimSpace(task.Kind))
switch {
case kind == "images.vectorize":
format := strings.ToLower(firstNonEmpty(
easyAIString(item["format"]),
easyAIString(task.Request["format"]),
))
if format == "svg" || format == "png" {
return "image"
}
if format != "" {
return "file"
}
case strings.HasPrefix(kind, "images."):
return "image"
case strings.HasPrefix(kind, "videos.") || kind == "video.upscale":
return "video"
case kind == "song.generations" || kind == "music.generations" || kind == "speech.generations":
return "audio"
}
lowerURL := strings.ToLower(mediaURL)
if index := strings.IndexAny(lowerURL, "?#"); index >= 0 {
lowerURL = lowerURL[:index]
}
switch {
case strings.HasSuffix(lowerURL, ".svg"),
strings.HasSuffix(lowerURL, ".png"),
strings.HasSuffix(lowerURL, ".jpg"),
strings.HasSuffix(lowerURL, ".jpeg"),
strings.HasSuffix(lowerURL, ".webp"),
strings.HasSuffix(lowerURL, ".gif"):
return "image"
case strings.HasSuffix(lowerURL, ".mp4"),
strings.HasSuffix(lowerURL, ".mov"),
strings.HasSuffix(lowerURL, ".webm"):
return "video"
case strings.HasSuffix(lowerURL, ".mp3"),
strings.HasSuffix(lowerURL, ".wav"),
strings.HasSuffix(lowerURL, ".m4a"),
strings.HasSuffix(lowerURL, ".aac"),
strings.HasSuffix(lowerURL, ".flac"):
return "audio"
case mediaURL != "":
return "file"
case item["text"] != nil || item["content"] != nil:
return "text"
default:
return ""
}
}
func easyAIOutputURLs(data []any) []string {
output := make([]string, 0, len(data))
for _, item := range data {
if typed, ok := item.(map[string]any); ok {
if mediaURL := easyAIOutputURL(typed); mediaURL != "" {
output = append(output, mediaURL)
}
}
}
return output
}
func easyAIString(value any) string {
typed, _ := value.(string)
return strings.TrimSpace(typed)
}
func cloneEasyAIMap(source map[string]any) map[string]any {
if len(source) == 0 {
return map[string]any{}
}
raw, err := json.Marshal(source)
if err != nil {
return map[string]any{}
}
var result map[string]any
if err := json.Unmarshal(raw, &result); err != nil {
return map[string]any{}
}
return result
}
@@ -0,0 +1,311 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestEasyAITaskAcceptedResponseKeepsGatewayFieldsAndAddsLegacyFields(t *testing.T) {
task := store.GatewayTask{
ID: "task-accepted-1",
Kind: "speech.generations",
Status: "queued",
AsyncMode: true,
}
got := easyAITaskAcceptedResponse(task)
if got["status"] != "submitted" || got["task_id"] != task.ID || got["taskId"] != task.ID {
t.Fatalf("unexpected EasyAI submission identity: %+v", got)
}
if _, ok := got["task"].(store.GatewayTask); !ok {
t.Fatalf("Gateway task envelope was removed: %+v", got)
}
next, _ := got["next"].(map[string]string)
if next["detail"] != "/api/v1/tasks/"+task.ID ||
next["events"] != "/api/v1/tasks/"+task.ID+"/events" ||
next["result"] != "/api/v1/ai/result/"+task.ID {
t.Fatalf("unexpected next links: %+v", next)
}
}
func TestEasyAITaskResultStatus(t *testing.T) {
for _, item := range []struct {
input string
want string
}{
{input: "queued", want: "submitted"},
{input: "pending", want: "submitted"},
{input: "running", want: "process"},
{input: "processing", want: "process"},
{input: "succeeded", want: "success"},
{input: "completed", want: "success"},
{input: "failed", want: "failed"},
{input: "cancelled", want: "failed"},
} {
t.Run(item.input, func(t *testing.T) {
if got := easyAITaskResultStatus(item.input); got != item.want {
t.Fatalf("easyAITaskResultStatus(%q)=%q, want %q", item.input, got, item.want)
}
})
}
}
func TestEasyAITaskResultResponseNormalizesMediaOutputs(t *testing.T) {
createdAt := time.Date(2026, 7, 23, 10, 0, 0, 0, time.UTC)
for _, item := range []struct {
name string
task store.GatewayTask
wantType string
wantURL string
wantCount int
}{
{
name: "image alias",
task: store.GatewayTask{
ID: "image-1", Kind: "images.generations", Status: "succeeded", CreatedAt: createdAt,
Result: map[string]any{"data": []any{map[string]any{
"image_url": "https://example.com/output.png",
}}},
},
wantType: "image", wantURL: "https://example.com/output.png", wantCount: 1,
},
{
name: "video raw content",
task: store.GatewayTask{
ID: "video-1", Kind: "videos.generations", Status: "succeeded", CreatedAt: createdAt,
Result: map[string]any{"raw": map[string]any{"content": map[string]any{
"video_url": "https://example.com/output.mp4",
"duration": 5,
}}},
},
wantType: "video", wantURL: "https://example.com/output.mp4", wantCount: 1,
},
{
name: "audio alias",
task: store.GatewayTask{
ID: "audio-1", Kind: "speech.generations", Status: "succeeded", CreatedAt: createdAt,
Result: map[string]any{"data": []any{map[string]any{
"audio_url": "https://example.com/output.wav",
}}},
},
wantType: "audio", wantURL: "https://example.com/output.wav", wantCount: 1,
},
{
name: "voice clone without media",
task: store.GatewayTask{
ID: "voice-1", Kind: "voice.clone", Status: "succeeded", CreatedAt: createdAt,
Result: map[string]any{"voice_id": "voice-test-1", "cloned_voice": map[string]any{"voiceId": "voice-test-1"}},
},
wantCount: 0,
},
} {
t.Run(item.name, func(t *testing.T) {
got := easyAITaskResultResponse(item.task)
if got["status"] != "success" || got["task_id"] != item.task.ID || got["created"] != createdAt.UnixMilli() {
t.Fatalf("unexpected task result identity: %+v", got)
}
data, _ := got["data"].([]any)
if len(data) != item.wantCount {
t.Fatalf("unexpected output count: got=%d response=%+v", len(data), got)
}
outputContent, _ := got["output_content"].([]any)
if len(outputContent) != item.wantCount {
t.Fatalf("output_content is not synchronized: %+v", got)
}
if item.wantCount == 0 {
if got["voice_id"] != "voice-test-1" {
t.Fatalf("voice clone fields were lost: %+v", got)
}
return
}
output, _ := data[0].(map[string]any)
if output["type"] != item.wantType || output["url"] != item.wantURL {
t.Fatalf("unexpected normalized output: %+v", output)
}
if _, exposed := output["b64_json"]; exposed {
t.Fatalf("base64 output was exposed: %+v", output)
}
urls, _ := got["output"].([]string)
if len(urls) != 1 || urls[0] != item.wantURL {
t.Fatalf("unexpected output URL list: %+v", got["output"])
}
})
}
}
func TestEasyAITaskResultResponsePreservesBase64ImageOutput(t *testing.T) {
base64Payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlB8AAAAASUVORK5CYII="
task := store.GatewayTask{
ID: "image-base64-1", Kind: "images.generations", Status: "succeeded",
Result: map[string]any{"data": []any{map[string]any{
"b64_json": base64Payload,
"mime_type": "image/png",
}}},
}
got := easyAITaskResultResponse(task)
data, _ := got["data"].([]any)
if len(data) != 1 {
t.Fatalf("unexpected base64 output count: %+v", got)
}
output, _ := data[0].(map[string]any)
if output["type"] != "image" || output["b64_json"] != base64Payload || output["mime_type"] != "image/png" {
t.Fatalf("base64 image output was not preserved: %+v", output)
}
if _, exposedAsURL := output["url"]; exposedAsURL {
t.Fatalf("base64 image must not be exposed as a URL: %+v", output)
}
outputContent, _ := got["output_content"].([]any)
content, _ := outputContent[0].(map[string]any)
if content["b64_json"] != base64Payload {
t.Fatalf("output_content lost base64 image: %+v", outputContent)
}
urls, _ := got["output"].([]string)
if len(urls) != 0 {
t.Fatalf("base64-only output must not create URL aliases: %+v", urls)
}
}
func TestEasyAITaskResultResponseMovesImageDataURLToB64JSON(t *testing.T) {
base64Payload := "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZlB8AAAAASUVORK5CYII="
task := store.GatewayTask{
ID: "image-data-url-1", Kind: "images.generations", Status: "succeeded",
Result: map[string]any{"data": []any{map[string]any{
"url": "data:image/png;base64," + base64Payload,
}}},
}
got := easyAITaskResultResponse(task)
data, _ := got["data"].([]any)
output, _ := data[0].(map[string]any)
if output["b64_json"] != base64Payload || output["mime_type"] != "image/png" || output["type"] != "image" {
t.Fatalf("image data URL was not normalized: %+v", output)
}
if _, exists := output["url"]; exists {
t.Fatalf("image data URL should move out of the URL field: %+v", output)
}
}
func TestEasyAITaskResultResponsePreservesAudioDataURLAsContent(t *testing.T) {
base64Payload := "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjMuMTAwAAAAAAAAAAAAAAD/"
task := store.GatewayTask{
ID: "audio-data-url-1", Kind: "speech.generations", Status: "succeeded",
Result: map[string]any{"data": []any{map[string]any{
"audio_url": "data:audio/mpeg;base64," + base64Payload,
}}},
}
got := easyAITaskResultResponse(task)
data, _ := got["data"].([]any)
output, _ := data[0].(map[string]any)
if output["content"] != "data:audio/mpeg;base64,"+base64Payload ||
output["mime_type"] != "audio/mpeg" ||
output["type"] != "audio" {
t.Fatalf("audio data URL was not normalized to inline content: %+v", output)
}
if _, exists := output["audio_url"]; exists {
t.Fatalf("audio data URL should move out of the URL field: %+v", output)
}
urls, _ := got["output"].([]string)
if len(urls) != 0 {
t.Fatalf("base64-only audio must not create URL aliases: %+v", urls)
}
}
func TestEasyAITaskResultResponseMapsVectorFormats(t *testing.T) {
for _, item := range []struct {
format string
wantType string
}{
{format: "svg", wantType: "image"},
{format: "png", wantType: "image"},
{format: "pdf", wantType: "file"},
{format: "eps", wantType: "file"},
} {
t.Run(item.format, func(t *testing.T) {
task := store.GatewayTask{
ID: "vector-" + item.format, Kind: "images.vectorize", Status: "succeeded",
Request: map[string]any{"format": item.format},
Result: map[string]any{"data": []any{map[string]any{"url": "https://example.com/output." + item.format}}},
}
got := easyAITaskResultResponse(task)
data, _ := got["data"].([]any)
output, _ := data[0].(map[string]any)
if output["type"] != item.wantType {
t.Fatalf("format %s mapped to %v, want %s", item.format, output["type"], item.wantType)
}
})
}
}
func TestEasyAITaskResultResponseClearsFailedOutputs(t *testing.T) {
task := store.GatewayTask{
ID: "failed-1", Kind: "videos.upscales", Status: "cancelled",
ErrorCode: "cancelled", ErrorMessage: "任务已取消",
Result: map[string]any{"data": []any{map[string]any{
"url": "https://example.com/partial.mp4",
}}},
}
got := easyAITaskResultResponse(task)
data, _ := got["data"].([]any)
outputContent, _ := got["output_content"].([]any)
if got["status"] != "failed" || got["code"] != "cancelled" || got["message"] != "任务已取消" ||
len(data) != 0 || len(outputContent) != 0 {
t.Fatalf("unexpected failed response: %+v", got)
}
}
func TestEasyAISynchronousVectorAndFileUploadResponsesAreAdditive(t *testing.T) {
vectorTask := store.GatewayTask{ID: "vector-sync-1", Kind: "images.vectorize"}
vector := easyAISynchronousTaskResponse(vectorTask, map[string]any{
"status": "success",
"data": []any{map[string]any{"url": "https://example.com/output.svg"}},
})
if vector["status"] != "success" || vector["taskId"] != vectorTask.ID || vector["task_id"] != vectorTask.ID {
t.Fatalf("unexpected vector response: %+v", vector)
}
upload := easyAIFileUploadResponse(map[string]any{
"id": "file-1", "url": "https://example.com/upload.png", "filename": "upload.png",
})
data, _ := upload["data"].(map[string]any)
if upload["status"] != "success" || upload["url"] != data["url"] || upload["id"] != data["id"] {
t.Fatalf("unexpected upload response: %+v", upload)
}
}
func TestEasyAIAsyncMediaRequestRequiresExplicitHeader(t *testing.T) {
syncRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
if easyAIAsyncMediaRequest("images.generations", syncRequest) {
t.Fatal("image request without X-Async must stay synchronous")
}
asyncRequest := httptest.NewRequest(http.MethodPost, "/api/v1/images/generations", nil)
asyncRequest.Header.Set("X-Async", "true")
if !easyAIAsyncMediaRequest("images.generations", asyncRequest) {
t.Fatal("image request with X-Async must enable EasyAI async compatibility")
}
if easyAIAsyncMediaRequest("chat.completions", asyncRequest) {
t.Fatal("chat completions must not enter EasyAI media async compatibility")
}
}
func TestWriteEasyAIAsyncErrorKeepsNestedGatewayError(t *testing.T) {
recorder := httptest.NewRecorder()
writeEasyAIAsyncError(recorder, http.StatusBadRequest, "invalid request", map[string]any{"param": "model"}, "invalid_parameter")
var got map[string]any
if err := json.NewDecoder(recorder.Body).Decode(&got); err != nil {
t.Fatal(err)
}
errorPayload, _ := got["error"].(map[string]any)
data, _ := got["data"].([]any)
if got["status"] != "failed" || got["code"] != "invalid_parameter" ||
errorPayload["message"] != "invalid request" || len(data) != 0 {
t.Fatalf("unexpected async error response: %+v", got)
}
}
@@ -0,0 +1,911 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
"github.com/jackc/pgx/v5/pgxpool"
)
type failurePolicyAcceptanceFixture struct {
ctx context.Context
cancel context.CancelFunc
db *store.Store
pool *pgxpool.Pool
server *httptest.Server
adminToken string
apiKey string
suffix string
}
type controlledProviderStub struct {
server *httptest.Server
calls atomic.Int64
}
type acceptancePlatform struct {
ID string `json:"id"`
}
type acceptancePlatformModel struct {
ID string `json:"id"`
}
type acceptanceTaskDetail struct {
ID string `json:"id"`
Status string `json:"status"`
Attempts []struct {
AttemptNo int `json:"attemptNo"`
PlatformID string `json:"platformId"`
PlatformName string `json:"platformName"`
PlatformModel string `json:"platformModelId"`
Status string `json:"status"`
ErrorCode string `json:"errorCode"`
DynamicMetrics map[string]any `json:"metrics"`
Trace []map[string]any `json:"trace"`
} `json:"attempts"`
}
func TestFailurePolicyHTTPAcceptance(t *testing.T) {
fixture := newFailurePolicyAcceptanceFixture(t)
defer fixture.close()
originalPolicy, err := fixture.db.GetActiveRunnerPolicy(fixture.ctx)
if err != nil {
t.Fatalf("read original runner policy: %v", err)
}
t.Cleanup(func() {
_, _ = fixture.db.UpsertDefaultRunnerPolicy(context.Background(), store.RunnerPolicyInput{
PolicyKey: originalPolicy.PolicyKey,
Name: originalPolicy.Name,
Description: originalPolicy.Description,
FailoverPolicy: originalPolicy.FailoverPolicy,
HardStopPolicy: originalPolicy.HardStopPolicy,
PriorityDemotePolicy: originalPolicy.PriorityDemotePolicy,
SingleSourcePolicy: originalPolicy.SingleSourcePolicy,
CacheAffinityPolicy: originalPolicy.CacheAffinityPolicy,
Metadata: originalPolicy.Metadata,
Status: originalPolicy.Status,
})
})
fixture.setRunnerPolicy(t, true)
t.Run("Gemini 429 后轮转且只冷却模型一次", fixture.testGemini429ImageFailover)
t.Run("5xx 同平台重试耗尽后降权并轮转", fixture.testServerErrorRetryThenFailover)
t.Run("401 立即禁用平台并轮转", fixture.testAuthFailureDisableThenFailover)
t.Run("400 参数错误硬停止", fixture.testBadRequestStops)
t.Run("旧 degradePolicy 仅转换一次", fixture.testLegacyDegradeDoesNotDoubleApply)
t.Run("并发单源保护至少保留一个来源", fixture.testConcurrentSingleSourceProtection)
t.Run("全部冷却同步返回脱敏 429", fixture.testAllCoolingSynchronousContract)
t.Run("全部冷却异步零 attempt 重排并恢复", fixture.testAllCoolingAsyncRecovery)
t.Run("流式首包边界控制轮转", fixture.testStreamingBoundary)
t.Run("管理接口严格校验且不覆盖原策略", fixture.testRunnerPolicyHTTPValidation)
}
func newFailurePolicyAcceptanceFixture(t *testing.T) *failurePolicyAcceptanceFixture {
t.Helper()
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
if databaseURL == "" {
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run failure-policy HTTP acceptance")
}
ctx, cancel := context.WithCancel(context.Background())
applyMigration(t, ctx, databaseURL)
db, err := store.Connect(ctx, databaseURL)
if err != nil {
cancel()
t.Fatalf("connect acceptance store: %v", err)
}
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
db.Close()
cancel()
t.Fatalf("connect acceptance pool: %v", err)
}
handler := NewServerWithContext(ctx, config.Config{
AppEnv: "test",
HTTPAddr: ":0",
DatabaseURL: databaseURL,
IdentityMode: "hybrid",
JWTSecret: "failure-policy-http-acceptance-secret",
BillingEngineMode: "observe",
CORSAllowedOrigin: "*",
}, db, slog.New(slog.NewTextHandler(io.Discard, nil)))
server := httptest.NewServer(handler)
fixture := &failurePolicyAcceptanceFixture{
ctx: ctx,
cancel: cancel,
db: db,
pool: pool,
server: server,
suffix: strconv.FormatInt(time.Now().UnixNano(), 10),
}
username := "failure_acceptance_" + fixture.suffix
password := "password123"
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/register", "", map[string]any{
"username": username,
"email": username + "@example.com",
"password": password,
}, http.StatusCreated, &map[string]any{})
if _, err := pool.Exec(ctx, `UPDATE gateway_users SET roles = '["admin"]'::jsonb WHERE username = $1`, username); err != nil {
fixture.close()
t.Fatalf("promote acceptance admin: %v", err)
}
var login struct {
AccessToken string `json:"accessToken"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
"account": username, "password": password,
}, http.StatusOK, &login)
fixture.adminToken = login.AccessToken
var gatewayUserID string
if err := pool.QueryRow(ctx, `SELECT id::text FROM gateway_users WHERE username=$1`, username).Scan(&gatewayUserID); err != nil {
fixture.close()
t.Fatalf("read acceptance user: %v", err)
}
doJSON(t, server.URL, http.MethodPatch, "/api/admin/users/"+gatewayUserID+"/wallet", fixture.adminToken, map[string]any{
"currency": "resource",
"balance": 1000000,
"reason": "seed isolated failure-policy acceptance wallet",
}, http.StatusOK, &map[string]any{})
var key struct {
Secret string `json:"secret"`
}
doJSON(t, server.URL, http.MethodPost, "/api/v1/api-keys", fixture.adminToken, map[string]any{
"name": "failure policy acceptance",
}, http.StatusCreated, &key)
fixture.apiKey = key.Secret
return fixture
}
func (f *failurePolicyAcceptanceFixture) close() {
if f.server != nil {
f.server.Close()
}
if f.cancel != nil {
f.cancel()
}
if f.pool != nil {
f.pool.Close()
}
if f.db != nil {
f.db.Close()
}
}
func (f *failurePolicyAcceptanceFixture) setRunnerPolicy(t *testing.T, singleSource bool) {
t.Helper()
doJSON(t, f.server.URL, http.MethodPatch, "/api/admin/runtime/runner-policy", f.adminToken, standardFailureRunnerPolicy(singleSource), http.StatusOK, &map[string]any{})
}
func standardFailureRunnerPolicy(singleSource bool) map[string]any {
return map[string]any{
"policyKey": "default-runner-v1",
"name": "失败策略 HTTP 验收",
"status": "active",
"description": "真实 provider HTTP client 的失败策略验收",
"failoverPolicy": map[string]any{
"enabled": true,
"maxPlatforms": 99,
"maxDurationSeconds": 600,
"actionRules": []any{
map[string]any{"categories": []any{"rate_limit"}, "statusCodes": []any{429}, "action": "cooldown_and_next", "target": "model", "cooldownSeconds": 30},
map[string]any{"categories": []any{"auth_error"}, "statusCodes": []any{401, 403}, "action": "disable_and_next", "target": "platform"},
map[string]any{"categories": []any{"timeout", "provider_5xx", "provider_overloaded"}, "statusCodes": []any{408, 500, 502, 503, 504}, "action": "demote_and_next", "target": "platform", "demoteSteps": 1},
map[string]any{"categories": []any{"network", "stream_error"}, "action": "next"},
map[string]any{"categories": []any{"request_error", "unsupported_model", "user_permission", "insufficient_balance"}, "statusCodes": []any{400, 404, 409, 422}, "action": "stop"},
},
},
"singleSourcePolicy": map[string]any{"enabled": singleSource},
"cacheAffinityPolicy": map[string]any{"enabled": false},
}
}
func (f *failurePolicyAcceptanceFixture) createBaseModel(t *testing.T, model string, modelType string) string {
t.Helper()
var created struct {
ID string `json:"id"`
}
doJSON(t, f.server.URL, http.MethodPost, "/api/admin/catalog/base-models", f.adminToken, map[string]any{
"providerKey": "openai",
"canonicalModelKey": "openai:" + model,
"invocationName": model,
"providerModelName": model,
"modelType": []string{modelType},
"displayName": model,
"status": "active",
"capabilities": map[string]any{},
"baseBillingConfig": map[string]any{},
}, http.StatusCreated, &created)
return created.ID
}
func (f *failurePolicyAcceptanceFixture) createPlatformModel(
t *testing.T,
name string,
provider string,
baseURL string,
priority int,
baseModelID string,
providerModelName string,
modelType string,
retryAttempts int,
runtimePolicySetID string,
runtimeOverride map[string]any,
) (acceptancePlatform, acceptancePlatformModel) {
t.Helper()
var platform acceptancePlatform
doJSON(t, f.server.URL, http.MethodPost, "/api/admin/platforms", f.adminToken, map[string]any{
"provider": provider,
"platformKey": "acceptance-" + name + "-" + f.suffix,
"name": "acceptance-" + name,
"baseUrl": strings.TrimRight(baseURL, "/"),
"authType": "bearer",
"credentials": map[string]any{"apiKey": "controlled-upstream-test-key"},
"priority": priority,
"status": "enabled",
}, http.StatusCreated, &platform)
var model acceptancePlatformModel
payload := map[string]any{
"baseModelId": baseModelID,
"providerModelName": providerModelName,
"modelType": []string{modelType},
"retryPolicy": map[string]any{"enabled": true, "maxAttempts": retryAttempts},
}
if runtimePolicySetID != "" {
payload["runtimePolicySetId"] = runtimePolicySetID
}
if len(runtimeOverride) > 0 {
payload["runtimePolicyOverride"] = runtimeOverride
}
doJSON(t, f.server.URL, http.MethodPost, "/api/admin/platforms/"+platform.ID+"/models", f.adminToken, payload, http.StatusCreated, &model)
return platform, model
}
func newControlledProviderStub(handler func(call int, w http.ResponseWriter, r *http.Request)) *controlledProviderStub {
stub := &controlledProviderStub{}
stub.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call := int(stub.calls.Add(1))
handler(call, w, r)
}))
return stub
}
func (s *controlledProviderStub) close() {
if s != nil && s.server != nil {
s.server.Close()
}
}
func writeStubJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeOpenAIChatSuccess(w http.ResponseWriter, content string) {
writeStubJSON(w, http.StatusOK, map[string]any{
"id": "chatcmpl-controlled", "object": "chat.completion", "created": time.Now().Unix(), "model": "controlled-model",
"choices": []any{map[string]any{
"index": 0, "message": map[string]any{"role": "assistant", "content": content}, "finish_reason": "stop",
}},
"usage": map[string]any{"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
})
}
func writeOpenAIImageSuccess(w http.ResponseWriter) {
writeStubJSON(w, http.StatusOK, map[string]any{
"created": time.Now().Unix(),
"data": []any{map[string]any{"url": "https://example.invalid/controlled-test.png"}},
})
}
func (f *failurePolicyAcceptanceFixture) postJSON(t *testing.T, path string, payload any, headers map[string]string) (int, http.Header, []byte) {
t.Helper()
raw, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal acceptance payload: %v", err)
}
request, err := http.NewRequest(http.MethodPost, f.server.URL+path, bytes.NewReader(raw))
if err != nil {
t.Fatalf("build acceptance request: %v", err)
}
request.Header.Set("Authorization", "Bearer "+f.apiKey)
request.Header.Set("Content-Type", "application/json")
for key, value := range headers {
request.Header.Set(key, value)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatalf("acceptance request %s: %v", path, err)
}
defer response.Body.Close()
body, _ := io.ReadAll(response.Body)
return response.StatusCode, response.Header.Clone(), body
}
func (f *failurePolicyAcceptanceFixture) taskIDForModel(t *testing.T, model string, startedAt time.Time) string {
t.Helper()
var taskID string
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
err := f.pool.QueryRow(f.ctx, `
SELECT id::text
FROM gateway_tasks
WHERE model = $1
AND created_at >= $2
ORDER BY created_at DESC
LIMIT 1`, model, startedAt.Add(-time.Second)).Scan(&taskID)
if err == nil && taskID != "" {
return taskID
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("task for model %q was not persisted", model)
return ""
}
func (f *failurePolicyAcceptanceFixture) loadTask(t *testing.T, taskID string) acceptanceTaskDetail {
t.Helper()
var detail acceptanceTaskDetail
doJSON(t, f.server.URL, http.MethodGet, "/api/v1/tasks/"+taskID, f.apiKey, nil, http.StatusOK, &detail)
return detail
}
func countFailureEffects(detail acceptanceTaskDetail, effect string) int {
count := 0
for _, attempt := range detail.Attempts {
trace := attempt.Trace
for _, raw := range anySlice(attempt.DynamicMetrics["trace"]) {
if entry, ok := raw.(map[string]any); ok {
trace = append(trace, entry)
}
}
for _, entry := range trace {
if entry["event"] == "failure_decision" && entry["effect"] == effect {
count++
}
}
}
return count
}
func anySlice(value any) []any {
items, _ := value.([]any)
return items
}
func (f *failurePolicyAcceptanceFixture) testGemini429ImageFailover(t *testing.T) {
model := "acceptance-gemini-image-" + f.suffix
baseModelID := f.createBaseModel(t, model, "image_generate")
gemini := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) {
writeStubJSON(w, http.StatusTooManyRequests, map[string]any{
"error": map[string]any{"code": 429, "message": "RESOURCE_EXHAUSTED controlled failure", "status": "RESOURCE_EXHAUSTED"},
})
})
defer gemini.close()
openai := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) { writeOpenAIImageSuccess(w) })
defer openai.close()
platformA, modelA := f.createPlatformModel(t, "gemini-image-a", "gemini", gemini.server.URL, 10, baseModelID, "gemini-image-controlled", "image_generate", 3, "", nil)
platformB, _ := f.createPlatformModel(t, "openai-image-b", "openai", openai.server.URL+"/v1", 20, baseModelID, "openai-image-controlled", "image_generate", 1, "", nil)
startedAt := time.Now()
status, _, body := f.postJSON(t, "/api/v1/images/generations", map[string]any{"model": model, "prompt": "controlled image"}, nil)
if status != http.StatusOK {
t.Fatalf("status=%d body=%s", status, body)
}
if gemini.calls.Load() != 1 || openai.calls.Load() != 1 {
t.Fatalf("upstream calls gemini=%d openai=%d, want 1/1", gemini.calls.Load(), openai.calls.Load())
}
var modelCooling, platformCooling bool
if err := f.pool.QueryRow(f.ctx, `
SELECT COALESCE(model.cooldown_until > now(), false), COALESCE(platform.cooldown_until > now(), false)
FROM platform_models model
JOIN integration_platforms platform ON platform.id = model.platform_id
WHERE model.id = $1::uuid`, modelA.ID).Scan(&modelCooling, &platformCooling); err != nil {
t.Fatalf("read Gemini cooldown state: %v", err)
}
if !modelCooling || platformCooling {
t.Fatalf("cooldown state model=%v platform=%v, want true/false for platform %s", modelCooling, platformCooling, platformA.ID)
}
detail := f.loadTask(t, f.taskIDForModel(t, model, startedAt))
if len(detail.Attempts) != 2 || detail.Attempts[0].PlatformID != platformA.ID || detail.Attempts[1].PlatformID != platformB.ID || countFailureEffects(detail, "cooldown") != 1 {
t.Fatalf("unexpected task attempts/trace: %+v", detail)
}
}
func (f *failurePolicyAcceptanceFixture) testServerErrorRetryThenFailover(t *testing.T) {
model := "acceptance-5xx-" + f.suffix
baseModelID := f.createBaseModel(t, model, "text_generate")
failed := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) {
writeStubJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]any{"message": "controlled 500"}})
})
defer failed.close()
success := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) { writeOpenAIChatSuccess(w, "fallback ok") })
defer success.close()
platformA, _ := f.createPlatformModel(t, "5xx-a", "openai", failed.server.URL+"/v1", 10, baseModelID, "controlled-5xx", "text_generate", 2, "", nil)
platformB, _ := f.createPlatformModel(t, "5xx-b", "openai", success.server.URL+"/v1", 20, baseModelID, "controlled-success", "text_generate", 1, "", nil)
startedAt := time.Now()
status, _, body := f.postJSON(t, "/api/v1/chat/completions", map[string]any{
"model": model, "messages": []any{map[string]any{"role": "user", "content": "retry"}},
}, nil)
if status != http.StatusOK {
t.Fatalf("status=%d body=%s", status, body)
}
if failed.calls.Load() != 2 || success.calls.Load() != 1 {
t.Fatalf("upstream calls failed=%d success=%d, want 2/1", failed.calls.Load(), success.calls.Load())
}
detail := f.loadTask(t, f.taskIDForModel(t, model, startedAt))
if len(detail.Attempts) != 3 ||
detail.Attempts[0].PlatformID != platformA.ID ||
detail.Attempts[1].PlatformID != platformA.ID ||
detail.Attempts[2].PlatformID != platformB.ID ||
countFailureEffects(detail, "demote") != 1 {
t.Fatalf("unexpected retry/failover chain: %+v", detail)
}
var dynamicPriority *int
if err := f.pool.QueryRow(f.ctx, `SELECT dynamic_priority FROM integration_platforms WHERE id=$1::uuid`, platformA.ID).Scan(&dynamicPriority); err != nil {
t.Fatalf("read demoted priority: %v", err)
}
if dynamicPriority == nil {
t.Fatal("failed platform should be demoted exactly once after retry exhaustion")
}
}
func (f *failurePolicyAcceptanceFixture) testAuthFailureDisableThenFailover(t *testing.T) {
model := "acceptance-auth-" + f.suffix
baseModelID := f.createBaseModel(t, model, "text_generate")
failed := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) {
writeStubJSON(w, http.StatusUnauthorized, map[string]any{"error": map[string]any{"message": "invalid api key"}})
})
defer failed.close()
success := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) { writeOpenAIChatSuccess(w, "auth fallback") })
defer success.close()
platformA, _ := f.createPlatformModel(t, "auth-a", "openai", failed.server.URL+"/v1", 10, baseModelID, "controlled-auth", "text_generate", 3, "", nil)
_, _ = f.createPlatformModel(t, "auth-b", "openai", success.server.URL+"/v1", 20, baseModelID, "controlled-success", "text_generate", 1, "", nil)
startedAt := time.Now()
status, _, body := f.postJSON(t, "/api/v1/chat/completions", map[string]any{
"model": model, "messages": []any{map[string]any{"role": "user", "content": "auth"}},
}, nil)
if status != http.StatusOK {
t.Fatalf("status=%d body=%s", status, body)
}
if failed.calls.Load() != 1 || success.calls.Load() != 1 {
t.Fatalf("upstream calls failed=%d success=%d, want 1/1", failed.calls.Load(), success.calls.Load())
}
var statusValue string
if err := f.pool.QueryRow(f.ctx, `SELECT status FROM integration_platforms WHERE id=$1::uuid`, platformA.ID).Scan(&statusValue); err != nil {
t.Fatalf("read disabled platform: %v", err)
}
detail := f.loadTask(t, f.taskIDForModel(t, model, startedAt))
if statusValue != "disabled" || countFailureEffects(detail, "disable") != 1 || len(detail.Attempts) != 2 {
t.Fatalf("unexpected auth disable result status=%s detail=%+v", statusValue, detail)
}
}
func (f *failurePolicyAcceptanceFixture) testBadRequestStops(t *testing.T) {
model := "acceptance-400-" + f.suffix
baseModelID := f.createBaseModel(t, model, "text_generate")
failed := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) {
writeStubJSON(w, http.StatusBadRequest, map[string]any{"error": map[string]any{"message": "controlled invalid parameter", "code": "invalid_parameter"}})
})
defer failed.close()
success := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) { writeOpenAIChatSuccess(w, "must not run") })
defer success.close()
platformA, modelA := f.createPlatformModel(t, "400-a", "openai", failed.server.URL+"/v1", 10, baseModelID, "controlled-bad", "text_generate", 3, "", nil)
_, _ = f.createPlatformModel(t, "400-b", "openai", success.server.URL+"/v1", 20, baseModelID, "controlled-success", "text_generate", 1, "", nil)
status, _, body := f.postJSON(t, "/api/v1/chat/completions", map[string]any{
"model": model, "messages": []any{map[string]any{"role": "user", "content": "bad"}},
}, nil)
if status != http.StatusBadRequest || success.calls.Load() != 0 || failed.calls.Load() != 1 {
t.Fatalf("status=%d calls=%d/%d body=%s", status, failed.calls.Load(), success.calls.Load(), body)
}
var platformStatus string
var modelEnabled bool
var platformCooldown, modelCooldown *time.Time
if err := f.pool.QueryRow(f.ctx, `
SELECT platform.status, model.enabled, platform.cooldown_until, model.cooldown_until
FROM integration_platforms platform
JOIN platform_models model ON model.platform_id=platform.id
WHERE platform.id=$1::uuid AND model.id=$2::uuid`, platformA.ID, modelA.ID).Scan(&platformStatus, &modelEnabled, &platformCooldown, &modelCooldown); err != nil {
t.Fatalf("read hard-stop state: %v", err)
}
if platformStatus != "enabled" || !modelEnabled || platformCooldown != nil || modelCooldown != nil {
t.Fatalf("hard stop mutated runtime state: status=%s enabled=%v platformCooldown=%v modelCooldown=%v", platformStatus, modelEnabled, platformCooldown, modelCooldown)
}
}
func (f *failurePolicyAcceptanceFixture) testLegacyDegradeDoesNotDoubleApply(t *testing.T) {
model := "acceptance-legacy-" + f.suffix
baseModelID := f.createBaseModel(t, model, "text_generate")
var policySet struct {
ID string `json:"id"`
}
doJSON(t, f.server.URL, http.MethodPost, "/api/admin/runtime/policy-sets", f.adminToken, map[string]any{
"policyKey": "acceptance-legacy-" + f.suffix,
"name": "acceptance legacy degrade",
"degradePolicy": map[string]any{
"enabled": true, "keywords": []any{"rate_limit"}, "cooldownSeconds": 45,
},
}, http.StatusCreated, &policySet)
failed := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) {
writeStubJSON(w, http.StatusTooManyRequests, map[string]any{"error": map[string]any{"message": "rate_limit controlled"}})
})
defer failed.close()
success := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) { writeOpenAIChatSuccess(w, "legacy fallback") })
defer success.close()
_, modelA := f.createPlatformModel(t, "legacy-a", "openai", failed.server.URL+"/v1", 10, baseModelID, "legacy-failed", "text_generate", 3, policySet.ID, nil)
_, _ = f.createPlatformModel(t, "legacy-b", "openai", success.server.URL+"/v1", 20, baseModelID, "legacy-success", "text_generate", 1, "", nil)
startedAt := time.Now()
status, _, body := f.postJSON(t, "/api/v1/chat/completions", map[string]any{
"model": model, "messages": []any{map[string]any{"role": "user", "content": "legacy"}},
}, nil)
if status != http.StatusOK || failed.calls.Load() != 1 || success.calls.Load() != 1 {
t.Fatalf("status=%d calls=%d/%d body=%s", status, failed.calls.Load(), success.calls.Load(), body)
}
detail := f.loadTask(t, f.taskIDForModel(t, model, startedAt))
if countFailureEffects(detail, "cooldown") != 1 {
t.Fatalf("legacy and global policy must not both execute: %+v", detail)
}
var remaining float64
if err := f.pool.QueryRow(f.ctx, `SELECT EXTRACT(EPOCH FROM cooldown_until-now())::float8 FROM platform_models WHERE id=$1::uuid`, modelA.ID).Scan(&remaining); err != nil {
t.Fatalf("read legacy cooldown: %v", err)
}
if remaining < 35 || remaining > 50 {
t.Fatalf("legacy cooldown should be applied once with 45 seconds, remaining=%f", remaining)
}
}
func (f *failurePolicyAcceptanceFixture) testConcurrentSingleSourceProtection(t *testing.T) {
f.setRunnerPolicy(t, true)
model := "acceptance-concurrent-" + f.suffix
baseModelID := f.createBaseModel(t, model, "text_generate")
stubs := make([]*controlledProviderStub, 0, 3)
modelIDs := make([]string, 0, 3)
for index := 0; index < 3; index++ {
stub := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) {
writeStubJSON(w, http.StatusTooManyRequests, map[string]any{"error": map[string]any{"message": "controlled 429"}})
})
stubs = append(stubs, stub)
_, platformModel := f.createPlatformModel(t, fmt.Sprintf("concurrent-%d", index), "openai", stub.server.URL+"/v1", 10+index*10, baseModelID, fmt.Sprintf("concurrent-%d", index), "text_generate", 1, "", nil)
modelIDs = append(modelIDs, platformModel.ID)
}
defer func() {
for _, stub := range stubs {
stub.close()
}
}()
var wait sync.WaitGroup
errorsFound := make(chan error, 12)
for index := 0; index < 12; index++ {
wait.Add(1)
go func() {
defer wait.Done()
status, _, body, err := f.postJSONNoFail("/api/v1/chat/completions", map[string]any{
"model": model, "messages": []any{map[string]any{"role": "user", "content": "concurrent"}},
})
if err != nil {
errorsFound <- err
return
}
if status != http.StatusTooManyRequests {
errorsFound <- fmt.Errorf("status=%d body=%s", status, body)
}
}()
}
wait.Wait()
close(errorsFound)
for err := range errorsFound {
t.Error(err)
}
var available int
if err := f.pool.QueryRow(f.ctx, `
SELECT count(*)
FROM platform_models model
JOIN integration_platforms platform ON platform.id=model.platform_id
WHERE model.id = ANY($1::uuid[])
AND model.enabled=true
AND platform.status='enabled'
AND (model.cooldown_until IS NULL OR model.cooldown_until <= now())
AND (platform.cooldown_until IS NULL OR platform.cooldown_until <= now())`, modelIDs).Scan(&available); err != nil {
t.Fatalf("count available sources: %v", err)
}
if available < 1 {
t.Fatal("concurrent cooldown decisions disabled or cooled every source")
}
before := make([]int64, len(stubs))
for index, stub := range stubs {
before[index] = stub.calls.Load()
}
status, _, _, err := f.postJSONNoFail("/api/v1/chat/completions", map[string]any{
"model": model, "messages": []any{map[string]any{"role": "user", "content": "post-cooldown"}},
})
if err != nil || status != http.StatusTooManyRequests {
t.Fatalf("post-cooldown request status=%d err=%v", status, err)
}
for index, modelID := range modelIDs {
var cooled bool
if err := f.pool.QueryRow(f.ctx, `SELECT COALESCE(cooldown_until > now(), false) FROM platform_models WHERE id=$1::uuid`, modelID).Scan(&cooled); err != nil {
t.Fatalf("read source cooldown: %v", err)
}
if cooled && stubs[index].calls.Load() != before[index] {
t.Fatalf("cooled source %d was called again: before=%d after=%d", index, before[index], stubs[index].calls.Load())
}
}
}
func (f *failurePolicyAcceptanceFixture) postJSONNoFail(path string, payload any) (int, http.Header, []byte, error) {
raw, err := json.Marshal(payload)
if err != nil {
return 0, nil, nil, err
}
request, err := http.NewRequest(http.MethodPost, f.server.URL+path, bytes.NewReader(raw))
if err != nil {
return 0, nil, nil, err
}
request.Header.Set("Authorization", "Bearer "+f.apiKey)
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
return 0, nil, nil, err
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
return response.StatusCode, response.Header.Clone(), body, err
}
func (f *failurePolicyAcceptanceFixture) testAllCoolingSynchronousContract(t *testing.T) {
f.setRunnerPolicy(t, false)
defer f.setRunnerPolicy(t, true)
model := "acceptance-all-cooling-" + f.suffix
baseModelID := f.createBaseModel(t, model, "image_generate")
stubA := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) {
writeStubJSON(w, http.StatusTooManyRequests, map[string]any{"error": map[string]any{"message": "private-a-raw-error"}})
})
defer stubA.close()
stubB := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) {
writeStubJSON(w, http.StatusTooManyRequests, map[string]any{"error": map[string]any{"message": "private-b-raw-error"}})
})
defer stubB.close()
override := func(seconds int) map[string]any {
return map[string]any{"failoverPolicy": map[string]any{"actionRules": []any{
map[string]any{"statusCodes": []any{429}, "action": "cooldown_and_next", "target": "model", "cooldownSeconds": seconds},
}}}
}
_, modelA := f.createPlatformModel(t, "cooling-a", "openai", stubA.server.URL+"/v1", 10, baseModelID, "cooling-a", "image_generate", 3, "", override(30))
_, modelB := f.createPlatformModel(t, "cooling-b", "openai", stubB.server.URL+"/v1", 20, baseModelID, "cooling-b", "image_generate", 3, "", override(60))
defer func() {
_, _ = f.pool.Exec(context.Background(), `UPDATE platform_models SET cooldown_until=NULL WHERE id IN ($1::uuid,$2::uuid)`, modelA.ID, modelB.ID)
}()
status, _, _ := f.postJSON(t, "/api/v1/images/generations", map[string]any{"model": model, "prompt": "cool both"}, nil)
if status != http.StatusTooManyRequests || stubA.calls.Load() != 1 || stubB.calls.Load() != 1 {
t.Fatalf("initial cooling request status=%d calls=%d/%d", status, stubA.calls.Load(), stubB.calls.Load())
}
startedAt := time.Now()
status, headers, body := f.postJSON(t, "/api/v1/images/generations", map[string]any{"model": model, "prompt": "all cooling"}, nil)
if status != http.StatusTooManyRequests {
t.Fatalf("cooldown contract status=%d body=%s", status, body)
}
retryAfter, err := strconv.Atoi(headers.Get("Retry-After"))
if err != nil || retryAfter < 20 || retryAfter > 31 {
t.Fatalf("Retry-After=%q, want earliest recovery near 30 seconds", headers.Get("Retry-After"))
}
bodyText := string(body)
for _, forbidden := range []string{"acceptance-cooling-a", "acceptance-cooling-b", stubA.server.URL, stubB.server.URL, "private-a-raw-error", "private-b-raw-error"} {
if strings.Contains(bodyText, forbidden) {
t.Fatalf("cooldown response leaked %q: %s", forbidden, bodyText)
}
}
var envelope struct {
Error struct {
Code string `json:"code"`
Details map[string]any `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatalf("decode cooldown response: %v body=%s", err, body)
}
if envelope.Error.Code != "model_cooling_down" || len(envelope.Error.Details) != 2 {
t.Fatalf("unexpected cooldown envelope: %+v", envelope)
}
detail := f.loadTask(t, f.taskIDForModel(t, model, startedAt))
if len(detail.Attempts) != 0 {
t.Fatalf("candidate selection cooldown must not create provider attempts: %+v", detail.Attempts)
}
}
func (f *failurePolicyAcceptanceFixture) testAllCoolingAsyncRecovery(t *testing.T) {
f.setRunnerPolicy(t, false)
defer f.setRunnerPolicy(t, true)
model := "acceptance-async-cooling-" + f.suffix
baseModelID := f.createBaseModel(t, model, "image_generate")
stubA := newControlledProviderStub(func(call int, w http.ResponseWriter, _ *http.Request) {
if call == 1 {
writeStubJSON(w, http.StatusTooManyRequests, map[string]any{"error": map[string]any{"message": "controlled first 429"}})
return
}
writeOpenAIImageSuccess(w)
})
defer stubA.close()
stubB := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) {
writeStubJSON(w, http.StatusTooManyRequests, map[string]any{"error": map[string]any{"message": "controlled second 429"}})
})
defer stubB.close()
override := func(seconds int) map[string]any {
return map[string]any{"failoverPolicy": map[string]any{"actionRules": []any{
map[string]any{"statusCodes": []any{429}, "action": "cooldown_and_next", "target": "model", "cooldownSeconds": seconds},
}}}
}
_, modelA := f.createPlatformModel(t, "async-cooling-a", "openai", stubA.server.URL+"/v1", 10, baseModelID, "async-a", "image_generate", 1, "", override(4))
_, modelB := f.createPlatformModel(t, "async-cooling-b", "openai", stubB.server.URL+"/v1", 20, baseModelID, "async-b", "image_generate", 1, "", override(8))
defer func() {
_, _ = f.pool.Exec(context.Background(), `UPDATE platform_models SET cooldown_until=NULL WHERE id IN ($1::uuid,$2::uuid)`, modelA.ID, modelB.ID)
}()
status, _, _ := f.postJSON(t, "/api/v1/images/generations", map[string]any{"model": model, "prompt": "prime cooldown"}, nil)
if status != http.StatusTooManyRequests {
t.Fatalf("prime cooldown status=%d", status)
}
startedAt := time.Now()
status, _, body := f.postJSON(t, "/api/v1/images/generations", map[string]any{"model": model, "prompt": "async recovery"}, map[string]string{"X-Async": "true"})
if status != http.StatusAccepted {
t.Fatalf("async submit status=%d body=%s", status, body)
}
taskID := f.taskIDForModel(t, model, startedAt)
deadline := time.Now().Add(2 * time.Second)
queuedObserved := false
for time.Now().Before(deadline) {
detail := f.loadTask(t, taskID)
if detail.Status == "queued" && len(detail.Attempts) == 0 {
queuedObserved = true
break
}
time.Sleep(25 * time.Millisecond)
}
if !queuedObserved {
t.Fatal("async cooling task was not observed queued with zero provider attempts")
}
deadline = time.Now().Add(12 * time.Second)
var completed acceptanceTaskDetail
for time.Now().Before(deadline) {
completed = f.loadTask(t, taskID)
if completed.Status == "succeeded" {
break
}
time.Sleep(100 * time.Millisecond)
}
if completed.Status != "succeeded" || len(completed.Attempts) != 1 || stubA.calls.Load() != 2 {
t.Fatalf("async task did not recover on first restored candidate: task=%+v callsA=%d callsB=%d", completed, stubA.calls.Load(), stubB.calls.Load())
}
}
func (f *failurePolicyAcceptanceFixture) testStreamingBoundary(t *testing.T) {
t.Run("首个 delta 前允许轮转", func(t *testing.T) {
model := "acceptance-stream-before-" + f.suffix
baseModelID := f.createBaseModel(t, model, "text_generate")
failed := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) {
writeStubJSON(w, http.StatusInternalServerError, map[string]any{"error": map[string]any{"message": "before delta"}})
})
defer failed.close()
success := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"id\":\"chunk-ok\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"stream fallback\"},\"finish_reason\":null}]}\n\n")
_, _ = io.WriteString(w, "data: {\"id\":\"chunk-ok\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
})
defer success.close()
_, _ = f.createPlatformModel(t, "stream-before-a", "openai", failed.server.URL+"/v1", 10, baseModelID, "stream-failed", "text_generate", 1, "", nil)
_, _ = f.createPlatformModel(t, "stream-before-b", "openai", success.server.URL+"/v1", 20, baseModelID, "stream-success", "text_generate", 1, "", nil)
status, _, body := f.postJSON(t, "/api/v1/chat/completions", map[string]any{
"model": model, "stream": true, "messages": []any{map[string]any{"role": "user", "content": "stream"}},
}, nil)
if status != http.StatusOK || failed.calls.Load() != 1 || success.calls.Load() != 1 || strings.Count(string(body), "stream fallback") != 1 || !strings.Contains(string(body), "[DONE]") {
t.Fatalf("unexpected pre-delta failover status=%d calls=%d/%d body=%s", status, failed.calls.Load(), success.calls.Load(), body)
}
})
t.Run("已输出 delta 后禁止轮转", func(t *testing.T) {
model := "acceptance-stream-after-" + f.suffix
baseModelID := f.createBaseModel(t, model, "text_generate")
broken := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) {
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "hijacking unavailable", http.StatusInternalServerError)
return
}
connection, buffer, err := hijacker.Hijack()
if err != nil {
return
}
defer connection.Close()
_, _ = buffer.WriteString("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 4096\r\n\r\n")
_, _ = buffer.WriteString("data: {\"id\":\"chunk-broken\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"first platform only\"},\"finish_reason\":null}]}\n\n")
_ = buffer.Flush()
})
defer broken.close()
fallback := newControlledProviderStub(func(_ int, w http.ResponseWriter, _ *http.Request) { writeOpenAIChatSuccess(w, "must not mix") })
defer fallback.close()
_, _ = f.createPlatformModel(t, "stream-after-a", "openai", broken.server.URL+"/v1", 10, baseModelID, "stream-broken", "text_generate", 2, "", nil)
_, _ = f.createPlatformModel(t, "stream-after-b", "openai", fallback.server.URL+"/v1", 20, baseModelID, "stream-fallback", "text_generate", 1, "", nil)
status, _, body := f.postJSON(t, "/api/v1/chat/completions", map[string]any{
"model": model, "stream": true, "messages": []any{map[string]any{"role": "user", "content": "stream"}},
}, nil)
if status != http.StatusOK || broken.calls.Load() != 1 || fallback.calls.Load() != 0 {
t.Fatalf("post-delta failure rotated unexpectedly status=%d calls=%d/%d body=%s", status, broken.calls.Load(), fallback.calls.Load(), body)
}
bodyText := string(body)
if !strings.Contains(bodyText, "first platform only") || strings.Contains(bodyText, "must not mix") || strings.Count(bodyText, "event: error") != 1 {
t.Fatalf("stream response mixed providers or missed terminal error: %s", bodyText)
}
})
}
func (f *failurePolicyAcceptanceFixture) testRunnerPolicyHTTPValidation(t *testing.T) {
valid := standardFailureRunnerPolicy(true)
var saved map[string]any
doJSON(t, f.server.URL, http.MethodPatch, "/api/admin/runtime/runner-policy", f.adminToken, valid, http.StatusOK, &saved)
var before map[string]any
doJSON(t, f.server.URL, http.MethodGet, "/api/admin/runtime/runner-policy", f.adminToken, nil, http.StatusOK, &before)
invalidRules := []map[string]any{
{"categories": []any{"rate_limit"}, "action": "rotate"},
{"categories": []any{"rate_limit"}, "action": "next", "target": "client"},
{"action": "next"},
{"statusCodes": []any{429}, "action": "cooldown_and_next", "target": "model", "cooldownSeconds": 0},
{"statusCodes": []any{500}, "action": "demote_and_next", "target": "platform", "demoteSteps": -1},
}
for index, rule := range invalidRules {
payload := standardFailureRunnerPolicy(true)
payload["failoverPolicy"].(map[string]any)["actionRules"] = []any{rule}
raw, _ := json.Marshal(payload)
request, _ := http.NewRequest(http.MethodPatch, f.server.URL+"/api/admin/runtime/runner-policy", bytes.NewReader(raw))
request.Header.Set("Authorization", "Bearer "+f.adminToken)
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatalf("invalid policy request %d: %v", index, err)
}
body, _ := io.ReadAll(response.Body)
response.Body.Close()
if response.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid policy %d status=%d body=%s", index, response.StatusCode, body)
}
}
var after map[string]any
doJSON(t, f.server.URL, http.MethodGet, "/api/admin/runtime/runner-policy", f.adminToken, nil, http.StatusOK, &after)
beforeFailover, _ := before["failoverPolicy"].(map[string]any)
afterFailover, _ := after["failoverPolicy"].(map[string]any)
if !jsonObjectsEqual(beforeFailover["actionRules"], afterFailover["actionRules"]) {
t.Fatalf("invalid PATCH overwrote runner action rules: before=%+v after=%+v", beforeFailover["actionRules"], afterFailover["actionRules"])
}
}
func jsonObjectsEqual(left any, right any) bool {
leftRaw, _ := json.Marshal(left)
rightRaw, _ := json.Marshal(right)
return bytes.Equal(leftRaw, rightRaw)
}
@@ -62,7 +62,7 @@ func (s *Server) uploadFile(w http.ResponseWriter, r *http.Request) {
writeError(w, status, err.Error())
return
}
writeJSON(w, http.StatusOK, upload)
writeJSON(w, http.StatusOK, easyAIFileUploadResponse(upload))
}
func firstNonEmptyFormValue(r *http.Request, key string, fallback string) string {
@@ -170,6 +170,10 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
writeGeminiTaskError(http.StatusConflict, "Idempotency-Key was reused for a different request", nil, "idempotency_key_reused")
return
}
if errors.Is(err, store.ErrTaskRequestBinaryNotMaterialized) {
writeGeminiTaskError(http.StatusBadRequest, err.Error(), nil, clients.ErrorCode(err))
return
}
s.logger.Error("create gemini task failed", "kind", mapping.Kind, "error_category", "task_create_failed")
writeGeminiTaskError(http.StatusInternalServerError, "create task failed", nil, "task_create_failed")
return
@@ -184,6 +188,11 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
return
}
if task.Status == "succeeded" {
task, err = s.hydrateTaskResult(r.Context(), task)
if err != nil {
writeGeminiTaskError(statusFromRunError(err), err.Error(), nil, clients.ErrorCode(err))
return
}
if _, native := task.Result["candidates"]; native {
writeJSON(w, http.StatusOK, task.Result)
return
@@ -209,6 +218,7 @@ func (s *Server) geminiGenerateContent(w http.ResponseWriter, r *http.Request) {
if !requestStillConnected(r) {
return
}
applyRunErrorHeaders(w, runErr)
if wire := clients.ErrorWireResponse(runErr); wireResponseMatches(wire, clients.ProtocolGeminiGenerateContent) {
writeWireResponse(w, wire)
return
@@ -261,6 +271,7 @@ func (s *Server) writeGeminiGenerateContentStream(runCtx context.Context, w http
if !requestStillConnected(r) {
return
}
applyRunErrorHeaders(w, runErr)
if !nativePassthrough && !convertedFrames {
if wire := clients.ErrorWireResponse(runErr); wireResponseMatches(wire, clients.ProtocolGeminiGenerateContent) {
writeWireResponse(w, wire)
+95 -19
View File
@@ -580,12 +580,20 @@ func (s *Server) listModels(w http.ResponseWriter, r *http.Request) {
// @Tags playground
// @Produce json
// @Security BearerAuth
// @Param usage_scene query string false "模型可选场景;不传时保持原有行为" Enums(canvas_model_node,desktop)
// @Success 200 {object} PlatformModelListResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/v1/models [get]
// @Router /api/v1/playground/models [get]
func (s *Server) listPlayableModels(w http.ResponseWriter, r *http.Request) {
usageScene, err := parseModelUsageSceneQuery(r.URL.Query())
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, _ := auth.UserFromContext(r.Context())
models, err := s.store.ListAccessiblePlatformModels(r.Context(), user)
if err != nil {
@@ -593,6 +601,15 @@ func (s *Server) listPlayableModels(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, "list playable models failed")
return
}
if usageScene != "" {
availableModelIDs, sceneErr := s.listServerMainUsageSceneModelIDs(r.Context(), usageScene)
if sceneErr != nil {
s.logger.Error("filter playable models by usage scene failed", "usage_scene", usageScene, "error", sceneErr)
writeError(w, http.StatusBadGateway, "model usage scene catalog is unavailable")
return
}
models = filterPlatformModelsByUsageSceneIDs(models, availableModelIDs)
}
writeJSON(w, http.StatusOK, map[string]any{"items": s.platformModelResponses(r.Context(), models)})
}
@@ -963,6 +980,7 @@ func (s *Server) estimatePricing(w http.ResponseWriter, r *http.Request) {
return
}
if errors.Is(err, store.ErrNoModelCandidate) {
applyRunErrorHeaders(w, err)
writeErrorWithDetails(w, statusFromRunError(err), runErrorMessage(err), runErrorDetails(err), store.ModelCandidateErrorCode(err))
return
}
@@ -1039,15 +1057,14 @@ func (s *Server) listModelRateLimitStatuses(w http.ResponseWriter, r *http.Reque
// @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/reranks [post]
// @Router /api/v1/videos/generations [post]
// @Router /api/v1/song/generations [post]
// @Router /api/v1/music/generations [post]
// @Router /api/v1/speech/generations [post]
// @Router /api/v1/voice_clone [post]
func (s *Server) createTask(kind string, compatible bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
targetProtocol := targetProtocolForTaskRequest(kind, r)
writeTaskError := func(status int, message string, details map[string]any, codes ...string) {
if easyAIAsyncMediaRequest(kind, r) {
writeEasyAIAsyncError(w, status, message, details, codes...)
return
}
if targetProtocol == "" {
writeErrorWithDetails(w, status, message, details, codes...)
return
@@ -1093,9 +1110,10 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
return
}
responsePlan := planTaskResponse(kind, compatible, body, r)
if targetProtocol != "" {
if targetProtocol != "" && !easyAIAsyncMediaRequest(kind, r) {
// OpenAI compatibility endpoints are synchronous APIs. Gateway async
// task envelopes belong only to native Gateway routes.
// task envelopes belong only to native Gateway routes, except for the
// explicit EasyAI media X-Async extension.
responsePlan.asyncMode = false
}
idempotencyKey, hasIdempotencyKey, err := optionalTaskIdempotencyKey(r)
@@ -1134,6 +1152,10 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
writeTaskError(http.StatusConflict, "Idempotency-Key was reused for a different request", nil, "idempotency_key_reused")
return
}
if errors.Is(err, store.ErrTaskRequestBinaryNotMaterialized) {
writeTaskError(http.StatusBadRequest, err.Error(), nil, clients.ErrorCode(err))
return
}
s.logger.Error("create task failed", "kind", kind, "error_category", "task_create_failed")
writeTaskError(http.StatusInternalServerError, "create task failed", nil, "task_create_failed")
return
@@ -1150,7 +1172,18 @@ func (s *Server) createTask(kind string, compatible bool) http.Handler {
writeTaskError(http.StatusConflict, "streaming idempotent replay is not supported", nil, "idempotency_stream_replay_unsupported")
return
}
if targetProtocol != "" {
if task.Status == "succeeded" && !responsePlan.asyncMode && (targetProtocol != "" || responsePlan.compatibleMode) {
task, err = s.hydrateTaskResult(r.Context(), task)
if err != nil {
if targetProtocol != "" {
writeProtocolError(w, targetProtocol, statusFromRunError(err), err.Error(), nil, clients.ErrorCode(err))
} else {
writeStoredBinaryResultError(w, err)
}
return
}
}
if targetProtocol != "" && !responsePlan.asyncMode {
if task.Status == "succeeded" {
writeJSON(w, http.StatusOK, task.Result)
return
@@ -1274,7 +1307,7 @@ func openAIEmbeddingsDoc() {}
// @Security BearerAuth
// @Param input body TaskRequest true "OpenAI Images 请求"
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
// @Success 200 {object} CompatibleResponse
// @Success 200 {object} OpenAIImagesResponse
// @Success 202 {object} TaskAcceptedResponse
// @Failure 400 {object} OpenAIErrorEnvelope
// @Failure 401 {object} OpenAIErrorEnvelope
@@ -1284,6 +1317,32 @@ func openAIEmbeddingsDoc() {}
// @Router /api/v1/images/edits [post]
func openAIImagesDoc() {}
// easyAIMediaTasksDoc godoc
// @Summary 创建 EasyAI 媒体任务
// @Description 默认同步返回 EasyAI GeneratedResponse;设置 X-Async=true 时返回同时兼容 Gateway 与 server-main EasyAIClient 的异步提交结构。
// @Tags media
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param X-Async header bool false "true 时异步创建任务并返回 202"
// @Param Idempotency-Key header string false "可选请求幂等键;同一用户范围内唯一"
// @Param input body TaskRequest true "媒体任务请求,字段随任务类型变化"
// @Success 200 {object} EasyAIGeneratedResponse
// @Success 202 {object} TaskAcceptedResponse
// @Failure 400 {object} ErrorEnvelope
// @Failure 401 {object} ErrorEnvelope
// @Failure 402 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 429 {object} ErrorEnvelope
// @Failure 502 {object} ErrorEnvelope
// @Router /api/v1/videos/generations [post]
// @Router /api/v1/song/generations [post]
// @Router /api/v1/music/generations [post]
// @Router /api/v1/speech/generations [post]
// @Router /api/v1/voice_clone [post]
func easyAIMediaTasksDoc() {}
func (s *Server) requestExecutionContext(r *http.Request) (context.Context, context.CancelFunc) {
base := context.WithoutCancel(r.Context())
if s.ctx == nil {
@@ -1363,6 +1422,7 @@ func writeProtocolCompatibleTaskResponse(runCtx context.Context, w http.Response
if !requestStillConnected(r) {
return
}
applyRunErrorHeaders(w, runErr)
errorPayload := map[string]any{"message": runErrorMessage(runErr), "type": "server_error", "param": nil, "code": runErrorCode(runErr)}
if status := statusFromRunError(runErr); status >= 400 && status < 500 {
errorPayload["type"] = "invalid_request_error"
@@ -1400,6 +1460,7 @@ func writeProtocolCompatibleTaskResponse(runCtx context.Context, w http.Response
if !requestStillConnected(r) {
return
}
applyRunErrorHeaders(w, runErr)
if wire := clients.ErrorWireResponse(runErr); wireResponseMatches(wire, targetProtocol) {
writeWireResponse(w, wire)
return
@@ -1418,7 +1479,7 @@ func writeProtocolCompatibleTaskResponse(runCtx context.Context, w http.Response
writeWireResponse(w, result.Wire)
return
}
writeJSON(w, http.StatusOK, result.Output)
writeJSON(w, http.StatusOK, easyAISynchronousTaskResponse(result.Task, result.Output))
}
func streamIncludeUsage(body map[string]any) bool {
@@ -1461,14 +1522,7 @@ func streamCompatibleKind(kind string) bool {
}
func writeTaskAccepted(w http.ResponseWriter, task store.GatewayTask) {
writeJSON(w, http.StatusAccepted, map[string]any{
"taskId": task.ID,
"task": task,
"next": map[string]string{
"events": fmt.Sprintf("/api/v1/tasks/%s/events", task.ID),
"detail": fmt.Sprintf("/api/v1/tasks/%s", task.ID),
},
})
writeJSON(w, http.StatusAccepted, easyAITaskAcceptedResponse(task))
}
func apiKeyScopeAllowed(user *auth.User, kind string) bool {
@@ -1578,6 +1632,12 @@ func scopeForTaskKind(kind string) string {
func statusFromRunError(err error) int {
switch {
case clients.ErrorCode(err) == "binary_result_expired":
return http.StatusGone
case clients.ErrorCode(err) == "binary_result_corrupted" || clients.ErrorCode(err) == "result_binary_not_materialized":
return http.StatusInternalServerError
case clients.ErrorCode(err) == "local_result_storage_unavailable":
return http.StatusServiceUnavailable
case clients.ErrorCode(err) == "billing_hold":
return http.StatusServiceUnavailable
case runner.IsPricingUnavailable(err):
@@ -1637,11 +1697,21 @@ func runErrorDetails(err error) map[string]any {
return map[string]any{"rateLimit": detail}
}
if detail := store.ModelCandidateErrorDetails(err); len(detail) > 0 {
return map[string]any{"modelCandidate": detail}
return detail
}
return nil
}
func applyRunErrorHeaders(w http.ResponseWriter, err error) {
if retryAfter := store.ModelCandidateRetryAfter(err); retryAfter > 0 {
seconds := int((retryAfter + time.Second - 1) / time.Second)
if seconds < 1 {
seconds = 1
}
w.Header().Set("Retry-After", strconv.Itoa(seconds))
}
}
func rateLimitErrorSummary(err error) string {
var limitErr *store.RateLimitExceededError
if !errors.As(err, &limitErr) {
@@ -1882,6 +1952,7 @@ func boolValue(body map[string]any, key string) bool {
// @Success 200 {object} store.GatewayTask
// @Failure 401 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope
// @Failure 410 {object} ErrorEnvelope
// @Failure 500 {object} ErrorEnvelope
// @Router /api/workspace/tasks/{taskID} [get]
// @Router /api/v1/tasks/{taskID} [get]
@@ -1901,6 +1972,11 @@ func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
task.Cancellable = &cancelState.Cancellable
task.Submitted = &cancelState.Submitted
task.Message = cancelState.Message
task, err = s.hydrateTaskResult(r.Context(), task)
if err != nil {
writeStoredBinaryResultError(w, err)
return
}
writeJSON(w, http.StatusOK, task)
return
}
@@ -0,0 +1,54 @@
package httpapi
import (
"context"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func requireDedicatedIntegrationTestDatabase(t *testing.T, ctx context.Context, pool *pgxpool.Pool) {
t.Helper()
var databaseName string
if err := pool.QueryRow(ctx, `SELECT current_database()`).Scan(&databaseName); err != nil {
t.Fatalf("read integration test database name: %v", err)
}
if !isDedicatedIntegrationTestDatabase(databaseName) {
t.Fatalf(
"AI_GATEWAY_TEST_DATABASE_URL must reference a dedicated test database; refusing to use %q",
databaseName,
)
}
}
func isDedicatedIntegrationTestDatabase(databaseName string) bool {
normalized := strings.ToLower(strings.TrimSpace(databaseName))
return normalized == "test" ||
strings.HasPrefix(normalized, "test_") ||
strings.HasSuffix(normalized, "_test") ||
strings.Contains(normalized, "_test_")
}
func TestDedicatedIntegrationTestDatabaseName(t *testing.T) {
t.Parallel()
for _, testCase := range []struct {
name string
database string
allowed bool
}{
{name: "documented local test database", database: "easyai_ai_gateway_test_codex", allowed: true},
{name: "test prefix", database: "test_gateway", allowed: true},
{name: "test suffix", database: "gateway_test", allowed: true},
{name: "production database", database: "easyai_ai_gateway", allowed: false},
{name: "similar production name", database: "gateway_contest", allowed: false},
} {
testCase := testCase
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
if got := isDedicatedIntegrationTestDatabase(testCase.database); got != testCase.allowed {
t.Fatalf("isDedicatedIntegrationTestDatabase(%q) = %v, want %v", testCase.database, got, testCase.allowed)
}
})
}
}
@@ -177,6 +177,10 @@ func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
writeKelingCompatError(w, requestID, kelingCompatGatewayError(staged.Err))
return
}
if errors.Is(createErr, store.ErrTaskRequestBinaryNotMaterialized) {
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusBadRequest, 1201, clients.ErrorCode(createErr)))
return
}
s.logger.Error("create Kling-compatible task failed", "error", createErr)
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusInternalServerError, 5000, "create task failed"))
return
@@ -202,15 +206,11 @@ func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
writeKelingCompatError(w, requestID, kelingCompatGatewayError(createErr))
return
}
if kelingTaskUsesNativeProtocol(task) && len(task.CompatibilitySubmitBody) > 0 {
writeJSON(w, task.CompatibilitySubmitHTTPStatus, task.CompatibilitySubmitBody)
return
}
writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{
Code: 0,
Message: "SUCCEED",
RequestID: requestID,
Data: kelingCompatTaskData(task),
Data: kelingCompatSubmissionData(task),
})
}
@@ -225,6 +225,7 @@ func (s *Server) createKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
// @Failure 401 {object} KelingCompatibleEnvelope
// @Failure 403 {object} KelingCompatibleEnvelope
// @Failure 404 {object} KelingCompatibleEnvelope
// @Failure 410 {object} KelingCompatibleEnvelope
// @Failure 500 {object} KelingCompatibleEnvelope
// @Router /api/v1/videos/omni-video/{taskID} [get]
func (s *Server) getKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
@@ -251,15 +252,10 @@ func (s *Server) getKelingOmniVideo(w http.ResponseWriter, r *http.Request) {
writeKelingCompatError(w, requestID, newKelingCompatError(http.StatusNotFound, 1203, "task not found"))
return
}
if kelingTaskUsesNativeProtocol(task) {
if raw, ok := task.Result["raw"].(map[string]any); ok && len(raw) > 0 {
writeJSON(w, http.StatusOK, raw)
return
}
if len(task.RemoteTaskPayload) > 0 {
writeJSON(w, http.StatusOK, task.RemoteTaskPayload)
return
}
task, err = s.hydrateTaskResult(r.Context(), task)
if err != nil {
writeKelingCompatError(w, requestID, newKelingCompatError(statusFromRunError(err), 5000, clients.ErrorCode(err)))
return
}
writeJSON(w, http.StatusOK, KelingCompatibleEnvelope{
Code: 0,
@@ -705,15 +701,22 @@ func kelingCompatTaskData(task store.GatewayTask) map[string]any {
return data
}
func kelingCompatPublicID(task store.GatewayTask) string {
if publicID := strings.TrimSpace(task.CompatibilityPublicID); publicID != "" {
return publicID
}
return task.ID
func kelingCompatSubmissionData(task store.GatewayTask) map[string]any {
data := kelingCompatTaskData(task)
data["task_status"] = "submitted"
delete(data, "task_status_code")
delete(data, "task_status_msg")
delete(data, "task_result")
return data
}
func kelingTaskUsesNativeProtocol(task store.GatewayTask) bool {
return task.CompatibilityProtocol == clients.ProtocolKlingV1Omni && task.CompatibilitySourceProtocol == clients.ProtocolKlingV1Omni
func kelingCompatPublicID(task store.GatewayTask) string {
if task.CompatibilityProtocol == task.CompatibilitySourceProtocol {
if remoteTaskID := strings.TrimSpace(task.RemoteTaskID); remoteTaskID != "" {
return remoteTaskID
}
}
return task.ID
}
func kelingCompatTaskStatus(status string) string {
@@ -209,6 +209,10 @@ func TestKelingCompatTaskDataAndOwnership(t *testing.T) {
if video["id"] != "video-1" || video["watermark_url"] != "https://example.com/watermarked.mp4" || video["duration"] != "5" {
t.Fatalf("unexpected compatible video: %+v", video)
}
submission := kelingCompatSubmissionData(task)
if submission["task_status"] != "submitted" || submission["task_result"] != nil {
t.Fatalf("submission response must be stable and minimal: %+v", submission)
}
}
func TestRequireKelingAPIKeyWritesOfficialAuthEnvelope(t *testing.T) {
+36 -30
View File
@@ -144,6 +144,8 @@ func (s *Server) createKlingCompatTask(w http.ResponseWriter, r *http.Request, v
writeKlingCompatError(w, http.StatusConflict, err.Error(), "idempotency_key_reused")
case errors.Is(err, store.ErrExternalTaskIDReused):
writeKlingCompatError(w, http.StatusConflict, err.Error(), "external_task_id_reused")
case errors.Is(err, store.ErrTaskRequestBinaryNotMaterialized):
writeKlingCompatError(w, http.StatusBadRequest, err.Error(), clients.ErrorCode(err))
default:
s.logger.Error("create Kling compatibility task failed", "version", version, "model", model, "error", err)
writeKlingCompatError(w, http.StatusInternalServerError, "create task failed", "task_create_failed")
@@ -177,10 +179,10 @@ func (s *Server) createKlingCompatTask(w http.ResponseWriter, r *http.Request, v
w.Header().Set("Idempotent-Replayed", "true")
}
if version == "v2" {
writeJSON(w, http.StatusOK, klingV2Envelope(task))
writeJSON(w, http.StatusOK, klingV2SubmissionEnvelope(task))
return
}
writeJSON(w, http.StatusOK, klingV1Envelope(task))
writeJSON(w, http.StatusOK, klingV1SubmissionEnvelope(task))
}
func klingCompatTaskBody(version string, model string, native map[string]any) (map[string]any, string, error) {
@@ -464,6 +466,7 @@ func validateKlingCompatBody(model string, body map[string]any) error {
// @Param taskID path string true "任务 ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} map[string]interface{}
// @Failure 410 {object} map[string]interface{}
// @Router /api/v1/kling/v1/videos/omni-video/{taskID} [get]
func (s *Server) klingV1GetOmniVideo(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context())
@@ -476,6 +479,11 @@ func (s *Server) klingV1GetOmniVideo(w http.ResponseWriter, r *http.Request) {
writeKlingCompatError(w, http.StatusInternalServerError, "get task failed", "task_query_failed")
return
}
task, err = s.hydrateTaskResult(r.Context(), task)
if err != nil {
writeKlingCompatError(w, statusFromRunError(err), err.Error(), clients.ErrorCode(err))
return
}
writeJSON(w, http.StatusOK, klingV1Envelope(task))
}
@@ -520,6 +528,7 @@ func (s *Server) klingV1ListOmniVideos(w http.ResponseWriter, r *http.Request) {
// @Param task_ids query string false "逗号分隔的任务 ID"
// @Param external_task_ids query string false "逗号分隔的外部任务 ID"
// @Success 200 {object} map[string]interface{}
// @Failure 410 {object} map[string]interface{}
// @Router /api/v1/kling/v2/tasks [get]
func (s *Server) klingV2GetTasks(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context())
@@ -543,6 +552,11 @@ func (s *Server) klingV2GetTasks(w http.ResponseWriter, r *http.Request) {
writeKlingCompatError(w, http.StatusInternalServerError, "get task failed", "task_query_failed")
return
}
task, err = s.hydrateTaskResult(r.Context(), task)
if err != nil {
writeKlingCompatError(w, statusFromRunError(err), err.Error(), clients.ErrorCode(err))
return
}
data = append(data, klingV2TaskData(task))
}
writeJSON(w, http.StatusOK, map[string]any{"code": 0, "message": "success", "request_id": klingRequestIDFromAny(data), "data": data})
@@ -612,20 +626,17 @@ func (s *Server) klingV2ListTasks(w http.ResponseWriter, r *http.Request) {
}
func klingV1Envelope(task store.GatewayTask) map[string]any {
if klingTaskUsesNativeV1Protocol(task) {
if raw := klingMap(task.Result["raw"]); len(raw) > 0 {
return raw
}
if len(task.RemoteTaskPayload) > 0 {
return task.RemoteTaskPayload
}
if raw := klingMap(task.CompatibilitySubmitBody); len(raw) > 0 {
return raw
}
}
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": klingV1TaskData(task)}
}
func klingV1SubmissionEnvelope(task store.GatewayTask) map[string]any {
data := klingV1TaskData(task)
data["task_status"] = "submitted"
delete(data, "task_status_msg")
delete(data, "task_result")
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": data}
}
func klingV1TaskData(task store.GatewayTask) map[string]any {
data := map[string]any{
"task_id": klingCompatibilityPublicID(task), "task_status": klingV1Status(task.Status),
@@ -646,20 +657,17 @@ func klingV1TaskData(task store.GatewayTask) map[string]any {
}
func klingV2Envelope(task store.GatewayTask) map[string]any {
if task.CompatibilityProtocol == clients.ProtocolKlingV2Omni && task.CompatibilitySourceProtocol == clients.ProtocolKlingV2Omni {
if raw := klingMap(task.Result["raw"]); len(raw) > 0 {
return raw
}
if len(task.RemoteTaskPayload) > 0 {
return task.RemoteTaskPayload
}
if len(task.CompatibilitySubmitBody) > 0 {
return task.CompatibilitySubmitBody
}
}
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": klingV2TaskData(task)}
}
func klingV2SubmissionEnvelope(task store.GatewayTask) map[string]any {
data := klingV2TaskData(task)
data["status"] = "submitted"
delete(data, "message")
delete(data, "outputs")
return map[string]any{"code": 0, "message": "success", "request_id": firstNonEmpty(task.RequestID, task.ID), "data": data}
}
func klingV2TaskData(task store.GatewayTask) map[string]any {
data := map[string]any{
"id": klingCompatibilityPublicID(task), "status": klingV2Status(task.Status),
@@ -676,16 +684,14 @@ func klingV2TaskData(task store.GatewayTask) map[string]any {
}
func klingCompatibilityPublicID(task store.GatewayTask) string {
if publicID := strings.TrimSpace(task.CompatibilityPublicID); publicID != "" {
return publicID
if task.CompatibilityProtocol == task.CompatibilitySourceProtocol {
if remoteTaskID := strings.TrimSpace(task.RemoteTaskID); remoteTaskID != "" {
return remoteTaskID
}
}
return task.ID
}
func klingTaskUsesNativeV1Protocol(task store.GatewayTask) bool {
return task.CompatibilityProtocol == clients.ProtocolKlingV1Omni && task.CompatibilitySourceProtocol == clients.ProtocolKlingV1Omni
}
func klingMap(value any) map[string]any {
result, _ := value.(map[string]any)
return result
@@ -6,6 +6,9 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestKlingV1O1CompatibilityBody(t *testing.T) {
@@ -86,6 +89,27 @@ func TestKlingV2CompatibilityBody(t *testing.T) {
}
}
func TestKlingSubmissionEnvelopesDoNotReplayCurrentResult(t *testing.T) {
task := store.GatewayTask{
ID: "gateway-task", Status: "succeeded", RemoteTaskID: "upstream-task",
CompatibilityProtocol: "kling-v1-omni",
CompatibilitySourceProtocol: "kling-v1-omni",
Result: map[string]any{"data": []any{map[string]any{"url": "https://example.invalid/result.mp4"}}},
CreatedAt: time.Unix(100, 0),
UpdatedAt: time.Unix(101, 0),
}
v1 := klingV1SubmissionEnvelope(task)
v1Data, _ := v1["data"].(map[string]any)
if v1Data["task_status"] != "submitted" || v1Data["task_result"] != nil {
t.Fatalf("V1 submission envelope=%#v", v1)
}
v2 := klingV2SubmissionEnvelope(task)
v2Data, _ := v2["data"].(map[string]any)
if v2Data["status"] != "submitted" || v2Data["outputs"] != nil {
t.Fatalf("V2 submission envelope=%#v", v2)
}
}
func TestKlingCompatibilityValidation(t *testing.T) {
tests := []struct {
name string
@@ -45,6 +45,58 @@ func (s *Server) cleanupExpiredLocalTempAssets(ctx context.Context, now time.Tim
for _, target := range targets {
deleted += s.cleanupExpiredLocalTempAssetsInDir(ctx, now, target)
}
deleted += s.cleanupExpiredLocalBinaryResults(now)
return deleted
}
func (s *Server) cleanupExpiredLocalBinaryResults(now time.Time) int {
storageDir := strings.TrimSpace(s.cfg.LocalGeneratedStorageDir)
if storageDir == "" {
storageDir = config.DefaultLocalGeneratedStorageDir
}
root := filepath.Join(storageDir, "results")
ttlHours := s.cfg.LocalResultTTLHours
if ttlHours <= 0 {
ttlHours = 24
}
expiredBefore := now.Add(-time.Duration(ttlHours) * time.Hour)
deleted := 0
var directories []string
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
if !errors.Is(walkErr, os.ErrNotExist) && s.logger != nil {
s.logger.Warn("walk local binary result failed", "path", path, "error", walkErr)
}
return nil
}
if entry.IsDir() {
if path != root {
directories = append(directories, path)
}
return nil
}
if entry.Type()&os.ModeSymlink != 0 {
return nil
}
info, infoErr := entry.Info()
if infoErr != nil || info.ModTime().After(expiredBefore) {
return nil
}
if removeErr := os.Remove(path); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
if s.logger != nil {
s.logger.Warn("remove local binary result failed", "path", path, "error", removeErr)
}
return nil
}
deleted++
return nil
})
if err != nil && !errors.Is(err, os.ErrNotExist) && s.logger != nil {
s.logger.Warn("scan local binary result root failed", "dir", root, "error", err)
}
for index := len(directories) - 1; index >= 0; index-- {
_ = os.Remove(directories[index])
}
return deleted
}
+46 -40
View File
@@ -62,17 +62,19 @@ type ModelCatalogProviderSummary struct {
}
type ModelCatalogSource struct {
ID string `json:"id"`
PlatformID string `json:"platformId,omitempty"`
PlatformName string `json:"platformName,omitempty"`
ProviderKey string `json:"providerKey"`
ProviderName string `json:"providerName"`
ModelName string `json:"modelName"`
ModelAlias string `json:"modelAlias,omitempty"`
DisplayName string `json:"displayName"`
ModelType []string `json:"modelType"`
Enabled bool `json:"enabled"`
RateLimits ModelCatalogRateLimits `json:"rateLimits"`
ID string `json:"id"`
PlatformID string `json:"platformId,omitempty"`
PlatformName string `json:"platformName,omitempty"`
ProviderKey string `json:"providerKey"`
ProviderName string `json:"providerName"`
ModelName string `json:"modelName"`
ProviderModelName string `json:"providerModelName,omitempty"`
ModelAlias string `json:"modelAlias,omitempty"`
LegacyAliases []string `json:"legacyAliases,omitempty"`
DisplayName string `json:"displayName"`
ModelType []string `json:"modelType"`
Enabled bool `json:"enabled"`
RateLimits ModelCatalogRateLimits `json:"rateLimits"`
}
type ModelCatalogText struct {
@@ -202,10 +204,10 @@ func buildModelCatalog(
sourceCount := 0
for _, model := range models {
platform := platformMap[model.PlatformID]
if !catalogSourceEnabled(model, platform) {
baseModel := baseModelMap[model.BaseModelID]
if !catalogSourceEnabled(model, platform, baseModel) {
continue
}
baseModel := baseModelMap[model.BaseModelID]
providerKey := modelProviderKey(model, baseModel, platform)
provider := providerMap[providerKey]
providerName := firstNonEmpty(
@@ -232,14 +234,14 @@ func buildModelCatalog(
rateLimits: effectiveModelRateLimits(model, platform, runtimePolicyMap),
}
key := catalogAliasKey(model)
key := catalogIdentityKey(model, baseModel)
current := groups[key]
if current == nil {
current = &catalogGroup{
key: key,
alias: firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName),
displayName: firstNonEmpty(model.DisplayName, model.ModelAlias, model.ModelName),
modelName: model.ModelName,
alias: firstNonEmpty(baseModel.InvocationName, model.ModelName),
displayName: firstNonEmpty(baseModel.DisplayName, model.DisplayName, baseModel.InvocationName, model.ModelName),
modelName: firstNonEmpty(baseModel.InvocationName, model.ModelName),
modelType: cloneStringSlice(model.ModelType),
capabilities: cloneObject(model.Capabilities),
}
@@ -274,8 +276,8 @@ func buildModelCatalog(
}
}
func catalogSourceEnabled(model store.PlatformModel, platform store.Platform) bool {
return model.Enabled && platform.ID != "" && platform.Status == "enabled"
func catalogSourceEnabled(model store.PlatformModel, platform store.Platform, baseModel store.BaseModel) bool {
return model.Enabled && platform.ID != "" && platform.Status == "enabled" && (baseModel.ID == "" || baseModel.Status == "" || baseModel.Status == "active")
}
func modelCatalogItem(group *catalogGroup, accessRuleGroups map[string]accessRuleGroup) ModelCatalogItem {
@@ -304,17 +306,19 @@ func modelCatalogItem(group *catalogGroup, accessRuleGroups map[string]accessRul
itemSources := make([]ModelCatalogSource, 0, len(sources))
for _, source := range sources {
itemSources = append(itemSources, ModelCatalogSource{
ID: source.model.ID,
PlatformID: source.model.PlatformID,
PlatformName: firstNonEmpty(source.platform.Name, source.model.PlatformName),
ProviderKey: source.providerKey,
ProviderName: source.providerName,
ModelName: source.model.ModelName,
ModelAlias: source.model.ModelAlias,
DisplayName: firstNonEmpty(source.model.DisplayName, source.model.ModelAlias, source.model.ModelName),
ModelType: cloneStringSlice(source.model.ModelType),
Enabled: source.model.Enabled && source.platform.Status == "enabled",
RateLimits: source.rateLimits,
ID: source.model.ID,
PlatformID: source.model.PlatformID,
PlatformName: firstNonEmpty(source.platform.Name, source.model.PlatformName),
ProviderKey: source.providerKey,
ProviderName: source.providerName,
ModelName: firstNonEmpty(source.baseModel.InvocationName, source.model.ModelName),
ProviderModelName: source.model.ProviderModelName,
ModelAlias: firstNonEmpty(source.baseModel.InvocationName, source.model.ModelName),
LegacyAliases: cloneStringSlice(source.baseModel.LegacyAliases),
DisplayName: firstNonEmpty(source.baseModel.DisplayName, source.model.DisplayName, source.baseModel.InvocationName, source.model.ModelName),
ModelType: cloneStringSlice(source.model.ModelType),
Enabled: source.model.Enabled && source.platform.Status == "enabled",
RateLimits: source.rateLimits,
})
}
@@ -483,18 +487,20 @@ func discountTitle(label string, discount float64) string {
}
func effectiveModelRateLimits(model store.PlatformModel, platform store.Platform, runtimePolicyMap map[string]store.RuntimePolicySet) ModelCatalogRateLimits {
overridePolicyRaw, hasOverridePolicy := model.RuntimePolicyOverride["rateLimitPolicy"]
overridePolicy := objectValue(overridePolicyRaw)
runtimePolicy := map[string]any(nil)
if model.RuntimePolicySetID != "" {
runtimePolicy = runtimePolicyMap[model.RuntimePolicySetID].RateLimitPolicy
}
policies := []rateLimitPolicySource{
{policy: overridePolicy, authoritative: hasOverridePolicy},
{policy: model.RateLimitPolicy, authoritative: len(model.RateLimitPolicy) > 0},
{policy: runtimePolicy, authoritative: strings.TrimSpace(model.RuntimePolicySetID) != ""},
{policy: platform.RateLimitPolicy},
}
policy := store.EffectiveRateLimitPolicy(store.EffectiveRateLimitPolicyInput{
BasePolicy: model.BaseRateLimitPolicy,
PlatformPolicy: platform.RateLimitPolicy,
RuntimePolicy: runtimePolicy,
RuntimePolicyExplicit: model.RuntimePolicySetID != "",
RuntimePolicyOverride: model.RuntimePolicyOverride,
ModelPolicy: model.RateLimitPolicy,
ModelPolicyMode: model.RateLimitPolicyMode,
})
policies := []rateLimitPolicySource{{policy: policy, authoritative: policy != nil}}
limits := ModelCatalogRateLimits{
RPM: firstRateLimit(policies, "rpm"),
TPM: firstRateLimit(policies, "tpm_total"),
@@ -1260,8 +1266,8 @@ func modelIconPath(model store.PlatformModel, baseModel store.BaseModel, provide
)
}
func catalogAliasKey(model store.PlatformModel) string {
key := strings.ToLower(strings.TrimSpace(firstNonEmpty(model.ModelAlias, model.DisplayName, model.ModelName)))
func catalogIdentityKey(model store.PlatformModel, baseModel store.BaseModel) string {
key := strings.ToLower(strings.TrimSpace(firstNonEmpty(baseModel.InvocationName, baseModel.CanonicalModelKey, model.ModelName)))
if key == "" {
return model.ID
}
@@ -131,13 +131,24 @@ func TestBuildModelCatalogKeepsDisplayNameSeparateFromCallAlias(t *testing.T) {
platforms := []store.Platform{
{ID: "platform-volces", Provider: "volces", Name: "火山引擎", Status: "enabled"},
}
baseModels := []store.BaseModel{
{
ID: "base-seedream-pro",
CanonicalModelKey: "seedream:seedream-5.0-pro",
InvocationName: "seedream-5.0-pro",
ProviderModelName: "doubao-seedream-5-0-pro-260628",
DisplayName: "Seedream 5.0 Pro",
Status: "active",
},
}
models[0].BaseModelID = "base-seedream-pro"
response := buildModelCatalog(models, platforms, nil, nil, nil, nil, nil)
response := buildModelCatalog(models, platforms, nil, nil, nil, nil, baseModels)
if len(response.Items) != 1 {
t.Fatalf("expected one Seedream Pro catalog item, got %+v", response.Items)
}
item := response.Items[0]
if item.Alias != "Seedream-5.0-Pro" || item.DisplayName != "Seedream 5.0 Pro" || item.ModelName != "Seedream-5.0-Pro" {
if item.Alias != "seedream-5.0-pro" || item.DisplayName != "Seedream 5.0 Pro" || item.ModelName != "seedream-5.0-pro" {
t.Fatalf("catalog should separate the call alias from the display name, got %+v", item)
}
if strings.Contains(item.Alias, " ") {
@@ -145,6 +156,46 @@ func TestBuildModelCatalogKeepsDisplayNameSeparateFromCallAlias(t *testing.T) {
}
}
func TestBuildModelCatalogAggregatesByInvocationNameNotDisplayName(t *testing.T) {
models := []store.PlatformModel{
{ID: "official-a", PlatformID: "platform-a", BaseModelID: "base-a", ModelName: "provider/a", ModelType: store.StringList{"text_generate"}, DisplayName: "同名营销标题", Enabled: true},
{ID: "official-b", PlatformID: "platform-b", BaseModelID: "base-b", ModelName: "provider/b", ModelType: store.StringList{"text_generate"}, DisplayName: "同名营销标题", Enabled: true},
{ID: "official-a-mirror", PlatformID: "platform-b", BaseModelID: "base-a-mirror", ModelName: "mirror/a", ModelType: store.StringList{"text_generate"}, DisplayName: "另一个展示标题", Enabled: true},
}
platforms := []store.Platform{
{ID: "platform-a", Provider: "provider-a", Status: "enabled"},
{ID: "platform-b", Provider: "provider-b", Status: "enabled"},
}
baseModels := []store.BaseModel{
{ID: "base-a", CanonicalModelKey: "provider-a:official-a", InvocationName: "official-a", DisplayName: "模型 A", Status: "active"},
{ID: "base-a-mirror", CanonicalModelKey: "provider-b:official-a", InvocationName: "official-a", DisplayName: "模型 A 镜像", Status: "active"},
{ID: "base-b", CanonicalModelKey: "provider-b:official-b", InvocationName: "official-b", DisplayName: "同名营销标题", Status: "active"},
}
response := buildModelCatalog(models, platforms, nil, nil, nil, nil, baseModels)
if response.Summary.ModelCount != 2 || response.Summary.SourceCount != 3 {
t.Fatalf("expected two official identities and three sources, got %+v", response.Summary)
}
for _, item := range response.Items {
if item.ModelName == "official-a" && item.SourceCount != 2 {
t.Fatalf("official-a mirrors should aggregate into one card: %+v", item)
}
}
}
func TestBuildModelCatalogExcludesDeprecatedBaseModels(t *testing.T) {
models := []store.PlatformModel{
{ID: "preview", PlatformID: "platform", BaseModelID: "base-preview", ModelName: "gemini-preview", ModelType: store.StringList{"text_generate"}, Enabled: true},
}
platforms := []store.Platform{{ID: "platform", Provider: "gemini", Status: "enabled"}}
baseModels := []store.BaseModel{{ID: "base-preview", InvocationName: "gemini-preview", Status: "deprecated"}}
response := buildModelCatalog(models, platforms, nil, nil, nil, nil, baseModels)
if response.Summary.ModelCount != 0 || response.Summary.SourceCount != 0 {
t.Fatalf("deprecated base models must not create new catalog cards: %+v", response.Summary)
}
}
func TestBuildModelCatalogUsesBaseModelProviderForProviderFilters(t *testing.T) {
models := []store.PlatformModel{
{
@@ -0,0 +1,39 @@
package httpapi
import (
"net/http/httptest"
"testing"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestModelCooldownErrorPublicContract(t *testing.T) {
recoveryAt := time.Now().UTC().Add(17 * time.Second).Truncate(time.Second)
err := &store.ModelCandidateUnavailableError{
Code: "model_cooling_down",
Message: "请求的模型暂时不可用,请稍后重试",
RetryAfter: 17 * time.Second,
RecoveryAt: recoveryAt,
Details: map[string]any{
"retryAfterSeconds": 17,
"recoveryAt": recoveryAt.Format(time.RFC3339),
},
}
recorder := httptest.NewRecorder()
applyRunErrorHeaders(recorder, err)
if got := statusFromRunError(err); got != 429 {
t.Fatalf("status = %d, want 429", got)
}
if got := runErrorCode(err); got != "model_cooling_down" {
t.Fatalf("code = %q, want model_cooling_down", got)
}
if got := recorder.Header().Get("Retry-After"); got != "17" {
t.Fatalf("Retry-After = %q, want 17", got)
}
details := runErrorDetails(err)
if len(details) != 2 || details["retryAfterSeconds"] != 17 || details["recoveryAt"] != recoveryAt.Format(time.RFC3339) {
t.Fatalf("unexpected public details: %#v", details)
}
}
@@ -0,0 +1,186 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
const maxUsageSceneCatalogResponseBytes = 8 << 20
var supportedModelUsageScenes = map[string]struct{}{
"canvas_model_node": {},
"desktop": {},
}
func parseModelUsageSceneQuery(query url.Values) (string, error) {
values, present := query["usage_scene"]
if !present {
return "", nil
}
if len(values) != 1 {
return "", errors.New("usage_scene must be provided exactly once")
}
scene := strings.TrimSpace(values[0])
if _, ok := supportedModelUsageScenes[scene]; !ok {
return "", errors.New("usage_scene must be canvas_model_node or desktop")
}
return scene, nil
}
func (s *Server) listServerMainUsageSceneModelIDs(ctx context.Context, usageScene string) (map[string]struct{}, error) {
baseURL := strings.TrimRight(strings.TrimSpace(s.cfg.ServerMainBaseURL), "/")
if baseURL == "" {
return nil, errors.New("SERVER_MAIN_BASE_URL is not configured")
}
var requestErrors []error
for _, endpoint := range serverMainUsageSceneCatalogEndpoints(baseURL, usageScene) {
ids, err := s.requestServerMainUsageSceneModelIDs(ctx, endpoint)
if err == nil {
return ids, nil
}
requestErrors = append(requestErrors, err)
}
return nil, fmt.Errorf("server-main model catalog request failed: %w", errors.Join(requestErrors...))
}
func serverMainUsageSceneCatalogEndpoints(baseURL string, usageScene string) []string {
path := "/integration-platform/models/strict/all?usage_scene=" + url.QueryEscape(usageScene)
if strings.HasSuffix(baseURL, "/api") {
return []string{baseURL + path}
}
return []string{baseURL + "/api" + path, baseURL + path}
}
func (s *Server) requestServerMainUsageSceneModelIDs(ctx context.Context, endpoint string) (map[string]struct{}, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("create server-main model catalog request: %w", err)
}
request.Header.Set("Accept", "application/json")
client := http.DefaultClient
if s.auth != nil && s.auth.HTTPClient != nil {
client = s.auth.HTTPClient
}
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("request server-main model catalog: %w", err)
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
_, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10))
return nil, fmt.Errorf("server-main model catalog returned HTTP %d", response.StatusCode)
}
var payload any
decoder := json.NewDecoder(io.LimitReader(response.Body, maxUsageSceneCatalogResponseBytes))
if err := decoder.Decode(&payload); err != nil {
return nil, fmt.Errorf("decode server-main model catalog: %w", err)
}
items, ok := usageSceneCatalogItems(payload)
if !ok {
return nil, errors.New("server-main model catalog payload is malformed")
}
return usageSceneModelIDs(items), nil
}
func usageSceneCatalogItems(payload any) ([]any, bool) {
if items, ok := payload.([]any); ok {
return items, true
}
record, ok := payload.(map[string]any)
if !ok {
return nil, false
}
for _, key := range []string{"modelList", "items", "data"} {
value, exists := record[key]
if !exists {
continue
}
if items, ok := value.([]any); ok {
return items, true
}
if items, ok := usageSceneCatalogItems(value); ok {
return items, true
}
}
return nil, false
}
func usageSceneModelIDs(items []any) map[string]struct{} {
ids := make(map[string]struct{}, len(items))
for _, item := range items {
record, ok := item.(map[string]any)
if !ok {
continue
}
for _, key := range []string{
"alias",
"invocationName",
"invocation_name",
"modelAlias",
"model_alias",
"name",
"modelName",
"model_name",
"providerModelName",
"provider_model_name",
} {
addUsageSceneModelID(ids, record[key])
}
for _, key := range []string{"legacyAliases", "legacy_aliases"} {
if aliases, ok := record[key].([]any); ok {
for _, alias := range aliases {
addUsageSceneModelID(ids, alias)
}
}
}
}
return ids
}
func filterPlatformModelsByUsageSceneIDs(models []store.PlatformModel, availableModelIDs map[string]struct{}) []store.PlatformModel {
filtered := make([]store.PlatformModel, 0, len(models))
for _, model := range models {
if platformModelMatchesUsageSceneIDs(model, availableModelIDs) {
filtered = append(filtered, model)
}
}
return filtered
}
func platformModelMatchesUsageSceneIDs(model store.PlatformModel, availableModelIDs map[string]struct{}) bool {
for _, value := range []string{model.ModelName, model.ProviderModelName, model.ModelAlias} {
if _, ok := availableModelIDs[normalizeUsageSceneModelID(value)]; ok {
return true
}
}
for _, alias := range model.LegacyAliases {
if _, ok := availableModelIDs[normalizeUsageSceneModelID(alias)]; ok {
return true
}
}
return false
}
func addUsageSceneModelID(ids map[string]struct{}, value any) {
text, ok := value.(string)
if !ok {
return
}
if normalized := normalizeUsageSceneModelID(text); normalized != "" {
ids[normalized] = struct{}{}
}
}
func normalizeUsageSceneModelID(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
@@ -0,0 +1,160 @@
package httpapi
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestParseModelUsageSceneQuery(t *testing.T) {
t.Parallel()
for _, testCase := range []struct {
name string
query url.Values
want string
wantErr bool
}{
{name: "missing", query: url.Values{}, want: ""},
{name: "desktop", query: url.Values{"usage_scene": {"desktop"}}, want: "desktop"},
{name: "canvas", query: url.Values{"usage_scene": {"canvas_model_node"}}, want: "canvas_model_node"},
{name: "empty", query: url.Values{"usage_scene": {""}}, wantErr: true},
{name: "unknown", query: url.Values{"usage_scene": {"mobile"}}, wantErr: true},
{name: "duplicate", query: url.Values{"usage_scene": {"desktop", "canvas_model_node"}}, wantErr: true},
} {
testCase := testCase
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
got, err := parseModelUsageSceneQuery(testCase.query)
if testCase.wantErr {
if err == nil {
t.Fatal("expected usage_scene validation error")
}
return
}
if err != nil {
t.Fatalf("parse usage_scene: %v", err)
}
if got != testCase.want {
t.Fatalf("usage_scene = %q, want %q", got, testCase.want)
}
})
}
}
func TestListServerMainUsageSceneModelIDs(t *testing.T) {
t.Parallel()
serverMain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/integration-platform/models/strict/all" {
t.Errorf("path = %q", r.URL.Path)
}
if got := r.URL.Query().Get("usage_scene"); got != "desktop" {
t.Errorf("usage_scene = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"data":[{"alias":"Qwen3.7-Plus"},{"invocationName":"GPT-5"},{"legacyAliases":["gpt-latest"]}]}`)
}))
defer serverMain.Close()
authenticator := auth.New("secret", serverMain.URL, "")
gateway := &Server{
cfg: config.Config{ServerMainBaseURL: serverMain.URL},
auth: authenticator,
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
ids, err := gateway.listServerMainUsageSceneModelIDs(context.Background(), "desktop")
if err != nil {
t.Fatalf("list server-main usage scene models: %v", err)
}
for _, id := range []string{"qwen3.7-plus", "gpt-5", "gpt-latest"} {
if _, ok := ids[id]; !ok {
t.Errorf("missing normalized model ID %q in %#v", id, ids)
}
}
}
func TestFilterPlatformModelsByUsageSceneIDs(t *testing.T) {
t.Parallel()
models := []store.PlatformModel{
{ID: "allowed-by-invocation", ModelName: "Qwen3.7-Plus", ProviderModelName: "qwen3.7-plus-2026-07-01"},
{ID: "allowed-by-provider-name", ModelName: "GPT-5", ProviderModelName: "gpt-5-2026-07-01"},
{ID: "allowed-by-legacy-alias", ModelName: "renamed-model", LegacyAliases: store.StringList{"old-model"}},
{ID: "desktop-restricted", ModelName: "Qwen3.6-Plus", ProviderModelName: "qwen3.6-plus-2026-04-02"},
}
allowed := map[string]struct{}{
"qwen3.7-plus": {},
"gpt-5-2026-07-01": {},
"old-model": {},
}
filtered := filterPlatformModelsByUsageSceneIDs(models, allowed)
if len(filtered) != 3 {
t.Fatalf("filtered model count = %d, want 3: %#v", len(filtered), filtered)
}
for _, model := range filtered {
if model.ID == "desktop-restricted" {
t.Fatalf("desktop-restricted model remained in filtered catalog: %#v", filtered)
}
}
}
func TestListServerMainUsageSceneModelIDsRejectsMalformedPayload(t *testing.T) {
t.Parallel()
serverMain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"success":true}`)
}))
defer serverMain.Close()
gateway := &Server{
cfg: config.Config{ServerMainBaseURL: serverMain.URL},
auth: auth.New("secret", serverMain.URL, ""),
}
if _, err := gateway.listServerMainUsageSceneModelIDs(context.Background(), "desktop"); err == nil {
t.Fatal("expected malformed upstream payload error")
}
}
func TestListServerMainUsageSceneModelIDsFallsBackToDirectServerMainRoute(t *testing.T) {
t.Parallel()
requestedPaths := make([]string, 0, 2)
serverMain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPaths = append(requestedPaths, r.URL.Path)
if r.URL.Path == "/api/integration-platform/models/strict/all" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `[{"alias":"Qwen3.7-Plus"}]`)
}))
defer serverMain.Close()
gateway := &Server{
cfg: config.Config{ServerMainBaseURL: serverMain.URL},
auth: auth.New("secret", serverMain.URL, ""),
}
ids, err := gateway.listServerMainUsageSceneModelIDs(context.Background(), "desktop")
if err != nil {
t.Fatalf("list direct server-main usage scene models: %v", err)
}
if _, ok := ids["qwen3.7-plus"]; !ok {
t.Fatalf("missing direct server-main model ID: %#v", ids)
}
if len(requestedPaths) != 2 ||
requestedPaths[0] != "/api/integration-platform/models/strict/all" ||
requestedPaths[1] != "/integration-platform/models/strict/all" {
t.Fatalf("unexpected server-main route probes: %#v", requestedPaths)
}
}
+74 -3
View File
@@ -189,6 +189,13 @@ type TaskListResponse struct {
PageSize int `json:"pageSize" example:"50"`
}
type AdminTaskListResponse struct {
Items []store.AdminGatewayTask `json:"items"`
Total int `json:"total" example:"42"`
Page int `json:"page" example:"1"`
PageSize int `json:"pageSize" example:"50"`
}
type TaskParamPreprocessingLogListResponse struct {
Items []store.TaskParamPreprocessingLog `json:"items"`
}
@@ -208,6 +215,17 @@ type FileUploadResponse struct {
ContentType string `json:"contentType,omitempty" example:"image/png"`
Size int `json:"size,omitempty" example:"1024"`
AssetStorage map[string]interface{} `json:"assetStorage,omitempty"`
Status string `json:"status" example:"success"`
Data FileUploadData `json:"data"`
}
type FileUploadData struct {
ID string `json:"id,omitempty" example:"file_abc123"`
URL string `json:"url,omitempty" example:"/static/uploaded/upload-abc123.png"`
Filename string `json:"filename,omitempty" example:"image.png"`
ContentType string `json:"contentType,omitempty" example:"image/png"`
Size int `json:"size,omitempty" example:"1024"`
AssetStorage map[string]interface{} `json:"assetStorage,omitempty"`
}
type ReplacePlatformModelsRequest struct {
@@ -215,9 +233,12 @@ type ReplacePlatformModelsRequest struct {
}
type TaskAcceptedResponse struct {
TaskID string `json:"taskId" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
Task store.GatewayTask `json:"task"`
Next TaskNextLinks `json:"next"`
Status string `json:"status" example:"submitted"`
LegacyTaskID string `json:"task_id" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
TaskID string `json:"taskId" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
QueryURL string `json:"query_url" example:"/api/v1/ai/result/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
Task store.GatewayTask `json:"task"`
Next TaskNextLinks `json:"next"`
}
type TaskCancelResponse struct {
@@ -231,6 +252,56 @@ type TaskCancelResponse struct {
type TaskNextLinks struct {
Events string `json:"events" example:"/api/v1/tasks/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25/events"`
Detail string `json:"detail" example:"/api/v1/tasks/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
Result string `json:"result" example:"/api/v1/ai/result/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
}
type EasyAIGeneratedResponse struct {
Status string `json:"status" example:"success" enums:"submitted,process,success,failed"`
TaskID string `json:"task_id" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
CamelTaskID string `json:"taskId,omitempty" example:"9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
UpstreamTaskID string `json:"upstream_task_id,omitempty" example:"provider-task-123"`
QueryURL string `json:"query_url,omitempty" example:"/api/v1/ai/result/9f4d8f3d-5f5f-4bb7-a4be-344a9f930e25"`
Created int64 `json:"created" example:"1784772000000"`
Message string `json:"message,omitempty"`
Code string `json:"code,omitempty"`
Data []EasyAIMediaOutput `json:"data"`
Output []string `json:"output"`
OutputContent []EasyAIMediaOutput `json:"output_content"`
Cancellable bool `json:"cancellable"`
Submitted bool `json:"submitted"`
Result map[string]interface{} `json:"result,omitempty"`
Usage map[string]interface{} `json:"usage,omitempty"`
}
type EasyAIMediaOutput struct {
Type string `json:"type,omitempty" example:"video" enums:"image,video,audio,file,text"`
URL string `json:"url,omitempty" example:"https://cdn.example.com/output.mp4"`
// B64JSON is retained when result media transfer is disabled.
B64JSON string `json:"b64_json,omitempty"`
ImageURL string `json:"image_url,omitempty"`
VideoURL string `json:"video_url,omitempty"`
AudioURL string `json:"audio_url,omitempty"`
Content string `json:"content,omitempty"`
Text string `json:"text,omitempty"`
MimeType string `json:"mime_type,omitempty"`
Format string `json:"format,omitempty"`
Duration float64 `json:"duration,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
SourceResolution string `json:"source_resolution,omitempty"`
TargetResolution string `json:"target_resolution,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type OpenAIImagesResponse struct {
Created int64 `json:"created" example:"1710000000"`
Data []OpenAIImageData `json:"data"`
}
type OpenAIImageData struct {
URL string `json:"url,omitempty" example:"https://cdn.example.com/output.png"`
B64JSON string `json:"b64_json,omitempty"`
RevisedPrompt string `json:"revised_prompt,omitempty"`
}
type TaskAcceptedEvent struct {
@@ -11,6 +11,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -235,6 +236,47 @@ func TestCleanupExpiredLocalTempAssetsDeletesExpiredStaticFiles(t *testing.T) {
}
}
func TestCleanupExpiredLocalTempAssetsDeletesNestedBinaryResultsWithIndependentTTL(t *testing.T) {
generatedDir := t.TempDir()
taskDir := filepath.Join(generatedDir, "results", "task-123")
if err := os.MkdirAll(taskDir, 0o750); err != nil {
t.Fatalf("create local result fixture dir: %v", err)
}
oldResult := filepath.Join(taskDir, strings.Repeat("a", 64)+".bin")
freshResult := filepath.Join(taskDir, strings.Repeat("b", 64)+".bin")
for _, path := range []string{oldResult, freshResult} {
if err := os.WriteFile(path, []byte("asset"), 0o640); err != nil {
t.Fatalf("write fixture %s: %v", path, err)
}
}
now := time.Now()
if err := os.Chtimes(oldResult, now.Add(-25*time.Hour), now.Add(-25*time.Hour)); err != nil {
t.Fatalf("age old binary result: %v", err)
}
if err := os.Chtimes(freshResult, now.Add(-23*time.Hour), now.Add(-23*time.Hour)); err != nil {
t.Fatalf("age fresh binary result: %v", err)
}
server := &Server{
cfg: config.Config{
LocalGeneratedStorageDir: generatedDir,
LocalResultTTLHours: 24,
},
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
deleted := server.cleanupExpiredLocalTempAssets(context.Background(), now)
if deleted != 1 {
t.Fatalf("expected one expired binary result delete, got %d", deleted)
}
if _, err := os.Stat(oldResult); !os.IsNotExist(err) {
t.Fatalf("old binary result should be deleted, stat err=%v", err)
}
if _, err := os.Stat(freshResult); err != nil {
t.Fatalf("fresh binary result should remain: %v", 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")
@@ -3,6 +3,7 @@ package httpapi
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
@@ -71,6 +72,10 @@ func (s *Server) updateRunnerPolicy(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid json body")
return
}
if err := validateRunnerPolicyInput(input); err != nil {
writeError(w, http.StatusBadRequest, err.Error(), "invalid_parameter")
return
}
item, err := s.store.UpsertDefaultRunnerPolicy(r.Context(), input)
if err != nil {
s.logger.Error("update runner policy failed", "error", err)
@@ -80,6 +85,89 @@ func (s *Server) updateRunnerPolicy(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, item)
}
func validateRunnerPolicyInput(input store.RunnerPolicyInput) error {
rawRules, exists := input.FailoverPolicy["actionRules"]
if !exists {
return nil
}
rules, ok := rawRules.([]any)
if !ok {
return errors.New("failoverPolicy.actionRules must be an array")
}
validActions := map[string]bool{
"stop": true, "next": true, "cooldown_and_next": true, "demote_and_next": true, "disable_and_next": true,
}
for index, rawRule := range rules {
rule, ok := rawRule.(map[string]any)
if !ok {
return fmt.Errorf("failoverPolicy.actionRules[%d] must be an object", index)
}
action := strings.TrimSpace(stringValue(rule["action"]))
if !validActions[action] {
return fmt.Errorf("failoverPolicy.actionRules[%d].action is invalid", index)
}
if target, present := rule["target"]; present {
targetValue := strings.TrimSpace(stringValue(target))
if targetValue != "model" && targetValue != "platform" {
return fmt.Errorf("failoverPolicy.actionRules[%d].target is invalid", index)
}
}
if !runnerActionRuleHasMatch(rule) {
return fmt.Errorf("failoverPolicy.actionRules[%d] must include at least one match condition", index)
}
if action == "cooldown_and_next" && positiveJSONInteger(rule["cooldownSeconds"]) <= 0 {
return fmt.Errorf("failoverPolicy.actionRules[%d].cooldownSeconds must be a positive integer", index)
}
if action == "demote_and_next" && positiveJSONInteger(rule["demoteSteps"]) <= 0 {
return fmt.Errorf("failoverPolicy.actionRules[%d].demoteSteps must be a positive integer", index)
}
}
return nil
}
func runnerActionRuleHasMatch(rule map[string]any) bool {
for _, key := range []string{"categories", "codes", "errorCodes", "statusCodes", "keywords"} {
switch values := rule[key].(type) {
case []any:
for _, value := range values {
if strings.TrimSpace(fmt.Sprint(value)) != "" {
return true
}
}
case []string:
for _, value := range values {
if strings.TrimSpace(value) != "" {
return true
}
}
}
}
return false
}
func positiveJSONInteger(value any) int {
switch typed := value.(type) {
case int:
if typed > 0 {
return typed
}
case int64:
if typed > 0 {
return int(typed)
}
case float64:
if typed > 0 && typed == float64(int(typed)) {
return int(typed)
}
case json.Number:
parsed, err := typed.Int64()
if err == nil && parsed > 0 {
return int(parsed)
}
}
return 0
}
type updatePlatformDynamicPriorityRequest struct {
DynamicPriority *int `json:"dynamicPriority" example:"10"`
Reset bool `json:"reset" example:"false"`
@@ -0,0 +1,57 @@
package httpapi
import (
"strings"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestValidateRunnerPolicyInputActionRules(t *testing.T) {
validRule := func() map[string]any {
return map[string]any{
"categories": []any{"rate_limit"},
"action": "cooldown_and_next",
"target": "model",
"cooldownSeconds": float64(60),
}
}
tests := []struct {
name string
mutate func(map[string]any)
wantErr string
}{
{name: "valid"},
{name: "invalid action", mutate: func(rule map[string]any) { rule["action"] = "rotate" }, wantErr: ".action is invalid"},
{name: "invalid target", mutate: func(rule map[string]any) { rule["target"] = "client" }, wantErr: ".target is invalid"},
{name: "empty match", mutate: func(rule map[string]any) { rule["categories"] = []any{} }, wantErr: "at least one match condition"},
{name: "missing cooldown", mutate: func(rule map[string]any) { delete(rule, "cooldownSeconds") }, wantErr: ".cooldownSeconds must be a positive integer"},
{name: "zero cooldown", mutate: func(rule map[string]any) { rule["cooldownSeconds"] = float64(0) }, wantErr: ".cooldownSeconds must be a positive integer"},
{name: "fractional cooldown", mutate: func(rule map[string]any) { rule["cooldownSeconds"] = 1.5 }, wantErr: ".cooldownSeconds must be a positive integer"},
{name: "missing demote steps", mutate: func(rule map[string]any) {
rule["action"] = "demote_and_next"
delete(rule, "cooldownSeconds")
}, wantErr: ".demoteSteps must be a positive integer"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rule := validRule()
if test.mutate != nil {
test.mutate(rule)
}
err := validateRunnerPolicyInput(store.RunnerPolicyInput{
FailoverPolicy: map[string]any{"actionRules": []any{rule}},
})
if test.wantErr == "" {
if err != nil {
t.Fatalf("unexpected validation error: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("validation error = %v, want substring %q", err, test.wantErr)
}
})
}
}
+10 -2
View File
@@ -120,8 +120,13 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
return server.identityRuntime.LegacyJWTEnabled()
}
server.auth.LocalAPIKeyVerifier = db.VerifyLocalAPIKey
server.runner.StartAsyncQueueWorker(ctx)
if cfg.AsyncQueueWorkerEnabled {
server.runner.StartAsyncQueueWorker(ctx)
} else if logger != nil {
logger.Info("asynchronous queue worker disabled for this process")
}
server.runner.StartBillingSettlementWorker(ctx)
server.runner.StartTaskHistoryWorkers(ctx)
server.startLocalTempAssetCleanup(ctx)
server.startOIDCSessionCleanup(ctx)
@@ -183,6 +188,9 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /api/admin/access-rules/batch", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.batchAccessRules)))
mux.Handle("PATCH /api/admin/access-rules/{ruleID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.updateAccessRule)))
mux.Handle("DELETE /api/admin/access-rules/{ruleID}", server.requireAdmin(auth.PermissionManager, http.HandlerFunc(server.deleteAccessRule)))
mux.Handle("GET /api/admin/tasks", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.listAdminTasks)))
mux.Handle("GET /api/admin/tasks/{taskID}", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.getAdminTask)))
mux.Handle("GET /api/admin/tasks/{taskID}/param-preprocessing", server.requireAdmin(auth.PermissionPower, http.HandlerFunc(server.adminTaskParamPreprocessing)))
mux.Handle("GET /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeys)))
mux.Handle("POST /api/v1/api-keys", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createAPIKey)))
mux.Handle("GET /api/v1/api-keys/access-rules", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.listAPIKeyAccessRules)))
@@ -268,7 +276,7 @@ func NewServerWithContext(ctx context.Context, cfg config.Config, db *store.Stor
mux.Handle("POST /api/v1/videos/upscales", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
mux.Handle("POST /api/v1/video/upscale", server.requireUser(auth.PermissionBasic, server.createVideoUpscaleTask()))
mux.Handle("POST /api/v1/video/generations", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.createLegacyVolcesVideoGeneration)))
mux.Handle("GET /api/v1/ai/result/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getLegacyVolcesVideoResult)))
mux.Handle("GET /api/v1/ai/result/{taskID}", server.requireUser(auth.PermissionBasic, http.HandlerFunc(server.getEasyAITaskResult)))
mux.Handle("POST /api/v1/song/generations", server.requireUser(auth.PermissionBasic, server.createTask("song.generations", true)))
mux.Handle("POST /api/v1/music/generations", server.requireUser(auth.PermissionBasic, server.createTask("music.generations", true)))
mux.Handle("POST /api/v1/speech/generations", server.requireUser(auth.PermissionBasic, server.createTask("speech.generations", true)))
@@ -1,8 +1,12 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestOptionalTaskIdempotencyKeyRejectsMultipleValues(t *testing.T) {
@@ -29,3 +33,29 @@ func TestTaskIdempotencyRequestHashIsCanonical(t *testing.T) {
t.Fatal("async response semantics must be part of the request hash")
}
}
func TestWriteIdempotentAsyncTaskReplayKeepsEasyAICompatibilityFields(t *testing.T) {
t.Parallel()
task := store.GatewayTask{
ID: "async-replay-1",
Kind: "images.generations",
Status: "queued",
AsyncMode: true,
}
recorder := httptest.NewRecorder()
writeIdempotentTaskReplay(recorder, task, true)
if recorder.Code != http.StatusAccepted ||
recorder.Header().Get("Idempotent-Replayed") != "true" ||
recorder.Header().Get("X-Gateway-Task-Id") != task.ID {
t.Fatalf("unexpected replay status/headers: code=%d headers=%v", recorder.Code, recorder.Header())
}
var got map[string]any
if err := json.NewDecoder(recorder.Body).Decode(&got); err != nil {
t.Fatal(err)
}
if got["status"] != "submitted" || got["task_id"] != task.ID || got["taskId"] != task.ID {
t.Fatalf("unexpected replay body: %+v", got)
}
}
@@ -61,6 +61,7 @@ func (s *Server) deleteClonedVoice(w http.ResponseWriter, r *http.Request) {
}
result, err := s.runner.DeleteClonedVoice(r.Context(), user, r.PathValue("voiceID"))
if err != nil {
applyRunErrorHeaders(w, err)
writeErrorWithDetails(w, statusFromRunError(err), runErrorMessage(err), runErrorDetails(err), runErrorCode(err))
return
}
@@ -56,12 +56,19 @@ func (s *Server) createVolcesContentsGenerationTask(w http.ResponseWriter, r *ht
// @Param taskID path string true "任务 ID"
// @Success 200 {object} VolcesContentsGenerationTaskResponse
// @Failure 404 {object} VolcesErrorEnvelope
// @Failure 410 {object} VolcesErrorEnvelope
// @Router /api/v1/contents/generations/tasks/{taskID} [get]
func (s *Server) getVolcesContentsGenerationTask(w http.ResponseWriter, r *http.Request) {
task, ok := s.volcesCompatibleTaskForUser(w, r)
if !ok {
return
}
var err error
task, err = s.hydrateTaskResult(r.Context(), task)
if err != nil {
writeVolcesError(w, statusFromRunError(err), err.Error(), clients.ErrorCode(err))
return
}
writeJSON(w, http.StatusOK, volcesCompatibleTask(task))
}
@@ -143,11 +150,11 @@ func (s *Server) deleteVolcesContentsGenerationTask(w http.ResponseWriter, r *ht
// createLegacyVolcesVideoGeneration godoc
// @Summary 创建 server-main 兼容视频任务
// @Description 兼容 server-main 的 /api/v1/video/generations,返回 submitted 和 task_id;额外保留火山任务字段。
// @Tags volces-compatible
// @Tags media
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} map[string]any
// @Success 200 {object} TaskAcceptedResponse
// @Router /api/v1/video/generations [post]
func (s *Server) createLegacyVolcesVideoGeneration(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
@@ -166,35 +173,46 @@ func (s *Server) createLegacyVolcesVideoGeneration(w http.ResponseWriter, r *htt
return
}
response := volcesCompatibleTask(task)
response["status"] = "submitted"
response["task_id"] = task.ID
writeJSON(w, http.StatusOK, response)
writeJSON(w, http.StatusOK, easyAITaskAcceptedResponseWithExtensions(task, response))
}
// getLegacyVolcesVideoResult godoc
// @Summary 查询 server-main 兼容视频结果
// @Tags volces-compatible
// getEasyAITaskResult godoc
// @Summary 查询 server-main EasyAIClient 兼容任务结果
// @Description 查询当前用户的图片、视频、音频、视频高清和矢量化任务,并返回 EasyAI GeneratedResponse 兼容结构。
// @Tags tasks
// @Produce json
// @Security BearerAuth
// @Param taskID path string true "任务 ID"
// @Success 200 {object} map[string]any
// @Success 200 {object} EasyAIGeneratedResponse
// @Failure 404 {object} EasyAIGeneratedResponse
// @Failure 410 {object} EasyAIGeneratedResponse
// @Router /api/v1/ai/result/{taskID} [get]
func (s *Server) getLegacyVolcesVideoResult(w http.ResponseWriter, r *http.Request) {
task, ok := s.volcesCompatibleTaskForUser(w, r)
if !ok {
func (s *Server) getEasyAITaskResult(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok || user == nil {
writeEasyAIAsyncError(w, http.StatusUnauthorized, "unauthorized", nil, "unauthorized")
return
}
compat := volcesCompatibleTask(task)
legacyStatus := "process"
switch compat["status"] {
case "succeeded":
legacyStatus = "success"
case "failed", "cancelled":
legacyStatus = "failed"
task, err := s.store.GetTask(r.Context(), strings.TrimSpace(r.PathValue("taskID")))
if err != nil {
if store.IsNotFound(err) {
writeEasyAIAsyncError(w, http.StatusNotFound, "task not found", nil, "not_found")
return
}
s.logger.Error("get EasyAI-compatible task failed", "error", err)
writeError(w, http.StatusInternalServerError, "get task failed", "internal_error")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"status": legacyStatus, "task_id": task.ID, "data": compat["content"], "result": compat,
})
if !runner.TaskAccessibleToUser(task, user) {
writeEasyAIAsyncError(w, http.StatusNotFound, "task not found", nil, "not_found")
return
}
task, err = s.hydrateTaskResult(r.Context(), task)
if err != nil {
writeEasyAIAsyncError(w, statusFromRunError(err), err.Error(), nil, clients.ErrorCode(err))
return
}
writeJSON(w, http.StatusOK, easyAITaskResultResponse(task))
}
func (s *Server) createVolcesCompatibleTask(r *http.Request, user *auth.User, body map[string]any) (store.GatewayTask, error) {
@@ -218,7 +236,6 @@ func (s *Server) createVolcesCompatibleTask(r *http.Request, user *auth.User, bo
return store.GatewayTask{}, err
}
task.CompatibilityProtocol = clients.ProtocolVolcesContents
task.CompatibilityPublicID = task.ID
if err := s.runner.EnqueueAsyncTask(r.Context(), task); err != nil {
return store.GatewayTask{}, &clients.ClientError{Code: "enqueue_failed", Message: err.Error(), StatusCode: http.StatusServiceUnavailable, Retryable: true}
}
@@ -253,7 +270,6 @@ func (s *Server) waitForCompatibilitySubmission(r *http.Request, task store.Gate
Message: firstNonEmpty(current.ErrorMessage, current.Error, current.Message),
StatusCode: status,
Retryable: false,
Wire: compatibilitySubmissionWire(current),
}
}
select {
@@ -266,34 +282,6 @@ func (s *Server) waitForCompatibilitySubmission(r *http.Request, task store.Gate
}
}
func compatibilitySubmissionWire(task store.GatewayTask) *clients.WireResponse {
if task.CompatibilitySubmitHTTPStatus == 0 || strings.TrimSpace(task.CompatibilitySourceProtocol) == "" {
return nil
}
headers := map[string][]string{}
for name, value := range task.CompatibilitySubmitHeaders {
switch typed := value.(type) {
case []any:
for _, item := range typed {
if text := strings.TrimSpace(volcesCompatString(item)); text != "" {
headers[name] = append(headers[name], text)
}
}
case []string:
headers[name] = append([]string(nil), typed...)
case string:
headers[name] = []string{typed}
}
}
return &clients.WireResponse{
Protocol: task.CompatibilitySourceProtocol,
StatusCode: task.CompatibilitySubmitHTTPStatus,
Headers: headers,
Body: cloneVolcesCompatibleMap(task.CompatibilitySubmitBody),
Converted: task.CompatibilitySourceProtocol != task.CompatibilityProtocol,
}
}
func (s *Server) volcesCompatibleTaskForUser(w http.ResponseWriter, r *http.Request) (store.GatewayTask, bool) {
user, ok := auth.UserFromContext(r.Context())
if !ok || user == nil {
@@ -322,15 +310,30 @@ func isVolcesCompatibleTask(task store.GatewayTask) bool {
}
func volcesCompatibleTask(task store.GatewayTask) map[string]any {
if volcesTaskUsesNativeProtocol(task) {
if raw := cloneVolcesCompatibleMap(mapFromVolcesCompat(task.Result["raw"])); len(raw) > 0 {
return raw
response := map[string]any{}
for _, key := range []string{"model", "seed", "resolution", "ratio", "duration", "frames", "framespersecond"} {
if task.Result[key] != nil {
response[key] = task.Result[key]
}
if raw := cloneVolcesCompatibleMap(task.RemoteTaskPayload); len(raw) > 0 {
return raw
}
if content, ok := task.Result["content"].(map[string]any); ok && len(content) > 0 {
// Transitional read support for historical canonical results. New
// results only persist data[] and reconstruct this protocol field.
response["content"] = content
} else if data, ok := task.Result["data"].([]any); ok && len(data) > 0 {
if item, ok := data[0].(map[string]any); ok {
content := map[string]any{}
if videoURL := firstNonEmpty(volcesCompatString(item["url"]), volcesCompatString(item["video_url"])); videoURL != "" {
content["video_url"] = videoURL
}
if lastFrameURL := volcesCompatString(item["last_frame_url"]); lastFrameURL != "" {
content["last_frame_url"] = lastFrameURL
}
if len(content) > 0 {
response["content"] = content
}
}
}
response := map[string]any{}
response["id"] = volcesCompatiblePublicID(task)
response["model"] = firstNonEmpty(volcesCompatString(response["model"]), task.Model)
response["status"] = volcesCompatibleTaskStatus(task.Status)
@@ -341,8 +344,10 @@ func volcesCompatibleTask(task store.GatewayTask) map[string]any {
response[key] = task.Request[key]
}
}
if len(task.Usage) > 0 && response["usage"] == nil {
response["usage"] = task.Usage
if usage := volcesCompatibleUsage(task.Usage); len(usage) > 0 {
response["usage"] = usage
} else if legacyUsage, ok := task.Result["usage"].(map[string]any); ok && len(legacyUsage) > 0 {
response["usage"] = legacyUsage
}
if task.Status == "failed" || task.Status == "cancelled" {
response["error"] = map[string]any{"code": firstNonEmpty(task.ErrorCode, strings.ToUpper(task.Status)), "message": firstNonEmpty(task.ErrorMessage, task.Error, task.Message)}
@@ -350,19 +355,28 @@ func volcesCompatibleTask(task store.GatewayTask) map[string]any {
return response
}
func volcesCompatibleCreateResponse(task store.GatewayTask) map[string]any {
if volcesTaskUsesNativeProtocol(task) {
if raw := cloneVolcesCompatibleMap(task.RemoteTaskPayload); len(raw) > 0 {
return raw
func volcesCompatibleUsage(usage map[string]any) map[string]any {
out := map[string]any{}
for outputKey, inputKeys := range map[string][]string{
"prompt_tokens": {"inputTokens", "promptTokens"},
"completion_tokens": {"outputTokens", "completionTokens"},
"total_tokens": {"totalTokens"},
} {
for _, inputKey := range inputKeys {
if value := usage[inputKey]; value != nil {
out[outputKey] = value
break
}
}
}
return out
}
func volcesCompatibleCreateResponse(task store.GatewayTask) map[string]any {
return map[string]any{"id": volcesCompatiblePublicID(task)}
}
func volcesCompatiblePublicID(task store.GatewayTask) string {
if strings.TrimSpace(task.CompatibilityPublicID) != "" {
return strings.TrimSpace(task.CompatibilityPublicID)
}
if volcesTaskUsesNativeProtocol(task) && strings.TrimSpace(task.RemoteTaskID) != "" {
return strings.TrimSpace(task.RemoteTaskID)
}
@@ -381,11 +395,6 @@ func volcesTaskUsesNativeProtocol(task store.GatewayTask) bool {
return false
}
func mapFromVolcesCompat(value any) map[string]any {
result, _ := value.(map[string]any)
return result
}
func volcesCompatibleTaskStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "succeeded", "success", "completed":
@@ -401,21 +410,6 @@ func volcesCompatibleTaskStatus(status string) string {
}
}
func cloneVolcesCompatibleMap(source map[string]any) map[string]any {
if len(source) == 0 {
return nil
}
raw, err := json.Marshal(source)
if err != nil {
return map[string]any{}
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
return map[string]any{}
}
return out
}
func writeVolcesCompatibleTaskError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
var staged *gatewayTaskCreationError
@@ -427,6 +421,8 @@ func writeVolcesCompatibleTaskError(w http.ResponseWriter, err error) {
status = clientErr.StatusCode
} else if errors.As(err, &clientErr) {
status = http.StatusBadRequest
} else if errors.Is(err, store.ErrTaskRequestBinaryNotMaterialized) {
status = http.StatusBadRequest
}
if wire := clients.ErrorWireResponse(err); wireResponseMatches(wire, clients.ProtocolVolcesContents) {
writeWireResponse(w, wire)
@@ -7,23 +7,22 @@ import (
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)
func TestVolcesCompatibleTaskPreservesNativeOfficialFieldsWithoutGatewayExtensions(t *testing.T) {
func TestVolcesCompatibleTaskBuildsProtocolResponseFromCanonicalResult(t *testing.T) {
now := time.Date(2026, 7, 18, 8, 0, 0, 0, time.UTC)
task := store.GatewayTask{
ID: "gateway-task-1", Kind: "videos.generations", Status: "succeeded", Model: "doubao-seedance-2-0-mini-260615",
RemoteTaskID: "cgt-upstream-1", CreatedAt: now, UpdatedAt: now.Add(time.Second),
Result: map[string]any{
"raw": map[string]any{
"id": "cgt-upstream-1", "model": "doubao-seedance-2-0-mini-260615", "status": "succeeded",
"content": map[string]any{"video_url": "https://example.com/out.mp4"}, "usage": map[string]any{"total_tokens": 9},
"future_official_field": "preserved",
},
"id": "cgt-upstream-1", "model": "doubao-seedance-2-0-mini-260615", "status": "succeeded",
"data": []any{map[string]any{"url": "https://example.com/out.mp4", "type": "video"}},
"future_official_field": "not-whitelisted",
},
Usage: map[string]any{"totalTokens": 9},
Billings: []any{map[string]any{"amount": 3}}, BillingSummary: map[string]any{"currency": "resource"}, FinalChargeAmount: 3,
Attempts: []store.TaskAttempt{{Provider: "volces"}},
}
got := volcesCompatibleTask(task)
if got["id"] != task.RemoteTaskID || got["status"] != "succeeded" || got["future_official_field"] != "preserved" {
if got["id"] != task.RemoteTaskID || got["status"] != "succeeded" || got["future_official_field"] != nil {
t.Fatalf("unexpected compatibility identity/status: %+v", got)
}
content, _ := got["content"].(map[string]any)