chore(git): 同步主分支发布与业务变更
# Conflicts: # apps/api/go.mod # apps/api/go.sum
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user