From da74bf13a4099061f71d6816f6e703fb72abf43d Mon Sep 17 00:00:00 2001 From: HuntercodeT <61233559+HuntercodeT@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:50:46 +0800 Subject: [PATCH 001/137] fix(gemini): discover antigravity mappings in native model lists --- .../gateway_model_allowlist_listing_test.go | 8 +- .../handler/gemini_mixed_models_test.go | 136 ++++++++++++++++++ .../internal/handler/gemini_v1beta_handler.go | 96 ++++++++++--- .../handler/gemini_v1beta_handler_test.go | 6 +- .../service/gemini_messages_compat_service.go | 27 ++++ 5 files changed, 249 insertions(+), 24 deletions(-) create mode 100644 backend/internal/handler/gemini_mixed_models_test.go diff --git a/backend/internal/handler/gateway_model_allowlist_listing_test.go b/backend/internal/handler/gateway_model_allowlist_listing_test.go index 55a663dbc0ac..cb1fd5d5fa7e 100644 --- a/backend/internal/handler/gateway_model_allowlist_listing_test.go +++ b/backend/internal/handler/gateway_model_allowlist_listing_test.go @@ -91,7 +91,7 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) { repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{ byGroup: map[int64][]service.Account{ // 只有 antigravity 账号:Gemini 选号失败后回落静态模型列表。 - 41: {{ID: 2, Platform: service.PlatformAntigravity, Status: service.StatusActive, Schedulable: true}}, + 41: {{ID: 2, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Status: service.StatusActive, Schedulable: true}}, }, }} h := &GatewayHandler{ @@ -109,7 +109,7 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) { Platform: service.PlatformGemini, ModelAllowlist: service.GroupModelAllowlist{ Enabled: true, - Models: []string{"gemini-2.5-pro", "gemini-3-*"}, + Models: []string{"gemini-3.8-flash-high", "gemini-3-*"}, }, }, }) @@ -129,8 +129,8 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) { names = append(names, model.Name) } // models/ 前缀的候选形式也应命中条目。 - require.Contains(t, names, "models/gemini-2.5-pro") - require.Contains(t, names, "models/gemini-3-pro-preview") + require.Contains(t, names, "models/gemini-3.8-flash-high") + require.Contains(t, names, "models/gemini-3-flash") require.NotContains(t, names, "models/gemini-2.5-flash") require.NotContains(t, names, "models/gemini-2.0-flash") } diff --git a/backend/internal/handler/gemini_mixed_models_test.go b/backend/internal/handler/gemini_mixed_models_test.go new file mode 100644 index 000000000000..6a0e2c2eb5b6 --- /dev/null +++ b/backend/internal/handler/gemini_mixed_models_test.go @@ -0,0 +1,136 @@ +package handler + +import ( + "encoding/json" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/gemini" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGeminiNativeModelsUsesAccountMappings(t *testing.T) { + for _, tt := range []struct { + name string + forced, mixed, allowlist bool + status int + }{ + {"mixed", false, true, false, 200}, + {"mixed allowlist", false, true, true, 200}, + {"disabled mixed", false, false, false, 503}, + {"forced without mixed opt-in", true, false, false, 200}, + {"forced allowlist", true, false, true, 200}, + } { + t.Run(tt.name, func(t *testing.T) { + groupID := int64(45) + repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{ + groupID: {{ID: 1, Platform: service.PlatformAntigravity, + Extra: map[string]any{"mixed_scheduling": tt.mixed}, + Credentials: map[string]any{"model_mapping": map[string]any{"gemini-synced-custom": "gemini-3.8-flash-high", "claude-custom": "claude-sonnet-4-6"}}}}, + }}} + h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil)} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformGemini, + ModelAllowlist: service.GroupModelAllowlist{Enabled: tt.allowlist, Models: []string{"gemini-synced-custom"}}, + }}) + if tt.forced { + c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity) + } + h.GeminiV1BetaListModels(c) + require.Equal(t, tt.status, rec.Code, rec.Body.String()) + if tt.status != 200 { + return + } + var got gemini.ModelsListResponse + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + names := []string{} + for _, model := range got.Models { + names = append(names, model.Name) + require.Contains(t, model.SupportedGenerationMethods, "generateContent") + } + require.Contains(t, names, "models/gemini-synced-custom") + require.NotContains(t, names, "models/claude-custom") + require.NotContains(t, names, "models/gemini-2.0-flash") + if tt.allowlist { + require.Equal(t, []string{"models/gemini-synced-custom"}, names) + } + }) + } +} + +func TestAppendUpstreamGeminiModelsPreservesMetadata(t *testing.T) { + body := []byte(`{"models":[{"name":"models/gemini-native","inputTokenLimit":123,"custom":{"a":true}}],"nextPageToken":"next","other":42}`) + extra := []gemini.Model{gemini.FallbackModel("gemini-native"), gemini.FallbackModel("gemini-synced-custom"), gemini.FallbackModel("gemini-synced-custom")} + merged, ok := appendUpstreamGeminiModels(body, extra) + require.True(t, ok) + require.JSONEq(t, `{"models":[{"name":"models/gemini-native","inputTokenLimit":123,"custom":{"a":true}},{"name":"models/gemini-synced-custom","supportedGenerationMethods":["generateContent","streamGenerateContent"]}],"nextPageToken":"next","other":42}`, string(merged)) + filtered, dropped, ok := filterUpstreamGeminiModelsBody(merged, service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-synced-*"}}) + require.True(t, ok) + require.True(t, dropped) + require.NotContains(t, string(filtered), "gemini-native") + require.Contains(t, string(filtered), "gemini-synced-custom") + for _, invalid := range []string{`null`, `not-json`, `{"error":"bad"}`, `{"models":{}}`} { + got, ok := appendUpstreamGeminiModels([]byte(invalid), extra) + require.False(t, ok) + require.Equal(t, invalid, string(got)) + } +} + +// Exercise the real handler's native-upstream branch, including scope fallback. +type geminiMixedModelsUpstream struct { + service.HTTPUpstream + status int + body string +} + +func (u *geminiMixedModelsUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return &http.Response{StatusCode: u.status, Header: http.Header{"Content-Type": {"application/json"}, "X-Request-Id": {"native-id"}}, Body: io.NopCloser(strings.NewReader(u.body))}, nil +} +func TestGeminiNativeModelsMergesNativeUpstream(t *testing.T) { + for _, tt := range []struct { + name string + status int + body string + expectedNative string + }{ + {"native", 200, `{"models":[{"name":"models/gemini-native","inputTokenLimit":123}],"nextPageToken":"next"}`, "models/gemini-native"}, + {"scope fallback", 403, `{"error":"insufficient authentication scopes"}`, "models/gemini-2.5-pro"}, + {"upstream error", 429, `{"error":"rate limited"}`, ""}, + } { + t.Run(tt.name, func(t *testing.T) { + id := int64(46) + repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{id: { + {ID: 1, Platform: service.PlatformGemini, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test"}}, + {ID: 2, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Credentials: map[string]any{"model_mapping": map[string]any{"gemini-synced-custom": "gemini-3.8-flash-high"}}}, + }}}} + upstream := &geminiMixedModelsUpstream{status: tt.status, body: tt.body} + h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, upstream, nil, &config.Config{})} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &id, Group: &service.Group{ID: id, Platform: service.PlatformGemini}}) + h.GeminiV1BetaListModels(c) + if tt.status == 429 { + require.Equal(t, 429, rec.Code) + require.JSONEq(t, tt.body, rec.Body.String()) + return + } + require.Equal(t, 200, rec.Code, rec.Body.String()) + require.Contains(t, rec.Body.String(), tt.expectedNative) + require.Contains(t, rec.Body.String(), "models/gemini-synced-custom") + if tt.status == 200 { + require.Contains(t, rec.Body.String(), `"inputTokenLimit":123`) + require.Contains(t, rec.Body.String(), `"nextPageToken":"next"`) + require.Equal(t, "native-id", rec.Header().Get("X-Request-Id")) + } + }) + } +} diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index d8ec24670bdb..a7ae9170c115 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -60,30 +60,24 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { return filtered } - // 强制 antigravity 模式:返回 antigravity 支持的模型列表 + agModelIDs, err := h.geminiCompatService.AntigravityGeminiModelIDs(c.Request.Context(), apiKey.GroupID, forcePlatform != service.PlatformAntigravity) + if err != nil { + googleError(c, http.StatusServiceUnavailable, "Unable to list Antigravity models") + return + } + agModels := make([]gemini.Model, 0, len(agModelIDs)) + for _, id := range agModelIDs { + agModels = append(agModels, gemini.FallbackModel(id)) + } if forcePlatform == service.PlatformAntigravity { - if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() { - agModels := antigravity.DefaultGeminiModels() - filtered := make([]antigravity.GeminiModel, 0, len(agModels)) - for _, model := range agModels { - if apiKey.Group.ModelAllowlist.Allows(model.Name) { - filtered = append(filtered, model) - } - } - c.JSON(http.StatusOK, antigravity.GeminiModelsListResponse{Models: filtered}) - return - } - c.JSON(http.StatusOK, antigravity.FallbackGeminiModelsList()) + c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)}) return } account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID) if err != nil { - // 没有 gemini 账户,检查是否有 antigravity 账户可用 - hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID) - if hasAntigravity { - // antigravity 账户使用静态模型列表 - c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())}) + if len(agModels) > 0 { + c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)}) return } markOpsRoutingCapacityLimitedIfNoAvailable(c, err) @@ -97,9 +91,15 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { return } if shouldFallbackGeminiModels(res) { - c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())}) + c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(mergeGeminiModelLists(gemini.DefaultModels(), agModels))}) return } + if res.StatusCode == http.StatusOK && len(agModels) > 0 { + if merged, ok := appendUpstreamGeminiModels(res.Body, agModels); ok { + res.Body = merged + } + } + if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() { if filtered, dropped, ok := filterUpstreamGeminiModelsBody(res.Body, apiKey.Group.ModelAllowlist); ok && dropped { // 只在确有条目被过滤时替换响应体;全命中或解析失败时保持原始响应, @@ -110,6 +110,64 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { writeUpstreamResponse(c, res) } +// mergeGeminiModelLists keeps native metadata when both sources advertise a model. +func mergeGeminiModelLists(native, extra []gemini.Model) []gemini.Model { + result := append([]gemini.Model{}, native...) + seen := make(map[string]bool, len(native)) + for _, model := range native { + seen[model.Name] = true + } + for _, model := range extra { + if !seen[model.Name] { + result = append(result, model) + seen[model.Name] = true + } + } + return result +} + +// appendUpstreamGeminiModels preserves unknown model metadata and envelope fields. +func appendUpstreamGeminiModels(body []byte, extra []gemini.Model) ([]byte, bool) { + var envelope map[string]json.RawMessage + if json.Unmarshal(body, &envelope) != nil || envelope == nil { + return body, false + } + var models []json.RawMessage + raw, exists := envelope["models"] + if !exists || json.Unmarshal(raw, &models) != nil { + return body, false + } + seen := make(map[string]bool, len(models)) + for _, raw := range models { + var model struct { + Name string `json:"name"` + } + if json.Unmarshal(raw, &model) != nil { + return body, false + } + seen[model.Name] = true + } + changed := false + for _, model := range extra { + if seen[model.Name] { + continue + } + raw, err := json.Marshal(model) + if err != nil { + return body, false + } + models = append(models, raw) + seen[model.Name] = true + changed = true + } + if !changed { + return body, true + } + envelope["models"], _ = json.Marshal(models) + merged, err := json.Marshal(envelope) + return merged, err == nil +} + // filterUpstreamGeminiModelsBody 按白名单过滤上游 /v1beta/models 响应中的 // models[].name,其余信封字段(如 nextPageToken)原样保留。 // 返回值:filtered 为过滤后的响应体;dropped 表示是否有条目被移除(全命中时 diff --git a/backend/internal/handler/gemini_v1beta_handler_test.go b/backend/internal/handler/gemini_v1beta_handler_test.go index 2bf25a6b9679..8604b07b8816 100644 --- a/backend/internal/handler/gemini_v1beta_handler_test.go +++ b/backend/internal/handler/gemini_v1beta_handler_test.go @@ -39,7 +39,11 @@ func TestGeminiV1BetaListModels_ForcedAntigravityAppliesAllowlist(t *testing.T) }) c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity) - (&GatewayHandler{}).GeminiV1BetaListModels(c) + groupID := int64(42) + key, _ := middleware.GetAPIKeyFromContext(c) + key.GroupID = &groupID + repo := &geminiAllowlistAccountRepoStub{} + (&GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil)}).GeminiV1BetaListModels(c) require.Equal(t, http.StatusOK, rec.Code) var got antigravity.GeminiModelsListResponse diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index edd727ce5eec..306d84d8f413 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -16,6 +16,7 @@ import ( mathrand "math/rand" "net/http" "regexp" + "sort" "strings" "time" @@ -483,6 +484,32 @@ func (s *GeminiMessagesCompatService) validateUpstreamBaseURL(raw string) (strin return normalized, nil } +// AntigravityGeminiModelIDs returns the effective Gemini mappings of schedulable +// Antigravity accounts. Mixed routes must additionally respect the account opt-in. +func (s *GeminiMessagesCompatService) AntigravityGeminiModelIDs(ctx context.Context, groupID *int64, requireMixed bool) ([]string, error) { + accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformAntigravity, false) + if err != nil { + return nil, err + } + seen := make(map[string]struct{}) + for _, account := range accounts { + if account.Platform != PlatformAntigravity || (requireMixed && !account.IsMixedSchedulingEnabled()) { + continue + } + for model := range account.GetModelMapping() { + if isAntigravityGeminiModel(model) { + seen[model] = struct{}{} + } + } + } + models := make([]string, 0, len(seen)) + for model := range seen { + models = append(models, model) + } + sort.Strings(models) + return models, nil +} + // HasAntigravityAccounts 检查是否有可用的 antigravity 账户 func (s *GeminiMessagesCompatService) HasAntigravityAccounts(ctx context.Context, groupID *int64) (bool, error) { accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformAntigravity, false) From 881f3202694c6bc932446931a30c27d9675178b9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:02:51 +0000 Subject: [PATCH 002/137] chore: sync VERSION to 0.2.5 [skip ci] --- backend/cmd/server/VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index abd410582dea..3a4036fb450f 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.2.4 +0.2.5 From d7ee1ab6beb618cf413bf7616ecc482251a62157 Mon Sep 17 00:00:00 2001 From: xO-ox-ai <104205598+xO-ox-ai@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:44:50 +0800 Subject: [PATCH 003/137] fix(openai): persist response affinity after client cancel --- .../openai_gateway_response_handling.go | 15 +++++- .../service/openai_gateway_service_test.go | 48 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 2563dbc3e33b..efb9f2e0d8d8 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -1464,12 +1464,23 @@ func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *g if store == nil { return } + // The client may close the stream immediately after receiving its terminal + // event, canceling the request context before these durable affinity writes + // run. Preserve request values, but give all Redis writes one bounded budget + // independent of the downstream connection lifecycle. + bindBaseCtx := context.Background() + if ctx != nil { + bindBaseCtx = context.WithoutCancel(ctx) + } + bindCtx, cancel := context.WithTimeout(bindBaseCtx, openAIWSStateStoreRedisTimeout) + defer cancel() + groupID := getOpenAIGroupIDFromContext(c) ttl := s.openAIWSResponseStickyTTL() - logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl)) + logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(bindCtx, groupID, responseID, account.ID, ttl)) if rawOwner, ok := c.Get(openAIHTTPResponseOwnerContextKey); ok { if owner, ok := rawOwner.(openAIHTTPResponseOwner); ok && owner.userID > 0 && owner.apiKeyID > 0 { - if err := s.BindOpenAIHTTPResponseOwner(ctx, groupID, responseID, owner.userID, owner.apiKeyID); err != nil { + if err := s.BindOpenAIHTTPResponseOwner(bindCtx, groupID, responseID, owner.userID, owner.apiKeyID); err != nil { logger.L().Warn( "openai.http_bind_response_owner_failed", zap.Int64("group_id", groupID), diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index 69113a59d9fe..cf4611ccceb6 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -589,6 +589,37 @@ func TestOpenAIGatewayService_BindHTTPResponseAccount(t *testing.T) { require.False(t, owned) } +func TestOpenAIGatewayService_BindHTTPResponseAccount_DetachesCanceledRequestContext(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + groupID := int64(4201) + c.Set("api_key", &APIKey{ID: 501, GroupID: &groupID}) + SetOpenAIHTTPResponseOwner(c, 601, 501) + + cache := &responseBindContextProbeCache{} + svc := &OpenAIGatewayService{cache: cache} + account := &Account{ID: 37001, Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + requestCtx, cancelRequest := context.WithCancel(context.Background()) + cancelRequest() + startedAt := time.Now() + + svc.bindHTTPResponseAccount(requestCtx, c, account, "resp_http_canceled_001") + + require.Len(t, cache.setContextErrors, 3) + for _, contextErr := range cache.setContextErrors { + require.NoError(t, contextErr, "response affinity writes must survive downstream cancellation") + } + require.Len(t, cache.setDeadlines, 3) + for _, deadline := range cache.setDeadlines { + require.True(t, deadline.After(startedAt)) + require.LessOrEqual(t, deadline.Sub(startedAt), openAIWSStateStoreRedisTimeout+100*time.Millisecond) + } + require.Len(t, cache.sessionBindings, 3) + require.Contains(t, cache.sessionBindings, openAIWSResponseAccountCacheKey("resp_http_canceled_001")) +} + func TestOpenAIGatewayService_GenerateExplicitSessionHash_SkipsContentFallback(t *testing.T) { gin.SetMode(gin.TestMode) svc := &OpenAIGatewayService{} @@ -710,6 +741,23 @@ type stubGatewayCache struct { deletedSessions map[string]int } +type responseBindContextProbeCache struct { + stubGatewayCache + setContextErrors []error + setDeadlines []time.Time +} + +func (c *responseBindContextProbeCache) SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error { + c.setContextErrors = append(c.setContextErrors, ctx.Err()) + if deadline, ok := ctx.Deadline(); ok { + c.setDeadlines = append(c.setDeadlines, deadline) + } + if err := ctx.Err(); err != nil { + return err + } + return c.stubGatewayCache.SetSessionAccountID(ctx, groupID, sessionHash, accountID, ttl) +} + func (c *stubGatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) { if id, ok := c.sessionBindings[sessionHash]; ok { return id, nil From 21532add46331a54d03493871abafc6c9662f309 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Tue, 15 Sep 2026 20:04:35 +0800 Subject: [PATCH 004/137] fix(payment): reset the order page when changing status --- frontend/src/views/user/UserOrdersView.vue | 2 +- .../__tests__/UserOrdersView.filters.spec.ts | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 frontend/src/views/user/__tests__/UserOrdersView.filters.spec.ts diff --git a/frontend/src/views/user/UserOrdersView.vue b/frontend/src/views/user/UserOrdersView.vue index c3ed80eb59ef..003357a09883 100644 --- a/frontend/src/views/user/UserOrdersView.vue +++ b/frontend/src/views/user/UserOrdersView.vue @@ -4,7 +4,7 @@
-
+
+ {{ t('common.total') }}: {{ historyTotal }} {{ t('pagination.results') }} + + + +
@@ -375,6 +399,11 @@ const errorMessage = ref('') // History data const history = ref([]) const loadingHistory = ref(false) +const historyPage = ref(1) +const historyPageSize = ref(20) +const historyTotal = ref(0) +let historyRequest = 0 +let loadedHistoryPageSize = 20 const contactInfo = ref('') // Helper functions for history display @@ -420,14 +449,25 @@ const formatHistoryValue = (item: RedeemHistoryItem) => { } } -const fetchHistory = async () => { +const fetchHistory = async (page = 1) => { + const request = ++historyRequest + const pageSize = historyPageSize.value loadingHistory.value = true try { - history.value = await redeemAPI.getHistory() + const result = await redeemAPI.getHistory(page, pageSize) + if (request !== historyRequest) return + history.value = result.items + historyTotal.value = result.total + historyPage.value = page + historyPageSize.value = pageSize + loadedHistoryPageSize = pageSize } catch (error) { + if (request !== historyRequest) return + historyPageSize.value = loadedHistoryPageSize + appStore.showError(t('redeem.historyLoadFailed')) console.error('Failed to fetch history:', error) } finally { - loadingHistory.value = false + if (request === historyRequest) loadingHistory.value = false } } diff --git a/frontend/src/views/user/__tests__/RedeemView.spec.ts b/frontend/src/views/user/__tests__/RedeemView.spec.ts index 7c71092fcaa4..85bfc9e6dac9 100644 --- a/frontend/src/views/user/__tests__/RedeemView.spec.ts +++ b/frontend/src/views/user/__tests__/RedeemView.spec.ts @@ -45,7 +45,7 @@ describe('RedeemView refresh after redemption', () => { beforeEach(() => { vi.clearAllMocks() redeem.mockResolvedValue({ type: 'balance', value: 20, message: 'Code applied' }) - getHistory.mockResolvedValue([]) + getHistory.mockResolvedValue({ items: [], total: 0 }) refreshUser.mockResolvedValue({ balance: 30, concurrency: 2 }) fetchActiveSubscriptions.mockResolvedValue([]) vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -59,9 +59,9 @@ describe('RedeemView refresh after redemption', () => { 'keeps a successful %s redemption when profile refresh fails', async (type) => { redeem.mockResolvedValue({ type, value: 20, message: 'Code applied' }) refreshUser.mockRejectedValue({ status: 503, message: 'Service unavailable' }) - getHistory.mockResolvedValueOnce([]).mockResolvedValueOnce([{ + getHistory.mockResolvedValueOnce({ items: [], total: 0 }).mockResolvedValueOnce({ total: 1, items: [{ id: 1, code: 'REDEEM-CODE', type, value: 20, used_at: '2026-03-08T00:00:00Z', - }]) + }] }) const wrapper = await submitCode() @@ -84,6 +84,122 @@ describe('RedeemView refresh after redemption', () => { } ) + it('pages on the server, changes size, and resets page and total after redeeming', async () => { + getHistory.mockResolvedValue({ items: [], total: 101 }) + const wrapper = mount(RedeemView, { + global: { stubs: { AppLayout: { template: '
' }, Icon: true } }, + }) + await flushPromises() + const button = (text: string) => wrapper.findAll('button').find(b => b.text() === text)! + expect(getHistory).toHaveBeenLastCalledWith(1, 20) + expect(button('pagination.previous').attributes('disabled')).toBeDefined() + await button('pagination.next').trigger('click') + await flushPromises() + expect(getHistory).toHaveBeenLastCalledWith(2, 20) + await button('pagination.previous').trigger('click') + await flushPromises() + expect(getHistory).toHaveBeenLastCalledWith(1, 20) + expect(wrapper.findAll('select option').map(o => o.text())).toEqual(['20', '50', '100']) + await wrapper.get('select').setValue('50') + await flushPromises() + expect(getHistory).toHaveBeenLastCalledWith(1, 50) + await wrapper.get('select').setValue('100') + await flushPromises() + await button('pagination.next').trigger('click') + await flushPromises() + expect(getHistory).toHaveBeenLastCalledWith(2, 100) + expect(button('pagination.next').attributes('disabled')).toBeDefined() + getHistory.mockResolvedValue({ items: [], total: 102 }) + await wrapper.get('input#code').setValue('NEW-CODE') + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(getHistory).toHaveBeenLastCalledWith(1, 100) + expect(wrapper.text()).toContain('102') + expect(button('pagination.previous').attributes('disabled')).toBeDefined() + wrapper.unmount() + }) + + it('restores the loaded size and keeps rows and navigation usable after a size request fails', async () => { + const item = { id: 1, code: 'OLD-ROWS', type: 'balance', value: 20, used_at: '2026-03-08T00:00:00Z' } + getHistory.mockResolvedValue({ items: [item], total: 61 }) + const wrapper = mount(RedeemView, { + global: { stubs: { AppLayout: { template: '
' }, Icon: true } }, + }) + await flushPromises() + const button = (text: string) => wrapper.findAll('button').find(b => b.text() === text)! + await button('pagination.next').trigger('click') + await flushPromises() + let rejectRequest!: (error: Error) => void + getHistory.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectRequest = reject })) + await wrapper.get('select').setValue('50') + expect(getHistory).toHaveBeenLastCalledWith(1, 50) + expect(button('pagination.next').attributes('disabled')).toBeDefined() + rejectRequest(new Error('Network error')) + await flushPromises() + expect(wrapper.get('select').element.value).toBe('20') + expect(wrapper.text()).toContain('OLD-ROWS') + expect(wrapper.text()).toContain('61') + expect(button('pagination.previous').attributes('disabled')).toBeUndefined() + expect(button('pagination.next').attributes('disabled')).toBeUndefined() + expect(showError).toHaveBeenCalledWith('redeem.historyLoadFailed') + await button('pagination.next').trigger('click') + await flushPromises() + expect(getHistory).toHaveBeenLastCalledWith(3, 20) + await wrapper.get('select').setValue('50') + await flushPromises() + expect(getHistory).toHaveBeenLastCalledWith(1, 50) + expect(wrapper.get('select').element.value).toBe('50') + expect(button('pagination.previous').attributes('disabled')).toBeDefined() + wrapper.unmount() + }) + + it.each(['success', 'failure'])('ignores a stale history %s after a newer size request succeeds', async (outcome) => { + let resolveOld!: (value: unknown) => void + let rejectOld!: (error: Error) => void + getHistory.mockImplementationOnce(() => new Promise((resolve, reject) => { + resolveOld = resolve + rejectOld = reject + })) + const wrapper = mount(RedeemView, { + global: { stubs: { AppLayout: { template: '
' }, Icon: true } }, + }) + getHistory.mockResolvedValue({ items: [{ + id: 2, code: 'NEW-ROWS', type: 'balance', value: 30, used_at: '2026-03-08T00:00:00Z', + }], total: 61 }) + // Force overlapping requests to exercise responses arriving out of order. + await wrapper.get('select').setValue('50') + await flushPromises() + const button = (text: string) => wrapper.findAll('button').find(b => b.text() === text)! + await button('pagination.next').trigger('click') + await flushPromises() + expect(getHistory).toHaveBeenLastCalledWith(2, 50) + if (outcome === 'success') { + resolveOld({ items: [], total: 0 }) + } else { + rejectOld(new Error('Stale network error')) + } + await flushPromises() + expect(wrapper.get('select').element.value).toBe('50') + expect(wrapper.text()).toContain('NEW-ROWS') + expect(wrapper.text()).toContain('61') + expect(button('pagination.previous').attributes('disabled')).toBeUndefined() + expect(button('pagination.next').attributes('disabled')).toBeDefined() + expect(wrapper.get('select').attributes('disabled')).toBeUndefined() + expect(showError).not.toHaveBeenCalled() + await button('pagination.previous').trigger('click') + await flushPromises() + expect(getHistory).toHaveBeenLastCalledWith(1, 50) + wrapper.unmount() + }) + + it('disables both paging buttons for empty history', async () => { + const wrapper = await submitCode() + for (const button of wrapper.findAll('button').filter(b => b.text().startsWith('pagination.'))) { + expect(button.attributes('disabled')).toBeDefined() + } + wrapper.unmount() + }) + it('finishes normally without a warning when profile refresh succeeds', async () => { const wrapper = await submitCode() From 2f16e0984ee97ba6c9000745c6613c6d280b7332 Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Mon, 14 Sep 2026 03:08:00 +0000 Subject: [PATCH 012/137] fix(openai): avoid duplicate manifest parsing --- .../service/openai_codex_models_service.go | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index a0253a42412a..4e4cef85603a 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -2461,25 +2461,18 @@ func mergeMissingCodexModelFields(current, defaults map[string]json.RawMessage) } func validateCodexModelsManifestEnvelope(body []byte) error { - var envelope map[string]json.RawMessage + var envelope *struct { + Models []json.RawMessage `json:"models"` + } if err := json.Unmarshal(body, &envelope); err != nil { return fmt.Errorf("decode JSON object: %w", err) } if envelope == nil { return errors.New("expected a JSON object") } - models, ok := envelope["models"] - if !ok { + if envelope.Models == nil { return errors.New("missing top-level models array") } - models = bytes.TrimSpace(models) - var entries []json.RawMessage - if len(models) == 0 || models[0] != '[' { - return errors.New("top-level models field is not an array") - } - if err := json.Unmarshal(models, &entries); err != nil { - return fmt.Errorf("decode top-level models array: %w", err) - } return nil } From 31f3003ff5dfc192a0ba1abb8ac9df6204294950 Mon Sep 17 00:00:00 2001 From: Xiangzhe Date: Mon, 14 Sep 2026 03:37:20 +0000 Subject: [PATCH 013/137] fix(openai): preserve manifest key validation --- .../internal/service/openai_codex_models_service.go | 11 +++++++---- .../service/openai_codex_models_service_test.go | 5 +++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index 4e4cef85603a..e2658581e798 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -2461,18 +2461,21 @@ func mergeMissingCodexModelFields(current, defaults map[string]json.RawMessage) } func validateCodexModelsManifestEnvelope(body []byte) error { - var envelope *struct { - Models []json.RawMessage `json:"models"` - } + var envelope map[string]json.RawMessage if err := json.Unmarshal(body, &envelope); err != nil { return fmt.Errorf("decode JSON object: %w", err) } if envelope == nil { return errors.New("expected a JSON object") } - if envelope.Models == nil { + models, ok := envelope["models"] + if !ok { return errors.New("missing top-level models array") } + models = bytes.TrimSpace(models) + if len(models) == 0 || models[0] != '[' { + return errors.New("top-level models field is not an array") + } return nil } diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 087b3b8dac35..888a32f55ccd 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -2562,6 +2562,11 @@ func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) { } } +func TestValidateCodexModelsManifestEnvelopeRequiresExactModelsKey(t *testing.T) { + require.NoError(t, validateCodexModelsManifestEnvelope([]byte(`{"models":[]}`))) + require.Error(t, validateCodexModelsManifestEnvelope([]byte(`{"Models":[]}`))) +} + func TestFetchCodexModelsManifestAPIKeyDoesNotCacheInvalidEnvelope(t *testing.T) { var calls atomic.Int32 upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { From 18bfa4bf20324b36df1885993cd6e16fc85c3c85 Mon Sep 17 00:00:00 2001 From: HypoxanthineOvO Date: Wed, 16 Sep 2026 16:11:43 +0800 Subject: [PATCH 014/137] fix(gateway): normalize developer roles for strict Chat upstreams --- backend/internal/service/openai_chat_roles.go | 82 ++++++++ .../service/openai_chat_roles_test.go | 185 ++++++++++++++++++ .../openai_gateway_chat_completions_raw.go | 5 + 3 files changed, 272 insertions(+) create mode 100644 backend/internal/service/openai_chat_roles.go create mode 100644 backend/internal/service/openai_chat_roles_test.go diff --git a/backend/internal/service/openai_chat_roles.go b/backend/internal/service/openai_chat_roles.go new file mode 100644 index 000000000000..1e80101f5dcb --- /dev/null +++ b/backend/internal/service/openai_chat_roles.go @@ -0,0 +1,82 @@ +package service + +import ( + "encoding/json" + "errors" + "net/url" + "strings" +) + +func requiresSystemChatRole(account *Account, targetURL string) bool { + if account == nil || account.Type != AccountTypeAPIKey { + return false + } + switch account.Platform { + case PlatformDeepseek, PlatformKimi, PlatformZhipu: + return true + } + // OpenAI-compatible accounts can point at the same strict providers. Match + // the selected destination's exact hostname, never the requested model ID. + u, err := url.Parse(targetURL) + if err != nil { + return false + } + switch strings.ToLower(u.Hostname()) { + case "api.deepseek.com", "api.kimi.com", "api.moonshot.cn", "api.moonshot.ai", "open.bigmodel.cn", "api.z.ai": + return true + } + return false +} + +// normalizeStrictChatDeveloperRoles adapts native Chat requests only after an +// account and destination have been selected. Unlike the Responses bridge, it +// does not reorder/merge messages or downgrade instructions to user messages. +// RawMessage preserves unknown fields and numeric precision. The input remains +// untouched so a retry on another account sees the original developer roles. +func normalizeStrictChatDeveloperRoles(account *Account, targetURL string, body []byte) ([]byte, error) { + if !requiresSystemChatRole(account, targetURL) { + return body, nil + } + var root map[string]json.RawMessage + if json.Unmarshal(body, &root) != nil || root == nil { + return nil, errors.New("chat role normalization: invalid request object") + } + raw, exists := root["messages"] + if !exists { + return body, nil + } + var messages []json.RawMessage + if json.Unmarshal(raw, &messages) != nil { + return nil, errors.New("chat role normalization: invalid messages array") + } + changed := false + for i, raw := range messages { + var message map[string]json.RawMessage + if json.Unmarshal(raw, &message) != nil || message == nil { + return nil, errors.New("chat role normalization: invalid message object") + } + var role string + if json.Unmarshal(message["role"], &role) != nil { + return nil, errors.New("chat role normalization: invalid message role") + } + if role != "developer" { + continue + } + message["role"] = json.RawMessage(`"system"`) + updated, err := json.Marshal(message) + if err != nil { + return nil, errors.New("chat role normalization: cannot encode message") + } + messages[i] = updated + changed = true + } + if !changed { + return body, nil + } + updated, err := json.Marshal(messages) + if err != nil { + return nil, errors.New("chat role normalization: cannot encode messages") + } + root["messages"] = updated + return json.Marshal(root) +} diff --git a/backend/internal/service/openai_chat_roles_test.go b/backend/internal/service/openai_chat_roles_test.go new file mode 100644 index 000000000000..27434ad98317 --- /dev/null +++ b/backend/internal/service/openai_chat_roles_test.go @@ -0,0 +1,185 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// Only the upstream transport is stubbed. Requests go through the actual Chat +// dispatcher, model mapping, request construction and response/usage handling. +type chatRoleContractUpstream struct { + httpUpstreamRecorder + strict bool +} + +func (u *chatRoleContractUpstream) Do(req *http.Request, proxyURL string, accountID int64, concurrency int) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + if err != nil { + return nil, err + } + req.Body = io.NopCloser(bytes.NewReader(body)) + status, contentType := http.StatusOK, "application/json" + result := `{"id":"chatcmpl_roles","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":1,"total_tokens":10}}` + if u.strict { + for _, message := range gjson.GetBytes(body, "messages").Array() { + if message.Get("role").String() == "developer" { + status = http.StatusBadRequest + result = "{\"error\":{\"message\":\"unknown variant `developer`\",\"type\":\"invalid_request_error\"}}" + } + } + } + if status == http.StatusOK && gjson.GetBytes(body, "stream").Bool() { + contentType = "text/event-stream" + result = "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":1,\"total_tokens\":10}}\n\ndata: [DONE]\n\n" + } + u.resp = &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {contentType}}, Body: io.NopCloser(strings.NewReader(result))} + return u.httpUpstreamRecorder.Do(req, proxyURL, accountID, concurrency) +} + +func TestForwardAsChatCompletions_StrictDeveloperRole(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, stream := range []bool{false, true} { + for _, target := range []struct{ platform, model, base string }{ + {PlatformDeepseek, "deepseek-flash", "https://api.deepseek.com"}, + {PlatformKimi, "k3", "https://api.kimi.com/coding"}, + {PlatformZhipu, "glm-5.3", "https://open.bigmodel.cn/api/paas/v4"}, + {PlatformOpenAI, "deepseek-flash", "https://api.deepseek.com"}, + {PlatformOpenAI, "k3", "https://api.kimi.com/coding"}, + {PlatformOpenAI, "kimi-k3", "https://api.moonshot.ai"}, + {PlatformOpenAI, "glm-5.3", "https://open.bigmodel.cn/api/paas/v4"}, + } { + t.Run(fmt.Sprintf("%s/%s/%s/stream=%t", target.platform, target.model, target.base, stream), func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"example-alias","messages":[{"role":"developer","content":"Keep these instructions."},{"role":"user","content":"ping"}],"stream":%t,"max_tokens":10}`, stream)) + original := bytes.Clone(body) + upstream := &chatRoleContractUpstream{strict: true} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := rawChatCompletionsTestAccount() + account.Platform = target.platform + account.Credentials["base_url"] = target.base + account.Credentials["api_protocol"] = APIProtocolChatCompletions + account.Credentials["model_mapping"] = map[string]any{"example-alias": target.model} + account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false} + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 9, result.Usage.InputTokens) + require.Equal(t, 1, result.Usage.OutputTokens) + require.Equal(t, target.model, gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, "system", gjson.GetBytes(upstream.lastBody, "messages.0.role").String()) + require.Equal(t, "Keep these instructions.", gjson.GetBytes(upstream.lastBody, "messages.0.content").String()) + require.Equal(t, original, body, "per-account adaptation must not mutate the retry body") + require.Equal(t, http.StatusOK, recorder.Code) + require.Contains(t, recorder.Body.String(), "pong") + if stream { + require.Contains(t, recorder.Body.String(), "data: [DONE]") + } + + // A subsequent attempt with the same body on a compatible account + // must still see developer, not the previous account's system role. + upstream.strict = false + account.Platform = PlatformOpenAI + account.Credentials["base_url"] = "https://compatible.example.test/v1" + recorder = httptest.NewRecorder() + c, _ = gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) + _, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "") + require.NoError(t, err) + require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "messages.0.role").String()) + }) + } + } +} + +func TestNormalizeStrictChatDeveloperRoles_PreservesConversation(t *testing.T) { + body := []byte(`{"model":"alias","messages":[{"role":"system","content":"first"},{"role":"developer","content":[{"type":"text","text":"developer is just text","cache_control":{"type":"ephemeral"}}],"name":"rules"},{"role":"user","content":"ping"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"echo","arguments":"{\"role\":\"developer\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"},{"role":"developer","content":"last"}],"metadata":{"large":9007199254740993,"role":"developer"},"stream":true,"reasoning_effort":"high"}`) + original := bytes.Clone(body) + want := strings.ReplaceAll(string(body), `"role":"developer","content"`, `"role":"system","content"`) + for _, platform := range []string{PlatformDeepseek, PlatformKimi, PlatformZhipu} { + t.Run(platform, func(t *testing.T) { + account := &Account{Platform: platform, Type: AccountTypeAPIKey} + got, err := normalizeStrictChatDeveloperRoles(account, "https://relay.example.test/v1/chat/completions", body) + require.NoError(t, err) + require.JSONEq(t, want, string(got)) + require.Contains(t, string(got), "9007199254740993") + require.Equal(t, original, body) + again, err := normalizeStrictChatDeveloperRoles(account, "https://relay.example.test/v1/chat/completions", got) + require.NoError(t, err) + require.Equal(t, got, again) + }) + } +} + +func TestNormalizeStrictChatDeveloperRoles_ExactOfficialHosts(t *testing.T) { + body := []byte(`{"messages":[{"role":"developer","content":"rules"}]}`) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + for _, host := range []string{"api.deepseek.com", "api.kimi.com", "api.moonshot.cn", "api.moonshot.ai", "open.bigmodel.cn", "api.z.ai", "API.DEEPSEEK.COM:443"} { + t.Run(host, func(t *testing.T) { + got, err := normalizeStrictChatDeveloperRoles(account, "https://"+host+"/v1/chat/completions", body) + require.NoError(t, err) + require.JSONEq(t, `{"messages":[{"role":"system","content":"rules"}]}`, string(got)) + }) + } +} + +func TestNormalizeStrictChatDeveloperRoles_OtherTargetsUntouched(t *testing.T) { + // Neither model names nor host-like strings in the path/userinfo may enable + // normalization on an otherwise compatible provider. + body := []byte("{ \"model\": \"deepseek-flash\", \"messages\": [{\"role\":\"developer\",\"content\":\"rules\"}] }") + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + for _, target := range []string{ + "https://api.openai.com/v1/chat/completions", + "https://relay.example.test/v1/chat/completions", + "https://api.deepseek.com.example.test/v1/chat/completions", + "https://example.test/api.kimi.com/chat/completions", + "https://api.deepseek.com@example.test/v1/chat/completions", + "://invalid-url", + } { + got, err := normalizeStrictChatDeveloperRoles(account, target, body) + require.NoError(t, err) + require.Equal(t, body, got) + } + for _, account := range []*Account{nil, {Platform: PlatformKimi, Type: AccountTypeOAuth}, {Platform: PlatformOpenAI, Type: AccountTypeOAuth}} { + got, err := normalizeStrictChatDeveloperRoles(account, "https://api.kimi.com/coding/v1/chat/completions", body) + require.NoError(t, err) + require.Equal(t, body, got) + } +} + +func TestNormalizeStrictChatDeveloperRoles_CompatibleBodyUntouched(t *testing.T) { + account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} + for _, body := range []string{ + "{ \"messages\": [{\"role\":\"system\",\"content\":\"rules\"}] }", + `{"messages":[]}`, + `{"input":[{"role":"developer","content":"Responses is not Chat"}]}`, + } { + got, err := normalizeStrictChatDeveloperRoles(account, "https://api.deepseek.com/v1/chat/completions", []byte(body)) + require.NoError(t, err) + require.Equal(t, body, string(got)) + } +} + +func TestNormalizeStrictChatDeveloperRoles_MalformedBodyDoesNotLeak(t *testing.T) { + account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} + for _, body := range []string{`{"secret":`, `null`, `[]`, `{"messages":"secret"}`, `{"messages":["secret"]}`, `{"messages":[null]}`, `{"messages":[{"content":"secret"}]}`, `{"messages":[{"role":12,"content":"secret"}]}`} { + _, err := normalizeStrictChatDeveloperRoles(account, "https://api.deepseek.com/v1/chat/completions", []byte(body)) + require.Error(t, err) + require.NotContains(t, err.Error(), "secret") + } +} diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index dd77e700d2e3..f778a682bc9b 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -178,6 +178,11 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions( if err != nil { return nil, err } + upstreamBody, err = normalizeStrictChatDeveloperRoles(account, targetURL, upstreamBody) + if err != nil { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return nil, err + } SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint) customUA := account.GetOpenAIUserAgent() if customUA == "" && account.IsGrokOAuth() { From 1a32b91eb21c022fb8a02bc35bdf707211fcf462 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 16 Sep 2026 18:15:12 +0800 Subject: [PATCH 015/137] fix(pagination): handle numeric jump input --- frontend/src/components/common/Pagination.vue | 2 +- .../common/__tests__/Pagination.jump.spec.ts | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/common/__tests__/Pagination.jump.spec.ts diff --git a/frontend/src/components/common/Pagination.vue b/frontend/src/components/common/Pagination.vue index 9b4ac200e210..cdccd965df05 100644 --- a/frontend/src/components/common/Pagination.vue +++ b/frontend/src/components/common/Pagination.vue @@ -230,7 +230,7 @@ const handlePageSizeChange = (value: string | number | boolean | null) => { } const submitJump = () => { - const value = jumpPage.value.trim() + const value = String(jumpPage.value).trim() if (!value) return const pageNum = Number.parseInt(value, 10) if (Number.isNaN(pageNum)) return diff --git a/frontend/src/components/common/__tests__/Pagination.jump.spec.ts b/frontend/src/components/common/__tests__/Pagination.jump.spec.ts new file mode 100644 index 000000000000..59e0870dbae0 --- /dev/null +++ b/frontend/src/components/common/__tests__/Pagination.jump.spec.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import Pagination from '../Pagination.vue' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) + +function mountPagination() { + return mount(Pagination, { + props: { total: 200, page: 1, pageSize: 20, showJump: true, showPageSizeSelector: false }, + global: { stubs: { Icon: true } } + }) +} + +describe('pagination jump input', () => { + it.each(['click', 'enter'])('jumps to the entered numeric page using %s', async (action) => { + const wrapper = mountPagination() + const input = wrapper.get('input[type="number"]') + await input.setValue('3') + if (action === 'click') await wrapper.get('.btn').trigger('click') + else await input.trigger('keyup', { key: 'Enter' }) + expect(wrapper.emitted('update:page')).toEqual([[3]]) + expect((input.element as HTMLInputElement).value).toBe('') + }) + + it('clamps an entered page to the last page', async () => { + const wrapper = mountPagination() + await wrapper.get('input').setValue('99') + await wrapper.get('.btn').trigger('click') + expect(wrapper.emitted('update:page')).toEqual([[10]]) + }) + + it('ignores an empty jump input', async () => { + const wrapper = mountPagination() + await wrapper.get('.btn').trigger('click') + expect(wrapper.emitted('update:page')).toBeUndefined() + }) +}) From ee9ac3e45fc6226ccd1e824ad2cd5247853553a8 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 16 Sep 2026 18:15:28 +0800 Subject: [PATCH 016/137] fix(auth): keep TOTP setup digits in sync with state --- .../user/profile/TotpSetupModal.vue | 1 + .../__tests__/TotpSetupModal.inputs.spec.ts | 65 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 frontend/src/components/user/profile/__tests__/TotpSetupModal.inputs.spec.ts diff --git a/frontend/src/components/user/profile/TotpSetupModal.vue b/frontend/src/components/user/profile/TotpSetupModal.vue index 312ecf7f4b3b..fd9070b4d9c9 100644 --- a/frontend/src/components/user/profile/TotpSetupModal.vue +++ b/frontend/src/components/user/profile/TotpSetupModal.vue @@ -135,6 +135,7 @@ v-for="(_, index) in 6" :key="index" :ref="(el) => setInputRef(el, index)" + :value="code[index]" type="text" maxlength="1" inputmode="numeric" diff --git a/frontend/src/components/user/profile/__tests__/TotpSetupModal.inputs.spec.ts b/frontend/src/components/user/profile/__tests__/TotpSetupModal.inputs.spec.ts new file mode 100644 index 000000000000..28975d95e154 --- /dev/null +++ b/frontend/src/components/user/profile/__tests__/TotpSetupModal.inputs.spec.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils' +import TotpSetupModal from '../TotpSetupModal.vue' + +const api = vi.hoisted(() => ({ getVerificationMethod: vi.fn(), initiateSetup: vi.fn(), enable: vi.fn() })) +vi.mock('@/api', () => ({ totpAPI: api })) +vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showError: vi.fn(), showSuccess: vi.fn() }) })) +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +vi.mock('qrcode', () => ({ default: { toDataURL: vi.fn() } })) +enableAutoUnmount(afterEach) + +beforeEach(() => { + vi.resetAllMocks() + api.getVerificationMethod.mockResolvedValue({ method: 'password' }) + api.initiateSetup.mockResolvedValue({ secret: 'EXAMPLE', qr_code_url: '', setup_token: 'setup-token' }) + api.enable.mockResolvedValue({ success: true }) +}) + +async function openCodeStep() { + const wrapper = mount(TotpSetupModal, { attachTo: document.body }) + await flushPromises() + await wrapper.get('input[type="password"]').setValue('password') + await wrapper.get('.btn-primary').trigger('click') + await flushPromises() + await wrapper.get('.btn-primary').trigger('click') + const inputs = wrapper.findAll('input[maxlength="1"]') + for (let i = 0; i < inputs.length; i++) await inputs[i].setValue(String(i + 1)) + return wrapper +} + +describe('TOTP setup input synchronization', () => { + it('clears displayed digits after verification fails and focuses the first cell', async () => { + api.enable.mockRejectedValueOnce({ message: 'Invalid code' }) + const wrapper = await openCodeStep() + await wrapper.get('form').trigger('submit') + await flushPromises() + const inputs = wrapper.findAll('input[maxlength="1"]') + expect(inputs.map(input => (input.element as HTMLInputElement).value)).toEqual(['', '', '', '', '', '']) + expect(document.activeElement).toBe(inputs[0].element) + expect(wrapper.get('[type="submit"]').attributes('disabled')).toBeDefined() + await inputs[0].trigger('paste', { clipboardData: { getData: () => '654321' } }) + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(api.enable).toHaveBeenLastCalledWith({ totp_code: '654321', setup_token: 'setup-token' }) + expect(wrapper.emitted('success')).toHaveLength(1) + }) + + it('displays retained digits when returning from the QR-code step', async () => { + const wrapper = await openCodeStep() + await wrapper.findAll('button').find(button => button.text() === 'common.back')!.trigger('click') + await wrapper.get('.btn-primary').trigger('click') + expect(wrapper.findAll('input[maxlength="1"]').map(input => (input.element as HTMLInputElement).value)).toEqual(['1', '2', '3', '4', '5', '6']) + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(api.enable).toHaveBeenCalledWith({ totp_code: '123456', setup_token: 'setup-token' }) + }) + + it('submits normally entered digits', async () => { + const wrapper = await openCodeStep() + await wrapper.get('form').trigger('submit') + await flushPromises() + expect(api.enable).toHaveBeenCalledWith({ totp_code: '123456', setup_token: 'setup-token' }) + expect(wrapper.emitted('success')).toHaveLength(1) + }) +}) From 98321a054ed41c65058966d1d7d705d93dfe902e Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 16 Sep 2026 18:17:14 +0800 Subject: [PATCH 017/137] fix(payment): restore accepted text after invalid amount input --- .../src/components/payment/AmountInput.vue | 8 +++- .../payment/__tests__/AmountInput.spec.ts | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/payment/__tests__/AmountInput.spec.ts diff --git a/frontend/src/components/payment/AmountInput.vue b/frontend/src/components/payment/AmountInput.vue index c3d9aa85e792..0e0c9d339ad3 100644 --- a/frontend/src/components/payment/AmountInput.vue +++ b/frontend/src/components/payment/AmountInput.vue @@ -88,8 +88,12 @@ function selectAmount(amt: number) { } function handleInput(e: Event) { - const val = (e.target as HTMLInputElement).value - if (!AMOUNT_PATTERN.test(val)) return + const input = e.target as HTMLInputElement + const val = input.value + if (!AMOUNT_PATTERN.test(val)) { + input.value = customText.value + return + } customText.value = val if (val === '') { emit('update:modelValue', null) diff --git a/frontend/src/components/payment/__tests__/AmountInput.spec.ts b/frontend/src/components/payment/__tests__/AmountInput.spec.ts new file mode 100644 index 000000000000..314cc08d3cd4 --- /dev/null +++ b/frontend/src/components/payment/__tests__/AmountInput.spec.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import AmountInput from '../AmountInput.vue' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) + +function mountInput(value: number | null = null) { + return mount(AmountInput, { props: { modelValue: value } }) +} + +describe('recharge amount input', () => { + it.each(['10abc', '10.555', '-10', '1e2'])('restores the accepted amount after rejecting %s', async (value) => { + const wrapper = mountInput(10) + const input = wrapper.get('input') + await input.setValue(value) + expect((input.element as HTMLInputElement).value).toBe('10') + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + }) + + it('restores the last typed amount rather than a stale prop', async () => { + const wrapper = mountInput() + const input = wrapper.get('input') + await input.setValue('12.50') + await input.setValue('12.500') + expect((input.element as HTMLInputElement).value).toBe('12.50') + expect(wrapper.emitted('update:modelValue')).toEqual([[12.5]]) + }) + + it('preserves decimal editing and allows clearing the amount', async () => { + const wrapper = mountInput() + const input = wrapper.get('input') + for (const value of ['0', '0.', '0.5', '0.50', '']) await input.setValue(value) + expect(wrapper.emitted('update:modelValue')).toEqual([[null], [null], [0.5], [0.5], [null]]) + expect((input.element as HTMLInputElement).value).toBe('') + }) +}) From 0838e0e6107fc7fb5b64d4ba9a4456120e8031eb Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 16 Sep 2026 18:17:29 +0800 Subject: [PATCH 018/137] fix(admin): reject negative platform quota limits before saving --- .../admin/user/UserPlatformQuotaModal.vue | 8 +++---- .../__tests__/UserPlatformQuotaModal.spec.ts | 24 ++++++++++++++++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue index 536f9a842408..57cb4b394def 100644 --- a/frontend/src/components/admin/user/UserPlatformQuotaModal.vue +++ b/frontend/src/components/admin/user/UserPlatformQuotaModal.vue @@ -231,14 +231,12 @@ function onClearAll() { async function onSave() { if (!props.user) return - // 校验所有 input:v-model.number 在用户输入"0."等中间状态时会写回 NaN, - // 之前的 normalizeLimit(NaN) 静默返回 null("无限制"),把"有限额"配置悄悄改成"无限制"。 - // 这里在 save 前显式检测 NaN,提示用户修正后再提交。 + // 拒绝非法数值,避免 normalizeLimit 将其静默转换为 null(无限额)。 const invalid: string[] = [] for (const row of quotas.value) { for (const win of ['daily', 'weekly', 'monthly'] as const) { const v = row[`${win}_limit_usd` as const] - if (typeof v === 'number' && Number.isNaN(v)) { + if (typeof v === 'number' && (!Number.isFinite(v) || v < 0)) { invalid.push(`${row.platform}.${win}`) } } @@ -268,7 +266,7 @@ async function onSave() { } // 仅在合法输入下返回数字:null/undefined/NaN/±Inf/负数 → null(视为"无限额")。 -// 调用方负责在 NaN 路径上做单独的用户提示(见 onSave)。 +// 调用方在保存前拦截非法数值(见 onSave)。 function normalizeLimit(v: number | null | undefined): number | null { if (v === null || v === undefined) return null if (typeof v === 'number' && Number.isFinite(v) && v >= 0) return v diff --git a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts index d5821262940b..50740b7cef7b 100644 --- a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts +++ b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts @@ -3,6 +3,7 @@ import { mount, flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' const apiMocks = vi.hoisted(() => ({ + showError: vi.fn(), getPlatformQuotas: vi.fn(), updatePlatformQuotas: vi.fn(), resetPlatformQuotaWindow: vi.fn(), @@ -20,7 +21,7 @@ vi.mock('@/api/admin', () => ({ vi.mock('@/stores/app', () => ({ useAppStore: () => ({ - showError: vi.fn(), + showError: apiMocks.showError, showSuccess: vi.fn(), }), })) @@ -74,6 +75,27 @@ beforeEach(() => { }) describe('UserPlatformQuotaModal', () => { + it.each([0, 4, 14])('does not turn a negative limit in input %s into unlimited', async (index) => { + const w = await mountAndOpen() + await w.findAll('input[type=number]')[index].setValue('-1') + await w.findAll('button').find(b => b.text() === 'admin.users.platformQuota.save')!.trigger('click') + await flushPromises() + expect(apiMocks.updatePlatformQuotas).not.toHaveBeenCalled() + expect(apiMocks.showError).toHaveBeenCalledWith('admin.users.platformQuota.invalidNumber') + expect(w.emitted('success')).toBeUndefined() + w.unmount() + }) + + it.each([['0', 0], ['', null]])('preserves the meaning of a limit entered as %j', async (input, expected) => { + const w = await mountAndOpen() + await w.findAll('input[type=number]')[0].setValue(input) + await w.findAll('button').find(b => b.text() === 'admin.users.platformQuota.save')!.trigger('click') + await flushPromises() + expect(apiMocks.updatePlatformQuotas).toHaveBeenCalledTimes(1) + expect(apiMocks.updatePlatformQuotas.mock.calls[0][1][0].daily_limit_usd).toBe(expected) + w.unmount() + }) + it('挂载并 show=true 时调用 getPlatformQuotas', async () => { await mountAndOpen() expect(apiMocks.getPlatformQuotas).toHaveBeenCalledWith(99) From f8e5a0d94f811fad399d02bdd97aeefc7dc1a284 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Wed, 16 Sep 2026 18:19:41 +0800 Subject: [PATCH 019/137] fix(channels): allow tabbing out of an empty model input --- .../admin/channel/ModelTagInput.vue | 8 +++- .../__tests__/ModelTagInput.keyboard.spec.ts | 39 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts diff --git a/frontend/src/components/admin/channel/ModelTagInput.vue b/frontend/src/components/admin/channel/ModelTagInput.vue index b91aa1196b9f..a268ae340af5 100644 --- a/frontend/src/components/admin/channel/ModelTagInput.vue +++ b/frontend/src/components/admin/channel/ModelTagInput.vue @@ -24,7 +24,7 @@ class="flex-1 min-w-[120px] border-none bg-transparent text-sm outline-none placeholder:text-gray-400 dark:text-white" :placeholder="models.length === 0 ? placeholder : ''" @keydown.enter.prevent="addModel" - @keydown.tab.prevent="addModel" + @keydown.tab="handleTab" @keydown.delete="handleBackspace" @paste="handlePaste" @blur="addModel" @@ -66,6 +66,12 @@ function addModel() { inputValue.value = '' } +function handleTab(event: KeyboardEvent) { + if (!inputValue.value.trim()) return + event.preventDefault() + addModel() +} + function removeModel(idx: number) { const newModels = [...props.models] newModels.splice(idx, 1) diff --git a/frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts b/frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts new file mode 100644 index 000000000000..44e4cc67b7ea --- /dev/null +++ b/frontend/src/components/admin/channel/__tests__/ModelTagInput.keyboard.spec.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import { nextTick } from 'vue' +import ModelTagInput from '../ModelTagInput.vue' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) + +function pressTab(input: Element, shiftKey = false) { + const event = new KeyboardEvent('keydown', { key: 'Tab', shiftKey, bubbles: true, cancelable: true }) + input.dispatchEvent(event) + return event +} + +describe('model tag keyboard navigation', () => { + it.each([false, true])('allows leaving an empty input with Tab (shift: %s)', (shift) => { + const wrapper = mount(ModelTagInput, { props: { models: ['gpt-4o'] } }) + expect(pressTab(wrapper.get('input').element, shift).defaultPrevented).toBe(false) + expect(wrapper.emitted('update:models')).toBeUndefined() + }) + + it('allows leaving a whitespace-only input', async () => { + const wrapper = mount(ModelTagInput, { props: { models: [] } }) + await wrapper.get('input').setValue(' ') + expect(pressTab(wrapper.get('input').element).defaultPrevented).toBe(false) + expect(wrapper.emitted('update:models')).toBeUndefined() + }) + + it('still commits a pending model with Tab, then allows the next Tab to leave', async () => { + const wrapper = mount(ModelTagInput, { props: { models: ['gpt-4o'] } }) + const input = wrapper.get('input') + await input.setValue('gpt-4.1') + expect(pressTab(input.element).defaultPrevented).toBe(true) + await nextTick() + expect(wrapper.emitted('update:models')).toEqual([[['gpt-4o', 'gpt-4.1']]]) + expect((input.element as HTMLInputElement).value).toBe('') + expect(pressTab(input.element).defaultPrevented).toBe(false) + }) +}) From fe36f4a9149bedf06fa40b195e144a257bf6c5a3 Mon Sep 17 00:00:00 2001 From: DanielSong <102885173+Liu8Can@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:34:52 +0800 Subject: [PATCH 020/137] fix: prevent promo code field flash on registration --- frontend/src/views/auth/RegisterView.vue | 8 ++- .../views/auth/__tests__/RegisterView.spec.ts | 51 ++++++++++++++++--- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue index ea43b8965fbc..a3f9c0e4a587 100644 --- a/frontend/src/views/auth/RegisterView.vue +++ b/frontend/src/views/auth/RegisterView.vue @@ -422,7 +422,13 @@ const confirmPassword = ref('') // Public settings const registrationEnabled = ref(true) const emailVerifyEnabled = ref(false) -const promoCodeEnabled = ref(true) +// Public settings are injected into the app store before Vue mounts. Use that +// value for the first render so a disabled promo-code field never flashes +// while the async settings request is still in flight. If injection is +// unavailable, fail closed until the request explicitly enables the field. +const promoCodeEnabled = ref( + appStore.cachedPublicSettings?.promo_code_enabled === true +) const invitationCodeEnabled = ref(false) const affiliateEnabled = ref(false) const turnstileEnabled = ref(false) diff --git a/frontend/src/views/auth/__tests__/RegisterView.spec.ts b/frontend/src/views/auth/__tests__/RegisterView.spec.ts index 9626a03b4a55..8a9ba3a30975 100644 --- a/frontend/src/views/auth/__tests__/RegisterView.spec.ts +++ b/frontend/src/views/auth/__tests__/RegisterView.spec.ts @@ -2,12 +2,25 @@ import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import RegisterView from '@/views/auth/RegisterView.vue' -const { getPublicSettingsMock, registerMock, showErrorMock, pushMock, verifyActionMock } = vi.hoisted(() => ({ +const { + getPublicSettingsMock, + registerMock, + showErrorMock, + pushMock, + verifyActionMock, + appStoreMock +} = vi.hoisted(() => ({ getPublicSettingsMock: vi.fn(), registerMock: vi.fn(), showErrorMock: vi.fn(), pushMock: vi.fn(), - verifyActionMock: vi.fn() + verifyActionMock: vi.fn(), + appStoreMock: { + cachedPublicSettings: null as { promo_code_enabled?: boolean } | null, + showError: (...args: unknown[]) => showErrorMock(...args), + showSuccess: vi.fn(), + showWarning: vi.fn() + } })) const publicSettings = { @@ -49,11 +62,7 @@ vi.mock('vue-i18n', () => ({ vi.mock('@/stores', () => ({ useAuthStore: () => ({ register: (...args: unknown[]) => registerMock(...args) }), - useAppStore: () => ({ - showError: (...args: unknown[]) => showErrorMock(...args), - showSuccess: vi.fn(), - showWarning: vi.fn() - }) + useAppStore: () => appStoreMock })) vi.mock('@/api/auth', async () => { @@ -93,12 +102,40 @@ describe('RegisterView', () => { showErrorMock.mockReset() pushMock.mockReset() verifyActionMock.mockReset() + appStoreMock.cachedPublicSettings = null sessionStorage.removeItem('register_data') verifyActionMock.mockResolvedValue({ token: 'ticket', randstr: 'randstr' }) getPublicSettingsMock.mockResolvedValue(publicSettings) registerMock.mockResolvedValue({}) }) + it('does not flash the promo-code field before disabled settings finish loading', async () => { + let resolveSettings!: (settings: typeof publicSettings) => void + getPublicSettingsMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveSettings = resolve + }) + ) + + const wrapper = mountRegister() + + expect(wrapper.find('#promo_code').exists()).toBe(false) + + resolveSettings(publicSettings) + await flushPromises() + + expect(wrapper.find('#promo_code').exists()).toBe(false) + }) + + it('uses injected public settings to show an enabled promo-code field on first render', () => { + appStoreMock.cachedPublicSettings = { promo_code_enabled: true } + getPublicSettingsMock.mockReturnValueOnce(new Promise(() => {})) + + const wrapper = mountRegister() + + expect(wrapper.find('#promo_code').exists()).toBe(true) + }) + it.each([ ['', 'auth.confirmPasswordRequired'], ['different-password', 'auth.passwordsDoNotMatch'] From 881ab1b0c6239a928fdccc3c87d2cd011aa337ec Mon Sep 17 00:00:00 2001 From: laoxiechuzheng Date: Thu, 17 Sep 2026 01:22:22 +0800 Subject: [PATCH 021/137] fix(deepseek): lift Responses tool output images DeepSeek native Responses rejects Codex view_image results when function_call_output.output contains input_image parts. Move media into a following user message and keep the tool output as a string so call_id pairing remains valid. --- .../apicompat/responses_tool_output_media.go | 121 ++++++++++++++++++ .../responses_tool_output_media_test.go | 57 +++++++++ backend/internal/service/cn_providers_test.go | 10 ++ .../service/openai_gateway_request_body.go | 21 ++- 4 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 backend/internal/pkg/apicompat/responses_tool_output_media.go create mode 100644 backend/internal/pkg/apicompat/responses_tool_output_media_test.go diff --git a/backend/internal/pkg/apicompat/responses_tool_output_media.go b/backend/internal/pkg/apicompat/responses_tool_output_media.go new file mode 100644 index 000000000000..9daa4068e69f --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_tool_output_media.go @@ -0,0 +1,121 @@ +package apicompat + +import ( + "encoding/json" + "fmt" + "strings" +) + +type responsesToolOutputMedia struct { + callID string + imageURL string +} + +// LiftResponsesToolOutputMedia moves image parts out of Responses tool outputs +// and into a following user message. Native Responses endpoints such as +// DeepSeek accept function_call_output.output as a string, but Codex view_image +// returns an array containing input_image. Keeping the image inside the tool +// output makes the upstream report "No tool output found for tool call ...". +func LiftResponsesToolOutputMedia(input any) (any, bool) { + items, ok := input.([]any) + if !ok { + return input, false + } + + rewritten := make([]any, 0, len(items)+1) + pending := make([]responsesToolOutputMedia, 0) + changed := false + + flushMedia := func() { + if len(pending) == 0 { + return + } + + content := make([]map[string]any, 0, len(pending)*2) + lastCallID := "" + for _, media := range pending { + if media.callID != lastCallID { + text := "Tool output media" + if media.callID != "" { + text = fmt.Sprintf(toolOutputMediaAttribution, media.callID) + } + content = append(content, map[string]any{ + "type": "input_text", + "text": text, + }) + lastCallID = media.callID + } + content = append(content, map[string]any{ + "type": "input_image", + "image_url": media.imageURL, + }) + } + + rewritten = append(rewritten, map[string]any{ + "type": "message", + "role": "user", + "content": content, + }) + pending = pending[:0] + } + + for _, rawItem := range items { + item, ok := rawItem.(map[string]any) + if !ok || !isResponsesToolOutputItem(item) { + flushMedia() + rewritten = append(rewritten, rawItem) + continue + } + + output, exists := item["output"] + if !exists { + rewritten = append(rewritten, item) + continue + } + outputRaw, err := json.Marshal(output) + if err != nil { + rewritten = append(rewritten, item) + continue + } + + outputText, media, didRewrite := extractToolOutputMedia(outputRaw) + if !didRewrite { + rewritten = append(rewritten, item) + continue + } + + item["output"] = outputText + callID := strings.TrimSpace(stringValue(item["call_id"])) + for _, part := range media { + if part.ImageURL == nil { + continue + } + imageURL := strings.TrimSpace(part.ImageURL.URL) + if imageURL == "" { + continue + } + pending = append(pending, responsesToolOutputMedia{ + callID: callID, + imageURL: imageURL, + }) + } + changed = true + rewritten = append(rewritten, item) + } + + flushMedia() + if !changed { + return input, false + } + return rewritten, true +} + +func isResponsesToolOutputItem(item map[string]any) bool { + switch strings.TrimSpace(stringValue(item["type"])) { + case "function_call_output", "custom_tool_call_output", + "tool_search_output", "tool_search_call_output", "mcp_tool_call_output": + return true + default: + return false + } +} diff --git a/backend/internal/pkg/apicompat/responses_tool_output_media_test.go b/backend/internal/pkg/apicompat/responses_tool_output_media_test.go new file mode 100644 index 000000000000..37417aba7795 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_tool_output_media_test.go @@ -0,0 +1,57 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLiftResponsesToolOutputMedia(t *testing.T) { + var input any + require.NoError(t, json.Unmarshal([]byte(`[ + {"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]} + ]`), &input)) + + lifted, changed := LiftResponsesToolOutputMedia(input) + require.True(t, changed) + + items, ok := lifted.([]any) + require.True(t, ok) + require.Len(t, items, 3) + + outputItem, ok := items[1].(map[string]any) + require.True(t, ok) + outputText, ok := outputItem["output"].(string) + require.True(t, ok) + require.Contains(t, outputText, toolOutputMediaMarker) + require.NotContains(t, outputText, "data:image/png") + + mediaMessage, ok := items[2].(map[string]any) + require.True(t, ok) + require.Equal(t, "message", mediaMessage["type"]) + require.Equal(t, "user", mediaMessage["role"]) + + parts, ok := mediaMessage["content"].([]map[string]any) + require.True(t, ok) + require.Len(t, parts, 2) + require.Equal(t, "input_text", parts[0]["type"]) + require.Equal(t, "[Tool output media for call call_image]", parts[0]["text"]) + require.Equal(t, "input_image", parts[1]["type"]) + require.Equal(t, "data:image/png;base64,AQID", parts[1]["image_url"]) +} + +func TestLiftResponsesToolOutputMediaLeavesPlainOutputUntouched(t *testing.T) { + input := []any{ + map[string]any{ + "type": "function_call_output", + "call_id": "call_text", + "output": "plain output", + }, + } + + lifted, changed := LiftResponsesToolOutputMedia(input) + require.False(t, changed) + require.Equal(t, input, lifted) +} diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go index 7165b2953705..44becd4e5bee 100644 --- a/backend/internal/service/cn_providers_test.go +++ b/backend/internal/service/cn_providers_test.go @@ -834,6 +834,16 @@ func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) { require.False(t, gjson.GetBytes(adaptiveNormalized, "store").Bool()) require.False(t, gjson.GetBytes(adaptiveNormalized, "previous_response_id").Exists()) + mediaBody := []byte(`{"model":"deepseek-v4.1-flash","store":true,"input":[{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},{"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}]}`) + mediaNormalized := normalizeDeepSeekResponsesRequestBody(deepseekResponses, mediaBody) + require.False(t, gjson.GetBytes(mediaNormalized, "store").Bool()) + require.Equal(t, gjson.String, gjson.GetBytes(mediaNormalized, "input.1.output").Type) + require.NotContains(t, gjson.GetBytes(mediaNormalized, "input.1.output").String(), "data:image/png") + require.Equal(t, "message", gjson.GetBytes(mediaNormalized, "input.2.type").String()) + require.Equal(t, "user", gjson.GetBytes(mediaNormalized, "input.2.role").String()) + require.Equal(t, "input_image", gjson.GetBytes(mediaNormalized, "input.2.content.1.type").String()) + require.Equal(t, "data:image/png;base64,AQID", gjson.GetBytes(mediaNormalized, "input.2.content.1.image_url").String()) + // 非 responses 协议(deepseek CC 账号)原样返回 deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body))) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 27306e34a486..70c10d104a5b 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -10,6 +10,7 @@ import ( "strings" "unicode/utf8" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" "github.com/gin-gonic/gin" @@ -141,7 +142,25 @@ func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte if stripped, err := sjson.DeleteBytes(normalized, "previous_response_id"); err == nil { normalized = stripped } - return normalized + + var requestBody map[string]any + if err := decodeOpenAIJSONUseNumber(normalized, &requestBody); err != nil { + return normalized + } + input, exists := requestBody["input"] + if !exists { + return normalized + } + liftedInput, changed := apicompat.LiftResponsesToolOutputMedia(input) + if !changed { + return normalized + } + requestBody["input"] = liftedInput + rebuilt, err := marshalOpenAIUpstreamJSON(requestBody) + if err != nil { + return normalized + } + return rebuilt } func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool { From aba34524f1d52c4f1b9fb59886fa6f55d70ef3ee Mon Sep 17 00:00:00 2001 From: Cai Date: Thu, 17 Sep 2026 04:14:14 +0900 Subject: [PATCH 022/137] feat: support native Seedance Ark video task API --- backend/internal/handler/endpoint.go | 3 + backend/internal/handler/grok_media.go | 45 +++-- .../internal/handler/grok_media_slots_test.go | 27 ++- backend/internal/handler/seedance.go | 63 +++++++ backend/internal/handler/seedance_test.go | 97 +++++++++++ backend/internal/server/routes/gateway.go | 5 + .../routes/gateway_model_allowlist_test.go | 4 + .../internal/server/routes/seedance_test.go | 34 ++++ backend/internal/service/account.go | 5 + backend/internal/service/grok_media.go | 13 +- backend/internal/service/seedance.go | 154 ++++++++++++++++++ backend/internal/service/seedance_test.go | 112 +++++++++++++ docs/seedance-api.md | 45 +++++ .../account/BulkEditAccountModal.vue | 9 +- .../components/account/CreateAccountModal.vue | 9 +- .../components/account/EditAccountModal.vue | 11 +- .../__tests__/BulkEditAccountModal.spec.ts | 12 ++ .../__tests__/EditAccountModal.spec.ts | 13 ++ frontend/src/types/index.ts | 2 +- 19 files changed, 631 insertions(+), 32 deletions(-) create mode 100644 backend/internal/handler/seedance.go create mode 100644 backend/internal/handler/seedance_test.go create mode 100644 backend/internal/server/routes/seedance_test.go create mode 100644 backend/internal/service/seedance.go create mode 100644 backend/internal/service/seedance_test.go create mode 100644 docs/seedance-api.md diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index c1bde61d631c..2115c2d7c010 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -29,6 +29,7 @@ const ( EndpointVideosEdits = "/v1/videos/edits" EndpointVideosExtensions = "/v1/videos/extensions" EndpointVideos = "/v1/videos" + EndpointSeedanceTasks = "/api/v3/contents/generations/tasks" EndpointGeminiModels = "/v1beta/models" ) @@ -81,6 +82,8 @@ const ( func NormalizeInboundEndpoint(path string) string { path = strings.TrimSpace(path) switch { + case strings.Contains(path, "/contents/generations/tasks"): + return EndpointSeedanceTasks case strings.Contains(path, EndpointResponsesInputTokens) || isResponsesInputTokensAliasPath(path): return EndpointResponsesInputTokens case strings.Contains(path, EndpointEmbeddings): diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index e5ab1dd04c1b..b61cd9515b96 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -52,6 +52,12 @@ func (h *OpenAIGatewayHandler) GrokVideoContent(c *gin.Context) { } func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.GrokMediaEndpoint, requestID string) { + platform := service.PlatformGrok + noAccountCode, noAccountMessage := "grok_media_no_eligible_account", "No eligible Grok media accounts" + if endpoint.IsSeedance() { + platform = service.PlatformOpenAI + noAccountCode, noAccountMessage = "seedance_no_eligible_account", "No eligible Seedance accounts" + } streamStarted := false defer h.recoverResponsesPanic(c, &streamStarted) @@ -99,6 +105,13 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. contentType := c.GetHeader("Content-Type") requestInfo := service.ParseGrokMediaRequest(contentType, body) + if endpoint == service.SeedanceEndpointCreate { + requestInfo, err = service.ParseSeedanceRequest(body) + if err != nil { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } + } requestModel := requestInfo.Model routingModel := service.NormalizeGrokMediaModelForEndpoint(endpoint, requestModel, requestInfo.HasInputImage()) if endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) == "" { @@ -215,8 +228,8 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. var selection *service.AccountSelectionResult var scheduleDecision service.OpenAIAccountScheduleDecision if boundLookupAccountID > 0 { - selection, scheduleDecision, err = h.gatewayService.SelectGrokMediaVideoRequestAccount( - requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel, + selection, scheduleDecision, err = h.gatewayService.SelectMediaVideoRequestAccount( + requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel, platform, ) } else { selection, scheduleDecision, err = h.gatewayService.SelectAccountWithSchedulerForCapability( @@ -231,7 +244,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. false, false, false, - service.PlatformGrok, + platform, ) } // Own an eagerly acquired slot before any rejection or eligibility probe. @@ -256,11 +269,11 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. if endpoint.IsGenerationRequest() && errors.Is(err, service.ErrNoAvailableAccounts) && (len(failedAccountIDs) == 0 || (mediaEligibilityRejected && lastFailoverErr == nil)) { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) - h.errorResponse(c, http.StatusServiceUnavailable, "grok_media_no_eligible_account", "No eligible Grok media accounts") + h.errorResponse(c, http.StatusServiceUnavailable, noAccountCode, noAccountMessage) return } if len(failedAccountIDs) == 0 { - cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, service.PlatformGrok) + cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, platform) if !cls.ModelNotFound { markOpsRoutingCapacityLimitedIfNoAvailable(c, err) } @@ -277,10 +290,10 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. if selection == nil || selection.Account == nil { if endpoint.IsGenerationRequest() { markOpsRoutingCapacityLimited(c) - h.errorResponse(c, http.StatusServiceUnavailable, "grok_media_no_eligible_account", "No eligible Grok media accounts") + h.errorResponse(c, http.StatusServiceUnavailable, noAccountCode, noAccountMessage) return } - cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, service.PlatformGrok) + cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, platform) if !cls.ModelNotFound { markOpsRoutingCapacityLimited(c) } @@ -309,7 +322,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. ) account := selection.Account - if endpoint.IsGenerationRequest() { + if endpoint.IsGenerationRequest() && !endpoint.IsSeedance() { eligible, eligibilityReason, eligibilityErr := h.ensureGrokMediaAccountEligibility(requestCtx, account) if !eligible { releaseAccount() @@ -360,6 +373,9 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. writerSizeBeforeForward := c.Writer.Size() result, err := func() (*service.OpenAIForwardResult, error) { defer releaseAccount() + if endpoint.IsSeedance() { + return h.gatewayService.ForwardSeedance(requestCtx, c, account, endpoint, requestID, body) + } return h.gatewayService.ForwardGrokMedia(requestCtx, c, account, endpoint, requestID, body, contentType) }() @@ -490,7 +506,11 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. } // Status poll OR content download can observe official done+video.url. // Both paths share the same claim key so the customer is charged once. - if endpoint == service.GrokMediaEndpointVideoStatus || endpoint == service.GrokMediaEndpointVideoContent { + if endpoint == service.SeedanceEndpointStatus { + if billResult := prepareSeedanceCompletionBilling(requestCtx, h, apiKey, subject, requestID, result); billResult != nil { + recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, requestID) + } + } else if endpoint == service.GrokMediaEndpointVideoStatus || endpoint == service.GrokMediaEndpointVideoContent { taskID := strings.TrimSpace(requestID) if billResult := prepareGrokVideoCompletionBilling(requestCtx, h, reqLog, apiKey, subject, taskID, result); billResult != nil { recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, taskID) @@ -521,6 +541,9 @@ func (h *OpenAIGatewayHandler) ensureGrokMediaAccountEligibility(ctx context.Con } func grokMediaRequiredCapability(endpoint service.GrokMediaEndpoint) service.OpenAIEndpointCapability { + if endpoint.IsSeedance() { + return service.OpenAIEndpointCapabilitySeedance + } if endpoint.IsGenerationRequest() { return service.OpenAIEndpointCapabilityGrokMediaGeneration } @@ -539,7 +562,7 @@ func grokMediaScheduleModel(account *service.Account, routingModel string, resul func isGrokVideoCreateEndpoint(endpoint service.GrokMediaEndpoint) bool { switch endpoint { - case service.GrokMediaEndpointVideosGenerations, + case service.SeedanceEndpointCreate, service.GrokMediaEndpointVideosGenerations, service.GrokMediaEndpointVideosEdits, service.GrokMediaEndpointVideosExtensions: return true @@ -712,7 +735,7 @@ func recordGrokMediaUsage( } // Async video: force durable task request id and release claim if billing fails. videoTaskID := "" - if result != nil && result.VideoCount > 0 { + if result != nil && (result.VideoCount > 0 || strings.HasPrefix(result.ResponseID, "seedance:")) { videoTaskID = strings.TrimSpace(firstNonEmptyString(requestID, result.ResponseID)) if stable := service.StableGrokVideoBillingRequestID(firstNonEmptyString(result.ResponseID, requestID)); stable != "" { result.RequestID = stable diff --git a/backend/internal/handler/grok_media_slots_test.go b/backend/internal/handler/grok_media_slots_test.go index 3393e1834483..81f14071dafd 100644 --- a/backend/internal/handler/grok_media_slots_test.go +++ b/backend/internal/handler/grok_media_slots_test.go @@ -120,10 +120,22 @@ func (s *grokMediaSlotsCache) assertReleased(t *testing.T) { type grokMediaSlotBindings struct { testutil.StubGatewayCache - owner int64 - writes int - key string - billed map[string]bool + owner int64 + writes int + key string + billed map[string]bool + pending map[string][]byte +} + +func (s *grokMediaSlotBindings) SetGrokVideoPendingBilling(_ context.Context, key string, body []byte, _ time.Duration) error { + if s.pending == nil { + s.pending = make(map[string][]byte) + } + s.pending[key] = append([]byte(nil), body...) + return nil +} +func (s *grokMediaSlotBindings) GetGrokVideoPendingBilling(_ context.Context, key string) ([]byte, error) { + return s.pending[key], nil } func (s *grokMediaSlotBindings) GetSessionAccountID(_ context.Context, groupID int64, key string) (int64, error) { @@ -186,7 +198,7 @@ func (p grokMediaSlotProber) ProbeMediaEligibility(ctx context.Context, id int64 return p(ctx, id) } -func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool) (*OpenAIGatewayHandler, *grokMediaSlotsCache, *grokMediaSlotBindings, *grokMediaSlotUpstream) { +func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool, platforms ...string) (*OpenAIGatewayHandler, *grokMediaSlotsCache, *grokMediaSlotBindings, *grokMediaSlotUpstream) { t.Helper() gin.SetMode(gin.TestMode) accounts := make([]service.Account, 3) @@ -194,6 +206,11 @@ func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool) (*OpenAIGateway accounts[i] = service.Account{ID: int64(i + 1), Platform: service.PlatformGrok, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, Concurrency: 50, Priority: i, GroupIDs: []int64{24}, Credentials: map[string]any{"api_key": "test-key", "access_token": "test-token"}} + if len(platforms) > 0 { + accounts[i].Platform = platforms[0] + accounts[i].Credentials["base_url"] = "https://ark.cn-beijing.volces.com/api/v3" + accounts[i].Credentials["openai_capabilities"] = []string{"seedance"} + } if oauth { accounts[i].Type = service.AccountTypeOAuth accounts[i].Credentials["refresh_token"] = "test-refresh" diff --git a/backend/internal/handler/seedance.go b/backend/internal/handler/seedance.go new file mode 100644 index 000000000000..ce76a43fc987 --- /dev/null +++ b/backend/internal/handler/seedance.go @@ -0,0 +1,63 @@ +package handler + +import ( + "context" + "mime" + "net/http" + "time" + + middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +// SeedanceTasks exposes Ark's native asynchronous video task protocol. +func (h *OpenAIGatewayHandler) SeedanceTasks(c *gin.Context) { + if c.Request.Method == http.MethodPost && c.GetHeader("Content-Type") != "" { + mediaType, _, err := mime.ParseMediaType(c.GetHeader("Content-Type")) + if err != nil || mediaType != "application/json" { + h.errorResponse(c, http.StatusUnsupportedMediaType, "invalid_request_error", "Seedance requires application/json") + return + } + } + key, ok := middleware.GetAPIKeyFromContext(c) + if !ok || key.Group == nil || (key.Group.Platform != service.PlatformOpenAI && key.Group.Platform != service.PlatformComposite) { + h.errorResponse(c, http.StatusForbidden, "permission_error", "Seedance requires an OpenAI or composite group") + return + } + endpoint := service.SeedanceEndpointCreate + setActualUpstreamEndpoint(c, EndpointSeedanceTasks) + taskID := "" + if c.Request.Method != http.MethodPost { + taskID = service.SeedanceTaskKey(c.Param("task_id")) + endpoint = service.SeedanceEndpointStatus + if c.Request.Method == http.MethodDelete { + endpoint = service.SeedanceEndpointDelete + } + } + h.handleGrokMedia(c, endpoint, taskID) +} + +// Ark reports actual completion tokens. Never infer tokens from duration or use +// Grok's per-second video tariff. Repeated polls share the durable task dedup key. +func prepareSeedanceCompletionBilling(ctx context.Context, h *OpenAIGatewayHandler, key *service.APIKey, subject middleware.AuthSubject, taskID string, result *service.OpenAIForwardResult) *service.OpenAIForwardResult { + if result == nil || result.Usage.OutputTokens <= 0 { + return nil + } + pending, err := h.gatewayService.LoadGrokVideoPendingBilling(ctx, taskID, subject.UserID, key.ID) + if err != nil || pending == nil { + return nil + } + claimed, err := h.gatewayService.ClaimGrokVideoBilling(ctx, taskID, subject.UserID, key.ID) + if err != nil || !claimed { + return nil + } + merged := *result + merged.Model = pending.Model + merged.BillingModel = firstNonEmptyString(pending.BillingModel, pending.Model) + merged.UpstreamModel = firstNonEmptyString(pending.UpstreamModel, result.UpstreamModel) + merged.RequestID = service.StableGrokVideoBillingRequestID(taskID) + merged.ResponseID = taskID + merged.Duration = service.GrokVideoE2EDuration(pending.CreatedAt, time.Now()) + return &merged +} diff --git a/backend/internal/handler/seedance_test.go b/backend/internal/handler/seedance_test.go new file mode 100644 index 000000000000..9ffbecdedafc --- /dev/null +++ b/backend/internal/handler/seedance_test.go @@ -0,0 +1,97 @@ +//go:build unit + +package handler + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSeedanceHandlerLifecycleAndOwnership(t *testing.T) { + h, slots, bindings, upstream := newGrokMediaSlotHandler(t, false, false, service.PlatformOpenAI) + var owner int64 + upstream.call = func(req *http.Request, id int64) (*http.Response, error) { + body := `{"id":"task-ark","status":"queued"}` + if req.Method == http.MethodPost { + owner = id + } else { + require.Equal(t, owner, id) + } + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil + } + newContext := func(method string) (*gin.Context, *httptest.ResponseRecorder) { + c, w := grokMediaSlotContext(context.Background(), method == http.MethodPost) + key, _ := middleware.GetAPIKeyFromContext(c) + key.Group.Platform = service.PlatformOpenAI + body := "" + if method == http.MethodPost { + body = `{"model":"doubao-seedance","content":[{"type":"text","text":"waves"}]}` + } + c.Request = httptest.NewRequest(method, "/api/v3/contents/generations/tasks", strings.NewReader(body)) + c.Params = gin.Params{{Key: "task_id", Value: "task-ark"}} + return c, w + } + c, w := newContext(http.MethodPost) + h.SeedanceTasks(c) + require.Equal(t, 200, w.Code, w.Body.String()) + require.Positive(t, owner) + require.Len(t, bindings.pending, 1) + slots.assertReleased(t) + for _, method := range []string{http.MethodGet, http.MethodDelete} { + c, w = newContext(method) + h.SeedanceTasks(c) + require.Equal(t, 200, w.Code, w.Body.String()) + slots.assertReleased(t) + } + for _, other := range []string{"user", "key", "group", "task", "provider"} { + c, w = newContext(http.MethodGet) + key, _ := middleware.GetAPIKeyFromContext(c) + switch other { + case "user": + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 11, Concurrency: 5}) + case "key": + key.ID = 21 + case "group": + group := int64(25) + key.GroupID = &group + case "task": + c.Params = gin.Params{{Key: "task_id", Value: "other"}} + case "provider": + c.Params = gin.Params{{Key: "request_id", Value: "task-ark"}} + } + before := upstream.calls + if other == "provider" { + h.GrokVideoStatus(c) + } else { + h.SeedanceTasks(c) + } + require.Equal(t, 404, w.Code, other+": "+w.Body.String()) + require.Equal(t, before, upstream.calls) + slots.assertReleased(t) + } + c, _ = newContext(http.MethodGet) + key, _ := middleware.GetAPIKeyFromContext(c) + subject, _ := middleware.GetAuthSubjectFromContext(c) + result := &service.OpenAIForwardResult{Usage: service.OpenAIUsage{OutputTokens: 12345}, ResponseID: "seedance:task-ark"} + for i := range 20 { + billed := prepareSeedanceCompletionBilling(context.Background(), h, key, subject, result.ResponseID, result) + if i == 0 { + require.NotNil(t, billed) + require.Equal(t, "doubao-seedance", billed.BillingModel) + require.Equal(t, 12345, billed.Usage.OutputTokens) + require.Zero(t, billed.VideoCount) + } else { + require.Nil(t, billed) + } + } + require.Len(t, bindings.billed, 1) +} diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index bd6e5e7ebe82..86f605993a13 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -368,6 +368,11 @@ func RegisterGatewayRoutes( rootRoute := func(method, path string, limit gin.HandlerFunc, handler gin.HandlerFunc) { r.Handle(method, path, limit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic, handler) } + for _, prefix := range []string{"/api/v3", "/v3", "/v1", ""} { + rootRoute(http.MethodPost, prefix+"/contents/generations/tasks", bodyLimit, h.OpenAIGateway.SeedanceTasks) + rootRoute(http.MethodGet, prefix+"/contents/generations/tasks/:task_id", bodyLimit, h.OpenAIGateway.SeedanceTasks) + rootRoute(http.MethodDelete, prefix+"/contents/generations/tasks/:task_id", bodyLimit, h.OpenAIGateway.SeedanceTasks) + } rootRoute(http.MethodPost, "/responses", bodyLimit, responsesHandler) rootRoute(http.MethodPost, "/responses/*subpath", bodyLimit, guardResponsesSubpath(responsesHandler)) rootRoute(http.MethodPost, "/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch) diff --git a/backend/internal/server/routes/gateway_model_allowlist_test.go b/backend/internal/server/routes/gateway_model_allowlist_test.go index 1fd154d709f6..85c509934279 100644 --- a/backend/internal/server/routes/gateway_model_allowlist_test.go +++ b/backend/internal/server/routes/gateway_model_allowlist_test.go @@ -157,6 +157,10 @@ func TestGatewayRoutesGroupModelAllowlistCoversRootAliasRoutes(t *testing.T) { {http.MethodPost, "/v1/embeddings", `{"model":"gpt-4.1","input":"hi"}`}, {http.MethodPost, "/v1/images/generations", `{"model":"gpt-4.1"}`}, {http.MethodPost, "/v1/videos/generations", `{"model":"gpt-4.1"}`}, + {http.MethodPost, "/api/v3/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`}, + {http.MethodPost, "/v3/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`}, + {http.MethodPost, "/v1/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`}, + {http.MethodPost, "/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`}, {http.MethodPost, "/v1/live", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`}, {http.MethodPost, "/backend-api/codex/responses", `{"model":"gpt-4.1"}`}, {http.MethodPost, "/backend-api/codex/realtime/calls", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`}, diff --git a/backend/internal/server/routes/seedance_test.go b/backend/internal/server/routes/seedance_test.go new file mode 100644 index 000000000000..301013f01abf --- /dev/null +++ b/backend/internal/server/routes/seedance_test.go @@ -0,0 +1,34 @@ +package routes + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestSeedanceNativeRoutes(t *testing.T) { + router := newGatewayRoutesTestRouter() + for _, prefix := range []string{"/api/v3", "/v3", "/v1", ""} { + for _, method := range []string{http.MethodPost, http.MethodGet, http.MethodDelete} { + path := prefix + "/contents/generations/tasks" + if method != http.MethodPost { + path += "/task-1" + } + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(method, path, strings.NewReader(`{"model":"seedance","content":[{"type":"text","text":"waves"}]}`))) + require.NotEqual(t, http.StatusNotFound, w.Code, method+" "+path) + } + } +} + +func TestSeedanceRejectsOtherPlatforms(t *testing.T) { + for _, platform := range []string{service.PlatformGrok, service.PlatformAnthropic, service.PlatformGemini} { + w := httptest.NewRecorder() + newGatewayRoutesTestRouter(platform).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v3/contents/generations/tasks", strings.NewReader(`{"model":"seedance","content":[{}]}`))) + require.Equal(t, http.StatusForbidden, w.Code) + } +} diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 484e3841b405..278d359c6578 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1828,6 +1828,11 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa if a == nil { return false } + if capability == OpenAIEndpointCapabilitySeedance { + configured, _ := a.openAIEndpointCapabilitySet() + return configured["seedance"] && a.Platform == PlatformOpenAI && a.Type == AccountTypeAPIKey && + strings.TrimSpace(a.GetCredential("base_url")) != "" + } if capability == "" { return true } diff --git a/backend/internal/service/grok_media.go b/backend/internal/service/grok_media.go index 24b4dcb1b33f..b73972ae58f4 100644 --- a/backend/internal/service/grok_media.go +++ b/backend/internal/service/grok_media.go @@ -41,10 +41,13 @@ func (e GrokMediaEndpoint) RequiresRequestBody() bool { } func (e GrokMediaEndpoint) IsVideoLookupRequest() bool { - return e == GrokMediaEndpointVideoStatus || e == GrokMediaEndpointVideoContent + return e == GrokMediaEndpointVideoStatus || e == GrokMediaEndpointVideoContent || e == SeedanceEndpointStatus || e == SeedanceEndpointDelete } func (e GrokMediaEndpoint) IsGenerationRequest() bool { + if e == SeedanceEndpointCreate { + return true + } switch e { case GrokMediaEndpointImagesGenerations, GrokMediaEndpointImagesEdits, GrokMediaEndpointVideosGenerations, GrokMediaEndpointVideosEdits, GrokMediaEndpointVideosExtensions: return true @@ -337,6 +340,12 @@ func (s *OpenAIGatewayService) ResolveGrokMediaVideoRequestAccount( // the ownership key; video lookups must neither escape nor refresh that key. func (s *OpenAIGatewayService) SelectGrokMediaVideoRequestAccount( ctx context.Context, groupID *int64, sessionHash string, accountID int64, requestedModel string, +) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { + return s.SelectMediaVideoRequestAccount(ctx, groupID, sessionHash, accountID, requestedModel, PlatformGrok) +} + +func (s *OpenAIGatewayService) SelectMediaVideoRequestAccount( + ctx context.Context, groupID *int64, sessionHash string, accountID int64, requestedModel, platform string, ) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { decision := OpenAIAccountScheduleDecision{Layer: openAIAccountScheduleLayerSessionSticky} if accountID <= 0 || strings.TrimSpace(sessionHash) == "" { @@ -345,7 +354,7 @@ func (s *OpenAIGatewayService) SelectGrokMediaVideoRequestAccount( ctx = s.withOpenAIGroupPrivacyRequirement(WithOpenAIProfitControlSuppressed(ctx), groupID) scheduler := &defaultOpenAIAccountScheduler{service: s} selection, _, err := scheduler.selectBySessionHash(ctx, OpenAIAccountScheduleRequest{ - GroupID: groupID, Platform: PlatformGrok, SessionHash: sessionHash, + GroupID: groupID, Platform: platform, SessionHash: sessionHash, StickyAccountID: accountID, PreserveStickyBinding: true, DisableStickyEscape: true, RequestedModel: requestedModel, RequiredTransport: OpenAIUpstreamTransportHTTPSSE, RequirePrivacySet: s.openAIGroupRequiresPrivacySet(ctx, groupID), diff --git a/backend/internal/service/seedance.go b/backend/internal/service/seedance.go new file mode 100644 index 000000000000..faa83da49eb0 --- /dev/null +++ b/backend/internal/service/seedance.go @@ -0,0 +1,154 @@ +package service + +import ( + "bytes" + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +const ( + SeedanceEndpointCreate GrokMediaEndpoint = "seedance_create" + SeedanceEndpointStatus GrokMediaEndpoint = "seedance_status" + SeedanceEndpointDelete GrokMediaEndpoint = "seedance_delete" + OpenAIEndpointCapabilitySeedance OpenAIEndpointCapability = "seedance" +) + +func (e GrokMediaEndpoint) IsSeedance() bool { + return e == SeedanceEndpointCreate || e == SeedanceEndpointStatus || e == SeedanceEndpointDelete +} + +// SeedanceTaskKey isolates ownership and billing keys from other video providers. +func SeedanceTaskKey(id string) string { return "seedance:" + strings.TrimSpace(id) } + +func ParseSeedanceRequest(body []byte) (GrokMediaRequestInfo, error) { + var info GrokMediaRequestInfo + if !gjson.ValidBytes(body) || !gjson.ParseBytes(body).IsObject() { + return info, fmt.Errorf("request body must be a JSON object") + } + model := gjson.GetBytes(body, "model") + if model.Type != gjson.String || strings.TrimSpace(model.String()) == "" { + return info, fmt.Errorf("model is required") + } + content := gjson.GetBytes(body, "content") + if !content.IsArray() || len(content.Array()) == 0 { + return info, fmt.Errorf("content must be a non-empty array") + } + info.Model = strings.TrimSpace(model.String()) + var texts []string + for _, item := range content.Array() { + switch item.Get("type").String() { + case "text": + texts = append(texts, item.Get("text").String()) + case "image_url": + info.InputImageURLs = append(info.InputImageURLs, item.Get("image_url.url").String()) + } + } + info.Prompt = strings.Join(texts, "\n") + return info, nil +} + +func buildSeedanceURL(base string, endpoint GrokMediaEndpoint, taskID string) (string, error) { + base = strings.TrimRight(base, "/") + // Accept an origin, a proxy prefix, or the full Ark API base. + if !strings.HasSuffix(base, "/api/v3") && !strings.HasSuffix(base, "/v3") { + base += "/api/v3" + } + base += "/contents/generations/tasks" + if endpoint != SeedanceEndpointCreate { + if err := validateUpstreamPathSegment("Seedance task ID", taskID); err != nil || strings.TrimSpace(taskID) == "" { + return "", fmt.Errorf("invalid Seedance task ID") + } + base += "/" + taskID + } + return base, nil +} + +// ForwardSeedance preserves the Ark protocol, including multimodal content and +// future fields. Only model is rewritten using the account's configured mapping. +func (s *OpenAIGatewayService) ForwardSeedance(ctx context.Context, c *gin.Context, account *Account, endpoint GrokMediaEndpoint, taskID string, body []byte) (*OpenAIForwardResult, error) { + if !account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance) || !endpoint.IsSeedance() { + return nil, fmt.Errorf("Seedance requires an OpenAI API key account with a custom base URL") + } + base, err := s.validateUpstreamBaseURL(account.GetCredential("base_url")) + if err != nil { + return nil, err + } + target, err := buildSeedanceURL(base, endpoint, strings.TrimPrefix(taskID, "seedance:")) + if err != nil { + return nil, err + } + model, upstreamModel := "", "" + method := http.MethodGet + if endpoint == SeedanceEndpointCreate { + info, parseErr := ParseSeedanceRequest(body) + if parseErr != nil { + return nil, parseErr + } + model = info.Model + upstreamModel = account.GetMappedModel(model) + body, err = sjson.SetBytes(body, "model", upstreamModel) + if err != nil { + return nil, err + } + method = http.MethodPost + } else if endpoint == SeedanceEndpointDelete { + method = http.MethodDelete + } + token := strings.TrimSpace(account.GetCredential("api_key")) + if token == "" { + return nil, fmt.Errorf("Seedance account missing api_key") + } + started := time.Now() + req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + account.ApplyHeaderOverrides(req.Header) + proxy := "" + if account.ProxyID != nil && account.Proxy != nil { + proxy = account.Proxy.URL() + } + resp, err := s.httpUpstream.Do(req, proxy, account.ID, account.Concurrency) + SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(started).Milliseconds()) + if err != nil { + return nil, err + } + defer resp.Body.Close() + responseBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) + if err != nil { + return nil, err + } + // Do not retry ambiguous asynchronous creates: the upstream may already have + // accepted a billable job. Preserve native error codes and response bodies. + if resp.StatusCode >= 300 { + writeGrokMediaResponse(c, resp, responseBody, s.responseHeaderFilter) + return nil, fmt.Errorf("Seedance upstream status %d", resp.StatusCode) + } + result := &OpenAIForwardResult{Model: model, BillingModel: model, UpstreamModel: upstreamModel, Duration: time.Since(started), ResponseHeaders: resp.Header.Clone()} + if endpoint == SeedanceEndpointCreate { + id := strings.TrimSpace(gjson.GetBytes(responseBody, "id").String()) + if id == "" { + return nil, fmt.Errorf("Seedance create response missing task ID") + } + result.ResponseID = SeedanceTaskKey(id) + } + if endpoint == SeedanceEndpointStatus { + result.ResponseID = taskID + result.UpstreamModel = gjson.GetBytes(responseBody, "model").String() + if gjson.GetBytes(responseBody, "status").String() == "succeeded" { + result.Usage.OutputTokens = max(0, int(gjson.GetBytes(responseBody, "usage.completion_tokens").Int())) + } + } + writeGrokMediaResponse(c, resp, responseBody, s.responseHeaderFilter) + return result, nil +} diff --git a/backend/internal/service/seedance_test.go b/backend/internal/service/seedance_test.go new file mode 100644 index 000000000000..cc9504b1ebce --- /dev/null +++ b/backend/internal/service/seedance_test.go @@ -0,0 +1,112 @@ +//go:build unit + +package service + +import ( + "context" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func seedanceTestAccount() *Account { + return &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "api_key": "ark-secret", "base_url": "https://ark.cn-beijing.volces.com/api/v3", + "openai_capabilities": []string{"seedance"}, + "model_mapping": map[string]any{"video": "ep-seedance"}, + }} +} + +func TestSeedanceNativeForwarding(t *testing.T) { + body := []byte(`{"model":"video","content":[{"type":"text","text":"waves"},{"type":"image_url","image_url":{"url":"https://example.com/first.png"},"role":"first_frame"},{"type":"audio_url","audio_url":{"url":"https://example.com/audio.mp3"}}],"duration":-1,"generate_audio":true,"future_field":{"keep":true}}`) + upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(`{"id":"task-1"}`)} + svc := &OpenAIGatewayService{httpUpstream: upstream} + c, w := grokMediaContentTestContext(http.MethodPost, "/api/v3/contents/generations/tasks", nil) + result, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointCreate, "", body) + require.NoError(t, err) + require.JSONEq(t, `{"id":"task-1"}`, w.Body.String()) + require.Equal(t, "seedance:task-1", result.ResponseID) + require.Zero(t, result.Usage.OutputTokens) + require.Equal(t, "video", result.BillingModel) + require.Equal(t, "ep-seedance", result.UpstreamModel) + require.Equal(t, "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks", upstream.request.URL.String()) + require.Equal(t, "Bearer ark-secret", upstream.request.Header.Get("Authorization")) + forwarded, err := io.ReadAll(upstream.request.Body) + require.NoError(t, err) + require.Equal(t, "ep-seedance", gjson.GetBytes(forwarded, "model").String()) + for _, field := range []string{"content", "duration", "generate_audio", "future_field"} { + require.Equal(t, gjson.GetBytes(body, field).Raw, gjson.GetBytes(forwarded, field).Raw) + } +} + +func TestSeedanceStatusAndDelete(t *testing.T) { + for _, status := range []string{"queued", "running", "failed", "cancelled", "expired", "succeeded"} { + t.Run(status, func(t *testing.T) { + body := `{"id":"task-1","status":"` + status + `","model":"ep-seedance","content":{"video_url":"https://cdn.example/video.mp4"},"usage":{"completion_tokens":12345}}` + upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(body)} + svc := &OpenAIGatewayService{httpUpstream: upstream} + c, w := grokMediaContentTestContext(http.MethodGet, "/api/v3/contents/generations/tasks/task-1", nil) + result, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointStatus, "seedance:task-1", nil) + require.NoError(t, err) + require.JSONEq(t, body, w.Body.String()) + require.Equal(t, "/api/v3/contents/generations/tasks/task-1", upstream.request.URL.Path) + if status == "succeeded" { + require.Equal(t, 12345, result.Usage.OutputTokens) + } else { + require.Zero(t, result.Usage.OutputTokens) + } + require.Zero(t, result.VideoCount, "must use token billing, not Grok seconds") + }) + } + upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse("")} + upstream.response.StatusCode = http.StatusNoContent + svc := &OpenAIGatewayService{httpUpstream: upstream} + c, w := grokMediaContentTestContext(http.MethodDelete, "/api/v3/contents/generations/tasks/task-1", nil) + _, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointDelete, "seedance:task-1", nil) + require.NoError(t, err) + require.Equal(t, http.MethodDelete, upstream.request.Method) + require.Equal(t, http.StatusNoContent, w.Code) +} + +func TestSeedanceValidationAndCapability(t *testing.T) { + for _, body := range []string{`{`, `[]`, `{}`, `{"model":12,"content":[{}]}`, `{"model":"x","content":[]}`} { + _, err := ParseSeedanceRequest([]byte(body)) + require.Error(t, err) + } + info, err := ParseSeedanceRequest([]byte(`{"model":"x","content":[{"type":"text","text":"first"},{"type":"text","text":"second"},{"type":"image_url","image_url":{"url":"https://example.com/a.png"}}]}`)) + require.NoError(t, err) + require.Contains(t, string(info.ModerationBody()), "first") + require.Contains(t, string(info.ModerationBody()), "second") + require.Contains(t, string(info.ModerationBody()), "https://example.com/a.png") + for _, id := range []string{"", "..", "a/b", "a?b", "a#b", "%2e%2e"} { + _, err := buildSeedanceURL("https://example.com", SeedanceEndpointStatus, id) + require.Error(t, err, id) + } + for _, base := range []string{"https://example.com", "https://example.com/api/v3/", "https://example.com/v3"} { + url, err := buildSeedanceURL(base, SeedanceEndpointCreate, "") + require.NoError(t, err) + require.NotContains(t, url, "/v3/api/v3") + } + a := seedanceTestAccount() + require.True(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance)) + a.Type = AccountTypeOAuth + require.False(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance)) + a.Type = AccountTypeAPIKey + delete(a.Credentials, "openai_capabilities") + require.False(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance)) +} + +func TestSeedancePreservesUpstreamErrorsWithoutRetry(t *testing.T) { + upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(`{"error":{"code":"QuotaExceeded","message":"quota exhausted"}}`)} + upstream.response.StatusCode = 429 + svc := &OpenAIGatewayService{httpUpstream: upstream} + c, w := grokMediaContentTestContext(http.MethodPost, "/api/v3/contents/generations/tasks", nil) + _, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointCreate, "", []byte(`{"model":"video","content":[{"type":"text","text":"waves"}]}`)) + require.Error(t, err) + require.Equal(t, 429, w.Code) + require.Contains(t, w.Body.String(), "QuotaExceeded") + require.Len(t, upstream.requests, 1) +} diff --git a/docs/seedance-api.md b/docs/seedance-api.md new file mode 100644 index 000000000000..4480b16e70a9 --- /dev/null +++ b/docs/seedance-api.md @@ -0,0 +1,45 @@ +# Seedance 原生 API + +支持火山方舟 Ark 的异步视频任务协议,无需把 `content[]` 转换成 OpenAI `messages` 或 Grok `prompt`。 + +## 配置 + +1. 创建 OpenAI 平台的 **API Key** 账号,填写 Ark API Key,Base URL 使用 `https://ark.cn-beijing.volces.com/api/v3`。兼容服务可填写自己的 `/api/v3` 或 `/v3` Base URL。 +2. 在账号的端点能力中勾选 **Seedance (Ark)**。默认不启用,避免请求误调度到其他 OpenAI 账号。支持创建、编辑与批量编辑。 +3. 将账号加入 OpenAI 分组并启用分组的「允许图片生成」媒体权限;合成分组也可路由到这些账号。 +4. 配置模型映射,例如将公开模型名 `seedance-video` 映射到实际 `doubao-seedance-*` 模型或 `ep-*` 推理接入点。配置对应模型的输出 token 价格;本接口不使用 Grok 的按秒视频价格。 + +## 调用 + +```bash +curl "$SUB2API_BASE_URL/api/v3/contents/generations/tasks" \ + -H "Authorization: Bearer $SUB2API_KEY" \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "seedance-video", + "content": [{"type": "text", "text": "海浪轻轻拍打沙滩"}], + "duration": 5, + "resolution": "720p", + "ratio": "16:9", + "generate_audio": true + }' + +# 使用创建响应中的原生 id 查询,直至 succeeded / failed / cancelled 等终态。 +curl "$SUB2API_BASE_URL/api/v3/contents/generations/tasks/$TASK_ID" \ + -H "Authorization: Bearer $SUB2API_KEY" + +curl -X DELETE "$SUB2API_BASE_URL/api/v3/contents/generations/tasks/$TASK_ID" \ + -H "Authorization: Bearer $SUB2API_KEY" +``` + +亦支持 `/v3`、`/v1` 和无版本前缀别名。Ark SDK 的 Base URL 可改为 `$SUB2API_BASE_URL/api/v3`。文本、图片、视频、音频内容、角色及扩展参数原样传递,仅按账号配置改写模型名;响应保持上游原生格式。 + +## 任务与计费 + +- 查询和删除只能访问同一用户、同一 API Key、同一分组创建的任务,并始终使用原提交账号;不会转到其他账号查询。 +- 创建时不扣 token 用量。首次查询到 `succeeded` 后,根据上游 `usage.completion_tokens` 计费;重复查询由共享缓存声明和持久化用量去重共同保护。失败、排队及运行中的任务不计费。 +- Redis 保存任务绑定及创建时的模型快照,默认 24 小时。需保留 Redis 状态并在有效期内查询完成结果。当前不会后台轮询;只使用回调而不查询的任务不会自动结算。 +- 不开放上游的任务列表接口,防止共享账号的任务泄露给其他用户。删除遵循上游语义,不自动退款。 +- 异步创建的上游错误不自动重试,以免重复创建付费任务。 + +协议依据:[火山官方 Go SDK](https://github.com/volcengine/volcengine-go-sdk/blob/master/service/arkruntime/model/content_generation.go)、[创建任务文档](https://www.volcengine.com/docs/82379/1520757)、[查询任务文档](https://www.volcengine.com/docs/82379/1521309)。 diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index f66fb85fda42..22f6f3e788e0 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -1786,7 +1786,8 @@ const openAIEndpointCapabilityOptions = computed< Array<{ value: OpenAIEndpointCapability; label: string }> >(() => [ { value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value }, - { value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') } + { value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }, + { value: 'seedance', label: 'Seedance (Ark)' } ]) const openAITextGenerationCapabilityEnabled = computed(() => openAIEndpointCapabilities.value.includes('chat_completions') @@ -1796,9 +1797,9 @@ const openAIResponsesModeApplicable = computed( ) const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => { - const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings'] + const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings', 'seedance'] const selected = allowed.filter((value) => values.includes(value)) - return selected.length > 0 ? selected : allowed + return selected.length > 0 ? selected : ['chat_completions', 'embeddings'] as OpenAIEndpointCapability[] } const toggleOpenAIEndpointCapability = ( @@ -1996,7 +1997,7 @@ const buildUpdatePayload = (): Record | null => { if (applyOpenAIEndpointCapabilities) { credentials.openai_capabilities = - openAIEndpointCapabilities.value.length === 2 + openAIEndpointCapabilities.value.length === 2 && !openAIEndpointCapabilities.value.includes('seedance') ? null : [...openAIEndpointCapabilities.value] credentialsChanged = true diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 54a6b2331f7c..c6616c679f5b 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -4524,16 +4524,17 @@ const openAITextEndpointCapabilityLabel = computed(() => { }) const openAIEndpointCapabilityOptions = computed<{ value: OpenAIEndpointCapability; label: string }[]>(() => [ { value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value }, - { value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') } + { value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }, + { value: 'seedance', label: 'Seedance (Ark)' } ]) const openAITextGenerationCapabilityEnabled = computed(() => openAIEndpointCapabilities.value.includes('chat_completions') ) const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => { - const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings'] + const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings', 'seedance'] const selected = allowed.filter((value) => values.includes(value)) - return selected.length > 0 ? selected : allowed + return selected.length > 0 ? selected : ['chat_completions', 'embeddings'] as OpenAIEndpointCapability[] } const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, event?: Event) => { @@ -4559,7 +4560,7 @@ const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, ev const applyOpenAIEndpointCapabilities = (credentials: Record) => { const capabilities = normalizeOpenAIEndpointCapabilities(openAIEndpointCapabilities.value) - if (capabilities.length === 2) { + if (capabilities.length === 2 && !capabilities.includes('seedance')) { delete credentials.openai_capabilities return } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 0d3c235bea05..4e9bbff7a12a 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -3683,16 +3683,17 @@ const openAITextEndpointCapabilityLabel = computed(() => { }) const openAIEndpointCapabilityOptions = computed<{ value: OpenAIEndpointCapability; label: string }[]>(() => [ { value: 'chat_completions', label: openAITextEndpointCapabilityLabel.value }, - { value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') } + { value: 'embeddings', label: t('admin.accounts.openai.capabilityEmbeddings') }, + { value: 'seedance', label: 'Seedance (Ark)' } ]) const openAITextGenerationCapabilityEnabled = computed(() => openAIEndpointCapabilities.value.includes('chat_completions') ) const normalizeOpenAIEndpointCapabilities = (values: OpenAIEndpointCapability[]) => { - const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings'] + const allowed: OpenAIEndpointCapability[] = ['chat_completions', 'embeddings', 'seedance'] const selected = allowed.filter((value) => values.includes(value)) - return selected.length > 0 ? selected : allowed + return selected.length > 0 ? selected : ['chat_completions', 'embeddings'] as OpenAIEndpointCapability[] } const readOpenAIEndpointCapabilities = (credentials?: Record): OpenAIEndpointCapability[] => { @@ -3700,7 +3701,7 @@ const readOpenAIEndpointCapabilities = (credentials?: Record): if (Array.isArray(raw)) { return normalizeOpenAIEndpointCapabilities( raw.filter((value): value is OpenAIEndpointCapability => - value === 'chat_completions' || value === 'embeddings' + value === 'chat_completions' || value === 'embeddings' || value === 'seedance' ) ) } @@ -3738,7 +3739,7 @@ const toggleOpenAIEndpointCapability = (capability: OpenAIEndpointCapability, ev const applyOpenAIEndpointCapabilities = (credentials: Record) => { const capabilities = normalizeOpenAIEndpointCapabilities(openAIEndpointCapabilities.value) - if (capabilities.length === 2) { + if (capabilities.length === 2 && !capabilities.includes('seedance')) { delete credentials.openai_capabilities return } diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index 817f0a729d20..daae21b9f9cb 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -557,6 +557,18 @@ describe('BulkEditAccountModal', () => { }) }) + it('persists Seedance in a two-capability bulk update', async () => { + const wrapper = mountModal({ selectedPlatforms: ['openai'], selectedTypes: ['apikey'] }) + await wrapper.get('#bulk-edit-openai-endpoint-capabilities-enabled').setValue(true) + await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-embeddings"]').setValue(false) + await wrapper.get('[data-testid="bulk-edit-openai-endpoint-capability-seedance"]').setValue(true) + await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') + await flushPromises() + expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { + credentials: { openai_capabilities: ['chat_completions', 'seedance'] } + }) + }) + it('关闭端点能力修改后 Responses 路由恢复独立可编辑', async () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 6a16c3f22f45..7b104410c1bc 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -1311,6 +1311,19 @@ describe('EditAccountModal', () => { expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.auto_pause_7d_disabled).toBeUndefined() }) + it('preserves Seedance when exactly two endpoint capabilities are selected', async () => { + const account = buildAccount() + account.credentials.openai_capabilities = ['chat_completions', 'seedance'] + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + const wrapper = mountModal(account) + expect(wrapper.get('[data-testid="openai-endpoint-capability-seedance"]').element.checked).toBe(true) + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials?.openai_capabilities).toEqual(['chat_completions', 'seedance']) + }) + it('keeps at least one OpenAI APIKey endpoint capability selected', async () => { const account = buildAccount() updateAccountMock.mockReset() diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 60eb7e58020c..3ae9838133d7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1455,7 +1455,7 @@ export interface CodexUsageSnapshot { export type OpenAICompactMode = 'auto' | 'force_on' | 'force_off' export type OpenAIResponsesMode = 'auto' | 'force_responses' | 'force_chat_completions' -export type OpenAIEndpointCapability = 'chat_completions' | 'embeddings' +export type OpenAIEndpointCapability = 'chat_completions' | 'embeddings' | 'seedance' export interface OpenAICompactState { openai_compact_mode?: OpenAICompactMode From be4a4990ffb960bf797465c01eb448e5a56cf017 Mon Sep 17 00:00:00 2001 From: zhudaguaneren <218366267+zhudaguaneren@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:57:53 +0800 Subject: [PATCH 023/137] fix(antigravity): strip Claude attribution metadata from system prompts --- .gitignore | 1 + .../pkg/antigravity/attribution_test.go | 88 +++++++++++++++++++ .../pkg/antigravity/request_transformer.go | 22 +++++ docs/ANTIGRAVITY_ATTRIBUTION_429.md | 61 +++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 backend/internal/pkg/antigravity/attribution_test.go create mode 100644 docs/ANTIGRAVITY_ATTRIBUTION_429.md diff --git a/.gitignore b/.gitignore index 20ed12275e87..5dafefb36b0e 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,7 @@ deploy/docker-compose.override.yml .gocache/ vite.config.js docs/* +!docs/ANTIGRAVITY_ATTRIBUTION_429.md !docs/PAYMENT.md !docs/PAYMENT_CN.md !docs/ADMIN_PAYMENT_INTEGRATION_API.md diff --git a/backend/internal/pkg/antigravity/attribution_test.go b/backend/internal/pkg/antigravity/attribution_test.go new file mode 100644 index 000000000000..8a1274e95d88 --- /dev/null +++ b/backend/internal/pkg/antigravity/attribution_test.go @@ -0,0 +1,88 @@ +package antigravity + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTransformClaudeToGemini_AttributionSystemText(t *testing.T) { + const attribution = "x-anthropic-billing-header: cc_version=2.1.271.4bf; cc_entrypoint=claude-desktop-3p;" + tests := []struct { + name string + text string + want string + }{ + {"metadata only", attribution, ""}, + {"metadata with newline", attribution + "\n", ""}, + {"leading whitespace", " \t\n" + attribution, ""}, + {"following instructions", attribution + "\nKeep these instructions.", "Keep these instructions."}, + {"CRLF", attribution + "\r\n Keep indentation.\n", " Keep indentation.\n"}, + {"CR", attribution + "\rKeep these instructions.", "Keep these instructions."}, + {"blank lines preserved", attribution + "\n\nKeep these instructions.", "\nKeep these instructions."}, + {"ordinary text", " Keep these instructions.\n", " Keep these instructions.\n"}, + {"no colon", "x-anthropic-billing-header keep", "x-anthropic-billing-header keep"}, + {"different field", "x-anthropic-billing-header-extra: keep", "x-anthropic-billing-header-extra: keep"}, + {"quoted within instructions", "Explain this metadata: " + attribution, "Explain this metadata: " + attribution}, + {"later line", "Example:\n" + attribution, "Example:\n" + attribution}, + } + for _, tt := range tests { + for _, arrayForm := range []bool{false, true} { + name := tt.name + "/string" + var system any = tt.text + if arrayForm { + name = tt.name + "/array" + system = []SystemBlock{{Type: "text", Text: tt.text}, {Type: "text", Text: "Keep the next block."}} + } + t.Run(name, func(t *testing.T) { + systemJSON, err := json.Marshal(system) + require.NoError(t, err) + userJSON, err := json.Marshal(attribution) + require.NoError(t, err) + input := &ClaudeRequest{ + Model: "gemini-3.8-flash-high", System: systemJSON, + Messages: []ClaudeMessage{{Role: "user", Content: userJSON}}, + Tools: []ClaudeTool{{Name: "example", Description: attribution, + InputSchema: map[string]any{"type": "object", "properties": map[string]any{"query": map[string]any{"type": "string"}}}}}, + } + before, err := json.Marshal(input) + require.NoError(t, err) + body, err := TransformClaudeToGeminiWithOptions(input, "test-project", input.Model, TransformOptions{}) + require.NoError(t, err) + var got V1InternalRequest + require.NoError(t, json.Unmarshal(body, &got)) + var texts []string + for _, part := range got.Request.SystemInstruction.Parts { + if part.Text != "\n--- [SYSTEM_PROMPT_END] ---" { + texts = append(texts, part.Text) + } + } + var want []string + if tt.want != "" { + want = append(want, tt.want) + } + if arrayForm { + want = append(want, "Keep the next block.") + } + require.Equal(t, want, texts) + // The same literal text in user content and tool descriptions is not metadata. + require.Equal(t, attribution, got.Request.Contents[0].Parts[0].Text) + require.Equal(t, attribution, got.Request.Tools[0].FunctionDeclarations[0].Description) + after, err := json.Marshal(input) + require.NoError(t, err) + require.JSONEq(t, string(before), string(after), "do not mutate the incoming request") + }) + } + } +} + +func TestTransformClaudeToGemini_AttributionDoesNotHideIdentity(t *testing.T) { + system, err := json.Marshal("x-anthropic-billing-header: cc_version=example;\nYou are Antigravity. Keep this identity.") + require.NoError(t, err) + got := buildSystemInstruction(system, "gemini-3.8-flash-high", DefaultTransformOptions(), nil) + require.Len(t, got.Parts, 1) + require.Equal(t, "You are Antigravity. Keep this identity.", got.Parts[0].Text) + require.False(t, strings.Contains(got.Parts[0].Text, "cc_version=")) +} diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 192c4cda39fd..c4af10dcd85e 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -293,6 +293,26 @@ func filterOpenCodePrompt(text string) string { return "" } +// stripClaudeAttribution removes the leading Claude Code attribution metadata line +// from Antigravity system text. This is prompt metadata, not an HTTP header; it +// can trigger RESOURCE_EXHAUSTED on the Google upstream. Keep this scoped to the +// Antigravity transformer: native Anthropic OAuth may require the attribution. +func stripClaudeAttribution(text string) string { + trimmed := strings.TrimLeft(text, " \t\r\n") + if !strings.HasPrefix(trimmed, "x-anthropic-billing-header:") { + return text + } + end := strings.IndexAny(trimmed, "\r\n") + if end < 0 { + return "" + } + rest := trimmed[end+1:] + if trimmed[end] == '\r' { + rest = strings.TrimPrefix(rest, "\n") + } + return rest +} + // buildSystemInstruction 构建 systemInstruction(与 Antigravity-Manager 保持一致) func buildSystemInstruction(system json.RawMessage, modelName string, opts TransformOptions, tools []ClaudeTool) *GeminiContent { var parts []GeminiPart @@ -305,6 +325,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans // 尝试解析为字符串 var sysStr string if err := json.Unmarshal(system, &sysStr); err == nil { + sysStr = stripClaudeAttribution(sysStr) if strings.TrimSpace(sysStr) != "" { if strings.Contains(sysStr, "You are Antigravity") { userHasAntigravityIdentity = true @@ -320,6 +341,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans var sysBlocks []SystemBlock if err := json.Unmarshal(system, &sysBlocks); err == nil { for _, block := range sysBlocks { + block.Text = stripClaudeAttribution(block.Text) if block.Type == "text" && strings.TrimSpace(block.Text) != "" { if strings.Contains(block.Text, "You are Antigravity") { userHasAntigravityIdentity = true diff --git a/docs/ANTIGRAVITY_ATTRIBUTION_429.md b/docs/ANTIGRAVITY_ATTRIBUTION_429.md new file mode 100644 index 000000000000..1a50a20ebe14 --- /dev/null +++ b/docs/ANTIGRAVITY_ATTRIBUTION_429.md @@ -0,0 +1,61 @@ +# Antigravity:Claude Desktop attribution 元数据触发 429 的排查记录 + +本文记录一次实际问题的定位和修复,适用于 Claude Desktop / Claude Code 经 Anthropic Messages 协议转到 Antigravity 的场景。它不是所有 `429 RESOURCE_EXHAUSTED` 的通用解释:额度耗尽、请求频率、上游容量等仍应独立排查。 + +## 现象与环境 + +- 实测部署为 Sub2API 0.2.0,Claude Desktop 2.110.0,内嵌 Claude Code 2.1.271,CC Switch 3.20.3。 +- 请求链路:Claude Desktop → CC Switch 本地代理 → Sub2API Antigravity → Google 上游;映射模型为 `gemini-3.8-flash-high`。 +- 新对话中的简单问候也返回 `429 RESOURCE_EXHAUSTED`,上游信息为 `Resource has been exhausted (e.g. check quota).`。 +- 随后账号进入模型冷却,单账号组还可能出现 `503 No available accounts`。 +- 独立构造的极简请求成功,但真实客户端请求失败。客户端即使只发送一句问候,也会附带 system 提示词和工具定义。 + +## 定位方法与对照结果 + +保留一次失败请求,在相同账号、模型和上游端点下逐项删减内容。不要仅用手写的短请求判断完整客户端链路已经恢复,也不要在公开 issue / PR 上传原始请求或访问令牌。 + +| 对照项 | 本次观察 | +| --- | --- | +| 原始客户端请求(约 187 KB、106 个工具) | 429 | +| 移除工具定义,保留 system 内容 | 429 | +| 移除 messages 中的 system 角色消息 | 429 | +| 直接请求 Google,保留原始顶层 system 内容 | 429 | +| 同一 Google 请求改用简单 system 内容 | 200 | +| 分别测试 system 文本块 | attribution 元数据块返回 429,其余指令块返回 200 | +| 原始请求仅移除 attribution 元数据块,保留模型、工具及其他内容 | 200,收到完整回复 | + +触发问题的文本形如: + +```text +x-anthropic-billing-header: cc_version=2.1.271.4bf; cc_entrypoint=claude-desktop-3p; +``` + +这里的 “header” 是请求 JSON 的 `system` 文本中的一行,**不是 HTTP 请求头**。因此,不需要在 Claude Desktop 的 “Custom inference headers” 里增删同名字段;也不应通过修改正常工作的 Bearer 认证来处理这个问题。 + +这些对照支持“该元数据文本在本次 Google 请求中触发拒绝”的判断,但不能据此确定 Google 内部策略,也不能推断所有账号或模型都具有相同行为。 + +## 本次修复 + +在 Antigravity 的 Claude → Gemini 请求转换中,移除顶层 `system` 字符串或文本块**开头**的 `x-anthropic-billing-header:` 行: + +- 支持字符串和文本块数组,兼容 LF / CRLF / CR 换行及前导空白。 +- 如果同一文本块后面还有指令,原样保留后续内容;仅元数据的空块不再发送。 +- 不匹配缺少冒号的普通文本、其他字段名,以及正常指令中间引用的相同文本。 +- 不修改用户消息、工具定义、模型映射、认证和账号额度状态。 +- 处理限于 Antigravity 转换器,不修改原生 Anthropic 转发路径。 + +上线前的临时修复采用相同范围的反向代理 JSON 过滤,并通过原始完整请求和真实 Claude Desktop 会话验证成功。本文 PR 将处理放入转换器,避免用户必须自行维护代理脚本。原部署曾尝试降低账号并发,但未解决问题;定位后已恢复原配置。 + +如需在旧版本临时处理,应只对目标 Antigravity Messages 路径修改 JSON 的顶层 system 文本。不要用全局字符串替换,否则可能误改消息、工具参数或代码示例。代理实现还需保持空数组和空对象类型、更新请求体长度,并覆盖实际使用的 token-count 路径。面板重新生成代理配置时也可能覆盖自定义过滤规则。 + +## 不要全局禁用 attribution + +Claude Code 提供 [`CLAUDE_CODE_ATTRIBUTION_HEADER`](https://code.claude.com/docs/en/env-vars) 环境变量,但桌面端是否继承该配置需要实测。本次仅修改全局 Claude 设置并未消除 Desktop 请求中的元数据。 + +更重要的是,原生 Anthropic OAuth 线路有相反的报告:禁用 attribution 会造成 429,见 [issue #6344](https://github.com/Wei-Shaw/sub2api/issues/6344)。因此,不建议为这个 Antigravity 问题统一设置 `CLAUDE_CODE_ATTRIBUTION_HEADER=0` 或在所有上游请求中删除该文本。 + +## 验证与仍需排查的情况 + +回归测试覆盖两种 system 格式、换行与空白、保留后续指令、保留普通文本和用户 / 工具内容,以及不修改原请求。已有的无冒号 billing-header 文本保留测试继续适用。 + +修复后应使用真实客户端新建会话验证完整响应,并确认已有冷却已到期。如果仍返回 429,继续检查原始上游错误、实际额度、并发和模型映射;不要用无限重试或反复清空冷却替代定位。 From 611c30f04fac9081d41bb6f4fa2712d8c7bbe359 Mon Sep 17 00:00:00 2001 From: HuntercodeT <61233559+HuntercodeT@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:33:48 +0800 Subject: [PATCH 024/137] =?UTF-8?q?chore(deps):=20=E5=8D=87=E7=BA=A7=20goo?= =?UTF-8?q?gle.golang.org/grpc=20=E5=88=B0=20v1.83.2=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20govulncheck=20=E6=8A=A5=E5=91=8A=E7=9A=84=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E5=85=AC=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security Scan / backend-security 自 2026-09-16 起在所有 PR 上失败,govulncheck 对 google.golang.org/grpc v1.82.1 报: - GO-2026-6443 Server panic via missing authority or Host headers(修复于 v1.83.2) - GO-2026-6348 Heap Memory Exhaustion via HTTP/2 DATA Frame Fragmentation(修复于 v1.83.1) 调用链在 pkg/pluginapi/v1/runtime.go 与 internal/service/plugin_runtime.go。 go get google.golang.org/grpc@v1.83.2 && go mod tidy。最小版本选择连带抬升了 golang.org/x/{crypto,mod,net,sync,term,sys,text,tools}、genproto/googleapis/rpc 与 go.opentelemetry.io/otel* 的间接依赖,均为补丁级别。 --- backend/go.mod | 28 ++++++++--------- backend/go.sum | 85 +++++++++++++++++++++----------------------------- 2 files changed, 50 insertions(+), 63 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 3cd44df4d99b..941f519d08e7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -50,13 +50,13 @@ require ( github.com/wechatpay-apiv3/wechatpay-go v0.2.21 github.com/zeromicro/go-zero v1.9.4 go.uber.org/zap v1.24.0 - golang.org/x/crypto v0.53.0 + golang.org/x/crypto v0.55.0 golang.org/x/image v0.41.0 - golang.org/x/mod v0.37.0 - golang.org/x/net v0.56.0 - golang.org/x/sync v0.21.0 - golang.org/x/term v0.44.0 - google.golang.org/grpc v1.82.1 + golang.org/x/mod v0.38.0 + golang.org/x/net v0.58.0 + golang.org/x/sync v0.22.0 + golang.org/x/term v0.45.0 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 @@ -192,20 +192,20 @@ require ( github.com/zclconf/go-cty v1.14.4 // indirect github.com/zclconf/go-cty-yaml v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.39.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.47.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + golang.org/x/tools v0.48.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect gopkg.in/ini.v1 v1.67.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index f639504856cd..caf01c3b6be7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -253,8 +253,6 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= @@ -300,8 +298,6 @@ github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCy github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= -github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -329,8 +325,6 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -369,8 +363,6 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -405,8 +397,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -442,8 +432,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= @@ -521,30 +509,30 @@ github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8 github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0= github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup8k= github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= -go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= -go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= @@ -574,8 +562,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= @@ -590,8 +578,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -613,8 +601,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -626,8 +614,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -645,7 +633,6 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -660,8 +647,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -674,8 +661,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= -golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -686,8 +673,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= -golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -702,8 +689,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -714,16 +701,16 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= -google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= -google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= From 017e9e98d1bb5a752da7d27e8304fdd478a3245b Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Thu, 17 Sep 2026 14:56:33 +0800 Subject: [PATCH 025/137] fix(payment): base refund balance warnings on the requested amount --- .../admin/payment/AdminRefundDialog.vue | 2 +- .../AdminRefundDialog.balance.spec.ts | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/admin/payment/__tests__/AdminRefundDialog.balance.spec.ts diff --git a/frontend/src/components/admin/payment/AdminRefundDialog.vue b/frontend/src/components/admin/payment/AdminRefundDialog.vue index f58b81038848..277416205089 100644 --- a/frontend/src/components/admin/payment/AdminRefundDialog.vue +++ b/frontend/src/components/admin/payment/AdminRefundDialog.vue @@ -214,7 +214,7 @@ const maxRefundable = computed(() => { const balanceInsufficient = computed(() => { if (props.userBalance == null || !props.order) return false - return props.userBalance < props.order.amount + return props.userBalance < form.amount }) watch(() => props.show, (val) => { diff --git a/frontend/src/components/admin/payment/__tests__/AdminRefundDialog.balance.spec.ts b/frontend/src/components/admin/payment/__tests__/AdminRefundDialog.balance.spec.ts new file mode 100644 index 000000000000..8b7322eac32f --- /dev/null +++ b/frontend/src/components/admin/payment/__tests__/AdminRefundDialog.balance.spec.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import type { PaymentOrder } from '@/types/payment' +import AdminRefundDialog from '../AdminRefundDialog.vue' + +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) +enableAutoUnmount(afterEach) + +async function openRefund(overrides: Partial = {}) { + const order: PaymentOrder = { + id: 1, user_id: 10, amount: 100, pay_amount: 100, fee_rate: 0, + payment_type: 'stripe', out_trade_no: 'order-1', status: 'COMPLETED', + order_type: 'balance', created_at: '2026-09-01', expires_at: '2026-09-02', + refund_amount: 0, ...overrides + } + const wrapper = mount(AdminRefundDialog, { + props: { show: false, order, userBalance: 50 }, + global: { stubs: { BaseDialog: { template: '
' } } } + }) + await wrapper.setProps({ show: true }) + return wrapper +} + +const warning = 'payment.admin.insufficientBalance' + +describe('refund balance warning', () => { + it('compares the available balance with the edited refund amount', async () => { + const wrapper = await openRefund() + expect(wrapper.text()).toContain(warning) + await wrapper.get('input[type="number"]').setValue('20') + expect(wrapper.text()).not.toContain(warning) + await wrapper.get('input[type="number"]').setValue('75') + expect(wrapper.text()).toContain(warning) + }) + + it('does not warn when the balance exactly covers the refund', async () => { + const wrapper = await openRefund() + await wrapper.get('input[type="number"]').setValue('50') + expect(wrapper.text()).not.toContain(warning) + }) + + it('uses the remaining amount for an already partially refunded order', async () => { + const wrapper = await openRefund({ status: 'PARTIALLY_REFUNDED', refund_amount: 80 }) + expect((wrapper.get('input[type="number"]').element as HTMLInputElement).value).toBe('20') + expect(wrapper.text()).not.toContain(warning) + }) + + it('keeps the warning hidden when balance deduction is disabled', async () => { + const wrapper = await openRefund() + await wrapper.get('#deduct-balance').setValue(false) + expect(wrapper.text()).not.toContain(warning) + }) +}) From be313735dc32f547f220e5592e160f9122f458f2 Mon Sep 17 00:00:00 2001 From: "chihao.ou" Date: Thu, 17 Sep 2026 14:56:58 +0800 Subject: [PATCH 026/137] fix(ui): keep dialog title IDs unique across instances --- frontend/src/components/common/BaseDialog.vue | 5 ++- .../common/__tests__/BaseDialog.ids.spec.ts | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/common/__tests__/BaseDialog.ids.spec.ts diff --git a/frontend/src/components/common/BaseDialog.vue b/frontend/src/components/common/BaseDialog.vue index 0a025bfce318..e24c6fc22626 100644 --- a/frontend/src/components/common/BaseDialog.vue +++ b/frontend/src/components/common/BaseDialog.vue @@ -42,12 +42,15 @@ + + diff --git a/frontend/src/components/admin/account/BatchTestAccountModal.vue b/frontend/src/components/admin/account/BatchTestAccountModal.vue index c830381bfcfe..ba742a0f821d 100644 --- a/frontend/src/components/admin/account/BatchTestAccountModal.vue +++ b/frontend/src/components/admin/account/BatchTestAccountModal.vue @@ -13,6 +13,7 @@ +