From 9c407405f32818a41811712372d20fb736adcbf0 Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sat, 4 Jul 2026 22:55:32 +0800 Subject: [PATCH 1/7] v1.0.4-preview.2 --- .../task/taskcommon/path_config_test.go | 25 +++++++++++++++++++ .../task/taskcommon/protocol_config.go | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/relay/channel/task/taskcommon/path_config_test.go b/relay/channel/task/taskcommon/path_config_test.go index 584cf3cc..ad2f220b 100644 --- a/relay/channel/task/taskcommon/path_config_test.go +++ b/relay/channel/task/taskcommon/path_config_test.go @@ -271,6 +271,31 @@ func TestParseConfiguredTaskResultPromotesResultURLToSuccess(t *testing.T) { assert.Equal(t, "https://example.com/video.mp4", result.Url) } +func TestParseConfiguredTaskResultReadsOpenAIVideoMetadataURL(t *testing.T) { + settings := dto.ChannelOtherSettings{ + TaskProtocol: TaskProtocolGenericVideo, + } + body := []byte(`{ + "id": "task_public_1", + "task_id": "upstream_task_1", + "object": "video", + "status": "completed", + "progress": 100, + "metadata": { + "url": "https://videos.example.com/upstream-result.mp4" + } + }`) + + result, parsed, err := ParseConfiguredTaskResult(body, settings) + + require.NoError(t, err) + require.True(t, parsed) + require.NotNil(t, result) + assert.Equal(t, string(model.TaskStatusSuccess), result.Status) + assert.Equal(t, "100%", result.Progress) + assert.Equal(t, "https://videos.example.com/upstream-result.mp4", result.Url) +} + func TestParseConfiguredTaskResultRequiresProtocol(t *testing.T) { settings := dto.ChannelOtherSettings{ TaskProtocolConfig: &dto.TaskProtocolConfig{ diff --git a/relay/channel/task/taskcommon/protocol_config.go b/relay/channel/task/taskcommon/protocol_config.go index 138cc400..f4383b93 100644 --- a/relay/channel/task/taskcommon/protocol_config.go +++ b/relay/channel/task/taskcommon/protocol_config.go @@ -55,7 +55,7 @@ func NormalizeTaskProtocolConfig(input *dto.TaskProtocolConfig) dto.TaskProtocol TaskIDPath: "task_id", StatusPath: "status", ProgressPath: "progress", - ResultURLPaths: []string{"result.primary_url", "result.urls.0", "result.url", "result.video_url", "result.output_url", "data.result.primary_url", "data.result.urls.0", "data.result.url", "data.result.video_url", "data.result.output_url", "url", "video_url", "output_url", "file_url", "download_url", "result"}, + ResultURLPaths: []string{"result.primary_url", "result.urls.0", "result.url", "result.video_url", "result.output_url", "metadata.url", "metadata.video_url", "metadata.output_url", "data.result.primary_url", "data.result.urls.0", "data.result.url", "data.result.video_url", "data.result.output_url", "data.metadata.url", "data.metadata.video_url", "data.metadata.output_url", "url", "video_url", "output_url", "file_url", "download_url", "result"}, CreatedAtPath: "created_at", UpdatedAtPath: "updated_at", StatusMap: map[string]string{ From b1dc9c529480e493e5427ca6ecf181121939d55a Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sun, 5 Jul 2026 01:04:46 +0800 Subject: [PATCH 2/7] v1.0.4-preview.2 --- .agents/upstream-sync/new-api-sync.md | 24 +++- controller/subscription.go | 3 +- controller/user.go | 33 ++--- main.go | 33 ++++- model/log.go | 47 +++---- model/user.go | 34 ++++- model/user_cache.go | 34 ++++- model/user_update_test.go | 91 ++++++++++++ relay/channel/ollama/stream.go | 89 ++++++++---- relay/channel/ollama/stream_test.go | 95 +++++++++++++ relay/channel/task/ali/adaptor.go | 85 +++++++++++- relay/channel/task/ali/adaptor_wan27_test.go | 130 ++++++++++++++++++ relay/channel/task/ali/constants.go | 2 + .../task/taskcommon/path_config_test.go | 29 ++++ .../task/taskcommon/protocol_config.go | 25 +++- relay/common/relay_utils.go | 2 + relay/common/relay_utils_test.go | 33 +++++ router/api-router.go | 2 +- tools/jsonwrapcheck/allowlist.txt | 19 --- .../src/routes/_authenticated/route.tsx | 10 +- 20 files changed, 701 insertions(+), 119 deletions(-) create mode 100644 model/user_update_test.go create mode 100644 relay/channel/ollama/stream_test.go create mode 100644 relay/channel/task/ali/adaptor_wan27_test.go create mode 100644 relay/common/relay_utils_test.go diff --git a/.agents/upstream-sync/new-api-sync.md b/.agents/upstream-sync/new-api-sync.md index 86104781..e4c2e5b1 100644 --- a/.agents/upstream-sync/new-api-sync.md +++ b/.agents/upstream-sync/new-api-sync.md @@ -6,11 +6,11 @@ This file tracks selective synchronization from upstream New API into MAX-API. I - Upstream checkout: `new-api/new-api` - Upstream remote: `https://github.com/QuantumNous/new-api.git` -- Last reviewed upstream commit: `25f998595d2da4ac9c749f3eae8fffcf9047bc3e` -- Last reviewed upstream tag: `v1.0.0-rc.15` +- Last reviewed upstream commit: `917a2cff64feed0acd687298252bd400adf293e0` +- Last reviewed upstream tag: `v1.0.0-rc.16-2-g917a2cff` - MAX-API branch reviewed: `cscitech` -- MAX-API commit reviewed: `477e4809bac3da3584dfec17be97c991fec78ba3` -- Last review date: `2026-06-29` +- MAX-API commit reviewed: `9c407405f32818a41811712372d20fb736adcbf0` +- Last review date: `2026-07-04` ## Sync Rules @@ -40,6 +40,22 @@ This file tracks selective synchronization from upstream New API into MAX-API. I | `4aee5f7d` | permissions, admin | Better admin permissions/RBAC for channels and users. | pending | Needs design review before porting. | - | Large change touching backend authz, routes, models, and frontend permissions. | | `966af88e` | frontend, playground | Improve Playground chat experience and Markdown rendering. | pending | Evaluate after preserving MAX-API one-click clear behavior from `477e4809`. | - | Large frontend refactor. | | `25f99859`, `1d166532` | frontend, channels | Refine channel management UI and channel drawer layout. | pending | Evaluate after higher-priority fixes. | - | Large channel drawer diff; likely conflicts with MAX-API custom channel UX. | +| `43591fba` | frontend, channels | Improve advanced custom route editor density and validation affordances. | pending | Evaluate separately against MAX-API custom channel UX. | - | UI-only priority after backend/relay fixes. | +| `c8491b41`, `e514db20` | tasks, billing | Doubao Seedance 2.0 output-resolution/video-input billing plus `safety_identifier`/`priority` request fields. | superseded | MAX-API already has equivalent or stronger Doubao support and local billing tests. | Existing `relay/channel/task/doubao` tests. | Do not overwrite local task billing enhancements. | +| `52858ad1` | tasks, relay | Add Wan2.7 i2v `input.media` mapping and single-image normalization. | ported | Added Wan2.7 model entries, `input.media` normalization, `image` to `images` direct-task normalization, and regression tests while preserving MAX-API Kling/multi-shot media logic. | `go test ./relay/channel/task/ali ./relay/common`; broader focused Go test set; `go run ./tools/jsonwrapcheck`; `git diff --check`. | Uses local `[]map[string]interface{}` media shape instead of upstream typed struct to preserve local extensions. | +| `fda81778`, `1f4d8d2b` | frontend, rendering | Isolate custom HTML rendering and clone app styles into the isolated root. | pending | Evaluate against MAX-API shared `html-sanitizer` and `RichContent` behavior before porting. | - | Optional unless custom HTML style bleed or style loss is reported. | +| `95e8c5ee` | frontend, build | Optimize Rsbuild/Tailwind build pipeline. | pending | Treat as a separate build tooling change. | - | Requires lockfile/package-manager validation. | +| `f9165e7b`, `e1fd9cc2` | dev, build | Align make targets and dev-web behavior with upstream web naming. | pending | Evaluate against MAX-API local frontend/dev workflow. | - | Low priority. | +| `759ab6bb` | frontend, routing | Keep page state when switching `$section` route tabs by keying transitions on route id. | pending | Evaluate with MAX-API page transition implementation. | - | Optional UX fix. | +| `986d90ae`, `8874d192` | ops, logs | Add graceful shutdown, flush quota dashboard cache on exit, and make quota logging synchronous. | ported | Replaced `server.Run` with `http.Server` graceful shutdown, flushes `SaveQuotaDataCache()`, delayed startup success until listen goroutine starts, and logs quota data synchronously. | `go test . ./controller ./model ./relay/channel/ollama ./relay/channel/task/ali ./relay/common`; `go run ./tools/jsonwrapcheck`; `git diff --check`. | Preserves MAX-API startup, pprof, and analytics injection. | +| `0565e626` | frontend, auth | Only treat 401 as session expiry in authenticated route guard. | ported | Updated `web/default` auth guard to leave local session intact on network errors, timeouts, and 5xx. | `bun run typecheck`; `git diff --check`. | No new i18n strings. | +| `c1903607` | frontend, channels | Persist channel status filter across page navigation. | pending | Evaluate against MAX-API URL-state table behavior. | - | Optional UX fix. | +| `69c4d83d`, `1dcb389d` | dependencies, security | Bump `golang.org/x/net` and `golang.org/x/image` plus related x/* modules. | pending | Handle in a dedicated dependency PR with full Go validation. | - | Avoid mixing dependency churn into behavior port. | +| `bfddc5fe` | security, users | Omit `access_token` from user queries. | superseded | MAX-API `User.AccessToken` already uses `json:"-"`; optional DB `Omit` can be considered separately. | - | No API JSON exposure found in current MAX-API user DTO. | +| `dfc0d632` | users, billing correctness | Harden user setting/profile updates so stale user snapshots do not overwrite quota counters; add critical rate limit for self update. | ported | Added `UpdateUserSetting`, refreshed only non-quota cache fields on profile/settings update, protected `quota`/`used_quota`/`request_count` in `User.Update`, updated setting paths, and added regression tests. | `go test ./model -run "TestUserUpdateDoesNotOverwriteAccountingFields|TestUpdateUserSettingOnlyUpdatesSetting"`; broader focused Go test set; `go run ./tools/jsonwrapcheck`; `git diff --check`. | Preserves explicit quota mutation paths and token/cache invalidation logic. | +| `0977965d` | relay, compatibility | Handle Ollama non-stream tool calls and use `tool_calls` finish reason. | ported | Converted Ollama tool calls to OpenAI responses in stream and non-stream paths, preserved tool-call arguments with explicit zero values, and migrated touched JSON calls to `common.*`. | `go test ./relay/channel/ollama`; broader focused Go test set; `go run ./tools/jsonwrapcheck`; `git diff --check`. | Keeps `json.RawMessage` type references only. | +| `55858f35` | CI, release | Add manual Docker image publishing workflow. | skipped | Do not directly merge upstream workflow because it hardcodes upstream image names and branding. | - | Any MAX-API equivalent must be designed with MAX-API image paths and protected project identity. | +| `12fc0100`, `5bf34683`, `bff701b0`, `917a2cff` | deps, docs, format | Electron lockfile bumps, frontend formatting, AGENTS docs, and `tmp` dev dependency bump. | pending | Evaluate only if those surfaces are maintained in this fork. | - | Low-priority/noisy changes. | ## Incremental Sync Procedure diff --git a/controller/subscription.go b/controller/subscription.go index 19f3cf76..fdcf42a3 100644 --- a/controller/subscription.go +++ b/controller/subscription.go @@ -89,8 +89,7 @@ func UpdateSubscriptionPreference(c *gin.Context) { } current := user.GetSetting() current.BillingPreference = pref - user.SetSetting(current) - if err := user.Update(false); err != nil { + if err := model.UpdateUserSetting(user.Id, current); err != nil { common.ApiError(c, err) return } diff --git a/controller/user.go b/controller/user.go index 3ef2384a..543e4f98 100644 --- a/controller/user.go +++ b/controller/user.go @@ -1,7 +1,6 @@ package controller import ( - "encoding/json" "errors" "fmt" "net/http" @@ -36,7 +35,7 @@ func Login(c *gin.Context) { return } var loginRequest LoginRequest - err := json.NewDecoder(c.Request.Body).Decode(&loginRequest) + err := common.DecodeJson(c.Request.Body, &loginRequest) if err != nil { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return @@ -180,7 +179,7 @@ func Register(c *gin.Context) { return } var user model.User - err := json.NewDecoder(c.Request.Body).Decode(&user) + err := common.DecodeJson(c.Request.Body, &user) if err != nil { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return @@ -563,7 +562,7 @@ func generateDefaultSidebarConfig(userRole int) string { // 普通用户不包含admin区域 // 转换为JSON字符串 - configBytes, err := json.Marshal(defaultConfig) + configBytes, err := common.Marshal(defaultConfig) if err != nil { common.SysLog("生成默认边栏配置失败: " + err.Error()) return "" @@ -601,7 +600,7 @@ func GetUserModels(c *gin.Context) { func UpdateUser(c *gin.Context) { var updatedUser model.User - err := json.NewDecoder(c.Request.Body).Decode(&updatedUser) + err := common.DecodeJson(c.Request.Body, &updatedUser) if err != nil || updatedUser.Id == 0 { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return @@ -689,8 +688,7 @@ func AdminClearUserBinding(c *gin.Context) { func UpdateSelf(c *gin.Context) { var requestData map[string]interface{} - err := json.NewDecoder(c.Request.Body).Decode(&requestData) - if err != nil { + if err := common.DecodeJson(c.Request.Body, &requestData); err != nil { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } @@ -712,9 +710,7 @@ func UpdateSelf(c *gin.Context) { currentSetting.SidebarModules = sidebarModulesStr } - // 保存更新后的设置 - user.SetSetting(currentSetting) - if err := user.Update(false); err != nil { + if err := model.UpdateUserSetting(user.Id, currentSetting); err != nil { common.ApiErrorI18n(c, i18n.MsgUpdateFailed) return } @@ -740,9 +736,7 @@ func UpdateSelf(c *gin.Context) { currentSetting.Language = langStr } - // 保存更新后的设置 - user.SetSetting(currentSetting) - if err := user.Update(false); err != nil { + if err := model.UpdateUserSetting(user.Id, currentSetting); err != nil { common.ApiErrorI18n(c, i18n.MsgUpdateFailed) return } @@ -753,13 +747,12 @@ func UpdateSelf(c *gin.Context) { // 原有的用户信息更新逻辑 var user model.User - requestDataBytes, err := json.Marshal(requestData) + requestDataBytes, err := common.Marshal(requestData) if err != nil { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } - err = json.Unmarshal(requestDataBytes, &user) - if err != nil { + if err = common.Unmarshal(requestDataBytes, &user); err != nil { common.ApiErrorI18n(c, i18n.MsgInvalidParams) return } @@ -880,7 +873,7 @@ func DeleteSelf(c *gin.Context) { func CreateUser(c *gin.Context) { var user model.User - err := json.NewDecoder(c.Request.Body).Decode(&user) + err := common.DecodeJson(c.Request.Body, &user) user.Username = strings.TrimSpace(user.Username) if err != nil || user.Username == "" || user.Password == "" { common.ApiErrorI18n(c, i18n.MsgInvalidParams) @@ -931,7 +924,7 @@ type ManageRequest struct { // ManageUser Only admin user can do this func ManageUser(c *gin.Context) { var req ManageRequest - err := json.NewDecoder(c.Request.Body).Decode(&req) + err := common.DecodeJson(c.Request.Body, &req) if err != nil { common.ApiErrorI18n(c, i18n.MsgInvalidParams) @@ -1345,9 +1338,7 @@ func UpdateUserSetting(c *gin.Context) { } } - // 更新用户设置 - user.SetSetting(settings) - if err := user.Update(false); err != nil { + if err := model.UpdateUserSetting(user.Id, settings); err != nil { common.ApiErrorI18n(c, i18n.MsgUpdateFailed) return } diff --git a/main.go b/main.go index f58dea3e..e7ea53b4 100644 --- a/main.go +++ b/main.go @@ -2,13 +2,17 @@ package main import ( "bytes" + "context" "embed" + "errors" "fmt" "log" "net/http" "os" + "os/signal" "strconv" "strings" + "syscall" "time" "github.com/MAX-API-Next/MAX-API/common" @@ -202,13 +206,34 @@ func main() { port = strconv.Itoa(*common.Port) } - // Log startup success message + srv := &http.Server{ + Addr: ":" + port, + Handler: server, + } + + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + common.FatalLog("failed to start HTTP server: " + err.Error()) + } + }() + common.LogStartupSuccess(startTime, port) - err = server.Run(":" + port) - if err != nil { - common.FatalLog("failed to start HTTP server: " + err.Error()) + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + sig := <-quit + common.SysLog(fmt.Sprintf("received signal: %v, shutting down...", sig)) + + shutdownTimeout := time.Duration(common.GetEnvOrDefault("SHUTDOWN_TIMEOUT_SECONDS", 120)) * time.Second + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + common.SysError(fmt.Sprintf("server forced to shutdown: %v", err)) + } + if common.DataExportEnabled { + model.SaveQuotaDataCache() } + common.SysLog("server exited") } func InjectUmamiAnalytics() { diff --git a/model/log.go b/model/log.go index 36ec1138..8908317f 100644 --- a/model/log.go +++ b/model/log.go @@ -13,7 +13,6 @@ import ( "github.com/gin-gonic/gin" - "github.com/bytedance/gopkg/util/gopool" "gorm.io/gorm" ) @@ -528,19 +527,17 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) } if common.DataExportEnabled { createdAt := common.GetTimestamp() - gopool.Go(func() { - LogQuotaData(QuotaDataLogParams{ - UserID: userId, - Username: username, - ModelName: params.ModelName, - Quota: params.Quota, - CreatedAt: createdAt, - TokenUsed: params.PromptTokens + params.CompletionTokens, - UseGroup: params.Group, - TokenID: params.TokenId, - ChannelID: params.ChannelId, - NodeName: common.NodeName, - }) + LogQuotaData(QuotaDataLogParams{ + UserID: userId, + Username: username, + ModelName: params.ModelName, + Quota: params.Quota, + CreatedAt: createdAt, + TokenUsed: params.PromptTokens + params.CompletionTokens, + UseGroup: params.Group, + TokenID: params.TokenId, + ChannelID: params.ChannelId, + NodeName: common.NodeName, }) } } @@ -593,18 +590,16 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) { if nodeName == "" { nodeName = common.NodeName } - gopool.Go(func() { - LogQuotaData(QuotaDataLogParams{ - UserID: params.UserId, - Username: username, - ModelName: params.ModelName, - Quota: params.Quota, - CreatedAt: createdAt, - UseGroup: params.Group, - TokenID: params.TokenId, - ChannelID: params.ChannelId, - NodeName: nodeName, - }) + LogQuotaData(QuotaDataLogParams{ + UserID: params.UserId, + Username: username, + ModelName: params.ModelName, + Quota: params.Quota, + CreatedAt: createdAt, + UseGroup: params.Group, + TokenID: params.TokenId, + ChannelID: params.ChannelId, + NodeName: nodeName, }) } } diff --git a/model/user.go b/model/user.go index 2d8e4f52..32aab551 100644 --- a/model/user.go +++ b/model/user.go @@ -2,7 +2,6 @@ package model import ( "database/sql" - "encoding/json" "errors" "fmt" "strconv" @@ -83,7 +82,7 @@ func (user *User) SetAccessToken(token string) { func (user *User) GetSetting() dto.UserSetting { setting := dto.UserSetting{} if user.Setting != "" { - err := json.Unmarshal([]byte(user.Setting), &setting) + err := common.Unmarshal([]byte(user.Setting), &setting) if err != nil { common.SysLog("failed to unmarshal setting: " + err.Error()) } @@ -92,7 +91,7 @@ func (user *User) GetSetting() dto.UserSetting { } func (user *User) SetSetting(setting dto.UserSetting) { - settingBytes, err := json.Marshal(setting) + settingBytes, err := common.Marshal(setting) if err != nil { common.SysLog("failed to marshal setting: " + err.Error()) return @@ -100,6 +99,21 @@ func (user *User) SetSetting(setting dto.UserSetting) { user.Setting = string(settingBytes) } +func UpdateUserSetting(userId int, setting dto.UserSetting) error { + if userId == 0 { + return errors.New("id 为空!") + } + settingBytes, err := common.Marshal(setting) + if err != nil { + return err + } + settingValue := string(settingBytes) + if err = DB.Model(&User{}).Where("id = ?", userId).Update("setting", settingValue).Error; err != nil { + return err + } + return updateUserSettingCache(userId, settingValue) +} + // 根据用户角色生成默认的边栏配置 func generateDefaultSidebarConfigForRole(userRole int) string { defaultConfig := map[string]interface{}{} @@ -153,7 +167,7 @@ func generateDefaultSidebarConfigForRole(userRole int) string { // 普通用户不包含admin区域 // 转换为JSON字符串 - configBytes, err := json.Marshal(defaultConfig) + configBytes, err := common.Marshal(defaultConfig) if err != nil { common.SysLog("生成默认边栏配置失败: " + err.Error()) return "" @@ -536,12 +550,18 @@ func (user *User) Update(updatePassword bool) error { } } newUser := *user - DB.First(&user, user.Id) - if err = DB.Model(user).Updates(newUser).Error; err != nil { + current := User{} + if err = DB.First(¤t, user.Id).Error; err != nil { + return err + } + if err = DB.Model(¤t).Omit("quota", "used_quota", "request_count").Updates(newUser).Error; err != nil { + return err + } + if err = DB.First(user, user.Id).Error; err != nil { return err } - return invalidateUserCache(user.Id) + return updateUserCache(*user) } func (user *User) Edit(updatePassword bool) error { diff --git a/model/user_cache.go b/model/user_cache.go index c24bdda1..de13d7a8 100644 --- a/model/user_cache.go +++ b/model/user_cache.go @@ -64,8 +64,7 @@ func InvalidateUserCache(userId int) error { return invalidateUserCache(userId) } -// updateUserCache updates all user cache fields using hash -func updateUserCache(user User) error { +func populateUserCache(user User) error { if !common.RedisEnabled { return nil } @@ -77,6 +76,28 @@ func updateUserCache(user User) error { ) } +// updateUserCache refreshes non-quota user cache fields. +// Quota is maintained by atomic quota delta paths and must not be overwritten +// by stale user snapshots from profile/settings updates. +func updateUserCache(user User) error { + if !common.RedisEnabled { + return nil + } + if err := updateUserGroupCache(user.Id, user.Group); err != nil { + return err + } + if err := updateUserEmailCache(user.Id, user.Email); err != nil { + return err + } + if err := updateUserStatusCache(user.Id, user.Status == common.UserStatusEnabled); err != nil { + return err + } + if err := updateUserNameCache(user.Id, user.Username); err != nil { + return err + } + return updateUserSettingCache(user.Id, user.Setting) +} + // GetUserCache gets complete user cache from hash func GetUserCache(userId int) (userCache *UserBase, err error) { var user *User @@ -85,7 +106,7 @@ func GetUserCache(userId int) (userCache *UserBase, err error) { // Update Redis cache asynchronously on successful DB read if shouldUpdateRedis(fromDB, err) && user != nil { gopool.Go(func() { - if err := updateUserCache(*user); err != nil { + if err := populateUserCache(*user); err != nil { common.SysLog("failed to update user status cache: " + err.Error()) } }) @@ -222,6 +243,13 @@ func UpdateUserGroupCache(userId int, group string) error { return updateUserGroupCache(userId, group) } +func updateUserEmailCache(userId int, email string) error { + if !common.RedisEnabled { + return nil + } + return common.RedisHSetField(getUserCacheKey(userId), "Email", email) +} + func updateUserNameCache(userId int, username string) error { if !common.RedisEnabled { return nil diff --git a/model/user_update_test.go b/model/user_update_test.go new file mode 100644 index 00000000..1f8a43ec --- /dev/null +++ b/model/user_update_test.go @@ -0,0 +1,91 @@ +package model + +import ( + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/dto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupUserUpdateTestState(t *testing.T) { + t.Helper() + truncateTables(t) + require.NoError(t, DB.Exec("DELETE FROM users").Error) + + oldRedisEnabled := common.RedisEnabled + oldBatchUpdateEnabled := common.BatchUpdateEnabled + common.RedisEnabled = false + common.BatchUpdateEnabled = false + t.Cleanup(func() { + common.RedisEnabled = oldRedisEnabled + common.BatchUpdateEnabled = oldBatchUpdateEnabled + }) +} + +func TestUserUpdateDoesNotOverwriteAccountingFields(t *testing.T) { + setupUserUpdateTestState(t) + + user := User{ + Id: 1, + Username: "quota-race-user", + Password: "password", + DisplayName: "before", + Status: common.UserStatusEnabled, + Quota: 1000, + UsedQuota: 20, + RequestCount: 3, + } + require.NoError(t, DB.Create(&user).Error) + + staleUser, err := GetUserById(user.Id, true) + require.NoError(t, err) + + require.NoError(t, DB.Model(&User{}).Where("id = ?", user.Id).Updates(map[string]interface{}{ + "quota": gorm.Expr("quota - ?", 400), + "used_quota": gorm.Expr("used_quota + ?", 400), + "request_count": gorm.Expr("request_count + ?", 1), + }).Error) + + staleUser.DisplayName = "after" + require.NoError(t, staleUser.Update(false)) + + var got User + require.NoError(t, DB.First(&got, user.Id).Error) + assert.Equal(t, "after", got.DisplayName) + assert.Equal(t, 600, got.Quota) + assert.Equal(t, 420, got.UsedQuota) + assert.Equal(t, 4, got.RequestCount) +} + +func TestUpdateUserSettingOnlyUpdatesSetting(t *testing.T) { + setupUserUpdateTestState(t) + + user := User{ + Id: 2, + Username: "setting-user", + Password: "password", + Status: common.UserStatusEnabled, + Quota: 1000, + UsedQuota: 20, + RequestCount: 3, + } + require.NoError(t, DB.Create(&user).Error) + + require.NoError(t, DB.Model(&User{}).Where("id = ?", user.Id).Updates(map[string]interface{}{ + "quota": gorm.Expr("quota - ?", 250), + "used_quota": gorm.Expr("used_quota + ?", 250), + "request_count": gorm.Expr("request_count + ?", 1), + }).Error) + + require.NoError(t, UpdateUserSetting(user.Id, dto.UserSetting{Language: "zh"})) + + var got User + require.NoError(t, DB.First(&got, user.Id).Error) + assert.Equal(t, 750, got.Quota) + assert.Equal(t, 270, got.UsedQuota) + assert.Equal(t, 4, got.RequestCount) + assert.Equal(t, "zh", got.GetSetting().Language) +} diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go index 0b99ff36..d533b0d3 100644 --- a/relay/channel/ollama/stream.go +++ b/relay/channel/ollama/stream.go @@ -9,6 +9,7 @@ import ( "time" "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" "github.com/MAX-API-Next/MAX-API/dto" "github.com/MAX-API-Next/MAX-API/logger" relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" @@ -24,15 +25,10 @@ type ollamaChatStreamChunk struct { CreatedAt string `json:"created_at"` // chat Message *struct { - Role string `json:"role"` - Content string `json:"content"` - Thinking json.RawMessage `json:"thinking"` - ToolCalls []struct { - Function struct { - Name string `json:"name"` - Arguments interface{} `json:"arguments"` - } `json:"function"` - } `json:"tool_calls"` + Role string `json:"role"` + Content string `json:"content"` + Thinking json.RawMessage `json:"thinking"` + ToolCalls []OllamaToolCall `json:"tool_calls"` } `json:"message"` // generate Response string `json:"response"` @@ -46,6 +42,35 @@ type ollamaChatStreamChunk struct { EvalDuration int64 `json:"eval_duration"` } +func ollamaToolCallsToOpenAI(toolCalls []OllamaToolCall, startIndex int, includeIndex bool) ([]dto.ToolCallResponse, int) { + if len(toolCalls) == 0 { + return nil, startIndex + } + result := make([]dto.ToolCallResponse, 0, len(toolCalls)) + for _, tc := range toolCalls { + argBytes := []byte("{}") + if tc.Function.Arguments != nil { + if data, err := common.Marshal(tc.Function.Arguments); err == nil && len(data) > 0 { + argBytes = data + } + } + toolCall := dto.ToolCallResponse{ + ID: fmt.Sprintf("call_%d", startIndex), + Type: "function", + Function: dto.FunctionResponse{ + Name: tc.Function.Name, + Arguments: string(argBytes), + }, + } + if includeIndex { + toolCall.SetIndex(startIndex) + } + startIndex++ + result = append(result, toolCall) + } + return result, startIndex +} + func toUnix(ts string) int64 { if ts == "" { return time.Now().Unix() @@ -87,7 +112,7 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http continue } var chunk ollamaChatStreamChunk - if err := json.Unmarshal([]byte(line), &chunk); err != nil { + if err := common.Unmarshal([]byte(line), &chunk); err != nil { logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line) return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } @@ -122,7 +147,7 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http if raw != "" && raw != "null" { // Unmarshal the JSON string to get the actual content without quotes var thinkingContent string - if err := json.Unmarshal(chunk.Message.Thinking, &thinkingContent); err == nil { + if err := common.Unmarshal(chunk.Message.Thinking, &thinkingContent); err == nil { delta.Choices[0].Delta.SetReasoningContent(thinkingContent) } else { // Fallback to raw string if it's not a JSON string @@ -132,16 +157,7 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http } // tool calls if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 { - delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse, 0, len(chunk.Message.ToolCalls)) - for _, tc := range chunk.Message.ToolCalls { - // arguments -> string - argBytes, _ := json.Marshal(tc.Function.Arguments) - toolId := fmt.Sprintf("call_%d", toolCallIndex) - tr := dto.ToolCallResponse{ID: toolId, Type: "function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}} - tr.SetIndex(toolCallIndex) - toolCallIndex++ - delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr) - } + delta.Choices[0].Delta.ToolCalls, toolCallIndex = ollamaToolCallsToOpenAI(chunk.Message.ToolCalls, toolCallIndex, true) } if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) @@ -157,6 +173,9 @@ func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http if finishReason == "" { finishReason = "stop" } + if toolCallIndex > 0 { + finishReason = constant.FinishReasonToolCalls + } // emit stop delta if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil { if data, err := common.Marshal(stop); err == nil { @@ -197,6 +216,8 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R reasoningBuilder strings.Builder lastChunk ollamaChatStreamChunk parsedAny bool + toolCallIndex int + toolCalls []dto.ToolCallResponse ) for _, ln := range lines { ln = strings.TrimSpace(ln) @@ -204,7 +225,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R continue } var ck ollamaChatStreamChunk - if err := json.Unmarshal([]byte(ln), &ck); err != nil { + if err := common.Unmarshal([]byte(ln), &ck); err != nil { if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } @@ -217,7 +238,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R if raw != "" && raw != "null" { // Unmarshal the JSON string to get the actual content without quotes var thinkingContent string - if err := json.Unmarshal(ck.Message.Thinking, &thinkingContent); err == nil { + if err := common.Unmarshal(ck.Message.Thinking, &thinkingContent); err == nil { reasoningBuilder.WriteString(thinkingContent) } else { // Fallback to raw string if it's not a JSON string @@ -230,11 +251,16 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R } else if ck.Response != "" { aggContent.WriteString(ck.Response) } + if ck.Message != nil && len(ck.Message.ToolCalls) > 0 { + var converted []dto.ToolCallResponse + converted, toolCallIndex = ollamaToolCallsToOpenAI(ck.Message.ToolCalls, toolCallIndex, false) + toolCalls = append(toolCalls, converted...) + } } if !parsedAny { var single ollamaChatStreamChunk - if err := json.Unmarshal(body, &single); err != nil { + if err := common.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } lastChunk = single @@ -244,7 +270,7 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R if raw != "" && raw != "null" { // Unmarshal the JSON string to get the actual content without quotes var thinkingContent string - if err := json.Unmarshal(single.Message.Thinking, &thinkingContent); err == nil { + if err := common.Unmarshal(single.Message.Thinking, &thinkingContent); err == nil { reasoningBuilder.WriteString(thinkingContent) } else { // Fallback to raw string if it's not a JSON string @@ -253,6 +279,11 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R } } aggContent.WriteString(single.Message.Content) + if len(single.Message.ToolCalls) > 0 { + var converted []dto.ToolCallResponse + converted, toolCallIndex = ollamaToolCallsToOpenAI(single.Message.ToolCalls, toolCallIndex, false) + toolCalls = append(toolCalls, converted...) + } } else { aggContent.WriteString(single.Response) } @@ -269,8 +300,16 @@ func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R if finishReason == "" { finishReason = "stop" } + if len(toolCalls) > 0 { + finishReason = constant.FinishReasonToolCalls + } msg := dto.Message{Role: "assistant", Content: contentPtr(content)} + if len(toolCalls) > 0 { + if rawToolCalls, err := common.Marshal(toolCalls); err == nil { + msg.ToolCalls = rawToolCalls + } + } if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = &rc } diff --git a/relay/channel/ollama/stream_test.go b/relay/channel/ollama/stream_test.go new file mode 100644 index 00000000..f0fc2158 --- /dev/null +++ b/relay/channel/ollama/stream_test.go @@ -0,0 +1,95 @@ +package ollama + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/MAX-API-Next/MAX-API/dto" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOllamaChatHandlerNonStreamToolCalls(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + raw string + }{ + { + name: "compact json per-line parse path", + raw: `{"model":"llama3.1","created_at":"2026-05-27T12:00:00Z","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"get_weather","arguments":{"city":"Paris","days":0}}}]},"done":true,"done_reason":"stop","prompt_eval_count":5,"eval_count":7}`, + }, + { + name: "pretty json fallback parse path", + raw: `{ + "model": "llama3.1", + "created_at": "2026-05-27T12:00:00Z", + "message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "function": { + "name": "get_weather", + "arguments": { + "city": "Paris", + "days": 0 + } + } + } + ] + }, + "done": true, + "done_reason": "stop", + "prompt_eval_count": 5, + "eval_count": 7 +}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(tt.raw)), + } + + usage, apiErr := ollamaChatHandler(c, &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "fallback-model"}, + }, resp) + + require.Nil(t, apiErr) + require.NotNil(t, usage) + assert.Equal(t, 12, usage.TotalTokens) + + var out dto.OpenAITextResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &out)) + require.Len(t, out.Choices, 1) + assert.Equal(t, constant.FinishReasonToolCalls, out.Choices[0].FinishReason) + + var toolCalls []dto.ToolCallResponse + require.NoError(t, common.Unmarshal(out.Choices[0].Message.ToolCalls, &toolCalls)) + require.Len(t, toolCalls, 1) + assert.NotEmpty(t, toolCalls[0].ID) + assert.Equal(t, "function", toolCalls[0].Type) + assert.Equal(t, "get_weather", toolCalls[0].Function.Name) + assert.Nil(t, toolCalls[0].Index) + + var args map[string]any + require.NoError(t, common.Unmarshal([]byte(toolCalls[0].Function.Arguments), &args)) + assert.Equal(t, "Paris", args["city"]) + assert.Equal(t, float64(0), args["days"]) + }) + } +} diff --git a/relay/channel/task/ali/adaptor.go b/relay/channel/task/ali/adaptor.go index 4a03ff58..abcb093d 100644 --- a/relay/channel/task/ali/adaptor.go +++ b/relay/channel/task/ali/adaptor.go @@ -208,13 +208,88 @@ func (a *TaskAdaptor) tryValidateAliOfficialRequest(c *gin.Context, info *relayc func firstNonEmpty(values ...string) string { for _, value := range values { - if value != "" { - return value + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed } } return "" } +func isWan27I2VModel(model string) bool { + return strings.HasPrefix(model, "wan2.7-i2v") +} + +func firstTaskImage(req relaycommon.TaskSubmitReq) string { + if image := strings.TrimSpace(req.Image); image != "" { + return image + } + for _, image := range req.Images { + if trimmed := strings.TrimSpace(image); trimmed != "" { + return trimmed + } + } + return strings.TrimSpace(req.InputReference) +} + +func secondTaskImage(req relaycommon.TaskSubmitReq) string { + nonEmptyImages := 0 + for _, image := range req.Images { + trimmed := strings.TrimSpace(image) + if trimmed == "" { + continue + } + nonEmptyImages++ + if nonEmptyImages == 2 { + return trimmed + } + } + return "" +} + +func normalizeWan27I2VInput(aliReq *AliVideoRequest, req relaycommon.TaskSubmitReq) error { + if !isWan27I2VModel(aliReq.Model) { + return nil + } + + if len(aliReq.Input.Media) == 0 { + firstFrameURL := firstNonEmpty(aliReq.Input.FirstFrameURL, aliReq.Input.ImgURL, firstTaskImage(req)) + lastFrameURL := firstNonEmpty(aliReq.Input.LastFrameURL, secondTaskImage(req)) + audioURL := strings.TrimSpace(aliReq.Input.AudioURL) + + if firstFrameURL != "" { + aliReq.Input.Media = append(aliReq.Input.Media, map[string]interface{}{ + "type": "first_frame", + "url": firstFrameURL, + }) + } + if lastFrameURL != "" { + aliReq.Input.Media = append(aliReq.Input.Media, map[string]interface{}{ + "type": "last_frame", + "url": lastFrameURL, + }) + } + if audioURL != "" { + aliReq.Input.Media = append(aliReq.Input.Media, map[string]interface{}{ + "type": "driving_audio", + "url": audioURL, + }) + } + } + + if len(aliReq.Input.Media) == 0 { + return fmt.Errorf("wan2.7-i2v requires image, images, input_reference, or input.media") + } + + // Wan2.7 image-to-video uses the new input.media protocol. Avoid sending + // legacy fields that belong to wan2.6 and earlier image-to-video APIs. + aliReq.Input.ImgURL = "" + aliReq.Input.FirstFrameURL = "" + aliReq.Input.LastFrameURL = "" + aliReq.Input.AudioURL = "" + return nil +} + func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { fallback := fmt.Sprintf("%s/api/v1/services/aigc/video-generation/video-synthesis", a.baseURL) return taskcommon.BuildTaskSubmitURL(info, fallback), nil @@ -356,7 +431,7 @@ func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relay Model: upstreamModel, Input: AliVideoInput{ Prompt: req.Prompt, - ImgURL: req.InputReference, + ImgURL: firstTaskImage(req), }, Parameters: &AliVideoParameters{ PromptExtend: true, // 默认开启智能改写 @@ -424,6 +499,10 @@ func (a *TaskAdaptor) convertToAliRequest(info *relaycommon.RelayInfo, req relay return nil, err } + if err := normalizeWan27I2VInput(aliReq, req); err != nil { + return nil, err + } + if aliReq.Model != upstreamModel { return nil, errors.New("can't change model with metadata") } diff --git a/relay/channel/task/ali/adaptor_wan27_test.go b/relay/channel/task/ali/adaptor_wan27_test.go new file mode 100644 index 00000000..f0c31680 --- /dev/null +++ b/relay/channel/task/ali/adaptor_wan27_test.go @@ -0,0 +1,130 @@ +package ali + +import ( + "strings" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + relaycommon "github.com/MAX-API-Next/MAX-API/relay/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testRelayInfo() *relaycommon.RelayInfo { + return &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{}, + } +} + +func TestConvertToAliRequestWan27I2VBuildsMediaFromImage(t *testing.T) { + adaptor := &TaskAdaptor{} + req := relaycommon.TaskSubmitReq{ + Model: "wan2.7-i2v", + Prompt: "animate the first frame", + Image: "https://example.com/first.png", + Size: "720p", + Duration: 10, + } + + aliReq, err := adaptor.convertToAliRequest(testRelayInfo(), req) + + require.NoError(t, err) + assert.Equal(t, "wan2.7-i2v", aliReq.Model) + assert.Equal(t, "720P", aliReq.Parameters.Resolution) + assert.Equal(t, 10, aliReq.Parameters.Duration) + assert.Equal(t, []map[string]interface{}{ + {"type": "first_frame", "url": "https://example.com/first.png"}, + }, aliReq.Input.Media) + assert.Empty(t, aliReq.Input.ImgURL) + + body, err := common.Marshal(aliReq) + require.NoError(t, err) + assert.Contains(t, string(body), `"media"`) + assert.NotContains(t, string(body), `"img_url"`) +} + +func TestConvertToAliRequestWan27I2VBuildsFirstAndLastFrameFromImages(t *testing.T) { + adaptor := &TaskAdaptor{} + req := relaycommon.TaskSubmitReq{ + Model: "wan2.7-i2v", + Prompt: "interpolate between frames", + Images: []string{ + "https://example.com/first.png", + "https://example.com/last.png", + }, + } + + aliReq, err := adaptor.convertToAliRequest(testRelayInfo(), req) + + require.NoError(t, err) + assert.Equal(t, []map[string]interface{}{ + {"type": "first_frame", "url": "https://example.com/first.png"}, + {"type": "last_frame", "url": "https://example.com/last.png"}, + }, aliReq.Input.Media) +} + +func TestConvertToAliRequestWan27I2VKeepsExplicitMetadataMedia(t *testing.T) { + adaptor := &TaskAdaptor{} + req := relaycommon.TaskSubmitReq{ + Model: "wan2.7-i2v", + Prompt: "continue the clip", + Image: "https://example.com/direct.png", + InputReference: "https://example.com/input-reference.png", + Metadata: map[string]interface{}{ + "input": map[string]interface{}{ + "media": []interface{}{ + map[string]interface{}{ + "type": "first_clip", + "url": "https://example.com/input.mp4", + }, + }, + }, + }, + } + + aliReq, err := adaptor.convertToAliRequest(testRelayInfo(), req) + + require.NoError(t, err) + assert.Equal(t, []map[string]interface{}{ + {"type": "first_clip", "url": "https://example.com/input.mp4"}, + }, aliReq.Input.Media) + assert.Empty(t, aliReq.Input.ImgURL) + + body, err := common.Marshal(aliReq) + require.NoError(t, err) + assert.Contains(t, string(body), `"media"`) + assert.NotContains(t, string(body), `"img_url"`) +} + +func TestConvertToAliRequestWan27I2VRequiresMedia(t *testing.T) { + adaptor := &TaskAdaptor{} + req := relaycommon.TaskSubmitReq{ + Model: "wan2.7-i2v", + Prompt: "animate without a frame", + } + + _, err := adaptor.convertToAliRequest(testRelayInfo(), req) + + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "requires image")) +} + +func TestConvertToAliRequestWan25I2VKeepsLegacyImgURL(t *testing.T) { + adaptor := &TaskAdaptor{} + req := relaycommon.TaskSubmitReq{ + Model: "wan2.5-i2v-preview", + Prompt: "animate the first frame", + Image: "https://example.com/first.png", + } + + aliReq, err := adaptor.convertToAliRequest(testRelayInfo(), req) + + require.NoError(t, err) + assert.Equal(t, "https://example.com/first.png", aliReq.Input.ImgURL) + assert.Empty(t, aliReq.Input.Media) + + body, err := common.Marshal(aliReq) + require.NoError(t, err) + assert.Contains(t, string(body), `"img_url"`) + assert.NotContains(t, string(body), `"media"`) +} diff --git a/relay/channel/task/ali/constants.go b/relay/channel/task/ali/constants.go index 6a8b922c..ae46e543 100644 --- a/relay/channel/task/ali/constants.go +++ b/relay/channel/task/ali/constants.go @@ -1,6 +1,8 @@ package ali var ModelList = []string{ + "wan2.7-i2v", // 万相2.7图生视频(新input.media协议) + "wan2.7-t2v", // 万相2.7文生视频 "wan2.5-i2v-preview", // 万相2.5 preview(有声视频)推荐 "wan2.2-i2v-flash", // 万相2.2极速版(无声视频) "wan2.2-i2v-plus", // 万相2.2专业版(无声视频) diff --git a/relay/channel/task/taskcommon/path_config_test.go b/relay/channel/task/taskcommon/path_config_test.go index ad2f220b..239a53bd 100644 --- a/relay/channel/task/taskcommon/path_config_test.go +++ b/relay/channel/task/taskcommon/path_config_test.go @@ -296,6 +296,35 @@ func TestParseConfiguredTaskResultReadsOpenAIVideoMetadataURL(t *testing.T) { assert.Equal(t, "https://videos.example.com/upstream-result.mp4", result.Url) } +func TestNormalizeTaskProtocolConfigDefaultResultURLPaths(t *testing.T) { + cfg := NormalizeTaskProtocolConfig(nil) + + assert.Equal(t, []string{ + "result.primary_url", + "result.urls.0", + "result.url", + "result.video_url", + "result.output_url", + "metadata.url", + "metadata.video_url", + "metadata.output_url", + "data.result.primary_url", + "data.result.urls.0", + "data.result.url", + "data.result.video_url", + "data.result.output_url", + "data.metadata.url", + "data.metadata.video_url", + "data.metadata.output_url", + "url", + "video_url", + "output_url", + "file_url", + "download_url", + "result", + }, cfg.ResultURLPaths) +} + func TestParseConfiguredTaskResultRequiresProtocol(t *testing.T) { settings := dto.ChannelOtherSettings{ TaskProtocolConfig: &dto.TaskProtocolConfig{ diff --git a/relay/channel/task/taskcommon/protocol_config.go b/relay/channel/task/taskcommon/protocol_config.go index f4383b93..7e4fb5a4 100644 --- a/relay/channel/task/taskcommon/protocol_config.go +++ b/relay/channel/task/taskcommon/protocol_config.go @@ -34,6 +34,29 @@ var videoTaskChannelTypes = map[int]struct{}{ constant.ChannelTypeSora: {}, } +var ( + defaultResultURLFullLeafKeys = []string{"primary_url", "urls.0", "url", "video_url", "output_url"} + defaultResultURLMetadataLeafKeys = []string{"url", "video_url", "output_url"} + defaultResultURLTopLevelPaths = []string{"url", "video_url", "output_url", "file_url", "download_url", "result"} +) + +func defaultResultURLPaths() []string { + paths := make([]string, 0, 2*len(defaultResultURLFullLeafKeys)+2*len(defaultResultURLMetadataLeafKeys)+len(defaultResultURLTopLevelPaths)) + paths = appendPrefixedResultURLPaths(paths, "result.", defaultResultURLFullLeafKeys) + paths = appendPrefixedResultURLPaths(paths, "metadata.", defaultResultURLMetadataLeafKeys) + paths = appendPrefixedResultURLPaths(paths, "data.result.", defaultResultURLFullLeafKeys) + paths = appendPrefixedResultURLPaths(paths, "data.metadata.", defaultResultURLMetadataLeafKeys) + paths = append(paths, defaultResultURLTopLevelPaths...) + return paths +} + +func appendPrefixedResultURLPaths(paths []string, prefix string, leafKeys []string) []string { + for _, key := range leafKeys { + paths = append(paths, prefix+key) + } + return paths +} + func HasTaskProtocolConfig(settings dto.ChannelOtherSettings) bool { return settings.TaskProtocolConfig != nil || UseConfiguredTaskProtocol(settings) } @@ -55,7 +78,7 @@ func NormalizeTaskProtocolConfig(input *dto.TaskProtocolConfig) dto.TaskProtocol TaskIDPath: "task_id", StatusPath: "status", ProgressPath: "progress", - ResultURLPaths: []string{"result.primary_url", "result.urls.0", "result.url", "result.video_url", "result.output_url", "metadata.url", "metadata.video_url", "metadata.output_url", "data.result.primary_url", "data.result.urls.0", "data.result.url", "data.result.video_url", "data.result.output_url", "data.metadata.url", "data.metadata.video_url", "data.metadata.output_url", "url", "video_url", "output_url", "file_url", "download_url", "result"}, + ResultURLPaths: defaultResultURLPaths(), CreatedAtPath: "created_at", UpdatedAtPath: "updated_at", StatusMap: map[string]string{ diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go index b2547a1d..3d2c173c 100644 --- a/relay/common/relay_utils.go +++ b/relay/common/relay_utils.go @@ -173,6 +173,8 @@ func ValidateMultipartDirect(c *gin.Context, info *RelayInfo) *dto.TaskError { } if req.InputReference != "" { req.Images = []string{req.InputReference} + } else if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" { + req.Images = []string{strings.TrimSpace(req.Image)} } if strings.TrimSpace(req.Model) == "" { diff --git a/relay/common/relay_utils_test.go b/relay/common/relay_utils_test.go new file mode 100644 index 00000000..3cba7803 --- /dev/null +++ b/relay/common/relay_utils_test.go @@ -0,0 +1,33 @@ +package common + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MAX-API-Next/MAX-API/constant" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestValidateMultipartDirectNormalizesImageField(t *testing.T) { + gin.SetMode(gin.TestMode) + body := strings.NewReader(`{"model":"wan2.7-i2v","prompt":"animate","image":" https://example.com/first.png "}`) + request := httptest.NewRequest(http.MethodPost, "/v1/video/generations", body) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + context.Request = request + info := &RelayInfo{ + TaskRelayInfo: &TaskRelayInfo{}, + } + + taskErr := ValidateMultipartDirect(context, info) + + require.Nil(t, taskErr) + storedReq, err := GetTaskRequest(context) + require.NoError(t, err) + require.Equal(t, []string{"https://example.com/first.png"}, storedReq.Images) + require.Equal(t, constant.TaskActionGenerate, info.Action) +} diff --git a/router/api-router.go b/router/api-router.go index 9de0b9f6..284d1bf7 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -83,7 +83,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.GET("/self/groups", controller.GetUserGroups) selfRoute.GET("/self", controller.GetSelf) selfRoute.GET("/models", controller.GetUserModels) - selfRoute.PUT("/self", controller.UpdateSelf) + selfRoute.PUT("/self", middleware.CriticalRateLimit(), controller.UpdateSelf) selfRoute.DELETE("/self", controller.DeleteSelf) selfRoute.GET("/token", controller.GenerateAccessToken) selfRoute.GET("/passkey", controller.PasskeyStatus) diff --git a/tools/jsonwrapcheck/allowlist.txt b/tools/jsonwrapcheck/allowlist.txt index 09358332..f24fe6e4 100644 --- a/tools/jsonwrapcheck/allowlist.txt +++ b/tools/jsonwrapcheck/allowlist.txt @@ -57,15 +57,6 @@ controller/topup_creem.go|*CreemAdaptor.RequestPay|Unmarshal|77623a5221a2cabc498 controller/topup_creem.go|genCreemLink|Marshal|64d944a5ed933c0e1229638016ea7b948f3f7af016473b552da179ef724a2588 controller/topup_creem.go|genCreemLink|Unmarshal|246f2230ef15513f964156c37938859a8f00a897f55e0c3cd0043470ebf40108 controller/uptime_kuma.go|getAndDecode|NewDecoder|4e62c918bd14f5107549183c31d628b8b7c5c0f6d37c3b60ee14c04233f62047 -controller/user.go|Login|NewDecoder|dc2d92e0dd4c66591ccceefe086dca58c0af182c9e2c61f92c80cda6833d2f9a -controller/user.go|Register|NewDecoder|dc2d92e0dd4c66591ccceefe086dca58c0af182c9e2c61f92c80cda6833d2f9a -controller/user.go|generateDefaultSidebarConfig|Marshal|7c9a09d4da0cf8040c54a7e4ace4fe8215ba43114bf9b33d19b15a52b521320c -controller/user.go|UpdateUser|NewDecoder|dc2d92e0dd4c66591ccceefe086dca58c0af182c9e2c61f92c80cda6833d2f9a -controller/user.go|UpdateSelf|NewDecoder|dc2d92e0dd4c66591ccceefe086dca58c0af182c9e2c61f92c80cda6833d2f9a -controller/user.go|UpdateSelf|Marshal|64d944a5ed933c0e1229638016ea7b948f3f7af016473b552da179ef724a2588 -controller/user.go|UpdateSelf|Unmarshal|291f38dc8a3faa20b0fc2b2f59f5beaf9926db3d9cd60117125e6ebad536fa4e -controller/user.go|CreateUser|NewDecoder|dc2d92e0dd4c66591ccceefe086dca58c0af182c9e2c61f92c80cda6833d2f9a -controller/user.go|ManageUser|NewDecoder|dc2d92e0dd4c66591ccceefe086dca58c0af182c9e2c61f92c80cda6833d2f9a controller/wechat.go|getWeChatIdByCode|NewDecoder|032c2fadb2b3bd14e88241bb5e9c7e1103ba0b6141f0525f0a9674892e6c8d59 dto/claude.go|*ClaudeRequest.GetEfforts|Unmarshal|3525f5b3c4bd5cbad903f267e36a9250edb08eb82f5ea217b5c72de9f368a9a3 dto/openai_request.go|*Message.ParseToolCalls|Unmarshal|2969087b249de385620df94374e7ded88cbe21b27c422ee1836cfcec6ef72d2a @@ -89,9 +80,6 @@ model/passkey.go|*PasskeyCredential.SetTransports|Marshal|891aca816b90a7b0706fdd model/prefill_group.go|*JSONValue.Scan|Marshal|be383ea6cfaaf292483613ff481847bcf18cbddf173a0870a25e345b29d9f5eb model/pricing.go|updatePricing|Unmarshal|fc4d7eb2d1aedddae8988bc2c29dadcd7441835badae2b3274c3b3c0da3b4ab6 model/pricing.go|updatePricing|Unmarshal|fc4d7eb2d1aedddae8988bc2c29dadcd7441835badae2b3274c3b3c0da3b4ab6 -model/user.go|*User.GetSetting|Unmarshal|4a791c47f2bd291b1fe6049a63ac150313e85247bb3d09792a75953be7e7aa9d -model/user.go|*User.SetSetting|Marshal|9d2cdad6be6a1f9f633367287f88adca4fe88a6d43dad2009ec37dcbffd9cfef -model/user.go|generateDefaultSidebarConfigForRole|Marshal|7c9a09d4da0cf8040c54a7e4ace4fe8215ba43114bf9b33d19b15a52b521320c oauth/discord.go|*DiscordProvider.ExchangeToken|NewDecoder|64ee82d1a886a8c84d5771d6a7733571f892d8b735ff5b5ffa0167dc5f1ecb39 oauth/discord.go|*DiscordProvider.GetUserInfo|NewDecoder|64ee82d1a886a8c84d5771d6a7733571f892d8b735ff5b5ffa0167dc5f1ecb39 oauth/github.go|*GitHubProvider.ExchangeToken|Marshal|143f0e7565c0ae9ec63514edc3df01ffd64656e84ebb437a776db18b41482840 @@ -155,13 +143,6 @@ relay/channel/ollama/relay-ollama.go|openAIChatToOllamaChat|Unmarshal|cdc0ef0788 relay/channel/ollama/relay-ollama.go|openAIChatToOllamaChat|Unmarshal|07a078b20e64cb7aab1e475cef9ea2651d4a2c279ae38cdec64dd0bf01ea3083 relay/channel/ollama/relay-ollama.go|openAIToGenerate|Unmarshal|cdc0ef0788af1c25be7a997463473148c1a5660910f5d793f0405b3ce78d3343 relay/channel/ollama/relay-ollama.go|FetchOllamaVersion|Unmarshal|63a9dd239b9dc0824369644993015b931b45ec167ed8555e7d530ce84d5c8678 -relay/channel/ollama/stream.go|ollamaStreamHandler|Unmarshal|473cc5efc8a677f70cc80e75e36587027f4cdc9a680472aed302a1c29d0179ff -relay/channel/ollama/stream.go|ollamaStreamHandler|Unmarshal|43d0e62aac56186195550da14d52b9b7c5f2afe7e3dc517903e9d7e4f069214b -relay/channel/ollama/stream.go|ollamaStreamHandler|Marshal|9df40aa3b01041edcc6f2b417c01217d29482588e16111f9a9eda30fbbfe0335 -relay/channel/ollama/stream.go|ollamaChatHandler|Unmarshal|99d571e205ff0289e9b63e5885b1f498bd82e692e0a0bc2ea43429376d0a8cf6 -relay/channel/ollama/stream.go|ollamaChatHandler|Unmarshal|e920799c17da08b8505bb9ca09d04cbdfcf6c771ab9f84a69481cfb62283a54b -relay/channel/ollama/stream.go|ollamaChatHandler|Unmarshal|d1ee2825137943883118d427d516773a4a78a4dce6d12429e628f1343ceeea78 -relay/channel/ollama/stream.go|ollamaChatHandler|Unmarshal|2f37a9dd4c008f5f0fe60c7d0a5723bf7fb7275e104686e96d80371fc2feebdf relay/channel/openai/adaptor.go|*Adaptor.ConvertOpenAIRequest|Unmarshal|44a1603ee0e1a8e1df85d054b5b756d4357abc38dbe367a0fb9192cab91c1e25 relay/channel/palm/relay-palm.go|palmStreamHandler|Unmarshal|9e6a91a6205bc2652fded36139425ad52ae7c49ef94ccd023817fca36089ced0 relay/channel/palm/relay-palm.go|palmStreamHandler|Marshal|92981973316a59184c1f9d4fdca6fe3a1045b5da6a30f89abb52b516f1849571 diff --git a/web/default/src/routes/_authenticated/route.tsx b/web/default/src/routes/_authenticated/route.tsx index 06c62b17..a5bb6fd4 100644 --- a/web/default/src/routes/_authenticated/route.tsx +++ b/web/default/src/routes/_authenticated/route.tsx @@ -38,13 +38,17 @@ export const Route = createFileRoute('/_authenticated')({ // 本地有用户信息,但需要验证 session 是否有效(每个会话只验证一次) if (!sessionVerified) { - const res = await getSelf().catch(() => null) + const res = await getSelf().catch((err: unknown) => + (err as { response?: { status?: number } })?.response?.status === 401 + ? { success: false } + : null + ) if (res?.success && res.data) { // 验证成功,更新用户信息(可能有变化) auth.setUser(res.data) sessionVerified = true - } else { - // 验证失败或 API 调用失败,清除本地缓存并跳转登录页 + } else if (res) { + // 验证失败,清除本地缓存并跳转登录页 auth.reset() throw redirect({ to: '/sign-in', From 410ebcc0072f31bda0ca43258fc9605b337f6ddf Mon Sep 17 00:00:00 2001 From: CSCITech Date: Sun, 5 Jul 2026 02:04:01 +0800 Subject: [PATCH 3/7] v1.0.4-preview.2 --- model/log_test.go | 15 +- relay/channel/ollama/stream_test.go | 79 + relay/channel/task/ali/adaptor.go | 23 +- relay/channel/task/ali/adaptor_wan27_test.go | 45 + .../drawers/channel-mutate-drawer.tsx | 5333 ++++++++++------- 5 files changed, 3209 insertions(+), 2286 deletions(-) diff --git a/model/log_test.go b/model/log_test.go index 70efa4a2..c105733a 100644 --- a/model/log_test.go +++ b/model/log_test.go @@ -605,10 +605,11 @@ func newRetryBackfillTestDB(t *testing.T, models ...interface{}) *gorm.DB { func createRetryFilterLogs(t *testing.T) []Log { t.Helper() + now := time.Now().Unix() logs := []Log{ { UserId: 1, - CreatedAt: time.Now().Unix() - 10, + CreatedAt: now - 10, Type: LogTypeConsume, Quota: 100, PromptTokens: 5, @@ -622,7 +623,7 @@ func createRetryFilterLogs(t *testing.T) []Log { }, { UserId: 1, - CreatedAt: time.Now().Unix() - 20, + CreatedAt: now - 20, Type: LogTypeConsume, Quota: 200, PromptTokens: 7, @@ -636,7 +637,7 @@ func createRetryFilterLogs(t *testing.T) []Log { }, { UserId: 1, - CreatedAt: time.Now().Unix() - 30, + CreatedAt: now - 30, Type: LogTypeConsume, Quota: 400, PromptTokens: 11, @@ -650,7 +651,7 @@ func createRetryFilterLogs(t *testing.T) []Log { }, { UserId: 2, - CreatedAt: time.Now().Unix() - 40, + CreatedAt: now - 40, Type: LogTypeConsume, Quota: 800, PromptTokens: 17, @@ -663,7 +664,7 @@ func createRetryFilterLogs(t *testing.T) []Log { }, { UserId: 2, - CreatedAt: time.Now().Unix() - 50, + CreatedAt: now - 50, Type: LogTypeConsume, Quota: 900, PromptTokens: 23, @@ -677,7 +678,7 @@ func createRetryFilterLogs(t *testing.T) []Log { }, { UserId: 1, - CreatedAt: time.Now().Unix() - 5, + CreatedAt: now - 5, Type: LogTypeError, Quota: 0, PromptTokens: 0, @@ -691,7 +692,7 @@ func createRetryFilterLogs(t *testing.T) []Log { }, { UserId: 1, - CreatedAt: time.Now().Unix() - 60, + CreatedAt: now - 55, Type: LogTypeConsume, Quota: 350, PromptTokens: 12, diff --git a/relay/channel/ollama/stream_test.go b/relay/channel/ollama/stream_test.go index f0fc2158..449eb332 100644 --- a/relay/channel/ollama/stream_test.go +++ b/relay/channel/ollama/stream_test.go @@ -93,3 +93,82 @@ func TestOllamaChatHandlerNonStreamToolCalls(t *testing.T) { }) } } + +func TestOllamaStreamHandlerToolCalls(t *testing.T) { + gin.SetMode(gin.TestMode) + + raw := strings.Join([]string{ + `{"model":"llama3.1","created_at":"2026-05-27T12:00:00Z","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"get_weather","arguments":{"city":"Paris","days":0}}}]},"done":false}`, + `{"model":"llama3.1","created_at":"2026-05-27T12:00:01Z","done":true,"done_reason":"stop","prompt_eval_count":5,"eval_count":7}`, + }, "\n") + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(raw)), + } + + usage, apiErr := ollamaStreamHandler(c, &relaycommon.RelayInfo{ + ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: "fallback-model"}, + }, resp) + + require.Nil(t, apiErr) + require.NotNil(t, usage) + assert.Equal(t, 12, usage.TotalTokens) + + chunks, doneSeen := parseOpenAIStreamChunks(t, recorder.Body.String()) + require.True(t, doneSeen) + + var toolCall *dto.ToolCallResponse + var finishReason string + for i := range chunks { + if len(chunks[i].Choices) == 0 { + continue + } + choice := chunks[i].Choices[0] + if len(choice.Delta.ToolCalls) > 0 { + toolCall = &choice.Delta.ToolCalls[0] + } + if choice.FinishReason != nil { + finishReason = *choice.FinishReason + } + } + + require.NotNil(t, toolCall) + assert.NotEmpty(t, toolCall.ID) + assert.Equal(t, "function", toolCall.Type) + assert.Equal(t, "get_weather", toolCall.Function.Name) + require.NotNil(t, toolCall.Index) + assert.Equal(t, 0, *toolCall.Index) + + var args map[string]any + require.NoError(t, common.Unmarshal([]byte(toolCall.Function.Arguments), &args)) + assert.Equal(t, "Paris", args["city"]) + assert.Equal(t, float64(0), args["days"]) + assert.Equal(t, constant.FinishReasonToolCalls, finishReason) +} + +func parseOpenAIStreamChunks(t *testing.T, body string) ([]dto.ChatCompletionsStreamResponse, bool) { + t.Helper() + + var chunks []dto.ChatCompletionsStreamResponse + var doneSeen bool + for _, frame := range strings.Split(body, "\n\n") { + frame = strings.TrimSpace(frame) + if frame == "" { + continue + } + require.True(t, strings.HasPrefix(frame, "data: "), "unexpected SSE frame: %q", frame) + payload := strings.TrimSpace(strings.TrimPrefix(frame, "data: ")) + if payload == "[DONE]" { + doneSeen = true + continue + } + + var chunk dto.ChatCompletionsStreamResponse + require.NoError(t, common.Unmarshal([]byte(payload), &chunk)) + chunks = append(chunks, chunk) + } + return chunks, doneSeen +} diff --git a/relay/channel/task/ali/adaptor.go b/relay/channel/task/ali/adaptor.go index abcb093d..c2803389 100644 --- a/relay/channel/task/ali/adaptor.go +++ b/relay/channel/task/ali/adaptor.go @@ -221,15 +221,15 @@ func isWan27I2VModel(model string) bool { } func firstTaskImage(req relaycommon.TaskSubmitReq) string { - if image := strings.TrimSpace(req.Image); image != "" { - return image + if inputReference := strings.TrimSpace(req.InputReference); inputReference != "" { + return inputReference } for _, image := range req.Images { if trimmed := strings.TrimSpace(image); trimmed != "" { return trimmed } } - return strings.TrimSpace(req.InputReference) + return strings.TrimSpace(req.Image) } func secondTaskImage(req relaycommon.TaskSubmitReq) string { @@ -247,6 +247,20 @@ func secondTaskImage(req relaycommon.TaskSubmitReq) string { return "" } +func hasWan27PrimaryMedia(media []map[string]interface{}) bool { + for _, item := range media { + mediaType, ok := item["type"].(string) + if !ok { + continue + } + switch strings.ToLower(strings.TrimSpace(mediaType)) { + case "first_frame", "first_clip": + return true + } + } + return false +} + func normalizeWan27I2VInput(aliReq *AliVideoRequest, req relaycommon.TaskSubmitReq) error { if !isWan27I2VModel(aliReq.Model) { return nil @@ -280,6 +294,9 @@ func normalizeWan27I2VInput(aliReq *AliVideoRequest, req relaycommon.TaskSubmitR if len(aliReq.Input.Media) == 0 { return fmt.Errorf("wan2.7-i2v requires image, images, input_reference, or input.media") } + if !hasWan27PrimaryMedia(aliReq.Input.Media) { + return fmt.Errorf("wan2.7-i2v input.media requires first_frame or first_clip") + } // Wan2.7 image-to-video uses the new input.media protocol. Avoid sending // legacy fields that belong to wan2.6 and earlier image-to-video APIs. diff --git a/relay/channel/task/ali/adaptor_wan27_test.go b/relay/channel/task/ali/adaptor_wan27_test.go index f0c31680..60b36c9e 100644 --- a/relay/channel/task/ali/adaptor_wan27_test.go +++ b/relay/channel/task/ali/adaptor_wan27_test.go @@ -43,6 +43,24 @@ func TestConvertToAliRequestWan27I2VBuildsMediaFromImage(t *testing.T) { assert.NotContains(t, string(body), `"img_url"`) } +func TestConvertToAliRequestWan27I2VPrefersInputReferenceOverImage(t *testing.T) { + adaptor := &TaskAdaptor{} + req := relaycommon.TaskSubmitReq{ + Model: "wan2.7-i2v", + Prompt: "animate the validated input reference", + Image: "https://example.com/direct-image.png", + InputReference: "https://example.com/input-reference.png", + } + + aliReq, err := adaptor.convertToAliRequest(testRelayInfo(), req) + + require.NoError(t, err) + assert.Equal(t, []map[string]interface{}{ + {"type": "first_frame", "url": "https://example.com/input-reference.png"}, + }, aliReq.Input.Media) + assert.Empty(t, aliReq.Input.ImgURL) +} + func TestConvertToAliRequestWan27I2VBuildsFirstAndLastFrameFromImages(t *testing.T) { adaptor := &TaskAdaptor{} req := relaycommon.TaskSubmitReq{ @@ -96,6 +114,33 @@ func TestConvertToAliRequestWan27I2VKeepsExplicitMetadataMedia(t *testing.T) { assert.NotContains(t, string(body), `"img_url"`) } +func TestConvertToAliRequestWan27I2VRejectsMediaWithoutPrimaryInput(t *testing.T) { + adaptor := &TaskAdaptor{} + req := relaycommon.TaskSubmitReq{ + Model: "wan2.7-i2v", + Prompt: "continue without a primary frame or clip", + Metadata: map[string]interface{}{ + "input": map[string]interface{}{ + "media": []interface{}{ + map[string]interface{}{ + "type": "last_frame", + "url": "https://example.com/last.png", + }, + map[string]interface{}{ + "type": "driving_audio", + "url": "https://example.com/audio.mp3", + }, + }, + }, + }, + } + + _, err := adaptor.convertToAliRequest(testRelayInfo(), req) + + require.Error(t, err) + assert.Contains(t, err.Error(), "first_frame or first_clip") +} + func TestConvertToAliRequestWan27I2VRequiresMedia(t *testing.T) { adaptor := &TaskAdaptor{} req := relaycommon.TaskSubmitReq{ diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 72f276bc..bca45fc4 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -24,13 +24,18 @@ import { useCallback, useRef, } from 'react' -import { useForm } from 'react-hook-form' +import { type SubmitErrorHandler, useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { useQuery, useQueryClient } from '@tanstack/react-query' import { ArrowRight, + AlertCircle, + Boxes, + CheckCircle2, + Circle, HelpCircle, Loader2, + Server, Sparkles, Trash2, Copy, @@ -50,6 +55,7 @@ import { import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { getLobeIcon } from '@/lib/lobe-icon' +import { cn } from '@/lib/utils' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock' import { Alert, AlertDescription } from '@/components/ui/alert' @@ -117,6 +123,7 @@ import { } from '../../api' import { ADD_MODE_OPTIONS, + CHANNEL_STATUS_LABELS, CHANNEL_TYPE_OPTIONS, CHANNEL_TYPE_WARNINGS, ERROR_MESSAGES, @@ -142,6 +149,8 @@ import { findMissingModelsInMapping, validateModelMappingJson, getChannelConfigValidationIssues, + BASE_URL_REQUIRED_TYPES, + OTHER_REQUIRED_TYPES, VIDEO_TASK_CHANNEL_TYPES, CHANNEL_TYPE_ADVANCED_CUSTOM, getAdvancedCustomStats, @@ -185,6 +194,25 @@ type ModelMappingGuardrail = { exposedTargetModels: string[] } +type ChannelEditorSectionStatus = 'complete' | 'configured' | 'error' | 'idle' + +type ChannelEditorNavChildItem = { + id: string + title: string + configured?: boolean +} + +type ChannelEditorNavItem = { + id: string + title: string + description?: string + statusLabel: string + status: ChannelEditorSectionStatus + icon: ReactNode + configured?: boolean + children?: ChannelEditorNavChildItem[] +} + // Helper functions const createEmptyModelMappingGuardrail = (): ModelMappingGuardrail => ({ invalidJson: false, @@ -202,6 +230,72 @@ const MODEL_MAPPING_PREVIEW_FALLBACK: Array<{ }> = [{ source: 'client-model', target: 'upstream-model' }] const ADVANCED_SETTINGS_EXPANDED_KEY = 'channel-advanced-settings-expanded' +const CHANNEL_EDITOR_SECTION_IDS = { + identity: 'channel-section-identity', + apiAccess: 'channel-section-api-access', + models: 'channel-section-models', + advanced: 'channel-section-advanced', +} as const +const CHANNEL_EDITOR_MAIN_SECTION_IDS = [ + CHANNEL_EDITOR_SECTION_IDS.identity, + CHANNEL_EDITOR_SECTION_IDS.apiAccess, + CHANNEL_EDITOR_SECTION_IDS.models, + CHANNEL_EDITOR_SECTION_IDS.advanced, +] +const ADVANCED_SETTINGS_SECTION_IDS = { + routingStrategy: 'channel-section-advanced-routing-strategy', + internalNotes: 'channel-section-advanced-internal-notes', + overrideRules: 'channel-section-advanced-override-rules', + videoTaskProtocol: 'channel-section-advanced-video-task-protocol', + responseMapping: 'channel-section-advanced-response-mapping', + fieldPassthrough: 'channel-section-advanced-field-passthrough', + extraSettings: 'channel-section-advanced-extra-settings', + upstreamModelDetection: 'channel-section-advanced-upstream-model-detection', +} as const +const ADVANCED_SETTINGS_CHILD_SECTION_IDS: string[] = Object.values( + ADVANCED_SETTINGS_SECTION_IDS +) +const ADVANCED_ERROR_FIELDS = [ + 'priority', + 'weight', + 'test_model', + 'auto_ban', + 'status_code_mapping', + 'tag', + 'remark', + 'param_override', + 'header_override', + 'force_format', + 'thinking_to_content', + 'proxy', + 'pass_through_body_enabled', + 'system_prompt', + 'system_prompt_override', + 'allow_service_tier', + 'disable_store', + 'allow_safety_identifier', + 'allow_include_obfuscation', + 'allow_inference_geo', + 'allow_speed', + 'claude_beta_query', + 'upstream_model_update_check_enabled', + 'upstream_model_update_auto_sync_enabled', + 'upstream_model_update_ignored_models', + 'video_task_path_override_enabled', + 'video_task_protocol_enabled', + 'video_task_submit_path', + 'video_task_query_path', + 'video_task_task_id_path', + 'video_task_status_path', + 'video_task_progress_path', + 'video_task_result_url_paths', + 'video_task_error_message_path', + 'video_task_status_submitted', + 'video_task_status_queued', + 'video_task_status_running', + 'video_task_status_succeeded', + 'video_task_status_failed', +] satisfies (keyof ChannelFormValues)[] const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8 function readAdvancedSettingsPreference(): boolean { @@ -209,13 +303,31 @@ function readAdvancedSettingsPreference(): boolean { return window.localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true' } +function hasConfiguredOverrideValue(value: unknown): boolean { + if (typeof value !== 'string') return false + + const trimmed = value.trim() + if (!trimmed || trimmed === 'null') return false + + try { + const parsed = JSON.parse(trimmed) + if (parsed === null) return false + if (Array.isArray(parsed)) return parsed.length > 0 + if (typeof parsed === 'object') return Object.keys(parsed).length > 0 + } catch { + return true + } + + return true +} + function hasAdvancedSettingsValues(values: ChannelFormValues): boolean { return Boolean( values.model_mapping?.trim() || values.advanced_custom?.trim() || - values.param_override?.trim() || - values.header_override?.trim() || - values.status_code_mapping?.trim() || + hasConfiguredOverrideValue(values.param_override) || + hasConfiguredOverrideValue(values.header_override) || + hasConfiguredOverrideValue(values.status_code_mapping) || values.tag?.trim() || values.remark?.trim() || values.priority || @@ -280,6 +392,161 @@ function SubHeading({ title, icon }: { title: string; icon?: ReactNode }) { ) } +function getSectionStatusIcon(status: ChannelEditorSectionStatus): ReactNode { + if (status === 'error') { + return