diff --git a/.gitignore b/.gitignore index 900265a..66e1f37 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,13 @@ CLAUDE.md logs/ docs/superpowers +# E2E / Playwright artifacts +test-artifacts/ +web/.playwright/ +web/playwright-report/ +web/test-results/ +web/e2e/.auth/ + electron/node_modules electron/dist data/ diff --git a/common/api_type.go b/common/api_type.go index 39c1fe9..8a8811f 100644 --- a/common/api_type.go +++ b/common/api_type.go @@ -53,6 +53,8 @@ func ChannelType2APIType(channelType int) (int, bool) { apiType = constant.APITypeMokaAI case constant.ChannelTypeVolcEngine: apiType = constant.APITypeVolcEngine + case constant.ChannelTypeDoubaoVideoCompatibleAiping: + apiType = constant.APITypeVolcEngine case constant.ChannelTypeBaiduV2: apiType = constant.APITypeBaiduV2 case constant.ChannelTypeOpenRouter: diff --git a/common/endpoint_defaults.go b/common/endpoint_defaults.go index 11ec792..eda1e1e 100644 --- a/common/endpoint_defaults.go +++ b/common/endpoint_defaults.go @@ -25,6 +25,7 @@ var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{ constant.EndpointTypeJinaRerank: {Path: "/v1/rerank", Method: "POST"}, constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"}, constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"}, + constant.EndpointTypeDoubaoVideo: {Path: "/api/v3/contents/generations/tasks", Method: "POST"}, } // GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在 diff --git a/common/endpoint_type.go b/common/endpoint_type.go index a5e2ff8..fb7a693 100644 --- a/common/endpoint_type.go +++ b/common/endpoint_type.go @@ -30,6 +30,8 @@ func GetEndpointTypesByChannelType(channelType int, modelName string) []constant endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI, constant.EndpointTypeOpenAIResponse} case constant.ChannelTypeSora: endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIVideo} + case constant.ChannelTypeDoubaoVideo, constant.ChannelTypeDoubaoVideoCompatibleAiping: + endpointTypes = []constant.EndpointType{constant.EndpointTypeDoubaoVideo} default: if IsOpenAIResponseOnlyModel(modelName) { endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse} diff --git a/common/utils.go b/common/utils.go index 3a8be45..5f94011 100644 --- a/common/utils.go +++ b/common/utils.go @@ -276,6 +276,10 @@ func Max(a int, b int) int { } func MessageWithRequestId(message string, id string) string { + id = strings.TrimSpace(id) + if id == "" || strings.Contains(message, "request id:") { + return message + } return fmt.Sprintf("%s (request id: %s)", message, id) } diff --git a/common/utils_test.go b/common/utils_test.go new file mode 100644 index 0000000..9f2a33d --- /dev/null +++ b/common/utils_test.go @@ -0,0 +1,27 @@ +package common + +import "testing" + +func TestMessageWithRequestIdAppendsRequestId(t *testing.T) { + got := MessageWithRequestId("upstream failed", "req-123") + want := "upstream failed (request id: req-123)" + if got != want { + t.Fatalf("MessageWithRequestId() = %q, want %q", got, want) + } +} + +func TestMessageWithRequestIdSkipsEmptyRequestId(t *testing.T) { + got := MessageWithRequestId("upstream failed", " ") + want := "upstream failed" + if got != want { + t.Fatalf("MessageWithRequestId() = %q, want %q", got, want) + } +} + +func TestMessageWithRequestIdDoesNotAppendTwice(t *testing.T) { + message := "upstream failed (request id: req-123)" + got := MessageWithRequestId(message, "req-123") + if got != message { + t.Fatalf("MessageWithRequestId() = %q, want %q", got, message) + } +} diff --git a/constant/channel.go b/constant/channel.go index 48502be..4798f99 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -1,61 +1,62 @@ package constant const ( - ChannelTypeUnknown = 0 - ChannelTypeOpenAI = 1 - ChannelTypeMidjourney = 2 - ChannelTypeAzure = 3 - ChannelTypeOllama = 4 - ChannelTypeMidjourneyPlus = 5 - ChannelTypeOpenAIMax = 6 - ChannelTypeOhMyGPT = 7 - ChannelTypeCustom = 8 - ChannelTypeAILS = 9 - ChannelTypeAIProxy = 10 - ChannelTypePaLM = 11 - ChannelTypeAPI2GPT = 12 - ChannelTypeAIGC2D = 13 - ChannelTypeAnthropic = 14 - ChannelTypeBaidu = 15 - ChannelTypeZhipu = 16 - ChannelTypeAli = 17 - ChannelTypeXunfei = 18 - ChannelType360 = 19 - ChannelTypeOpenRouter = 20 - ChannelTypeAIProxyLibrary = 21 - ChannelTypeFastGPT = 22 - ChannelTypeTencent = 23 - ChannelTypeGemini = 24 - ChannelTypeMoonshot = 25 - ChannelTypeZhipu_v4 = 26 - ChannelTypePerplexity = 27 - ChannelTypeLingYiWanWu = 31 - ChannelTypeAws = 33 - ChannelTypeCohere = 34 - ChannelTypeMiniMax = 35 - ChannelTypeSunoAPI = 36 - ChannelTypeDify = 37 - ChannelTypeJina = 38 - ChannelCloudflare = 39 - ChannelTypeSiliconFlow = 40 - ChannelTypeVertexAi = 41 - ChannelTypeMistral = 42 - ChannelTypeDeepSeek = 43 - ChannelTypeMokaAI = 44 - ChannelTypeVolcEngine = 45 - ChannelTypeBaiduV2 = 46 - ChannelTypeXinference = 47 - ChannelTypeXai = 48 - ChannelTypeCoze = 49 - ChannelTypeKling = 50 - ChannelTypeJimeng = 51 - ChannelTypeVidu = 52 - ChannelTypeSubmodel = 53 - ChannelTypeDoubaoVideo = 54 - ChannelTypeSora = 55 - ChannelTypeReplicate = 56 - ChannelTypeCodex = 57 - ChannelTypeDummy // this one is only for count, do not add any channel after this + ChannelTypeUnknown = 0 + ChannelTypeOpenAI = 1 + ChannelTypeMidjourney = 2 + ChannelTypeAzure = 3 + ChannelTypeOllama = 4 + ChannelTypeMidjourneyPlus = 5 + ChannelTypeOpenAIMax = 6 + ChannelTypeOhMyGPT = 7 + ChannelTypeCustom = 8 + ChannelTypeAILS = 9 + ChannelTypeAIProxy = 10 + ChannelTypePaLM = 11 + ChannelTypeAPI2GPT = 12 + ChannelTypeAIGC2D = 13 + ChannelTypeAnthropic = 14 + ChannelTypeBaidu = 15 + ChannelTypeZhipu = 16 + ChannelTypeAli = 17 + ChannelTypeXunfei = 18 + ChannelType360 = 19 + ChannelTypeOpenRouter = 20 + ChannelTypeAIProxyLibrary = 21 + ChannelTypeFastGPT = 22 + ChannelTypeTencent = 23 + ChannelTypeGemini = 24 + ChannelTypeMoonshot = 25 + ChannelTypeZhipu_v4 = 26 + ChannelTypePerplexity = 27 + ChannelTypeLingYiWanWu = 31 + ChannelTypeAws = 33 + ChannelTypeCohere = 34 + ChannelTypeMiniMax = 35 + ChannelTypeSunoAPI = 36 + ChannelTypeDify = 37 + ChannelTypeJina = 38 + ChannelCloudflare = 39 + ChannelTypeSiliconFlow = 40 + ChannelTypeVertexAi = 41 + ChannelTypeMistral = 42 + ChannelTypeDeepSeek = 43 + ChannelTypeMokaAI = 44 + ChannelTypeVolcEngine = 45 + ChannelTypeBaiduV2 = 46 + ChannelTypeXinference = 47 + ChannelTypeXai = 48 + ChannelTypeCoze = 49 + ChannelTypeKling = 50 + ChannelTypeJimeng = 51 + ChannelTypeVidu = 52 + ChannelTypeSubmodel = 53 + ChannelTypeDoubaoVideo = 54 + ChannelTypeSora = 55 + ChannelTypeReplicate = 56 + ChannelTypeCodex = 57 + ChannelTypeDoubaoVideoCompatibleAiping = 58 + ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -118,63 +119,65 @@ var ChannelBaseURLs = []string{ "https://api.openai.com", //55 "https://api.replicate.com", //56 "https://chatgpt.com", //57 + "", //58 } var ChannelTypeNames = map[int]string{ - ChannelTypeUnknown: "Unknown", - ChannelTypeOpenAI: "OpenAI", - ChannelTypeMidjourney: "Midjourney", - ChannelTypeAzure: "Azure", - ChannelTypeOllama: "Ollama", - ChannelTypeMidjourneyPlus: "MidjourneyPlus", - ChannelTypeOpenAIMax: "OpenAIMax", - ChannelTypeOhMyGPT: "OhMyGPT", - ChannelTypeCustom: "Custom", - ChannelTypeAILS: "AILS", - ChannelTypeAIProxy: "AIProxy", - ChannelTypePaLM: "PaLM", - ChannelTypeAPI2GPT: "API2GPT", - ChannelTypeAIGC2D: "AIGC2D", - ChannelTypeAnthropic: "Anthropic", - ChannelTypeBaidu: "Baidu", - ChannelTypeZhipu: "Zhipu", - ChannelTypeAli: "Ali", - ChannelTypeXunfei: "Xunfei", - ChannelType360: "360", - ChannelTypeOpenRouter: "OpenRouter", - ChannelTypeAIProxyLibrary: "AIProxyLibrary", - ChannelTypeFastGPT: "FastGPT", - ChannelTypeTencent: "Tencent", - ChannelTypeGemini: "Gemini", - ChannelTypeMoonshot: "Moonshot", - ChannelTypeZhipu_v4: "ZhipuV4", - ChannelTypePerplexity: "Perplexity", - ChannelTypeLingYiWanWu: "LingYiWanWu", - ChannelTypeAws: "AWS", - ChannelTypeCohere: "Cohere", - ChannelTypeMiniMax: "MiniMax", - ChannelTypeSunoAPI: "SunoAPI", - ChannelTypeDify: "Dify", - ChannelTypeJina: "Jina", - ChannelCloudflare: "Cloudflare", - ChannelTypeSiliconFlow: "SiliconFlow", - ChannelTypeVertexAi: "VertexAI", - ChannelTypeMistral: "Mistral", - ChannelTypeDeepSeek: "DeepSeek", - ChannelTypeMokaAI: "MokaAI", - ChannelTypeVolcEngine: "VolcEngine", - ChannelTypeBaiduV2: "BaiduV2", - ChannelTypeXinference: "Xinference", - ChannelTypeXai: "xAI", - ChannelTypeCoze: "Coze", - ChannelTypeKling: "Kling", - ChannelTypeJimeng: "Jimeng", - ChannelTypeVidu: "Vidu", - ChannelTypeSubmodel: "Submodel", - ChannelTypeDoubaoVideo: "DoubaoVideo", - ChannelTypeSora: "Sora", - ChannelTypeReplicate: "Replicate", - ChannelTypeCodex: "Codex", + ChannelTypeUnknown: "Unknown", + ChannelTypeOpenAI: "OpenAI", + ChannelTypeMidjourney: "Midjourney", + ChannelTypeAzure: "Azure", + ChannelTypeOllama: "Ollama", + ChannelTypeMidjourneyPlus: "MidjourneyPlus", + ChannelTypeOpenAIMax: "OpenAIMax", + ChannelTypeOhMyGPT: "OhMyGPT", + ChannelTypeCustom: "Custom", + ChannelTypeAILS: "AILS", + ChannelTypeAIProxy: "AIProxy", + ChannelTypePaLM: "PaLM", + ChannelTypeAPI2GPT: "API2GPT", + ChannelTypeAIGC2D: "AIGC2D", + ChannelTypeAnthropic: "Anthropic", + ChannelTypeBaidu: "Baidu", + ChannelTypeZhipu: "Zhipu", + ChannelTypeAli: "Ali", + ChannelTypeXunfei: "Xunfei", + ChannelType360: "360", + ChannelTypeOpenRouter: "OpenRouter", + ChannelTypeAIProxyLibrary: "AIProxyLibrary", + ChannelTypeFastGPT: "FastGPT", + ChannelTypeTencent: "Tencent", + ChannelTypeGemini: "Gemini", + ChannelTypeMoonshot: "Moonshot", + ChannelTypeZhipu_v4: "ZhipuV4", + ChannelTypePerplexity: "Perplexity", + ChannelTypeLingYiWanWu: "LingYiWanWu", + ChannelTypeAws: "AWS", + ChannelTypeCohere: "Cohere", + ChannelTypeMiniMax: "MiniMax", + ChannelTypeSunoAPI: "SunoAPI", + ChannelTypeDify: "Dify", + ChannelTypeJina: "Jina", + ChannelCloudflare: "Cloudflare", + ChannelTypeSiliconFlow: "SiliconFlow", + ChannelTypeVertexAi: "VertexAI", + ChannelTypeMistral: "Mistral", + ChannelTypeDeepSeek: "DeepSeek", + ChannelTypeMokaAI: "MokaAI", + ChannelTypeVolcEngine: "VolcEngine", + ChannelTypeBaiduV2: "BaiduV2", + ChannelTypeXinference: "Xinference", + ChannelTypeXai: "xAI", + ChannelTypeCoze: "Coze", + ChannelTypeKling: "Kling", + ChannelTypeJimeng: "Jimeng", + ChannelTypeVidu: "Vidu", + ChannelTypeSubmodel: "Submodel", + ChannelTypeDoubaoVideo: "DoubaoVideo", + ChannelTypeSora: "Sora", + ChannelTypeReplicate: "Replicate", + ChannelTypeCodex: "Codex", + ChannelTypeDoubaoVideoCompatibleAiping: "DoubaoVideoCompatibleAiping", } func GetChannelTypeName(channelType int) string { diff --git a/constant/endpoint_type.go b/constant/endpoint_type.go index 0c642fa..fef4e4f 100644 --- a/constant/endpoint_type.go +++ b/constant/endpoint_type.go @@ -24,6 +24,7 @@ const ( EndpointTypeEmbeddings EndpointType = "embeddings" // OpenAI Video API,如 Sora 视频生成 EndpointTypeOpenAIVideo EndpointType = "openai-video" + EndpointTypeDoubaoVideo EndpointType = "doubao-video" //EndpointTypeMidjourney EndpointType = "midjourney-proxy" //EndpointTypeSuno EndpointType = "suno-proxy" //EndpointTypeKling EndpointType = "kling" diff --git a/controller/doubao_aiping_video.go b/controller/doubao_aiping_video.go new file mode 100644 index 0000000..7d3587a --- /dev/null +++ b/controller/doubao_aiping_video.go @@ -0,0 +1,160 @@ +package controller + +import ( + "fmt" + "net/http" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +func convertAipingNativeVideoRequest(body []byte) (relaycommon.TaskSubmitReq, error) { + var raw map[string]any + if err := common.Unmarshal(body, &raw); err != nil { + return relaycommon.TaskSubmitReq{}, err + } + modelName, _ := raw["model"].(string) + if strings.TrimSpace(modelName) == "" { + return relaycommon.TaskSubmitReq{}, fmt.Errorf("model field is required") + } + + metadata := map[string]any{} + for key, value := range raw { + if key == "model" || key == "content" { + continue + } + metadata[key] = value + } + + var passthroughContent []any + if rawContent, ok := raw["content"].([]any); ok { + for _, item := range rawContent { + itemMap, ok := item.(map[string]any) + if !ok { + passthroughContent = append(passthroughContent, item) + continue + } + passthroughContent = append(passthroughContent, itemMap) + } + } + if len(passthroughContent) > 0 { + metadata["content"] = passthroughContent + } + + return relaycommon.TaskSubmitReq{ + Model: strings.TrimSpace(modelName), + Metadata: metadata, + }, nil +} + +func prepareAipingNativeVideoSubmit(c *gin.Context, info *relaycommon.RelayInfo) error { + bodyStorage, err := common.GetBodyStorage(c) + if err != nil { + return err + } + body, err := bodyStorage.Bytes() + if err != nil { + return err + } + req, err := convertAipingNativeVideoRequest(body) + if err != nil { + return err + } + info.OriginModelName = req.Model + info.Action = constant.TaskActionGenerate + relaycommon.StoreTaskRequest(c, info, constant.TaskActionGenerate, req) + return nil +} + +func AipingNativeVideoSubmit(c *gin.Context) { + relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, &dto.TaskError{ + Code: "gen_relay_info_failed", + Message: err.Error(), + StatusCode: http.StatusInternalServerError, + }) + return + } + c.Set("channel_type", constant.ChannelTypeDoubaoVideoCompatibleAiping) + if relayInfo.ChannelMeta == nil { + relayInfo.ChannelMeta = &relaycommon.ChannelMeta{} + } + relayInfo.ChannelType = constant.ChannelTypeDoubaoVideoCompatibleAiping + if err := prepareAipingNativeVideoSubmit(c, relayInfo); err != nil { + c.JSON(http.StatusBadRequest, &dto.TaskError{ + Code: "invalid_request", + Message: err.Error(), + StatusCode: http.StatusBadRequest, + }) + return + } + relayTaskWithInfo(c, relayInfo) +} + +func buildAipingNativeFetchResponse(task *model.Task) ([]byte, error) { + payload := map[string]any{} + if len(task.Data) > 0 { + if err := common.Unmarshal(task.Data, &payload); err != nil { + return nil, err + } + } + delete(payload, "aiping_id") + if _, ok := payload["error"]; !ok { + payload["error"] = nil + } + payload["id"] = task.TaskID + if _, ok := payload["model"]; !ok { + payload["model"] = task.Properties.OriginModelName + } + if _, ok := payload["status"]; !ok { + payload["status"] = mapAipingNativeStatus(task.Status) + } + if _, ok := payload["created_at"]; !ok { + payload["created_at"] = task.CreatedAt + } + if _, ok := payload["updated_at"]; !ok { + payload["updated_at"] = task.UpdatedAt + } + return common.Marshal(payload) +} + +func mapAipingNativeStatus(status model.TaskStatus) string { + switch status { + case model.TaskStatusQueued, model.TaskStatusSubmitted: + return "queued" + case model.TaskStatusInProgress: + return "running" + case model.TaskStatusSuccess: + return "succeeded" + case model.TaskStatusFailure: + return "failed" + default: + return "running" + } +} + +func AipingNativeVideoFetch(c *gin.Context) { + taskID := c.Param("task_id") + task, exist, err := model.GetByTaskId(c.GetInt("id"), taskID) + if err != nil { + c.JSON(http.StatusInternalServerError, &dto.TaskError{Code: "get_task_failed", Message: err.Error(), StatusCode: http.StatusInternalServerError}) + return + } + if !exist { + c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"code": "not_found", "message": "task not found"}}) + return + } + data, err := buildAipingNativeFetchResponse(task) + if err != nil { + c.JSON(http.StatusInternalServerError, &dto.TaskError{Code: "build_response_failed", Message: err.Error(), StatusCode: http.StatusInternalServerError}) + return + } + c.Data(http.StatusOK, "application/json", data) +} diff --git a/controller/doubao_aiping_video_test.go b/controller/doubao_aiping_video_test.go new file mode 100644 index 0000000..1dc7088 --- /dev/null +++ b/controller/doubao_aiping_video_test.go @@ -0,0 +1,209 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestConvertAipingNativeVideoRequestPreservesNativeContentOrder(t *testing.T) { + req, err := convertAipingNativeVideoRequest([]byte(`{ + "model":"doubao-seedance-2-0-260128", + "content":[ + {"type":"text","text":"first prompt"}, + {"type":"image_url","image_url":{"url":"asset://img1"},"role":"reference_image"}, + {"type":"text","text":"second prompt"}, + {"type":"video_url","video_url":{"url":"https://example.test/input.mp4"},"role":"reference_video"} + ], + "duration":5, + "resolution":"480p", + "tools":[{"type":"web_search"}] + }`)) + + require.NoError(t, err) + require.Equal(t, "doubao-seedance-2-0-260128", req.Model) + require.Empty(t, req.Prompt) + require.Equal(t, float64(5), req.Metadata["duration"]) + require.Equal(t, "480p", req.Metadata["resolution"]) + require.NotNil(t, req.Metadata["tools"]) + content, ok := req.Metadata["content"].([]any) + require.True(t, ok) + require.Len(t, content, 4) + require.Equal(t, "text", content[0].(map[string]any)["type"]) + require.Equal(t, "image_url", content[1].(map[string]any)["type"]) + require.Equal(t, "text", content[2].(map[string]any)["type"]) + require.Equal(t, "video_url", content[3].(map[string]any)["type"]) +} + +func TestConvertAipingNativeVideoRequestPreservesArkDocumentFields(t *testing.T) { + req, err := convertAipingNativeVideoRequest([]byte(`{ + "model":"doubao-seedance-2-0-260128", + "content":[ + {"type":"text","text":"开场:海边日落"}, + {"type":"image_url","image_url":{"url":"asset://image-1"},"role":"reference_image"}, + {"type":"video_url","video_url":{"url":"asset://video-1"},"role":"reference_video"}, + {"type":"audio_url","audio_url":{"url":"asset://audio-1"},"role":"reference_audio"}, + {"type":"draft_task","draft_task":{"id":"cgt-draft"}} + ], + "callback_url":"https://example.test/callback", + "return_last_frame":true, + "service_tier":"default", + "execution_expires_after":3600, + "generate_audio":false, + "draft":true, + "tools":[{"type":"web_search"}], + "safety_identifier":"user-hash-1", + "priority":5, + "resolution":"480p", + "ratio":"1:1", + "duration":5, + "frames":29, + "seed":11, + "camera_fixed":false, + "watermark":true + }`)) + + require.NoError(t, err) + require.Equal(t, "doubao-seedance-2-0-260128", req.Model) + require.Empty(t, req.Prompt) + require.Equal(t, "https://example.test/callback", req.Metadata["callback_url"]) + require.Equal(t, true, req.Metadata["return_last_frame"]) + require.Equal(t, "default", req.Metadata["service_tier"]) + require.Equal(t, float64(3600), req.Metadata["execution_expires_after"]) + require.Equal(t, false, req.Metadata["generate_audio"]) + require.Equal(t, true, req.Metadata["draft"]) + require.Equal(t, "user-hash-1", req.Metadata["safety_identifier"]) + require.Equal(t, float64(5), req.Metadata["priority"]) + require.Equal(t, "480p", req.Metadata["resolution"]) + require.Equal(t, "1:1", req.Metadata["ratio"]) + require.Equal(t, float64(5), req.Metadata["duration"]) + require.Equal(t, float64(29), req.Metadata["frames"]) + require.Equal(t, float64(11), req.Metadata["seed"]) + require.Equal(t, false, req.Metadata["camera_fixed"]) + require.Equal(t, true, req.Metadata["watermark"]) + + content, ok := req.Metadata["content"].([]any) + require.True(t, ok) + require.Len(t, content, 5) + require.Equal(t, "audio_url", content[3].(map[string]any)["type"]) + require.Equal(t, "draft_task", content[4].(map[string]any)["type"]) + tools, ok := req.Metadata["tools"].([]any) + require.True(t, ok) + require.Equal(t, "web_search", tools[0].(map[string]any)["type"]) +} + +func TestConvertAipingNativeVideoRequestRejectsMissingModel(t *testing.T) { + _, err := convertAipingNativeVideoRequest([]byte(`{"content":[{"type":"text","text":"prompt"}]}`)) + require.ErrorContains(t, err, "model") +} + +func TestPrepareAipingNativeVideoSubmitStoresConvertedRequest(t *testing.T) { + c := newControllerJSONContext(t, "/api/v3/contents/generations/tasks", `{ + "model":"doubao-seedance-2-0-260128", + "content":[{"type":"text","text":"prompt"}] + }`) + info := &relaycommon.RelayInfo{TaskRelayInfo: &relaycommon.TaskRelayInfo{}} + + err := prepareAipingNativeVideoSubmit(c, info) + + require.NoError(t, err) + stored, err := relaycommon.GetTaskRequest(c) + require.NoError(t, err) + require.Equal(t, "doubao-seedance-2-0-260128", stored.Model) + require.Empty(t, stored.Prompt) + content, ok := stored.Metadata["content"].([]any) + require.True(t, ok) + require.Equal(t, "prompt", content[0].(map[string]any)["text"]) + require.Equal(t, constant.TaskActionGenerate, info.Action) + require.Equal(t, "doubao-seedance-2-0-260128", info.OriginModelName) +} + +func TestBuildAipingNativeFetchResponseRewritesIDAndPreservesUpstreamFields(t *testing.T) { + task := &model.Task{ + TaskID: "task_public", + Status: model.TaskStatusSuccess, + CreatedAt: 1781496040, + UpdatedAt: 1781496278, + Properties: model.Properties{OriginModelName: "doubao-seedance-2-0-260128"}, + Data: []byte(`{ + "id":"cgt-upstream", + "aiping_id":"3d2c8c17-36ad-4138-8602-88b3c60e56c6", + "model":"doubao-seedance-2-0-260128", + "status":"succeeded", + "content":{"video_url":"https://example.test/output.mp4"}, + "usage":{"completion_tokens":48400,"total_tokens":48400}, + "created_at":1781496040, + "updated_at":1781496278, + "seed":73812, + "resolution":"480p", + "ratio":"1:1", + "duration":5, + "framespersecond":24, + "service_tier":"default", + "execution_expires_after":172800, + "generate_audio":true, + "draft":false, + "priority":0 + }`), + } + + data, err := buildAipingNativeFetchResponse(task) + + require.NoError(t, err) + var payload map[string]any + require.NoError(t, common.Unmarshal(data, &payload)) + require.Equal(t, "task_public", payload["id"]) + require.Equal(t, "succeeded", payload["status"]) + require.Equal(t, "doubao-seedance-2-0-260128", payload["model"]) + require.Equal(t, "https://example.test/output.mp4", payload["content"].(map[string]any)["video_url"]) + require.Equal(t, float64(48400), payload["usage"].(map[string]any)["total_tokens"]) + require.Equal(t, float64(73812), payload["seed"]) + require.Equal(t, "480p", payload["resolution"]) + require.Equal(t, "1:1", payload["ratio"]) + require.Equal(t, float64(5), payload["duration"]) + require.Equal(t, float64(24), payload["framespersecond"]) + require.Equal(t, "default", payload["service_tier"]) + require.Equal(t, float64(172800), payload["execution_expires_after"]) + require.Equal(t, true, payload["generate_audio"]) + require.Equal(t, false, payload["draft"]) + require.Equal(t, float64(0), payload["priority"]) + require.Contains(t, payload, "error") + require.Nil(t, payload["error"]) + require.NotContains(t, payload, "aiping_id") +} + +func TestBuildAipingNativeFetchResponseFallsBackForFailedTask(t *testing.T) { + task := &model.Task{ + TaskID: "task_public", + Status: model.TaskStatusFailure, + CreatedAt: 100, + UpdatedAt: 200, + Properties: model.Properties{OriginModelName: "doubao-seedance-2-0-260128"}, + Data: []byte(`{"id":"cgt-upstream","status":"failed","error":{"code":"InvalidParameter","message":"duration is invalid"}}`), + } + + data, err := buildAipingNativeFetchResponse(task) + + require.NoError(t, err) + require.Contains(t, string(data), `"id":"task_public"`) + require.Contains(t, string(data), `"code":"InvalidParameter"`) + require.Contains(t, string(data), `"message":"duration is invalid"`) + require.NotContains(t, string(data), `"error":null`) +} + +func newControllerJSONContext(t *testing.T, path string, body string) *gin.Context { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + return c +} diff --git a/controller/doubao_asset.go b/controller/doubao_asset.go new file mode 100644 index 0000000..e56f42b --- /dev/null +++ b/controller/doubao_asset.go @@ -0,0 +1,203 @@ +package controller + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" + + "github.com/gin-gonic/gin" +) + +const defaultDoubaoAssetBaseURL = "https://ark.cn-beijing.volcengineapi.com" + +var blockedAssetActions = map[string]struct{}{ + "createassetgroup": {}, + "getassetgroup": {}, + "listassetgroups": {}, + "updateassetgroup": {}, + "deleteassetgroup": {}, +} + +func assetProxyError(c *gin.Context, status int, errType, message string) { + c.JSON(status, gin.H{ + "error": gin.H{ + "message": message, + "type": errType, + }, + }) +} + +func buildDoubaoAssetURL(channel *model.Channel, action string, version string) (string, error) { + baseURL := defaultDoubaoAssetBaseURL + if channel != nil && channel.BaseURL != nil { + if configuredBaseURL := strings.TrimSpace(*channel.BaseURL); configuredBaseURL != "" { + baseURL = configuredBaseURL + } + } + + u, err := url.Parse(baseURL) + if err != nil { + return "", err + } + if u.Scheme == "" || u.Host == "" { + return "", fmt.Errorf("invalid Doubao asset base URL: %s", baseURL) + } + + u.Path = strings.TrimRight(u.Path, "/") + "/api/v1/volcengine/asset" + u.RawQuery = "" + u.Fragment = "" + + query := u.Query() + query.Set("Action", action) + if strings.TrimSpace(version) == "" { + version = "2024-01-01" + } + query.Set("Version", version) + u.RawQuery = query.Encode() + + return u.String(), nil +} + +func effectiveDoubaoAssetGroup(c *gin.Context) string { + if group := strings.TrimSpace(common.GetContextKeyString(c, constant.ContextKeyUsingGroup)); group != "" { + return group + } + return strings.TrimSpace(common.GetContextKeyString(c, constant.ContextKeyTokenGroup)) +} + +func concreteDoubaoAssetGroupsForRequest(c *gin.Context, autoGroups func(string) []string) []string { + group := effectiveDoubaoAssetGroup(c) + if group != "auto" { + if group == "" { + return nil + } + return []string{group} + } + + userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) + groups := autoGroups(userGroup) + concreteGroups := make([]string, 0, len(groups)) + for _, candidate := range groups { + candidate = strings.TrimSpace(candidate) + if candidate == "" || candidate == "auto" { + continue + } + concreteGroups = append(concreteGroups, candidate) + } + return concreteGroups +} + +func resolveDoubaoAssetChannelForRequest(c *gin.Context) (*model.Channel, string, error) { + userId := c.GetInt("id") + var lastErr error + for _, group := range concreteDoubaoAssetGroupsForRequest(c, service.GetUserAutoGroup) { + channel, err := service.ResolveDoubaoAssetChannel(userId, group) + if err != nil { + lastErr = err + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to resolve Doubao asset channel for group %s: %v", group, err)) + continue + } + if channel != nil { + return channel, group, nil + } + } + if lastErr != nil { + return nil, "", lastErr + } + return nil, "", nil +} + +func DoubaoAssetProxy(c *gin.Context) { + action := strings.TrimSpace(c.Query("Action")) + if action == "" { + assetProxyError(c, http.StatusBadRequest, "invalid_request_error", "Action query parameter is required") + return + } + if _, ok := blockedAssetActions[strings.ToLower(action)]; ok { + assetProxyError(c, http.StatusBadRequest, "invalid_request_error", fmt.Sprintf("Asset group API (%s) is not supported", action)) + return + } + + version := strings.TrimSpace(c.Query("Version")) + if version == "" { + version = "2024-01-01" + } + + channel, _, err := resolveDoubaoAssetChannelForRequest(c) + if err != nil { + assetProxyError(c, http.StatusBadGateway, "server_error", fmt.Sprintf("Failed to resolve Doubao asset channel: %v", err)) + return + } + if channel == nil { + assetProxyError(c, http.StatusBadGateway, "server_error", "Failed to resolve Doubao asset channel") + return + } + if strings.TrimSpace(channel.Key) == "" { + assetProxyError(c, http.StatusBadGateway, "server_error", "Doubao asset channel key is missing") + return + } + + upstreamURL, err := buildDoubaoAssetURL(channel, action, version) + if err != nil { + assetProxyError(c, http.StatusBadGateway, "server_error", fmt.Sprintf("Failed to build upstream URL: %v", err)) + return + } + + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(upstreamURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Doubao asset URL blocked: %v", err)) + assetProxyError(c, http.StatusForbidden, "server_error", fmt.Sprintf("request blocked: %v", err)) + return + } + + client, err := service.GetHttpClientWithProxy(channel.GetSetting().Proxy) + if err != nil { + assetProxyError(c, http.StatusBadGateway, "server_error", fmt.Sprintf("Failed to create proxy client: %v", err)) + return + } + if client == nil { + assetProxyError(c, http.StatusBadGateway, "server_error", "Failed to create proxy client") + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, c.Request.Body) + if err != nil { + assetProxyError(c, http.StatusBadGateway, "server_error", fmt.Sprintf("Failed to create upstream request: %v", err)) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(channel.Key)) + + resp, err := client.Do(req) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to proxy Doubao asset request to %s: %s", upstreamURL, err.Error())) + assetProxyError(c, http.StatusBadGateway, "server_error", fmt.Sprintf("Failed to proxy Doubao asset request: %v", err)) + return + } + defer resp.Body.Close() + + for key, values := range resp.Header { + for _, value := range values { + c.Writer.Header().Add(key, value) + } + } + c.Writer.WriteHeader(resp.StatusCode) + if _, err = io.Copy(c.Writer, resp.Body); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to copy Doubao asset upstream response: %s", err.Error())) + } +} diff --git a/controller/doubao_asset_test.go b/controller/doubao_asset_test.go new file mode 100644 index 0000000..ca378c0 --- /dev/null +++ b/controller/doubao_asset_test.go @@ -0,0 +1,103 @@ +package controller + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupDoubaoAssetProxyRouter(t *testing.T) *gin.Engine { + t.Helper() + + oldMode := gin.Mode() + gin.SetMode(gin.TestMode) + t.Cleanup(func() { + gin.SetMode(oldMode) + }) + + r := gin.New() + r.POST("/api/v1/volcengine/asset", DoubaoAssetProxy) + return r +} + +func decodeDoubaoAssetErrorMessage(t *testing.T, body string) string { + t.Helper() + + var payload struct { + Error struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` + } + require.NoError(t, common.Unmarshal([]byte(body), &payload)) + return payload.Error.Message +} + +func TestDoubaoAssetProxyMissingActionReturns400(t *testing.T) { + router := setupDoubaoAssetProxyRouter(t) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/volcengine/asset?Version=2024-01-01", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, "Action query parameter is required", decodeDoubaoAssetErrorMessage(t, w.Body.String())) +} + +func TestDoubaoAssetProxyBlocksAssetGroupActionsCaseInsensitively(t *testing.T) { + router := setupDoubaoAssetProxyRouter(t) + + for _, action := range []string{"CreateAssetGroup", "getassetgroup", "LISTASSETGROUPS", "UpdateAssetGroup", "deleteassetgroup"} { + t.Run(action, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/volcengine/asset?Action="+action, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, decodeDoubaoAssetErrorMessage(t, w.Body.String()), "Asset group API ("+action+") is not supported") + }) + } +} + +func TestBuildDoubaoAssetURLDefaultBaseAndEscapedQuery(t *testing.T) { + got, err := buildDoubaoAssetURL(&model.Channel{}, "ApplyUploadInner&Space", "") + + require.NoError(t, err) + assert.Equal(t, defaultDoubaoAssetBaseURL+"/api/v1/volcengine/asset?Action=ApplyUploadInner%26Space&Version=2024-01-01", got) +} + +func TestBuildDoubaoAssetURLExplicitBaseURLOverridesDefault(t *testing.T) { + baseURL := "https://example.com/custom/" + + got, err := buildDoubaoAssetURL(&model.Channel{BaseURL: &baseURL}, "CommitUploadInner", "2025-02-03") + + require.NoError(t, err) + assert.Equal(t, "https://example.com/custom/api/v1/volcengine/asset?Action=CommitUploadInner&Version=2025-02-03", got) +} + +func TestEffectiveDoubaoAssetGroupUsesUsingGroupBeforeBlankTokenGroup(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + common.SetContextKey(c, constant.ContextKeyTokenGroup, "") + common.SetContextKey(c, constant.ContextKeyUsingGroup, "default") + + assert.Equal(t, "default", effectiveDoubaoAssetGroup(c)) +} + +func TestConcreteDoubaoAssetGroupsForAutoUsesUserAutoGroups(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + common.SetContextKey(c, constant.ContextKeyUsingGroup, "auto") + common.SetContextKey(c, constant.ContextKeyTokenGroup, "") + + groups := concreteDoubaoAssetGroupsForRequest(c, func(string) []string { + return []string{"default", "vip"} + }) + + assert.Equal(t, []string{"default", "vip"}, groups) +} diff --git a/controller/model_pricing_controller.go b/controller/model_pricing_controller.go new file mode 100644 index 0000000..401a745 --- /dev/null +++ b/controller/model_pricing_controller.go @@ -0,0 +1,58 @@ +package controller + +import ( + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +func GetModelPricingRules(c *gin.Context) { + common.ApiSuccess(c, ratio_setting.GetPricingConfigCopy()) +} + +func GetModelPricingRule(c *gin.Context) { + modelName := c.Param("model") + cfg := ratio_setting.GetPricingConfig(modelName) + if cfg == nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "model pricing rule not found"}) + return + } + common.ApiSuccess(c, cfg) +} + +func UpdateModelPricingRule(c *gin.Context) { + modelName := c.Param("model") + var cfg types.PricingConfig + if err := common.DecodeJson(c.Request.Body, &cfg); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) + return + } + if err := ratio_setting.SetPricingConfig(modelName, &cfg); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) + return + } + if err := model.UpdateOption(ratio_setting.ModelPricingRulesOptionKey, ratio_setting.ModelPricingRules2JSONString()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) + return + } + model.RefreshPricing() + common.ApiSuccess(c, ratio_setting.GetPricingConfig(modelName)) +} + +func DeleteModelPricingRule(c *gin.Context) { + modelName := c.Param("model") + if err := ratio_setting.DeletePricingConfig(modelName); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) + return + } + if err := model.UpdateOption(ratio_setting.ModelPricingRulesOptionKey, ratio_setting.ModelPricingRules2JSONString()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) + return + } + model.RefreshPricing() + common.ApiSuccess(c, nil) +} diff --git a/controller/model_pricing_controller_test.go b/controller/model_pricing_controller_test.go new file mode 100644 index 0000000..15c182a --- /dev/null +++ b/controller/model_pricing_controller_test.go @@ -0,0 +1,135 @@ +package controller + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupModelPricingControllerTest(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.Option{})) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + + origDB := model.DB + origUsingSQLite := common.UsingSQLite + origRedisEnabled := common.RedisEnabled + model.DB = db + common.UsingSQLite = true + common.RedisEnabled = false + model.InitOptionMap() + require.NoError(t, ratio_setting.UpdateModelPricingRulesByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"video-test":0.25}`)) + + t.Cleanup(func() { + model.DB = origDB + common.UsingSQLite = origUsingSQLite + common.RedisEnabled = origRedisEnabled + require.NoError(t, ratio_setting.UpdateModelPricingRulesByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`)) + require.NoError(t, sqlDB.Close()) + }) + + r := gin.New() + r.GET("/api/option/model_pricing", GetModelPricingRules) + r.GET("/api/option/model_pricing/:model", GetModelPricingRule) + r.PUT("/api/option/model_pricing/:model", UpdateModelPricingRule) + r.DELETE("/api/option/model_pricing/:model", DeleteModelPricingRule) + return r +} + +func TestModelPricingController_CRUD(t *testing.T) { + r := setupModelPricingControllerTest(t) + cfg := validControllerPricingConfig() + + w := performJSONRequest(t, r, http.MethodPut, "/api/option/model_pricing/video-test", cfg) + require.Equal(t, http.StatusOK, w.Code) + assert.True(t, responseSuccess(t, w.Body.Bytes())) + + w = performJSONRequest(t, r, http.MethodGet, "/api/option/model_pricing/video-test", nil) + require.Equal(t, http.StatusOK, w.Code) + assert.True(t, responseSuccess(t, w.Body.Bytes())) + assert.Contains(t, w.Body.String(), `"billing_unit":"per_call"`) + + w = performJSONRequest(t, r, http.MethodGet, "/api/option/model_pricing", nil) + require.Equal(t, http.StatusOK, w.Code) + assert.True(t, responseSuccess(t, w.Body.Bytes())) + assert.Contains(t, w.Body.String(), "video-test") + + w = performJSONRequest(t, r, http.MethodDelete, "/api/option/model_pricing/video-test", nil) + require.Equal(t, http.StatusOK, w.Code) + assert.True(t, responseSuccess(t, w.Body.Bytes())) + + w = performJSONRequest(t, r, http.MethodGet, "/api/option/model_pricing/video-test", nil) + assert.Equal(t, http.StatusNotFound, w.Code) + legacyPrice, ok := ratio_setting.GetModelPrice("video-test", false) + require.True(t, ok) + assert.Equal(t, 0.25, legacyPrice) +} + +func TestModelPricingController_PutRejectsBadConfig(t *testing.T) { + r := setupModelPricingControllerTest(t) + cfg := validControllerPricingConfig() + cfg.Table = nil + + w := performJSONRequest(t, r, http.MethodPut, "/api/option/model_pricing/video-test", cfg) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "table must not be empty") +} + +func performJSONRequest(t *testing.T, r *gin.Engine, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var payload []byte + if body != nil { + var err error + payload, err = common.Marshal(body) + require.NoError(t, err) + } + req := httptest.NewRequest(method, path, bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func responseSuccess(t *testing.T, payload []byte) bool { + t.Helper() + var body struct { + Success bool `json:"success"` + } + require.NoError(t, common.Unmarshal(payload, &body)) + return body.Success +} + +func validControllerPricingConfig() types.PricingConfig { + return types.PricingConfig{ + SchemaVersion: 1, + Scope: types.PricingScopeModel, + BillingUnit: types.BillingUnitPerCall, + PreconsumeStrategy: types.PreconsumeStrategyExact, + Dimensions: []types.PricingDimension{ + {Key: "resolution", Source: "request.resolution", Type: "string"}, + }, + Table: []types.PricingRow{ + {"resolution": "720P", "price": 0.1, "source": types.PricingRowSourceManual}, + }, + Fallback: types.PricingFallback{Strategy: types.PricingFallbackReject}, + } +} diff --git a/controller/pricing.go b/controller/pricing.go index 2b07b9f..ef9e8f8 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -121,21 +121,27 @@ func GetUserPricing(c *gin.Context) { return } - totalRatio := groupRatio savingsPercent := int(math.Round((1 - totalRatio) * 100)) result := gin.H{ - "success": true, - "model_name": pricingData.ModelName, - "quota_type": pricingData.QuotaType, - "group": userGroup, - "group_ratio": groupRatio, - "savings_percent": savingsPercent, - "logged_in": true, + "success": true, + "model_name": pricingData.ModelName, + "quota_type": pricingData.QuotaType, + "group": userGroup, + "group_ratio": groupRatio, + "savings_percent": savingsPercent, + "logged_in": true, + } + if pricingData.PricingConfig != nil { + result["pricing_config"] = pricingData.PricingConfig } if pricingData.QuotaType == model.QuotaTypeByTokens { + if pricingData.PricingConfig != nil { + result["original_price"] = pricingData.ModelPrice + result["user_price"] = pricingData.ModelPrice * totalRatio + } originalInput := pricingData.ModelRatio * 2 originalOutput := pricingData.ModelRatio * pricingData.CompletionRatio * 2 result["original_input"] = originalInput @@ -163,9 +169,16 @@ func respondOriginalPrice(c *gin.Context, pricingData *model.Pricing) { "logged_in": false, } if pricingData.QuotaType == model.QuotaTypeByTokens { + if pricingData.PricingConfig != nil { + result["pricing_config"] = pricingData.PricingConfig + result["original_price"] = pricingData.ModelPrice + } result["original_input"] = pricingData.ModelRatio * 2 result["original_output"] = pricingData.ModelRatio * pricingData.CompletionRatio * 2 } else { + if pricingData.PricingConfig != nil { + result["pricing_config"] = pricingData.PricingConfig + } result["original_price"] = pricingData.ModelPrice } c.JSON(200, result) diff --git a/controller/relay.go b/controller/relay.go index 4ca1497..55fd96f 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -24,6 +24,7 @@ import ( "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" "github.com/bytedance/gopkg/util/gopool" @@ -190,21 +191,21 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { }() retryParam := &service.RetryParam{ - Ctx: c, - TokenGroup: relayInfo.TokenGroup, - ModelName: relayInfo.OriginModelName, - Retry: common.GetPointer(0), + Ctx: c, + TokenGroup: relayInfo.TokenGroup, + ModelName: relayInfo.OriginModelName, + Retry: common.GetPointer(0), + RequireMatrixUsageBilling: relayInfo.RequireMatrixUsageBilling, } for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() { - channel, channelErr := getChannel(c, relayInfo, retryParam) + channel, _, channelErr := getChannel(c, relayInfo, retryParam) if channelErr != nil { logger.LogError(c, channelErr.Error()) newAPIError = channelErr break } - addUsedChannel(c, channel.Id) bodyStorage, bodyErr := common.GetBodyStorage(c) if bodyErr != nil { @@ -295,7 +296,33 @@ func fastTokenCountMetaForPricing(request dto.Request) *types.TokenCountMeta { return meta } -func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service.RetryParam) (*model.Channel, *types.NewAPIError) { +func concreteDoubaoVideoBindingGroup(c *gin.Context, group string) string { + group = strings.TrimSpace(group) + if group == "" { + group = strings.TrimSpace(common.GetContextKeyString(c, constant.ContextKeyTokenGroup)) + } + if group == "auto" { + autoGroup := strings.TrimSpace(common.GetContextKeyString(c, constant.ContextKeyAutoGroup)) + if autoGroup == "" || autoGroup == "auto" { + return "" + } + return autoGroup + } + return group +} + +func persistDoubaoVideoTaskBindingIfNeeded(userId int, group string, channel *model.Channel) error { + group = strings.TrimSpace(group) + if group == "" || group == "auto" { + return nil + } + if !service.IsUsableDoubaoAssetChannel(channel, group) { + return nil + } + return model.BindUserAssetChannel(userId, service.DoubaoAssetChannelType, group, channel.Id) +} + +func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service.RetryParam) (*model.Channel, string, *types.NewAPIError) { if info.ChannelMeta == nil { autoBan := c.GetBool("auto_ban") autoBanInt := 1 @@ -307,24 +334,34 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service Type: c.GetInt("channel_type"), Name: c.GetString("channel_name"), AutoBan: &autoBanInt, - }, nil + }, concreteDoubaoVideoBindingGroup(c, info.TokenGroup), nil } channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam) info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info) if err != nil { - return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, info.OriginModelName, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + if apiErr, ok := err.(*types.NewAPIError); ok { + return nil, selectGroup, apiErr + } + return nil, selectGroup, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, info.OriginModelName, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) } if channel == nil { - return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, info.OriginModelName), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + return nil, selectGroup, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, info.OriginModelName), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) } newAPIError := middleware.SetupContextForSelectedChannel(c, channel, info.OriginModelName) if newAPIError != nil { - return nil, newAPIError + return nil, selectGroup, newAPIError + } + return channel, selectGroup, nil +} + +func requiredTaskChannelTypeForRequest(c *gin.Context) int { + if strings.HasPrefix(c.Request.URL.Path, "/api/v3/contents/generations/tasks") { + return constant.ChannelTypeDoubaoVideoCompatibleAiping } - return channel, nil + return 0 } func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) bool { @@ -488,6 +525,95 @@ func RelayTaskFetch(c *gin.Context) { } } +func preloadTaskPricingConfig(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { + modelName := strings.TrimSpace(info.OriginModelName) + action := strings.TrimSpace(info.Action) + contentType := c.Request.Header.Get("Content-Type") + + storage, err := common.GetBodyStorage(c) + if err != nil { + status := http.StatusBadRequest + if common.IsRequestBodyTooLargeError(err) || errors.Is(err, common.ErrRequestBodyTooLarge) { + status = http.StatusRequestEntityTooLarge + } + return service.TaskErrorWrapperLocal(err, "read_request_body_failed", status) + } + defer func() { + _, _ = storage.Seek(0, io.SeekStart) + c.Request.Body = io.NopCloser(storage) + }() + + switch { + case strings.HasPrefix(contentType, "application/json"): + body, err := storage.Bytes() + if err != nil { + return service.TaskErrorWrapperLocal(err, "read_request_body_failed", http.StatusBadRequest) + } + if strings.TrimSpace(string(body)) != "" { + var payload map[string]any + if err := common.Unmarshal(body, &payload); err != nil { + return service.TaskErrorWrapperLocal(err, "invalid_json", http.StatusBadRequest) + } + if raw, ok := payload["model"].(string); ok && strings.TrimSpace(raw) != "" { + modelName = strings.TrimSpace(raw) + } + if raw, ok := payload["action"].(string); ok && strings.TrimSpace(raw) != "" { + action = strings.TrimSpace(raw) + } + } + case strings.Contains(contentType, gin.MIMEMultipartPOSTForm): + form, err := common.ParseMultipartFormReusable(c) + if err != nil { + return service.TaskErrorWrapperLocal(err, "invalid_multipart_form", http.StatusBadRequest) + } + if vals := form.Value["model"]; len(vals) > 0 && strings.TrimSpace(vals[0]) != "" { + modelName = strings.TrimSpace(vals[0]) + } + if vals := form.Value["action"]; len(vals) > 0 && strings.TrimSpace(vals[0]) != "" { + action = strings.TrimSpace(vals[0]) + } + } + + if modelName == "" && action != "" { + platform := constant.TaskPlatform(c.GetString("platform")) + modelName = service.CoverTaskActionToModelName(platform, action) + } + if modelName != "" { + info.OriginModelName = modelName + } + if action != "" { + info.Action = action + } + + info.PricingConfigSnapshotLoaded = true + info.PricingConfigSnapshot = ratio_setting.GetPricingConfig(info.OriginModelName) + info.RequireMatrixUsageBilling = info.PricingConfigSnapshot != nil && + info.PricingConfigSnapshot.BillingUnit == types.BillingUnitPer1MTokens + return nil +} + +func buildTaskBillingContext(info *relaycommon.RelayInfo) *model.TaskBillingContext { + bc := &model.TaskBillingContext{ + ModelPrice: info.PriceData.ModelPrice, + GroupRatio: info.PriceData.GroupRatioInfo.GroupRatio, + ModelRatio: info.PriceData.ModelRatio, + OtherRatios: info.PriceData.OtherRatios, + OriginModelName: info.OriginModelName, + PerCallBilling: common.StringsContains(constant.TaskPricePatches, info.OriginModelName), + } + if decision := info.PricingDecisionFrozen; decision != nil && decision.BillingMode == types.BillingModeMatrix { + bc.BillingMode = decision.BillingMode + bc.PricingSnapshot = types.CloneMapAny(decision.Snapshot) + bc.BillingUnit = decision.BillingUnit + bc.TokenUnitPriceUSD = decision.TokenUnitPriceUSD + bc.PerCallBilling = decision.PerCallBilling + bc.ModelPrice = decision.PriceUSD + bc.GroupRatio = decision.GroupRatioInfo.GroupRatio + bc.OtherRatios = types.CloneRatios(decision.OtherRatios) + } + return bc +} + func RelayTask(c *gin.Context) { relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil) if err != nil { @@ -498,11 +624,18 @@ func RelayTask(c *gin.Context) { }) return } + relayTaskWithInfo(c, relayInfo) +} +func relayTaskWithInfo(c *gin.Context, relayInfo *relaycommon.RelayInfo) { if taskErr := relay.ResolveOriginTask(c, relayInfo); taskErr != nil { respondTaskError(c, taskErr) return } + if taskErr := preloadTaskPricingConfig(c, relayInfo); taskErr != nil { + respondTaskError(c, taskErr) + return + } var result *relay.TaskSubmitResult var taskErr *dto.TaskError @@ -513,26 +646,27 @@ func RelayTask(c *gin.Context) { }() retryParam := &service.RetryParam{ - Ctx: c, - TokenGroup: relayInfo.TokenGroup, - ModelName: relayInfo.OriginModelName, - Retry: common.GetPointer(0), + Ctx: c, + TokenGroup: relayInfo.TokenGroup, + ModelName: relayInfo.OriginModelName, + Retry: common.GetPointer(0), + RequiredChannelType: requiredTaskChannelTypeForRequest(c), } for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() { var channel *model.Channel + var selectedGroup string if lockedCh, ok := relayInfo.LockedChannel.(*model.Channel); ok && lockedCh != nil { channel = lockedCh - if retryParam.GetRetry() > 0 { - if setupErr := middleware.SetupContextForSelectedChannel(c, channel, relayInfo.OriginModelName); setupErr != nil { - taskErr = service.TaskErrorWrapperLocal(setupErr.Err, "setup_locked_channel_failed", http.StatusInternalServerError) - break - } + selectedGroup = concreteDoubaoVideoBindingGroup(c, relayInfo.TokenGroup) + if setupErr := middleware.SetupContextForSelectedChannel(c, channel, relayInfo.OriginModelName); setupErr != nil { + taskErr = service.TaskErrorWrapperLocal(setupErr.Err, "setup_locked_channel_failed", http.StatusInternalServerError) + break } } else { var channelErr *types.NewAPIError - channel, channelErr = getChannel(c, relayInfo, retryParam) + channel, selectedGroup, channelErr = getChannel(c, relayInfo, retryParam) if channelErr != nil { logger.LogError(c, channelErr.Error()) taskErr = service.TaskErrorWrapperLocal(channelErr.Err, "get_channel_failed", http.StatusInternalServerError) @@ -540,6 +674,11 @@ func RelayTask(c *gin.Context) { } } + if bindErr := persistDoubaoVideoTaskBindingIfNeeded(c.GetInt("id"), selectedGroup, channel); bindErr != nil { + taskErr = service.TaskErrorWrapperLocal(bindErr, "bind_doubao_video_channel_failed", http.StatusServiceUnavailable) + break + } + addUsedChannel(c, channel.Id) bodyStorage, bodyErr := common.GetBodyStorage(c) if bodyErr != nil { @@ -587,14 +726,8 @@ func RelayTask(c *gin.Context) { task.PrivateData.BillingSource = relayInfo.BillingSource task.PrivateData.SubscriptionId = relayInfo.SubscriptionId task.PrivateData.TokenId = relayInfo.TokenId - task.PrivateData.BillingContext = &model.TaskBillingContext{ - ModelPrice: relayInfo.PriceData.ModelPrice, - GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, - ModelRatio: relayInfo.PriceData.ModelRatio, - OtherRatios: relayInfo.PriceData.OtherRatios, - OriginModelName: relayInfo.OriginModelName, - PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName), - } + task.PrivateData.BillingContext = buildTaskBillingContext(relayInfo) + task.PrivateData.UpstreamRequest = relay.BuildUpstreamRequestSnapshotForTask(result.UpstreamReqJSON) task.Quota = result.Quota task.Data = result.TaskData task.Action = relayInfo.Action diff --git a/docs/superpowers/plans/2026-06-18-e2e-test-foundation.md b/docs/superpowers/plans/2026-06-18-e2e-test-foundation.md new file mode 100644 index 0000000..09013f5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-e2e-test-foundation.md @@ -0,0 +1,608 @@ +# E2E Test Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a reusable Playwright E2E foundation and cover the model multidimensional pricing save/reload workflow. + +**Architecture:** Playwright runs from `web/`, starts the React app through Vite, and starts the Go backend through a global setup helper. The backend uses a temporary SQLite database and a fixed test session secret so each E2E run is isolated and repeatable. + +**Tech Stack:** Playwright, Bun, React/Vite, Go backend, SQLite. + +--- + +## File Structure + +- Create `web/playwright.config.js`: Playwright configuration, projects, web server, artifacts, and env wiring. +- Create `web/e2e/global-setup.js`: start backend with temporary SQLite and wait for readiness. +- Create `web/e2e/global-teardown.js`: stop backend process and remove temporary database directory. +- Create `web/e2e/auth.setup.js`: log in as `root / 123456` and save storage state. +- Create `web/e2e/model-pricing.spec.js`: first real E2E workflow for model multidimensional pricing. +- Create `web/e2e/utils/process.js`: cross-platform process spawn/kill helpers. +- Create `web/e2e/utils/wait.js`: HTTP polling helper. +- Modify `web/package.json`: add E2E scripts. +- Modify `web/src/components/settings/RatioSetting.jsx`: add `data-testid` to the model pricing tab content. +- Modify `web/src/pages/Setting/Operation/ModelPricing/index.jsx`: add stable selectors for the page, add button, search input, and table. +- Modify `web/src/pages/Setting/Operation/ModelPricing/Editor.jsx`: add stable selectors for model input, save, generator, and JSON editor. +- Modify `web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx`: add stable selectors for add dimension and editable cells. +- Modify `web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx`: add stable selectors for base price, add ratio, apply, and ratio inputs. + +## Task 1: Add Playwright Scripts + +**Files:** +- Modify: `web/package.json` + +- [ ] **Step 1: Add E2E scripts** + +Add these entries inside `scripts`: + +```json +"e2e": "playwright test", +"e2e:ui": "playwright test --ui", +"e2e:headed": "playwright test --headed", +"e2e:install": "playwright install chromium" +``` + +- [ ] **Step 2: Verify package scripts parse** + +Run: + +```powershell +cd web +bun pm pkg get scripts.e2e +``` + +Expected output includes: + +```text +"playwright test" +``` + +- [ ] **Step 3: Commit** + +```powershell +git add web/package.json +git commit -m "test: add e2e scripts" +``` + +## Task 2: Add Backend Process Helpers + +**Files:** +- Create: `web/e2e/utils/process.js` +- Create: `web/e2e/utils/wait.js` + +- [ ] **Step 1: Create process helper** + +Create `web/e2e/utils/process.js`: + +```js +import { spawn } from 'node:child_process'; + +export function startProcess(command, args, options = {}) { + const child = spawn(command, args, { + cwd: options.cwd, + env: { ...process.env, ...(options.env || {}) }, + shell: process.platform === 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const output = []; + child.stdout.on('data', (chunk) => output.push(chunk.toString())); + child.stderr.on('data', (chunk) => output.push(chunk.toString())); + + child.once('exit', (code) => { + if (code !== null && code !== 0) { + output.push(`\nprocess exited with code ${code}\n`); + } + }); + + return { child, output }; +} + +export async function stopProcess(child) { + if (!child || child.killed) return; + await new Promise((resolve) => { + child.once('exit', resolve); + child.kill(process.platform === 'win32' ? undefined : 'SIGTERM'); + setTimeout(() => { + if (!child.killed) child.kill('SIGKILL'); + resolve(); + }, 5000).unref(); + }); +} +``` + +- [ ] **Step 2: Create HTTP wait helper** + +Create `web/e2e/utils/wait.js`: + +```js +export async function waitForHttp(url, options = {}) { + const timeoutMs = options.timeoutMs || 60000; + const intervalMs = options.intervalMs || 500; + const start = Date.now(); + let lastError; + + while (Date.now() - start < timeoutMs) { + try { + const response = await fetch(url, { cache: 'no-store' }); + if (response.ok) return response; + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + throw new Error(`Timed out waiting for ${url}: ${lastError?.message || 'no response'}`); +} +``` + +- [ ] **Step 3: Commit** + +```powershell +git add web/e2e/utils/process.js web/e2e/utils/wait.js +git commit -m "test: add e2e process helpers" +``` + +## Task 3: Add Playwright Runtime Setup + +**Files:** +- Create: `web/e2e/global-setup.js` +- Create: `web/e2e/global-teardown.js` +- Create: `web/playwright.config.js` + +- [ ] **Step 1: Create global setup** + +Create `web/e2e/global-setup.js`: + +```js +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { startProcess } from './utils/process.js'; +import { waitForHttp } from './utils/wait.js'; + +const statePath = path.resolve('.playwright/e2e-state.json'); + +export default async function globalSetup() { + const repoRoot = path.resolve('..'); + const backendPort = process.env.E2E_BACKEND_PORT || '3001'; + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'new-api-e2e-')); + const sqlitePath = path.join(tempDir, 'e2e.db'); + + await fs.mkdir(path.dirname(statePath), { recursive: true }); + + const backend = startProcess('go', ['run', 'main.go'], { + cwd: repoRoot, + env: { + PORT: backendPort, + SQLITE_PATH: `${sqlitePath}?_busy_timeout=30000`, + SESSION_SECRET: 'e2e_session_secret_for_local_tests', + }, + }); + + try { + await waitForHttp(`http://127.0.0.1:${backendPort}/api/setup`, { timeoutMs: 90000 }); + } catch (error) { + console.error(backend.output.join('')); + backend.child.kill(); + throw error; + } + + await fs.writeFile( + statePath, + JSON.stringify({ + backendPid: backend.child.pid, + tempDir, + backendPort, + }), + 'utf8', + ); +} +``` + +- [ ] **Step 2: Create global teardown** + +Create `web/e2e/global-teardown.js`: + +```js +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const statePath = path.resolve('.playwright/e2e-state.json'); + +export default async function globalTeardown() { + try { + const state = JSON.parse(await fs.readFile(statePath, 'utf8')); + if (state.backendPid) { + try { + process.kill(state.backendPid); + } catch {} + } + if (state.tempDir) { + await fs.rm(state.tempDir, { recursive: true, force: true }); + } + await fs.rm(statePath, { force: true }); + } catch {} +} +``` + +- [ ] **Step 3: Create Playwright config** + +Create `web/playwright.config.js`: + +```js +import { defineConfig, devices } from '@playwright/test'; + +const frontendPort = process.env.E2E_FRONTEND_PORT || '5173'; +const backendPort = process.env.E2E_BACKEND_PORT || '3001'; + +export default defineConfig({ + testDir: './e2e', + globalSetup: './e2e/global-setup.js', + globalTeardown: './e2e/global-teardown.js', + timeout: 60_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + retries: process.env.CI ? 1 : 0, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + baseURL: `http://127.0.0.1:${frontendPort}`, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'off', + }, + webServer: { + command: `bun run dev -- --host 127.0.0.1 --port ${frontendPort}`, + url: `http://127.0.0.1:${frontendPort}`, + reuseExistingServer: !process.env.CI, + env: { + VITE_REACT_APP_SERVER_URL: `http://127.0.0.1:${backendPort}`, + }, + timeout: 90_000, + }, + projects: [ + { name: 'setup', testMatch: /auth\.setup\.js/ }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'e2e/.auth/root.json', + }, + dependencies: ['setup'], + testIgnore: /auth\.setup\.js/, + }, + ], +}); +``` + +- [ ] **Step 4: Run Playwright list to verify config loads** + +Run: + +```powershell +cd web +bunx playwright test --list +``` + +Expected: command exits successfully and lists no feature tests yet, or only setup if Task 4 has already been completed. + +- [ ] **Step 5: Commit** + +```powershell +git add web/playwright.config.js web/e2e/global-setup.js web/e2e/global-teardown.js +git commit -m "test: add e2e runtime setup" +``` + +## Task 4: Add Login Storage Setup + +**Files:** +- Create: `web/e2e/auth.setup.js` + +- [ ] **Step 1: Create auth setup test** + +Create `web/e2e/auth.setup.js`: + +```js +import { test, expect } from '@playwright/test'; +import fs from 'node:fs/promises'; + +test('authenticate as root', async ({ page }) => { + await fs.mkdir('e2e/.auth', { recursive: true }); + await page.goto('/login'); + + await page.getByPlaceholder(/用户名|username/i).fill('root'); + await page.getByPlaceholder(/密码|password/i).fill('123456'); + + const loginResponse = page.waitForResponse((response) => + response.url().includes('/api/user/login') && response.request().method() === 'POST', + ); + await page.getByRole('button', { name: /登录|login/i }).click(); + const response = await loginResponse; + expect(response.ok()).toBeTruthy(); + + await expect(page).toHaveURL(/\/console/); + await page.context().storageState({ path: 'e2e/.auth/root.json' }); +}); +``` + +- [ ] **Step 2: Run setup only** + +Run: + +```powershell +cd web +bunx playwright test --project=setup +``` + +Expected: setup test passes and creates `web/e2e/.auth/root.json`. + +- [ ] **Step 3: Ignore generated auth state** + +If `web/e2e/.auth/root.json` appears in `git status`, add this to `.gitignore`: + +```gitignore +web/e2e/.auth/ +web/.playwright/ +web/playwright-report/ +web/test-results/ +``` + +- [ ] **Step 4: Commit** + +```powershell +git add web/e2e/auth.setup.js .gitignore +git commit -m "test: add e2e root login setup" +``` + +## Task 5: Add Stable Selectors To Model Pricing UI + +**Files:** +- Modify: `web/src/components/settings/RatioSetting.jsx` +- Modify: `web/src/pages/Setting/Operation/ModelPricing/index.jsx` +- Modify: `web/src/pages/Setting/Operation/ModelPricing/Editor.jsx` +- Modify: `web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx` +- Modify: `web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx` + +- [ ] **Step 1: Add tab content selector** + +In `web/src/components/settings/RatioSetting.jsx`, wrap the model pricing tab content: + +```jsx + +
+ +
+
+``` + +- [ ] **Step 2: Add page-level selectors** + +In `web/src/pages/Setting/Operation/ModelPricing/index.jsx`, use these props: + +```jsx + +``` + +```jsx +